背景
Windows PCでAndroidアプリを開発していると、実機との間で細かい往復作業が頻繁に発生します。
- PCでビルドしたAPKを実機にインストールしたい
- Androidで撮ったスクリーンショットをすぐPCに戻したい
- PCで見ているURLやコマンドの断片を、ちょっとだけAndroid側にも渡したい
それぞれ別の手段(USB/adb、クラウドストレージ経由、チャットアプリへの自己送信など)でこなすことはできますが、毎回「今回はどれを使うんだっけ」となるのが地味に面倒でした。
そこで、この3つの往復だけに絞った小さなLAN上のbridgeを作りました。
何を作ったか
SafeDrop — Android Dev Bridge
Androidアプリ(Kotlin/Jetpack Compose)と、Windows側で動くNode.jsサーバーのペアです。同じ信頼できるLAN(自宅Wi-Fiなど)上で使うことを前提にしています。
Dropbox/AirDropのような汎用ファイル転送アプリを目指したものではなく、開発時・日常の短い往復作業に絞ったツールです。
3つの主要ワークフロー
PC → Android: APK配布
Windows側サーバーに登録したAPKを、Androidのブラウザから一覧表示してダウンロード・インストールできます。この経路自体はadb接続を必要としません。
PC ↔ Android: Text Inbox
IPアドレスやURL、コマンドの断片といった短いテキストを、PCとAndroidの間でやり取りできます。直近10件を保持し、それぞれコピー・再送信フォームへの読み込み・個別削除ができます。
Android → PC: スクリーンショット / ファイル共有
Androidの共有メニューからSafeDropを選ぶだけで、Windows側の指定フォルダにファイルが届きます。
なぜLAN上のHTTPにしたか
- Android・Windowsどちらからも扱いやすい(ブラウザとHTTPさえあれば良い)
- 毎回USB接続を要求しない
- ローカルネットワーク内で完結する
- サーバー側をNode.js標準モジュールだけの小さな1ファイルに保てる(
http/https/fs/path/os/cryptoのみ使用、npm依存なし)
ただし、これは「安全な暗号化ファイル転送プロトコル」を目指したものではありません。信頼できるLAN内での利用を前提に、トークンによる誤送信防止程度の保護にとどめています(詳細は後述)。
技術的な話:同期I/Oでイベントループ全体が止まった話
開発中に見つけたバグの話です。SafeDropサーバーには、設定したWebアプリへのリンクを表示する「Hub」画面があり、各カードの表示可否を確認するために、対象パスの存在確認をしていました。
最初の実装では、この存在確認に同期版のfs.statSync()を使っていました。ここで、対象パスがWSL経由のUNCパス(\\wsl.localhost\...)のようなネットワーク越しの遅い/応答しないパスだった場合に問題が起きました。
計測結果(実測値):
- 修正前:
GET /(Hub画面のトップ)が 約80秒 かかる - さらに悪いことに、その約80秒の間に別途投げた
GET /ping(本来ミリ秒で返る、fsアクセスなしの単純なエンドポイント)も 約78秒 ブロックされる
これは、Node.jsがシングルスレッドでイベントループを回している以上、同期的なfs.statSync()呼び出しが特定のリクエスト処理だけでなく、サーバー全体のイベントループを止めてしまうために起きます。UNCパスやネットワークドライブは、OS側のタイムアウト(今回の環境では約60〜80秒)に達するまで応答が返ってこないため、その間サーバーは他のリクエストを一切処理できなくなっていました。
修正は、この存在確認を非同期・タイムアウト付きにすることでした。実際のコード(pc-server/server.js)の該当部分を簡略化すると、次のような形です。
const APP_CARD_STAT_TIMEOUT_MS = 400;
function statWithTimeout(targetPath, timeoutMs, statFn = fs.promises.stat) {
return new Promise((resolve) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
resolve({ ok: false });
}, timeoutMs);
if (typeof timer.unref === "function") timer.unref();
Promise.resolve()
.then(() => statFn(targetPath))
.then((stat) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ ok: true, stat });
})
.catch(() => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ ok: false });
});
});
}
// 複数のカードを並列・タイムアウト付きでチェックする
const availabilities = await Promise.all(
items.map(item => appCardAvailable(item, statFn, timeoutMs))
);
ポイントは3つです。
-
fs.promises.stat()(非同期版)を使い、イベントループを塞がない -
setTimeoutで400msの上限を設け、応答がなければ「利用不可」として早めに諦める - 複数カードがあっても
Promise.allで並列にチェックし、直列に待たない
修正後の計測値:
-
GET /→ 約0.4秒 - 同時に投げた
GET /ping→ 約1.9ms(イベントループは塞がれていない)
得られた教訓は、「Node.jsサーバーのリクエスト処理経路に、ネットワークファイルシステムへの同期I/Oを置くと、そのリクエストだけでなくイベントループ全体が止まる」ということです。特にUNCパス・WSL越しのパス・ネットワークドライブなどは、ローカルディスクと同じ感覚でstatSyncしてはいけない、というのが今回の具体的な教訓でした(一般論として全ての同期I/Oが危険というより、ネットワーク越しの応答不定なパスに対して同期I/Oを使うのが危険、という話です)。
セキュリティ・プライバシーについて
正確に書いておきます。
- 信頼できるLAN内での利用を前提にしています。ポートフォワーディングや公開リバースプロキシで、インターネットに直接公開することは想定していません。
- デフォルトはプレーンHTTPです。暗号化したい場合は
receiver.config.jsonでHTTPSを明示的に設定する必要があり、その場合は自分でローカル証明書を用意する必要があります。 - トークンによる保護は、あくまで「無関係な通信が誤って届くのを防ぐ」レベルのものです。悪意ある攻撃者がいる前提のネットワークでの利用は想定していません。
- テレメトリ・アナリティクスの類は一切ありません。
現在の位置づけ・ダウンロード
- 現在 v0.6.0、pre-release です。
- 配布物は2つ: Android APK(署名済み)と、Windowsサーバー用のzip(Node.js製、依存パッケージなし)。
- リポジトリ: https://github.com/iwadjp/safe-explorer-pc-drop
- リリース: https://github.com/iwadjp/safe-explorer-pc-drop/releases/tag/v0.6.0
セットアップ手順はリポジトリのREADMEに沿って進めてください(サーバーの展開・トークン設定・起動・Android側でのプロファイル追加、の順です)。