About

ローカル開発画面をMacアプリに — Electronではなくmanifest一枚とlaunchd

iframeでもラッパーアプリでもない。ブラウザがそのページに専用ウィンドウを付ける

毎朝開く個人用の画面がある。ローカルのRails開発サーバーで動いているページだ。これをブラウザのタブではなく、Macアプリのように使いたかった。まずElectronやTauriが浮かんだが、結局Webアプリのmanifest一枚とlaunchdひとつで終わった。

この通りにやれば、http://myapp.localhost:3000/ のような開発画面が、Dockアイコン付きの独立したウィンドウになる。

何がアプリになるのか

iframeのように別のページの中に枠を作るのではない。ブラウザがそのページに専用のウィンドウを付ける。タブもアドレスバーもなく、Cmd+Tabにも別に出る。ウィンドウそのものがそのページだ。

Dock icon My Desk no tabs · no URL bar · own Cmd+Tab entry Chrome app window (from manifest.json) Rails dev server myapp.localhost:3000 same code, no cache launchd at login start only if the port is free

ファイル三つと一行

manifestとアイコン二枚を public/ に置き、レイアウトの <head> に一行足す。

{
  "name": "My Desk",
  "short_name": "Desk",
  "start_url": "/",
  "display": "standalone",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

<link rel="manifest" href="/manifest.json">

つまずいたところが二つあった。

最初はファイル名を manifest.webmanifest にしていた。Railsの開発サーバーはこれを text/plain で返した。manifest.json に変えたら application/json になった。

インストールボタンを出すにはservice workerが必要だと思い込んでいた。違った。2026-10-01、macOS 14.6.1・Chrome 154で、service workerなしでもlocalhostのページに beforeinstallprompt が届いた。インストールすると ~/Applications/Chrome Apps/ の下にアプリができた。service workerがないのでキャッシュもなく、コードを直せばウィンドウにもそのまま反映される。名前やアイコンなどmanifestの変更だけは、Chromeへの反映が遅い。

インストールボタンを自分で付けるなら

beforeinstallprompt の prompt() は一回しか使えない。一度出して閉じると、リロードするまでボタンが反応しなくなる。機会を使い切ったら「アドレスバー右のインストールアイコン、または ⋮ メニューからページをアプリとしてインストール」と案内するように変えた。

let deferred = null;
window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  deferred = e;
  installButton.hidden = false;
});
installButton.addEventListener('click', async () => {
  if (!deferred) { showMenuHint(); return; }
  deferred.prompt();
  await deferred.userChoice;
  deferred = null; // 二度は使えない
});

インストールの確認ダイアログはページの外、ブラウザのUI側に出る。だから拡張機能のような自動化では、最後の「インストール」を押せない。そこは人が押す。

サーバーはlaunchdが立てる

アイコンを押してもサーバーが止まっていたら意味がない。ログイン時にlaunchdが開発サーバーを立てるようにした。

うちのリポジトリの起動コマンド(make lad)は、ポートを掴んでいるプロセスを先に kill -9 する。だからポートが空いているときだけ起動するように包んだ。launchdのジョブが終わるときに立てたサーバーまで道連れにならないよう、AbandonProcessGroup も有効にした。

<key>ProgramArguments</key>
<array>
  <string>/bin/zsh</string><string>-lc</string>
  <string>lsof -iTCP:3000 -sTCP:LISTEN || (cd ~/src/myapp &amp;&amp; make lad)</string>
</array>
<key>RunAtLoad</key><true/>
<key>AbandonProcessGroup</key><true/>

Electron・Tauriと比べると

方法 必要なもの 得られるもの
manifestでインストール ファイル3つ + link一行 Dockアイコン、アドレスバーのないウィンドウ、Cmd+Tab
Tauri・Electronのラッパー 新しいプロジェクト・ビルド・署名 メニューバー、グローバルショートカット、ウィンドウ状態の記憶
サーバーごと同梱した独立アプリ Rails・DBを同梱 オフライン

ラッパーをかぶせても、中身は同じ画面だ。メニューバーのアイコンやグローバルショートカットがどうしても要るならTauriにする。Dockから押してすぐ開ければいい個人用ツールなら、manifestで足りる。ビルドと署名のパイプラインをもうひとつ抱えても、管理コストが増えるだけだ。

まだ確かめていないこともある。Safariの「Dockに追加」が同じmanifestで動くか、サーバーが止まっているときにアプリのウィンドウが何を表示するかは見ていない。

社内の管理画面や個人用ツールを「アプリっぽく」使いたいけれど、どこまで作ればいいか分からないなら、https://www.negabaro.com/contact から聞いてほしい。

動作フロー

1

public/にmanifest.jsonとアイコン192・512を置き、<head>にlinkを一行

2

Chromeでページをアプリとしてインストール → Dockアイコンと専用ウィンドウ

3

launchdがログイン時、ポートが空いているときだけ開発サーバーを立てる

メリット

  • ✓ ビルド・署名なし、コードの変更がそのまま反映
  • ✓ service workerなしでもインストールできた

デメリット

  • ✗ メニューバーアイコン・グローバルショートカットはない
  • ✗ インストールの最後のボタンは人が押す
← Postgresのボリュームパスが3年間間違っていたのに動いていた理由 — VOLUME宣言がこっそり付けた名前のないボリューム Finderの色タグはAIではなく過去の自分が付けた — xattrの中の「レッド\n6」という文字列 →