[Rust #04] Win10風タイル型スタートメニュー「DDlauncher」を作り、開閉のちらつきとアイコン取得を詰めるまで

[Rust #04] Win10風タイル型スタートメニュー「DDlauncher」を作り、開閉のちらつきとアイコン取得を詰めるまで

はじめに

これまでは ExplorerPatcher で、Windows 11のスタートメニューをWin10風に戻して使っていました。ただ、Windowsの更新のたびにスタートメニューが壊れ、再インストールすると、Win10のタイルの配置も全部消えました。

そこで、Explorerの内部に依存しない、独立したアプリとして、Win10のスタートメニューを作りました。名前は DDlauncher です。「Win10のあのスタートメニュー」をそのまま再現して、そこに便利な機能を足す方針です。

  • 左にアプリ一覧(頭文字の見出し、フォルダ展開つき)、右にタイル。サイズは小・中・横長・大
  • 検索。Ctrl+S、または文字キーを打つだけで、検索欄が開く
  • グローバルホットキー。どのアプリが前面でも開き、マウスのあるモニターに出る
  • ブックマーク(アプリ・フォルダ・コマンド)と、左端のアイコン列
  • トレイ常駐、自動起動、設定のバックアップ(書き出し/読み込み)

言語は Rust です。画面は Tauri v2 と素のHTML・CSS・JavaScript(フレームワークなし)で、Windowsの呼び出しはRustです。対象は Windows 11(25H2)で、動作確認もそこだけです。Windows 10 は確かめていません。

スクリーンショット

[Rust #04] DDlauncher メイン画面 [Rust #04] DDlauncher 検索 [Rust #04] DDlauncher 設定ダイアログ

タスクバーにピン止め

[Rust #04] DDlauncher 設定ダイアログ

リポジトリ:

前回の記事(Rustで作ったホットキーランチャーの話):

1. 構成:Rustは窓口、画面は素のJavaScript

src-tauri/src/
  main.rs        窓の開閉と位置、トレイ、ホットキー、設定とデータの読み書き、コマンド
  startmenu.rs   スタートメニューの .lnk の走査
  appsfolder.rs  ストアアプリの列挙(shell:AppsFolder)
  icons.rs       アイコンの取得とPNGキャッシュ
  usage.rs       起動履歴(最近使ったアプリ)
  script.rs      ブックマークのコマンドを .bat にする
  backup.rs      バックアップの書き出しと読み込みの検査
src/
  apps.js  tiles.js  tile-engine.js  search.js  rail.js  bookmarks.js  main.js  style.css

データは %APPDATA%\com.fixtan.ddlauncher\ に、JSONで分けて置きます(settings.json、tiles.json、rail.json、bookmarks.json、usage.json)。書き込みは、一時ファイルに書いてから置き換え、直前の内容を .bak に1世代残します。壊れたファイルを空の設定で上書きしない、という考え方は、[Rust #03] の HotRun と同じです。

私の手元には、Windowsのコードをコンパイルして確かめる環境がありません。そこで、検証は3層に分けました。

  • Windowsを呼ばないRustのモジュール(履歴、コマンドの .bat、バックアップの検査)は、別のクレートに写して cargo test
  • 画面のJavaScriptは、Node(jsdom)で動きを確認
  • 見た目は、本物のChromiumを Playwright で動かして確認

Windows専用のコードだけは、実機でビルドして、結果を見ます。この分け方の限界は、あとの「つまずいたこと」に書きます。

2. 開閉:2回目の起動を、開閉の合図にする

スタートボタンの隣のピン止めアイコンを押すと、メニューが開き、もう一度押すと閉じる。これが、最初に決めた使い方です。

ただ、DDlauncherのウィンドウは、タスクバーに出しません(skipTaskbar)。そのため、ピン止めアイコンを押すたびに、ddlauncher.exe が新しく起動されます。これを、tauri-plugin-single-instance で受けます。常駐している側が「2回目の起動が来た」と知り、開閉を切り替えて、2つ目のプロセスは終了します。

閉じるためにアイコンを押すと、また開く

開いている状態でアイコンを押すと、こう動きます。

  1. アイコンをクリックした瞬間に、DDlauncherがフォーカスを失い、フォーカス喪失で閉じる
  2. そのあと、2回目の起動が届いて、「閉じているので開く」になる

閉じたいのに、また開いてしまいます。対策は、閉じてから一定時間(ガード)以内の「開く」を無視することです。

無視(閉じてから 171ms < ガード 800ms)

実機のログでは、閉じてから171msで2回目の起動が来て、ガード(800ms)が効きました。ガードは設定で変えられます。代わりに、アイコンを素早く連打しても、閉じたままになる時間ができます。これは、許容にしました。

フォーカス喪失で閉じる処理に、例外を3つ足した

「フォーカスが外れたら閉じる」は、WindowEvent::Focused(false) で受けます。単純ですが、実際には例外が要りました。

fn handle_blur(app: &AppHandle) {
    // ダイアログを出している間は、フォーカスが移るので閉じない
    if busy { return; }
    if !hide_on_blur { return; }
    // 表示から 300 ms 以内のフォーカス喪失は無視して、フォーカスを取り直す
    if shown_elapsed < Duration::from_millis(300) {
        window.set_focus();
        return;
    }
    if !left_button_down() {
        hide_window(app, "フォーカス喪失");
        return;
    }
    // ボタンを押したままなら、離すまで待つ(次の節)
}
  • 表示の直後(300ms以内): 初回の起動などで、別のウィンドウにフォーカスを奪われ、14ms後に閉じたことがありました。表示から300ms以内の喪失は、無視して、フォーカスを取り直します。
  • ダイアログの表示中: バックアップの保存/読み込みで、OSのダイアログを出すと、フォーカスが移ります。そのあいだは busy フラグを立てて、閉じません。
  • ボタンを押したまま: 次の節です。

エクスプローラーからファイルを掴むと、閉じてしまう

エクスプローラーのexeを、タイル領域へドロップして追加したい。ところが、エクスプローラーのファイルを掴んだ時点で、DDlauncherはフォーカスを失い、閉じてしまいます。ドロップ先が、なくなります。

そこで、マウスの左ボタンが押されたままフォーカスを失ったときは、閉じずに、離すまで待ちます。離したあと250ms以内に、このウィンドウへのドロップが来ていたら閉じず、来ていなければ閉じます。

while left_button_down() && t0.elapsed() < Duration::from_secs(60) {
    std::thread::sleep(Duration::from_millis(30));
}
// ドロップのイベントは、ボタンを離した直後に届く
std::thread::sleep(Duration::from_millis(250));

副作用として、外側をクリックして閉じる動作が、約0.3秒遅れます。気になる遅さではなかったので、そのままにしました。

位置:マウスのあるモニターの作業領域

位置は、画面の隅(左下・中央下・右下、左上・中央上・右上)から選びます。基準は、カーソルのあるモニターの作業領域です(MonitorFromPoint と GetMonitorInfoW)。ホットキーで開いたとき、マウスのあるモニターに出るのは、これです。

ウィンドウは、枠なし・透過・最前面です。透明度は、設定のスライダーで0.05〜1に変えられます。スライダーを動かすたびに設定を保存していたので、1秒に数十回、書き込んでいました。操作が止まってから300ms後に、1回だけ保存するよう直しました。

3. アプリ一覧:.lnk を走査し、ストアアプリを足す

一覧の元は、2つのスタートメニューのフォルダです。

%AppData%\Microsoft\Windows\Start Menu\Programs
C:\ProgramData\Microsoft\Windows\Start Menu\Programs

この下の .lnk を、再帰で集めます。最初の実装では、392件で13msでした。

ストアアプリは、.lnk にない

Win10のスタートメニューと見比べると、3D ビューアー、メール、フォト、Microsoft Store などが、出ていません。ストアアプリは .lnk を持たないからです。

shell:AppsFolder を列挙して、識別子が「パッケージ名!アプリID」の形のものだけを、一覧の最上位に足しました。起動は、ShellExecuteW に shell:AppsFolder\<識別子> を渡します。ただし AppsFolder は、階層のない一覧です。フォルダの構造は、これまでどおり .lnk の走査から作ります。

  • .lnk とストアアプリの両方に同名の項目があれば、足さない(Edgeなど)
  • 名前が ms-resource:DisplayName のまま、解決されていない項目が1件出たので、そういう名前の項目は出さない
  • AppsFolder の列挙に失敗しても、.lnk だけの一覧は出す

ストアアプリを足したあとは、一覧の取得に約1.1秒かかるようになりました。起動直後だけ、遅れて出ます。

フォルダ名と項目名が英語だった

Accessories や Administrative Tools が、英語で出ていました。ディスク上の名前をそのまま出していたからです。SHGetFileInfoW(SHGFI_DISPLAYNAME) で、Windowsの表示名に置き換えました。ディスク上は英語で、日本語は desktop.ini の指定で出ている、と推測しています(その部分は、仕組みまでは確かめていません)。

4. アイコン:取れない、矢印が付く、大きさが足りない

アイコンの取得には、3回つまずきました。

矢印が付いた

.lnk のアイコンを、シェルに頼んで取ると、ショートカットの矢印が付いた状態で返ります。実装のコメントに「矢印は付けない」と書いておきながら、そうなっていませんでした。想定が外れていた、ということです。

直し方は、.lnk の中身を読むことです(IShellLink)。アイコンの指定があれば、そのファイルから取ります。なければ、指す先(exeなど)から取ります。指す先が取れないときだけ、従来の方法(矢印つき)に戻します。

大きさが足りない

アイコンは、タイルの大きさに合わせて、取る大きさを変えます。最初は、小32px、中・横長48px、大96pxにしましたが、差が小さく、大でもタイルに対して小さく見えました。いまは、一覧が32px、小が40px、中・横長が64px、大が128pxです。タイルのサイズを変えると、取り直します。キャッシュは、パスと更新日時と大きさをキーにして、PNGで保存します。

取れなかった結果を覚えていた

ドロップで追加したexe(hotrun-app)のアイコンが、頭文字の「H」になりました。ログには、起動直後に1件だけ「SHGetFileInfoW がアイコンを返さなかった」とありました。

確定した不具合は、画面側のJavaScriptが、失敗した取得結果(null)を覚えていて、アプリを再起動するまで取り直さなかったことです。ドロップし直しても「H」のまま、ログにも新しい失敗が出ませんでした。失敗は覚えないよう直しました。

そもそもなぜ最初に取れなかったのかは、特定できていません。可能性は、ファイルが移動・再ビルドで置き換わっていたか、新しい取り方が失敗して従来の方法も失敗したか、のどちらかだと考えていますが、確かめていません。そこで、診断を足しました。失敗したときは、試した方法ごとの理由(.lnk の指す先、シェルの画像、アイコン資源、従来の方法)を、パスと大きさつきでログに出します。exe・dll・ico は、シェルを通さず、ファイル自身のアイコン資源から直接取る方法も足しました。その後は、同じ症状は出ていません。

5. タイル:マス座標で、隙間を保つ

タイルは、マス座標で配置します。Win10と同じく、空けた隙間は、そのまま保ちます。

const SIZES = { S:{w:1,h:1}, M:{w:2,h:2}, W:{w:4,h:2}, L:{w:4,h:4} };
const SNAP  = { S:1, M:2, W:2, L:2 };   // 位置のスナップ単位

小は1マス、中は2×2、横長は4×2、大は4×4です。小は、中の枠に4つ入ります。私は小アイコン4つを1ブロックとして使っているので、タイルのグループ機能は作りませんでした。

動かしたタイルは固定して、重なった他のタイルを、下へ押し下げます。押し下げられたタイルが、さらに別のタイルに重なれば、それも下げます。

function resolve(tiles, movedId){
  const queue = [tiles.find(t => t.id === movedId)];
  while(queue.length){
    const m = queue.shift();
    for(const c of [...tiles].sort(byPos)){
      if(c === m || c.id === movedId) continue;
      if(overlaps(m,c)){
        c.y = snapUp(m.y + m.h, SNAP[c.size]);
        queue.push(c);
      }
    }
  }
}

実際のループには、無限ループを防ぐ回数の上限があります。このエンジンは、画面から切り離した形で書き、docs/prototype/ の試作のテスト(Node)で、重なり・押し出し・隙間の保持を確かめています。読み込んだJSONは sanitize で、重複した id、不正なサイズ、数値でない座標を捨てて、重ならない状態に直します。Win10の実機での押し出しの細かい規則とは、一致しているか未確認です。

ドラッグは、ブラウザ標準を使わない

一覧のアプリをタイル領域へドラッグして追加する機能は、ブラウザ標準のドラッグ&ドロップを使っていません。標準のものは、エクスプローラーのファイルのドロップ受付(Tauriの DragDropEvent)と両立しないので、ポインターイベントで実装しました。タイルの移動と同じ方式です。

エクスプローラーからのドロップは、座標が物理ピクセルで届くので、devicePixelRatio で換算します。

6. 検索:前方一致が上、途中一致が下

検索は、左端の🔎、Ctrl+S(Ctrl+F)、または何も開いていない状態で文字キーを打つと開きます。文字キーで開いたときは、最初の1文字が、そのまま入力欄に入ります。

並び順は、前方一致、単語の頭の一致、途中一致の順で、同順位は名前の短い順です。空白で区切ると、すべてを含むものだけが残ります。大文字小文字、全角半角、ひらがなとカタカナは、区別しません。

  • 最初は、開いたときに入力欄を全選択していて、2文字目で1文字目が消えました。文字キーで開いたときは、選択しないよう直しました。
  • 日本語入力は、変換中のEnterで起動してしまわないよう、確定後のEnterだけを受けます。日本語を打つときは、🔎かCtrl+Sで開いてから打ちます。文字キーでの直接の起動は、英数字を想定しています。

↑↓で選んで、Enterで起動します。これができたとき、Win10のスタートメニューを超えた、と思いました。

7. ホットキー:[Rust #03] とは逆に、e.code

DDlauncher のホットキーは、tauri-plugin-global-shortcut で登録します。[Rust #03] の HotRun は RegisterHotKey を直接呼んで、仮想キーコードを keyCode で読みましたが、こちらは、プラグインの書式に合わせて、e.code の名前を使います。

Ctrl+KeyD
Ctrl+Alt+Space

修飾キー(Ctrl、Alt、Shift)を先に、キーを最後に書きます。設定の欄をクリックして、キーを押すと登録されます。

  • 欄を選んでいる間は、登録済みのキーを一時的に解除します。同じ組み合わせを押して、ウィンドウが開閉してしまわないようにするためです。
  • 修飾キーなしの文字キーは受け付けません。F1〜F24は単独で使えます。
  • Winキーは、OSが使うので、使えません。
  • 他のアプリが取っているキーは、登録に失敗します。理由をログに出して、元のキーに戻します。

ホットキーが押されたときは、開閉を切り替えて、開いたときは、画面側に ddl-open-search というイベントを送ります。設定で、検索欄から始めるかどうかを選べます。押してそのまま打てば、Enterで起動できるので、マウスなしで使えます。

8. ブックマークと、コマンドの実行

左端の🔖で、リストが出ます。登録できるのは、アプリ、フォルダ、コマンドで、グループ(フォルダ)に分けられます(最大4階層、全体で500件)。ファイルやフォルダを🔖にドロップしても、左の一覧を右クリックしても、追加できます。

コマンドは、複数行で書けます。たとえば、こうです。

cd C:\Users\me\project
npm run build

これを、一時的な .bat に書いて、新しいコンソールで実行します(cmd.exe の /K か /C、CREATE_NEW_CONSOLE)。

pub fn build_script(command: &str, cwd: &str) -> String {
    let mut s = String::new();
    s.push_str("@chcp 65001 >nul\r\n");          // 日本語のパスや出力が化けないように
    if !cwd.trim().is_empty() {
        s.push_str(&format!("cd /d \"{}\"\r\n", cwd.trim().replace('"', "")));
    }
    for line in command.lines() {
        s.push_str(line.trim_end_matches('\r'));
        s.push_str("\r\n");
    }
    s
}

「実行後も閉じない」なら /K、外せば /C です。.bat は %TEMP%\ddlauncher\ に作り、古いものは1日で削除します。

PowerShellや、管理者での実行は、未対応です。コマンドの実行は、Rustのテストで .bat の中身を確かめていますが、実機でのコンソールの動きは、私の記録では、まだ確認が終わっていません。

9. タスクバーへの自動ピン止めは、できなかった

最初は、トレイのメニューに「タスクバーにピン止めする」を付けたいと考えました。Windows 11では、アプリからタスクバーに自動でピン止めする公式APIが、ないと思い込んでいました。調べ直すと、TaskbarManager.RequestPinCurrentAppAsync がありました(先に調べずに「ない」と言ったのは、間違いでした)。

試したところ、IsSupported=true でしたが、IsPinningAllowed=false が返り、確認ダイアログは出ませんでした。前面にいる状態で呼んでいて、レジストリのトークンやポリシーにも、原因らしい設定は見つかりませんでした。パッケージ化されていないアプリだから、という可能性は考えましたが、確かめていません。

結論として、ピン止めは、手動にしました。アイコンを右クリックして、「その他のオプションを確認」から「タスクバーにピン留めする」です。使い方のページに、手順を載せています。

10. バックアップとインストーラー

設定、タイル、左端、ブックマークは、1つのJSONに書き出せます。kind が ddlauncher-backup で version が1のものだけを受け付け、読み込む前に検査して、各ファイルを .bak に残してから置き換えます。保存と読み込みのダイアログは、tauri-plugin-dialog です。ダイアログを出している間は、前に書いた busy フラグで、フォーカス喪失による閉じる動作を止めます。

実際に、アンインストールでデータを消してから、バックアップを読み込んだところ、タイル・左端・ブックマークが元に戻りました。

インストーラーは、tauri build のNSISです。ユーザー単位(%LOCALAPPDATA%\DDlauncher)で、サイズは約2.3MBです。署名はしていないので、SmartScreenの警告が出ます。

自動起動の登録(HKCU\...\Run)は、アンインストールしても、残る場合があるかもしれません。そこまでは確かめていないので、READMEに、設定で自動起動をオフにしてから消すよう書いています。

11. つまずいたこと

アイコンを差し替えても、exeが変わらない

新しいアイコンの icon.ico に差し替えて、インストーラーを作り直しても、exeのアイコンが古いままでした。再起動しても、Windowsのアイコンキャッシュを消しても、同じでした。

tauri-build 2.7.3 のソースを読むと、アイコンのファイルを cargo:rerun-if-changed に出していませんでした。アイコンだけを変えても、build.rs が再実行されず、埋め込み済みのリソースが使い回されます。tauri.conf.json を変えたときだけ、再実行されていました。build.rs に、2行を足して直りました。

fn main() {
    println!("cargo:rerun-if-changed=icons/icon.ico");
    println!("cargo:rerun-if-changed=icons/icon.png");
    tauri_build::build()
}

[Rust #03] でも、アイコンが古いままになる話を書きましたが、今回は、Windowsのキャッシュではなく、ビルド側の問題でした。見分け方は、トレイのアイコンです。トレイは、埋め込んだ画像から作るので、Explorerのキャッシュを通りません。

設定ダイアログが、画面の外へ落ちる

ログのタブを開くと、ダイアログが下へずれて、画面の外に消えました。ダイアログを格子(grid)の中央に置き、高さを % で指定していたため、ログの行数ぶんの高さが格子を押し広げ、その中央に置かれた箱が、下へずれたのが原因です。高さを vh にして、ログの欄は絶対配置にしました。

もうひとつ、長いパスが入ると、タブの中身が横にはみ出しました。こちらも、gridの列が、内容に引っ張られて広がっていたので、列を minmax(0, 1fr) にして、ダイアログの幅に収めました。

jsdomでは、見た目の不具合が見つからない

どちらのレイアウトの不具合も、jsdomのテストでは、見つかりません。jsdomは、レイアウトを計算しないからです。本物のChromiumを、Playwrightで動かして、要素の位置と scrollWidth を測って、確認し直しました。ダイアログの位置は、直す前は top=900、直したあとは top=130 でした。見た目に関わる修正は、Chromiumで確かめる、という決まりにしました。

ZIPで上書きすると、設定ファイルも上書きされる

ZIPで更新したソースを上書きしたとき、.gitignore も、ZIPの中の版に置き換わりました。VS Codeの履歴フォルダ(.history)を、除外に入れていたのに、その行が消えて、リポジトリに入ってしまいました。[Rust #03] の、ZIPで上書きしても古いファイルは消えない話の、逆のパターンです。ZIPの中にあるファイルは、手元の編集を、すべて上書きします。

まとめ:開閉、取れなかった結果、押し出し、そして、ビルドの罠

  • ピン止めアイコンの2回目の起動を、開閉の合図にする。閉じるためにアイコンを押すと、また開くので、閉じてから一定時間は「開く」を無視する(ガード)
  • フォーカス喪失で閉じる処理には、例外が要る。表示の直後、ダイアログの表示中、ボタンを押したままのドロップ
  • 一覧は、.lnk の走査に、shell:AppsFolder のストアアプリを足す。アイコンは、.lnk の中身を読み、大きさをタイルに合わせ、失敗を覚えない
  • タイルはマス座標で、動かしたものを固定して、重なったものを下へ押す。エンジンは、画面から切り離してテストする
  • ホットキーは、プラグインの書式に合わせて e.code。[Rust #03] の keyCode とは、逆の選択
  • コマンドは、一時 .bat にして、新しいコンソールで走らせる
  • タスクバーへの自動ピン止めは、パッケージ化していないアプリでは、できなかった。手動にして、手順を書く
  • アイコンだけを差し替えても、tauri-build は再ビルドしない。build.rs に rerun-if-changed を足す
  • レイアウトの不具合は、jsdomでは見つからない。本物のブラウザで測る

動作確認は、Windows 11 だけです。ホットキー、一覧からタイルへのドラッグ、エクスプローラーからのドロップ、バックアップ、インストーラー、自動起動は、動いています。まだ確かめていないのは、複数モニターを横に並べたときの位置とDPI、ブックマークのコマンドの実行(実機でのコンソールの動き)、アンインストール後に自動起動の登録が残るかどうか、Windows 10 です。