🔖

SnippingToolに機能を足して自作してみた【タスクトレイ常駐アプリとしてのアーキテクチャ】

に公開

はじめに

Windows標準の Snipping Toolは、Win + Shift + S で画面の一部をサッと切り取れて便利ですが、モザイクやぼかしを入れる機能が無いんですよね。

毎回キャプチャした画像を保存して、
別アプリで開いて、
モザイクやぼかしを入れて、
また保存して、

という手間でした。個人情報が写り込んだ画面をブログや社内チャットに貼るたびにこれをやるのが地味にストレスで、「だったら Snipping Tool 相当の使用感のまま、モザイク・ぼかしまで一気通貫でできるツールを自分で作ればいいのでは」と思い立って作ったのが CapEdit です。

アプリを作るうえで、実装の中でのポイントとなる箇所を記事にしていきます。何回かに分けて記載するつもりです。
作ったアプリは公開しています。記事を読むうえで参考にして下さい。また良かったら使ってみて下さい。
https://github.com/nori3-dev/CapEdit

開発環境が無い人やGitHubって何?って人は、Boothで販売しています。
https://nori33.booth.pm/items/8680938

タスクトレイ常駐アプリとしてのアーキテクチャ

メインウィンドウを持たないWPFアプリ

WPFアプリは既定では、最初に表示されるウィンドウ(StartupUri で指定したウィンドウなど)が閉じられるとアプリ全体が終了します。しかしCapEditはそもそも「常時表示されるメインウィンドウ」を持たず、設定画面やキャプチャオーバーレイは必要なときだけポップアップする形にしたいアプリです。

そこで App.xaml.csOnStartup で明示的に ShutdownMode を切り替えています。

protected override void OnStartup(StartupEventArgs e)
{
    base.OnStartup(e);

    ShutdownMode = ShutdownMode.OnExplicitShutdown;

    var appSettings = SettingsStore.Load();

    ThemeManager.Initialize(appSettings.Theme.FollowSystem, appSettings.Theme.IsDark);

    SetupTrayIcon();

    _hotkeyHook = new LowLevelKeyboardHook();
    _hotkeyHook.ApplyHotkeySettings(appSettings.Hotkey);
    _hotkeyHook.CaptureRequested += OnCaptureRequested;
    _hotkeyHook.Install();

    UpdateTrayText(appSettings.Hotkey);

    // キャプチャ処理中の想定外の例外でアプリ全体が落ちたり、
    // オーバーレイが画面に残って操作不能になったりしないようにする
    DispatcherUnhandledException += (_, args) =>
    {
        System.Diagnostics.Debug.WriteLine(args.Exception);
        args.Handled = true;
    };
}

ShutdownMode.OnExplicitShutdown にすることで、「表示していたウィンドウ(設定画面や編集画面)が全部閉じられても、アプリ自体は終了しない」状態になります。アプリを終了させるには、トレイメニューの「終了」から明示的に Shutdown() を呼ぶしかありません。これにより、設定画面や編集画面を閉じてもタスクトレイのアイコンだけが常駐し続ける、常駐アプリらしい挙動が実現できます。

WPFにWinFormsのNotifyIconを混在させる

.NETのWPFには標準でタスクトレイアイコン(システムトレイアイコン)を扱うクラスがありません。そのため CapEdit では csprojUseWindowsForms も有効にし、Windows Formsの NotifyIcon / ContextMenuStrip を借りて実装しています。DllImportのようなWindows APIの直接呼び出しではなく、.NETが用意している普通のクラスなので、ここから先は見慣れた書き方が続きます。

右クリックメニューの組み立て

