
こんにちは!Web製品のマネージャーをしているT.Kです。
弊社はWeb製品も多いですが、昔からあるデスクトップ製品のラインナップも多い会社です。 Web アプリとローカルのデスクトップアプリを連携したいと考えた時、最初に必ず直面する問題があります。
ブラウザから、ローカルのデスクトップアプリとどう連携するのか。
実はブラウザとローカルデスクトップが連携したアプリは世の中にたくさんあります。 例えば、ブラウザで Zoom の会議リンクをクリックすると Zoom アプリが起動したり、Web サービスからファイルを開くとローカルアプリで編集画面が開いたりします。
今回は、その問題に対する定番アプローチである カスタムプロトコル と localhost の2方式を比較してみます。 さらに、2つを組み合わせた ハイブリッド方式 も試作してみたので、3パターンの比較を共有します。
- 前提:ブラウザはローカル exe ファイルを直接起動できない
- 方式1:カスタムプロトコル(カスタム URI スキーム)
- 方式2:localhost
- 方式3:ハイブリッド(試作してみた)
- 比較まとめ
- おわりに
前提:ブラウザはローカル exe ファイルを直接起動できない
大前提として、モダンブラウザはサンドボックスの中で動いているので、任意のローカル exe ファイルを直接起動することはできません。 そのため Web とデスクトップをつなぐには、「ブラウザが安全に叩ける受け口」を用意して、その先でローカル exe を動かしてあげる必要があります。
この受け口の作り方について、比較してみました。
方式1:カスタムプロトコル(カスタム URI スキーム)
mailto: や slack://、zoom:// 等、様々なアプリで利用されている方法です。
自前のスキーム(例:myapp://)を OS に登録しておくと、ブラウザでその URL を開いたときに OS が紐づくアプリを起動し、URL 全体を引数として渡してくれます。
仕組み
Windows では、カスタムプロトコルを使うには レジストリ登録 が必要です。
例えば myapp というカスタムプロトコルを使いたい場合、以下の値をレジストリ追加します。
設定場所(キー):HKEY_CURRENT_USER\Software\Classes\myapp
| 名前 | 種類 | 値 |
|---|---|---|
| (既定) | REG_SZ | URL:myapp Protocol(表示名) |
| URL Protocol | REG_SZ | (空欄) |
※ URL Protocol の値があるとプロトコルとして扱われます
設定場所(キー):HKEY_CURRENT_USER\Software\Classes\myapp\shell\open\command
| 名前 | 種類 | 値 |
|---|---|---|
| (既定) | REG_SZ | "C:\path\MyApp.exe\" \"%1\" |
配布する際は、インストーラ等でレジストリ登録を行います。
ここまで準備した状態で、ブラウザで myapp://・・・ URL を開くと "C:\path\MyApp.exe を起動し、その command の "%1" の部分に URL を渡します。
実装はシンプルにできますが、注意点が2つあります。
任意のWebサイトが発火できる:カスタムプロトコルは悪意あるサイトからも呼び出せます。引数で渡される
"%1"は、アプリ内で必ず正しい値か検証してください。URLに機密情報を載せない:
%1で渡される URL はOSのプロセス一覧やブラウザ履歴にそのまま残るため、パスワードや認証トークンを直接埋め込むのは避けてください。認証が必要な場合は起動後にアプリ側で改めて取得する設計にしましょう。
評価
- 長所
- 常駐不要でシンプルに実装可能。OS 標準搭載なのでレジストリ登録しておけばすぐに動きます。
- 試していませんが、ディープリンクとしてモバイルにも応用が効くようです。
- 短所
- 基本的に一方向 のみの通信のため、アプリ側の状態(通信の成否等)を Web 側に返せません。
- 初回起動時にブラウザの確認ダイアログが出る点も煩わしいと感じます。
方式2:localhost
Windowsアプリが http://127.0.0.1:<port> で HTTP サーバーを立てて待ち受ける方式です。
Web からは fetch で叩けるため、通常の API と同じ感覚で双方向にやり取りできます。
仕組み
Windows アプリ起動時に HTTP サーバーを起動し、/ping・/open などのエンドポイントを公開します。
Web 側は fetch で localhost にリクエストを送信し、アプリの状態確認や命令の送信を行います。
※この方式では、ブラウザからローカル exe の起動はできません。 運用時は
- アプリを常駐させて待ち受ける
- 別の手段(カスタムプロトコル等)で起動する
といった構成を取る必要があります。
using System.Net; var listener = new HttpListener(); listener.Prefixes.Add($"http://localhost:{port}/"); // localhost のみ listener.Start(); while (listener.IsListening) { var ctx = await listener.GetContextAsync(); _ = Task.Run(() => HandleAsync(ctx)); // リクエストを非同期で処理 } async Task HandleAsync(HttpListenerContext ctx) { var req = ctx.Request; var res = ctx.Response; // Origin 許可リスト + Host 検証(DNS リバインディング対策) var origin = req.Headers["Origin"]; var originOk = origin is not null && AllowedOrigins.Contains(origin); var hostOk = req.Url?.Host is "127.0.0.1" or "localhost"; if (!originOk || !hostOk) { res.StatusCode = 403; return; } switch (req.Url!.AbsolutePath) { case "/ping": /* 起動確認を返す */ break; case "/open": /* セッション開始 */ break; case "/bye": /* タブ閉じ通知 → 終了判定 */ break; } }
Web 側は「まず生存確認 → 反応がなければプロトコルで起動 → 数秒ポーリング」という流れにできます。
async function isHelperAvailable() { try { // 常駐アプリに通信を送り、返答があるか確認 const res = await fetch("http://127.0.0.1:{port}/ping", { signal: AbortSignal.timeout(500) }); return res.ok; } catch { // 常駐していない、またはエラーの場合 return false; } } // 利用例 async function startProcess() { const isRunning = await isHelperAvailable(); if (!isRunning) { alert("ローカルアプリが起動していません。アプリを起動してから再試行してください。"); return; } // 以降、通常の処理(/open へのPOSTなど) }
評価
- 長所
- ブラウザはリクエストを投げた際に、レスポンスでデスクトップ側の応答を受け取ることができ、双方向のやり取りが可能です。
/pingでデスクトップ側の生存確認ができ、ブラウザにデスクトップの状態を表示することが可能です。
- ブラウザはリクエストを投げた際に、レスポンスでデスクトップ側の応答を受け取ることができ、双方向のやり取りが可能です。
- 短所
- デスクトップアプリは常駐状態で待機しておく必要があります。
localhostは外部ネットワークからのアクセスは遮断されますが、同一マシン上の悪意あるスクリプト等からのリクエストを考慮し、Hostヘッダ検証や受け取る引数の妥当性検証 は必要です。- また、ポート番号を固定しているため、他アプリと衝突した際の対処も必要です。
方式3:ハイブリッド(試作してみた)
「では良いとこ取りをしよう」ということで、次の合わせ技を試作してみました。
- 起動はカスタムプロトコル(常駐不要・シンプル)
- 起動後、編集中だけ localhost(双方向通信)
「常にアプリが常駐はしないが、編集中はアプリ間のやり取りができる」状態を実現してみました! Web 側から一定間隔の /ping を投げ続け、タブを閉じて /ping が途絶えたら終了、といった制御もしてみました。
ハイブリッド版の流れ
ハイブリッド版の大まかな流れは以下の通りです。

試作して見えた正直な感想
良いとこどりになったように見えますが、実は短所も2倍になった感があります。
- カスタムプロトコルで起動するので「起動確認ダイアログ」問題は残る
- localhost 由来の「セキュリティ考慮」も 結局必要
- さらに、2方式をつなぐ部分 という 新たな複雑さ が加わる。カスタムプロトコルでアプリ起動~localhost 起動までの時間差を吸収する考慮や、ポーリングのリトライ回数・タイムアウト設計など、「2つをうまくかみ合わせるための処理」が必要。
つまり、考えることが多くなって複雑さが増す!という結果になりました。
比較まとめ
| 観点 | カスタムプロトコル | localhost | ハイブリッド |
|---|---|---|---|
| 受け口 | OS スキームハンドラ(レジストリ登録) | ローカル HTTP サーバー | 両方 |
| 実装のシンプルさ | ◎ | △ | △ |
| 双方向のやり取り | ✕ | ◎ | ◎ |
| セキュリティ考慮の少なさ | ◎ | △ | △ |
| 常駐しなくてよい | ◎ | ✕ | 〇(編集中だけ) |
実装してみての結論としては以下の通りです。
- 双方向通信が不要なら → カスタムプロトコル
- 状態同期やUXを重視するなら → localhost
- ハイブリッドは原則おすすめしない(複雑性が増すため)
採用するなら、ハイブリッドよりはどちらかに振り切った方が、作りがシンプルになって良いというのが個人の感想です。
おわりに
KENTEMでは、様々な拠点でエンジニアを大募集しています! 建設×ITにご興味頂いた方は、是非下記のリンクからご応募ください。 recruit.kentem.jp career.kentem.jp