private void SetupTrayIcon()
{
    _enabledMenuItem = new Forms.ToolStripMenuItem("有効")
    {
        CheckOnClick = true,
        Checked = true,
    };
    _enabledMenuItem.Click += (_, _) =>
    {
        if (_hotkeyHook != null)
        {
            _hotkeyHook.IsEnabled = _enabledMenuItem.Checked;
        }
    };

    var settingsMenuItem = new Forms.ToolStripMenuItem("設定");
    settingsMenuItem.Click += (_, _) => OpenSettingsWindow();

    var exitMenuItem = new Forms.ToolStripMenuItem("終了");
    exitMenuItem.Click += (_, _) => Shutdown();

    var menu = new Forms.ContextMenuStrip();
    menu.Items.Add(_enabledMenuItem);
    menu.Items.Add(settingsMenuItem);
    menu.Items.Add(new Forms.ToolStripSeparator());
    menu.Items.Add(exitMenuItem);

トレイアイコンを右クリックしたときに出るメニューは、Windows Formsの ToolStripMenuItem(メニュー項目1つを表すクラス)を3つ作り、ContextMenuStrip(メニュー全体を表すクラス)にまとめているだけです。「有効」はチェックボックス付きのメニュー項目(CheckOnClick = true)にして、クリックするたびにオン/オフが切り替わるようにし、そのチェック状態を前回の記事で説明した LowLevelKeyboardHook.IsEnabled にそのまま反映しています。「設定」「終了」はクリックされたら対応するメソッドを呼ぶだけの普通のメニュー項目です。

トレイアイコン本体の生成とアイコン画像の読み込み

    _trayIcon = new Forms.NotifyIcon
    {
        Icon = LoadTrayIcon(),
        Visible = true,
        Text = "CapEdit",
        ContextMenuStrip = menu,
    };
    _trayIcon.DoubleClick += (_, _) => BeginCapture();
}

private static System.Drawing.Icon LoadTrayIcon()
{
    using var stream = System.Reflection.Assembly.GetExecutingAssembly()
        .GetManifestResourceStream("CapEdit.icon.ico");
    return stream != null ? new System.Drawing.Icon(stream) : System.Drawing.SystemIcons.Application;
}

NotifyIcon がタスクトレイのアイコンそのものを表すクラスです。Iconプロパティに表示したいアイコン画像を、ContextMenuStripプロパティに先ほど組み立てたメニューを設定し、Visible = trueにすることでタスクトレイに表示されます。DoubleClickイベントには、キャプチャを開始する BeginCapture(後述)を割り当てています。

アイコン画像自体は icon.ico というファイルを、ビルド時に「埋め込みリソース(EmbeddedResource)」としてexeファイルの中に一緒に焼き込んでおき、Assembly.GetManifestResourceStream という .NET のメソッドで実行時に読み出しています。埋め込みリソースにしているのは、実行ファイル本体(favicon.icoApplicationIcon に指定したexe自身のアイコン)とは別に、トレイ表示用のアイコンを確実に単一ファイル内で完結させ、配布時にアイコンファイルを別途同梱しなくて済むようにするためです。

トレイアイコンのツールチップには、現在のホットキー設定を反映させています。

internal void UpdateTrayText(HotkeySettings settings)
{
    if (_trayIcon == null)
    {
        return;
    }

    var modifier1 = ModifierToLabel(settings.Modifier1);
    var modifier2 = settings.Modifier2.HasValue ? ModifierToLabel(settings.Modifier2.Value) + "+" : string.Empty;
    var text = $"CapEdit ({modifier1}+{modifier2}{settings.TriggerKeyDisplay} で起動)";

    // NotifyIcon.Textの上限は127文字(.NET Framework時代は63文字)。
    // ツールチップとして長すぎても読みにくいため、余裕をもって切り詰める
    _trayIcon.Text = text.Length > 63 ? text[..63] : text;
}

NotifyIcon.Text には文字数の上限があり、超えると ArgumentOutOfRangeException になります。この上限は .NET のバージョンで異なります。

  • .NET Framework:63文字まで
  • .NET Core 3.0 以降(現在の .NET):127文字まで

Web上には「63文字まで」と書かれた情報が多く残っていますが、これは .NET Framework 時代のものです。実際、.NET 10 の System.Windows.Forms.dll に含まれる例外メッセージは Text length must be less than 128 characters long. になっています(.NET Framework では less than 64)。裏側のWin32構造体 NOTIFYICONDATAszTip フィールドがWindows 2000で64文字から128文字に拡張されており、.NET側の実装がようやくそれに追随した形です。

CapEditは .NET 10 なので127文字まで設定できますが、ツールチップとして長すぎても読みにくいだけなので、ここでは余裕をもって63文字で切り詰めています。

設定画面の多重起動防止

トレイメニューから「設定」を選ぶたびに新しいウィンドウを作ってしまうと、同じ設定画面が何枚も開いてしまいます。それを防ぐシングルトン的な管理です。

private SettingsWindow? _settingsWindow;

private void OpenSettingsWindow()
{
    if (_settingsWindow != null)
    {
        _settingsWindow.Activate();
        return;
    }

    _settingsWindow = new SettingsWindow();
    _settingsWindow.Closed += (_, _) => _settingsWindow = null;
    _settingsWindow.Show();
    _settingsWindow.Activate();
}

すでにウィンドウが開いている場合は新規作成せず Activate() でフォーカスを当てるだけにし、閉じられたタイミング(Closedイベント)でフィールドを null に戻すことで、次回はまた新規作成できるようにしています。「設定画面はすでに開いていたら、新しく開かずそのウィンドウをアクティブにするだけ」という仕様を、このシンプルな仕組みで実現しています。

キャプチャ開始からクリップボードコピーまでの一連の流れ

private void BeginCapture()
{
    var overlay = new CaptureOverlayWindow();
    overlay.CaptureCompleted += (_, bitmap) =>
    {
        CopyToClipboard(bitmap);

        var editWindow = new Editing.EditWindow(bitmap);
        editWindow.Show();
        editWindow.Activate();
    };
    overlay.Show();
    overlay.Activate();
}

ホットキー押下(またはトレイアイコンのダブルクリック)から BeginCapture が呼ばれます。まずキャプチャ用のオーバーレイを表示し、CaptureCompleted イベント(前々回の記事で説明した、キャプチャが完了した瞬間に発火するイベント)でキャプチャ画像を受け取り次第クリップボードにコピーし、続けて編集画面を開く、という一直線のフローです。

private static void CopyToClipboard(System.Windows.Media.Imaging.BitmapSource bitmap)
{
    try
    {
        System.Windows.Clipboard.SetImage(bitmap);
    }
    catch
    {
        // 他プロセスがクリップボードをロックしている場合等は失敗しても致命的ではないため無視する
    }
}

Clipboard.SetImagetry/catch で囲んでいるのは、Windowsのクリップボードは他プロセス(例えばクリップボード履歴アプリやリモートデスクトップのクリップボード同期など)に一時的にロックされることがあるためです。ここで例外を投げてしまうとキャプチャ・編集フロー自体が止まってしまうため、コピーに失敗しても致命的ではないとみなして握りつぶし、後続の編集画面表示は必ず継続されるようにしています。

想定外の例外でオーバーレイが残り続けないようにする

DispatcherUnhandledException += (_, args) =>
{
    System.Diagnostics.Debug.WriteLine(args.Exception);
    args.Handled = true;
};

WPFのUIスレッドで捕捉されなかった例外は、既定では未処理例外としてアプリ全体をクラッシュさせます。常駐アプリでこれが起きると、タスクトレイのアイコンごと消えてしまい、ユーザーは「アプリが落ちたことにすら気づけない」まま今後ホットキーが反応しなくなります。そこで DispatcherUnhandledException を捕捉して Handled = true とすることで、UIスレッドの例外によるアプリ全体のクラッシュを防いでいます(デバッグ用に内容は Debug.WriteLine で出力するのみに留めています)。

終了処理でのリソース解放

protected override void OnExit(ExitEventArgs e)
{
    _hotkeyHook?.Dispose();
    if (_trayIcon != null)
    {
        _trayIcon.Visible = false;
        _trayIcon.Dispose();
    }

    base.OnExit(e);
}

キーボードフックの解除(UnhookWindowsHookEx)はもちろんですが、_trayIcon.Visible = falseDispose() の前に明示的に呼んでいる点がポイントです。NotifyIconDispose() だけでもアイコンは消えますが、タイミングによってはタスクトレイの通知領域にゴーストアイコン(実体が無いのにマウスを乗せるまで表示が残る)が残ることがあるため、先に非表示にしてから破棄することで、この見た目の不具合を避けています。

他の記事もあります

ひとつ前の記事です
https://zenn.dev/da39nbv3/articles/3d872d56db49be

最後に

普段はWindows向けの便利ツールをいくつか個人開発していて、BOOTHで販売もしています。
RAMDash はPCのメモリ(RAM)の一部を超高速ドライブ(RAMディスク)に変え、それらのキャッシュ・一時ファイルをまるごとメモリ上として、読み書き速度を高速化させる常駐アプリです。
ブラウザ、ゲームのシェーダー、開発ツール、チャットアプリ等々、PCは裏側で、消えても困らない「キャッシュ」を絶え間なくSSD/HDDへ書き込み続けていますので、これらをチェックだけの簡単操作で仮想RAMディスクへ移動して、高速化を実現します。
https://nori33.booth.pm/items/8527152

customMenu以外のアプリもいろいろと作ってます。
https://nori33.booth.pm/

Discussion