Feat/mac device - #8
Conversation
Walkthroughデスクトップアプリに対するモバイルペアリング機能を実装します。Tauri バックエンドではペアリングサーバーと状態管理を追加し、フロントエンドではペアリングダイアログとQRコード生成機能を実装。同時に姿勢トラッキングロジックを強化します。 Changes
Sequence DiagramssequenceDiagram
participant Mobile as モバイルアプリ
participant Desktop as デスクトップアプリ<br/>(フロントエンド)
participant Backend as デスクトップアプリ<br/>(Tauri バックエンド)
participant PairingServer as ペアリングサーバー<br/>(TCP/WebSocket)
rect rgba(100, 200, 255, 0.5)
Note over Desktop,PairingServer: ペアリング初期化
Desktop->>Backend: get_pairing_info()
activate Backend
Backend->>PairingServer: ホスト/ポート/トークン取得
PairingServer-->>Backend: PairingInfo 返却
Backend-->>Desktop: PairingInfo
deactivate Backend
Desktop->>Desktop: QRコード生成
end
rect rgba(100, 255, 100, 0.5)
Note over Mobile,PairingServer: モバイルペアリング接続
Mobile->>PairingServer: /pair (トークン付き)
activate PairingServer
PairingServer->>PairingServer: トークン検証
PairingServer->>PairingServer: paired = true
PairingServer-->>Mobile: ペアリング成功
deactivate PairingServer
end
rect rgba(255, 200, 100, 0.5)
Note over Mobile,PairingServer: WebSocket 接続・イベント
Mobile->>PairingServer: /ws (WebSocket アップグレード)
activate PairingServer
PairingServer->>Mobile: スナップショットイベント送信
loop ペアリング中
Mobile->>Desktop: 姿勢データ
Desktop->>Backend: emit_posture_signal(is_bad)
Backend->>PairingServer: broadcast_ws_state_event()
PairingServer->>Mobile: 状態変更イベント配信
end
deactivate PairingServer
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 15
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🟡 Minor comments (10)
src/App.css-422-427 (1)
422-427:⚠️ Potential issue | 🟡 Minor
inset短縮プロパティが直前のleft/rightを上書きしています(stylelint エラー)。
left: 12px;とright: 12px;の直後にinset: 72px 12px 12px;を宣言しているため、短縮形が個別宣言を完全に上書きしてしまい、declaration-block-no-shorthand-property-overridesで CI が落ちます。展開後の値は同一ですが、冗長宣言を削除してinset単独に揃えるのが適切です。🛠 提案差分
.blackout-panel { - left: 12px; - right: 12px; inset: 72px 12px 12px; max-width: none; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/App.css` around lines 422 - 427, CSS short-hand inset is overriding the explicit left/right declarations in .blackout-panel causing stylelint's declaration-block-no-shorthand-property-overrides error; remove the redundant left: 12px; and right: 12px; lines and keep only inset: 72px 12px 12px; (or alternatively expand inset and drop the shorthand) so the declarations are not duplicated and the rule is satisfied.src/features/pairing/hooks/usePairingState.ts-107-135 (1)
107-135:⚠️ Potential issue | 🟡 Minor
refresh()中のローディング表示ができない点を確認してください。
refresh()は成功・失敗の終端でのみisLoading: falseを設定しており、関数開始時にisLoading: trueを設定していません。そのため、ユーザーが「更新」ボタンを押してもisLoadingは変化せず、PairingDialog側でフェッチ中のフィードバック(スピナー/ボタン disable)を出すことができません。♻️ 提案: 開始時にローディング状態へ遷移
refresh: async () => { + setState((prev) => ({ ...prev, isLoading: true })); try { const [pairingInfo, status] = await readPairingSnapshot();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/pairing/hooks/usePairingState.ts` around lines 107 - 135, In refresh() inside usePairingState.ts, start the fetch by setting isLoading: true (and clear error) before awaiting readPairingSnapshot so the UI can show a spinner/disable; implement this by calling setState(prev => ({ ...prev, isLoading: true, error: null })) immediately after the isMountedRef check at the top of refresh(), then keep the existing success and catch blocks that set isLoading: false (and update pairingInfo/status or error) so loading is correctly reset regardless of outcome.src-tauri/src/pairing/state.rs-198-204 (1)
198-204:⚠️ Potential issue | 🟡 Minor
timestamp_stringがエポック秒の数値文字列になっており UI 上で読めません
PairingDialog.tsxではstatus?.lastSeenAt ?? "-"をそのまま表示しているため、ユーザには"1761526023"のような数字列が出ます。RFC 3339 (ISO 8601) 形式 (chrono::Utc::now().to_rfc3339()) で返すか、フロント側でnew Date(Number(s) * 1000)に変換するかのどちらかで読めるようにしてください。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src-tauri/src/pairing/state.rs` around lines 198 - 204, The timestamp_string() function currently returns epoch seconds (e.g. "1761526023") which is not human-readable in the UI; update the code so the UI shows a readable RFC3339/ISO8601 timestamp: either modify timestamp_string() to return a RFC3339 string using chrono (e.g. use chrono::Utc::now().to_rfc3339() inside timestamp_string) or keep timestamp_string() as-is and change PairingDialog.tsx to detect numeric epoch strings and render new Date(Number(s) * 1000).toISOString()/toLocaleString(); prefer the Rust change by replacing SystemTime-based logic in timestamp_string with chrono::Utc::now().to_rfc3339() so status?.lastSeenAt can be displayed directly.src-tauri/src/pairing/state.rs-214-225 (1)
214-225:⚠️ Potential issue | 🟡 Minorネットワーク不通時のフォールバック
127.0.0.1でペアリングが成立しません
UdpSocket::bind→connect("8.8.8.8:80")→local_addr()のトリックは便利ですが、
- インターネット非接続(社内 LAN のみ・オフラインデモ)
- DNS や 8.8.8.8 をブロックするルータ
の環境では
connectがErrになり127.0.0.1にフォールバックします。すると QR のhost=127.0.0.1がスマホへ表示され、当然繋がりません。LAN ペアリングが本機能の本質である以上、
- まず NIC を列挙し ループバック以外の IPv4 (
192.168.*など RFC1918) を優先- 取得失敗時は接続先候補(例: 既知のルータ)へ何度かフォールバック
する実装にするか、フロントへ「LAN IP を取得できませんでした」とエラーを返す設計を推奨します。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src-tauri/src/pairing/state.rs` around lines 214 - 225, The local_ip_address() helper currently falls back to "127.0.0.1" when the UDP-connect trick fails; change it to enumerate network interfaces and prefer a non-loopback IPv4 (RFC1918) address first (e.g., via get_if_addrs or similar) and only if none found attempt one or two explicit reachability attempts (connect to a configured/known gateway IP or multiple public IPs) before giving up; if still unavailable, return an Err or propagate an explicit error to the caller/front-end instead of returning "127.0.0.1" so pairing can surface a clear "LAN IP not found" message. Reference: local_ip_address(), UdpSocket::bind/connect and the function's callers that consume its String result.src/lib/desktopBridge.ts-5-6 (1)
5-6:⚠️ Potential issue | 🟡 Minor
token未指定時のデフォルト"pending"が QR に乗りますトークン取得前のレース期間中、
buildPairingUrl(ip)がtoken=pendingを含む QR を一瞬出してしまい、それを読んだスマホはINVALID_TOKENエラーになります。pairingTokenがnull/空のときは QR 生成を抑制し、ローディング表示にとどめる方が UX として安全です。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/desktopBridge.ts` around lines 5 - 6, The current buildPairingUrl function injects a default "pending" token which causes invalid QR codes during token race; modify buildPairingUrl(deviceIp: string, token?: string) so it does NOT use the "pending" fallback and instead returns null/undefined (or throws) when token is null/empty, e.g., if (!token) return null; otherwise build the URL with the real token using PAIRING_PORT; then update any callers that render QR codes to check the buildPairingUrl return and show a loading state instead of generating a QR when it returns null.src/App.tsx-463-503 (1)
463-503:⚠️ Potential issue | 🟡 Minor
getPairingStatusのフォールバック値で誤動作する可能性があります
src/lib/desktopBridge.ts:48-60でgetPairingStatusは失敗時に{ running:false, port:PAIRING_PORT, paired:false, pairedPhoneIp:null, pairingToken:"" }を返します。そのため、ブラウザプレビューや Tauri コマンドが未登録の環境では、毎回「サーバ未起動」と判定されstartPairingServerが呼ばれ続けます (Line 486-495)。加えて
pairingStatus.pairingToken ?? null(Line 478) は空文字列""を「トークン取得済み」として扱うため、以降のbuildPairingUrl(deviceIp, "" as token)がtoken=のような QR を生成し、/pair?token=でバックエンドのトークン検証が走り「正しくありません」となります。
pairingStatus.pairingTokenが空文字列の場合はnull扱いに正規化、もしくは Tauri 環境判定 (isTauri()等) でショートサーキットすることを推奨します。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/App.tsx` around lines 463 - 503, Normalize the fallback pairing status before using it: after calling getPairingStatus() in the useEffect, treat an empty pairingStatus.pairingToken as null (e.g. setPairingToken(pairingStatus.pairingToken && pairingStatus.pairingToken.trim() ? pairingStatus.pairingToken : null)) so you don't build a token= QR; also avoid repeatedly starting the pairing server in non-Tauri/browser-preview environments by gating the startPairingServer() call with an environment check (e.g. isTauri() or equivalent) or by checking a real runtime indicator on pairingStatus before calling startPairingServer(); update the code around getPairingStatus, setPairingToken and the startPairingServer call to apply these guards/normalization.src/App.tsx-587-610 (1)
587-610:⚠️ Potential issue | 🟡 Minor
qrPayload更新条件が偏っており、トークン更新が QR に反映されませんLine 592 の条件は「
qrPayload === "取得中..."のときだけ」buildPairingUrl(...)で組み立てた payload に置換します。startPairingServer()が一度成功してqrPayloadに URL がセットされた後は、pairingTokenがポーリング (syncPairingState) で更新されても、その新しいトークンはqrPayload(したがって QR) に反映されません。
qrPayloadをuseMemo/派生値にしてdeviceIp/pairingToken/pairingUrlから計算し、startPairingServer()戻り値はあくまで「サーバが返したパッケージ済み URL」として別の state に持つ構造にすると、トークンローテーションにも追従できます。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/App.tsx` around lines 587 - 610, The QR payload is only replaced when qrPayload === "取得中...", so later pairingToken rotations (via syncPairingState) don’t update the QR; change the design so the server-returned packaged URL from startPairingServer is stored in its own state (e.g., pairingUrlFromServer) and compute the displayed qrPayload as a derived value (useMemo or inline) from pairingUrlFromServer, deviceIp and pairingToken using buildPairingUrl(deviceIp, pairingToken) when no server URL exists; update the effect that generates the QR (generateQrDataUrl) to depend on the derived qrPayload (or on pairingUrlFromServer, deviceIp, pairingToken) so new tokens cause regeneration, remove the special-case check for "取得中..." and only keep startPairingServer’s return assigned to pairingUrlFromServer while using the derived value for display/QR.src/App.tsx-631-638 (1)
631-638:⚠️ Potential issue | 🟡 Minor
stopPairingServer()をアンマウント時に呼ぶと開発時のホットリロードでサーバが落ちますReact 19 + StrictMode 下では開発時にコンポーネントが mount → unmount → remount されるため、この cleanup で毎回
stopPairingServer()が走り、その直後の re-mount で再起動を試みます。前述の WS_SINK がOnceLockであるサーバ側の制約 (src-tauri/src/pairing/server.rs:16-24) により、再起動が中途半端に成功して新クライアントへブロードキャストできなくなる可能性があります。そもそもアプリ常駐の Tauri ウィンドウで
Appが unmount されるのはアプリ終了時か HMR 時くらいなので、このクリーンアップは Rust 側のtauri::Builder::default().build().run(|app, event|...)のRunEvent::ExitRequestedなどで実装した方が安全です。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/App.tsx` around lines 631 - 638, 現在の App コンポーネントの useEffect のクリーンアップから stopPairingServer() を呼ぶと HMR や React StrictMode 下で不必要にサーバが停止し再起動失敗を招くので、useEffect の return ブロックから stopPairingServer() を削除してクライアント側のアンマウントでサーバ停止を行わないようにし、代わりに Tauri 側のランタイム終了ハンドラ(tauri::Builder::default().build().run(|app, event| { ... }) の RunEvent::ExitRequested)でペアリングサーバ停止処理を呼び出す実装に移行してください(参照: stopPairingServer(), App コンポーネントの useEffect クリーンアップ、並びに src-tauri/src/pairing/server.rs の OnceLock 制約)。src/features/pairing/components/PairingDialog.tsx-76-98 (1)
76-98:⚠️ Potential issue | 🟡 Minor
startVibeTestのawait中にstopVibeTestが呼ばれると interval がリークします
await emitPostureSignal(true)(Line 79) を待っている間にユーザーがトグルでき、その時点ではvibeIntervalRef.current === nullのためstopVibeTestは何もせずvibeStateを"idle"に戻します。その直後に await が解決して Line 86–97 が実行され、vibeStateを"running"に再セットして新しいsetIntervalを登録するため、ユーザーには「停止したつもりが止まっていない」状態になり、interval が残ってバイブも止まりません。トグル禁止フラグや AbortController で多重起動を防ぐか、開始処理が終わるまでボタンを
disabledにすることを推奨します。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/pairing/components/PairingDialog.tsx` around lines 76 - 98, startVibeTest can register a second interval if stopVibeTest is invoked while awaiting emitPostureSignal, so add a short-lived "starting" guard to prevent concurrent start/stop races: set a flag (e.g., isStartingRef or setVibeState("starting")) before the first await emitPostureSignal(true), check that flag/state after the await and only proceed to setVibeState("running") and create window.setInterval if the flag still indicates starting (and clear the flag on stopVibeTest); alternatively use an AbortController to cancel the pending emitPostureSignal when stopVibeTest runs or disable the toggle button while starting — reference startVibeTest, stopVibeTest, vibeIntervalRef, emitPostureSignal, and vibeState when making the change.src/App.tsx-612-623 (1)
612-623:⚠️ Potential issue | 🟡 Minor
alertDisplayMode切替のたびにフルスクリーン副作用が走ります依存配列が
[isBadPosture, shouldBlackoutScreen]ですが、shouldBlackoutScreen = isBadPosture && alertDisplayMode === "blackout"のためモード切替時にも effect が再実行されます。alertDisplayModeを"debug"にしてもisBadPostureがtrueならshouldBlackoutScreenはfalseに変わるので、appWindow.setFullscreen(false)が呼ばれて意図通りですが、逆にfalseの場合は依存値も変わらないため OK。実害はほぼないものの、
lastBroadcastedPostureRefの比較は OK ですが- setFullscreen は
appWindow.setFullscreenの Promise を投げっぱなしで失敗時の状態が分からないため、ブラウザプレビュー時の
getCurrentWindow()呼び出しが例外を投げると effect 全体が以後止まる可能性があります。try/catchで保護してください。🛡️ 提案: 例外で他処理が止まらないよう保護
useEffect(() => { - void setBlackoutWindow(shouldBlackoutScreen); - - // タスクバーも含めて画面全体を覆うためにフルスクリーンを制御する - const appWindow = getCurrentWindow(); - void appWindow.setFullscreen(shouldBlackoutScreen); + void setBlackoutWindow(shouldBlackoutScreen); + + // タスクバーも含めて画面全体を覆うためにフルスクリーンを制御する + try { + const appWindow = getCurrentWindow(); + void appWindow.setFullscreen(shouldBlackoutScreen).catch(() => {}); + } catch { + // Tauri 以外の環境(ブラウザ preview)では無視 + } if (lastBroadcastedPostureRef.current !== isBadPosture) { lastBroadcastedPostureRef.current = isBadPosture; void sendPostureSignal(isBadPosture); } }, [isBadPosture, shouldBlackoutScreen]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/App.tsx` around lines 612 - 623, The effect in useEffect presently calls getCurrentWindow() and appWindow.setFullscreen(shouldBlackoutScreen) without protection, so a thrown exception or an unhandled rejected Promise can abort the whole effect and later updates; wrap the window logic in a try/catch and await (or attach .catch) the Promise from appWindow.setFullscreen to handle errors, ensuring that setBlackoutWindow(...) and the posture broadcast (lastBroadcastedPostureRef and sendPostureSignal) still run even if getCurrentWindow() or setFullscreen fails; specifically modify the useEffect body around getCurrentWindow(), appWindow.setFullscreen, and setBlackoutWindow so errors are caught and logged instead of escaping.
🧹 Nitpick comments (10)
src/lib/qrcode.ts (1)
3-13: 戻り値型の明示(推奨)と誤り訂正レベルの確認。
公開 API なので戻り値を
Promise<string>と明示すると呼び出し側の型推論・補完が安定します。誤り訂正レベルについて:ペアリングコード(
vibeapp://pair?...token=...のような短〜中尺ペイロード)を240px画面表示するユースケースであれば、現在のM(約15%)は公式推奨に沿っており実用上問題ありません。Q(約25%)への変更は不要ですが、将来的に印刷配布やスキャン時の悪条件対応が要件に加われば検討してください。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/lib/qrcode.ts` around lines 3 - 13, Add an explicit return type Promise<string> to the exported function generateQrDataUrl to stabilize call-site type inference and completions; leave the QRCode.toDataURL options as-is (errorCorrectionLevel: "M", margin: 2, width: 240, color: { dark: "#020617", light: "#ffffff" }) since current use-case doesn't require increasing to "Q". Ensure the function signature reads generateQrDataUrl(payload: string): Promise<string>.src-tauri/src/lib.rs (1)
17-30: LGTM。ただし起動失敗時のフォールバックは検討の余地あり。
PairingStateHandle::new()→start_pairing_server(clone)→.manage(pairing_state)の順に同一のArc<Mutex<...>>を共有しているため、サーバスレッドと Tauri コマンドが同じ状態を参照する点は問題ありません。一点だけ補足として、
start_pairing_serverは内部でTcpListener::bind("0.0.0.0:0")の失敗をResult<(), String>として返しますが、ここでは.expectで即パニックしているため、ファイアウォール/権限/ネットワーク不在などの環境ではアプリ自体が起動しなくなります。ペアリングは付帯機能なので、起動はそのまま続行してエラーを UI で通知するなど、ユーザーへの可視化を将来的に検討するとよいかもしれません(今回は対象外でも構いません)。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src-tauri/src/lib.rs` around lines 17 - 30, start_pairing_server(...) is being .expect(...)'d which will panic and abort app startup if TcpListener::bind fails; instead catch the Result from start_pairing_server(pairing_state.clone()), log or store the error into the shared PairingStateHandle (so the UI/commands can surface it), and continue building the tauri::Builder so the app still launches. Update the call site that currently calls start_pairing_server(pairing_state.clone()).expect(...) to handle Err(e) by setting an error/status on pairing_state (or logging) and not panicking; keep references to PairingStateHandle::new() and .manage(pairing_state) intact so commands (greet, get_pairing_info, get_pairing_status, emit_posture_signal) still share the same Arc/Mutex state.src/features/pairing/hooks/usePairingState.ts (1)
71-97: ポーリングが他の操作のエラー表示を上書きする可能性があります。ポーリング成功時に
error: nullで常に上書きしているため、refresh()や初期ロードで設定したエラーが、直後にポーリングが走った瞬間(最大 1.5 秒以内)に消えてしまいます。statusの取得は成功してもペアリング情報の取得が失敗している、という状態を伝えにくくなります。ポーリング成功時のエラーリセットは「直前のポーリングエラー」だけに限定するなど、エラーの種類を区別すると親切です(必須ではありません)。
加えて、コールバック内の
await getDesktopPairingStatus()がPOLL_INTERVAL_MSを超えた場合に呼び出しが多重発行される可能性もあるため、AbortController での古いリクエスト破棄や再帰setTimeoutへの置き換えも検討の余地があります。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/pairing/hooks/usePairingState.ts` around lines 71 - 97, The polling interval unconditionally clears error state which can overwrite real errors from refresh() or initial load; in the interval callback (the window.setInterval block using getDesktopPairingStatus, POLL_INTERVAL_MS, isMountedRef) change the setState so it only clears the error when the previous error is the polling-specific one (e.g., equals STATUS_POLL_ERROR_MESSAGE) or when the error originated from a prior polling attempt, otherwise preserve prev.error; additionally, prevent overlapping requests by either wiring an AbortController into getDesktopPairingStatus calls or replacing setInterval with a recursive setTimeout that awaits completion before scheduling the next call to avoid concurrent invocations.src/features/pairing/components/PairingDialog.css (1)
147-165:!importantの利用は最小限にとどめることを検討。
.pairing-actions button(Line 132–140) のスタイルを上書きするために!importantを使っていますが、セレクタの詳細度を上げる(例:.pairing-actions button.pairing-vibe-test)ことでも同等のことが可能で、将来的なテーマ調整時の上書きが楽になります。今すぐ修正する必要はないですが、長期的なメンテ性向上のための任意改善案です。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/pairing/components/PairingDialog.css` around lines 147 - 165, Replace the use of !important on .pairing-vibe-test and .pairing-vibe-stop by increasing selector specificity to target the buttons inside the parent actions container (e.g., change selectors to .pairing-actions button.pairing-vibe-test and .pairing-actions button.pairing-vibe-stop and similarly for hover states like .pairing-actions button.pairing-vibe-test:hover:not(:disabled)), then remove the !important declarations so the styles override .pairing-actions button naturally while remaining easier to override in the future.src/features/pairing/services/pairingLink.ts (1)
3-9: URLSearchParams を使用してクエリパラメータを処理することを推奨します。現在のトークン生成は16進数形式(0-9, a-f)で URL 内で安全ですが、将来の実装変更に対する防御的対策として、および コードの可読性を向上させるため、
URLSearchParamsを使用することを推奨します。♻️ 提案: URLSearchParams を使用したエンコード
- return `vibeapp://pair?host=${pairingInfo.host}&port=${pairingInfo.port}&token=${pairingInfo.token}`; + const params = new URLSearchParams({ + host: pairingInfo.host, + port: String(pairingInfo.port), + token: pairingInfo.token, + }); + return `vibeapp://pair?${params.toString()}`;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/pairing/services/pairingLink.ts` around lines 3 - 9, The buildPairingLink function currently concatenates query params manually which can be fragile; update buildPairingLink to use URLSearchParams to encode the host, port and token from the PairingInfo object (still return empty string when pairingInfo is null), e.g. construct a URLSearchParams, set "host", "port", "token" from pairingInfo.host/port/token, and return `vibeapp://pair?` + params.toString() so all values are properly percent-encoded and readable.src/features/pairing/components/PairingDialog.tsx (1)
124-141: 閉じるボタンにaria-labelを付与してアクセシビリティを改善できます
role="dialog"+aria-modal="true"の構成は良いですが、フォーカストラップは未実装で、Tab で背後の要素に抜けてしまいます。フォーカストラップが過剰であれば、最低限ダイアログ表示時に「閉じる」ボタンへフォーカスを当てる、復帰時に呼び出し元のモバイル連携ボタンへ戻す、などの処理を入れるとモーダルとしての挙動が完成します。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/features/pairing/components/PairingDialog.tsx` around lines 124 - 141, The Close button (button.pairing-close) in PairingDialog lacks an aria-label and there’s no focus management; update the component (PairingDialog) so the close button includes a descriptive aria-label (e.g., aria-label="閉じる"), and implement minimal focus handling: on mount save document.activeElement, move focus to the close button (query/select pairing-close) when the dialog opens, and onClose restore focus to the previously focused element; optionally add a simple focus trap if needed to prevent tabbing out of the dialog.src-tauri/src/pairing/server.rs (2)
19-21:0.0.0.0バインドはネットワーク全体に公開されます
TcpListener::bind("0.0.0.0:0")(Line 19) で全 NIC に公開されます。LAN 用途なら問題ない設計ですが、公衆 Wi-Fi など信頼できないネットワーク上では、ローカルセグメントの全端末に/health/pair/wsが見える状態になります。トークン保護があるとはいえ、最低限 README/README 風コメントで「信頼できる LAN でのみ使用してください」と明示する、または接続元 IP を保存済みの phone IP に制限するなどの追加防御を検討してください。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src-tauri/src/pairing/server.rs` around lines 19 - 21, TcpListener::bind("0.0.0.0:0") exposes /health,/pair,/ws to the whole network; change this by either binding to loopback for local-only use (use 127.0.0.1 instead of 0.0.0.0) or make the bind address configurable, and if LAN access must be supported implement an allowlist check on incoming connections (validate peer_addr against the stored phone IP before accepting/handling requests) and document the risk in README or a top-of-file comment; update the TcpListener::bind call and keep state.set_port(port) logic but ensure listeners only accept connections from permitted IPs when running in non-local mode.
181-191: WebSocket フレームの解析・close フレーム送信を行っていません
read_until_socket_closesは受信した生バイトを単に捨てているため、
- クライアントからの ping フレームに pong を返さない
- close フレームを送らずに突然 TCP を切るため、対向のクライアントによっては「異常切断」扱いになる
- マスクされた payload を一切検証しない (RFC 6455 違反: server は client からのフレームに mask bit が立っていることを要求する)
特に長時間接続を維持する PWA/モバイル WebView では、中継機器のアイドルタイムアウトに対抗するため ping/pong は実質必須です。やはり
tungstenite系のライブラリへの移行を強く推奨します。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src-tauri/src/pairing/server.rs` around lines 181 - 191, read_until_socket_closes currently discards raw bytes and must be replaced with proper WebSocket handling: either implement RFC6455 frame parsing to validate client-side masking, reply to Ping with Pong, and send a Close frame on receiving Close, or (recommended) replace the loop with a tungstenite-based accept/handshake (e.g. call tungstenite::server::accept on the TcpStream), then loop over the WebSocket object (read_message/send_message or read/close APIs) to handle Message::Ping => send Message::Pong, Message::Close => send Message::Close and break, and let tungstenite enforce mask bits and framing for you; update read_until_socket_closes to use the tungstenite WebSocket type instead of raw TcpStream so ping/pong, close handshake, and mask validation are correctly handled.src-tauri/src/commands/pairing_commands.rs (1)
17-25: 送信失敗・未ペアリング時のフィードバックが呼び出し元に伝わりません
emit_posture_signalは戻り値が()のため、WS 接続クライアントが 0 件の場合でもフロントは「送信成功」と認識します(src/features/pairing/services/desktopBridge.ts:19-21)。PairingDialogの "バイブテスト" は本当にスマホへ届いたかを判別できず、ユーザーに誤った成功フィードバックを返す可能性があります。最低限
paired状態 / WS クライアントが 0 件の場合はResult<(), String>でエラーを返す、もしくは「送信先クライアント数」を返す API にすると UX が改善します。🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src-tauri/src/commands/pairing_commands.rs` around lines 17 - 25, emit_posture_signal currently returns () so callers can't know if delivery failed or no WS clients exist; change its signature (function emit_posture_signal) to return a Result or an integer (e.g., Result<(), String> or usize) and detect paired state / client count before broadcasting: call state.mark_posture_signal(), inspect the WS client list or pairing flag (the same source used by broadcast_ws_state_event), and if zero clients or not paired return Err("no paired clients") (or return 0) otherwise call broadcast_ws_state_event(&state, event_type) and return Ok(()) (or the number of clients). Ensure callers in src/features/pairing/services/desktopBridge.ts are updated to handle the Result/returned client count.src/App.tsx (1)
9-23: 古いバイブテスト実装は既にUI削除されていますが、死コードが残存していますApp.txsの
handleSendVibrationSignal関数、vibrationTestIntervalRef、関連するテスト処理は定義されているものの、どこからも呼び出されていません。UIボタンはすでに削除されているため、PairingDialog内のバイブテスト実装と並行実行する心配はなくなっています。ただし、以下の死コードを削除して整理することを推奨します:
handleSendVibrationSignal関数(lines 386-402)vibrationTestIntervalRef変数(line 246)isVibrationTestRunning状態(line 264)clearVibrationTestInterval関数(lines 370-375)stopVibrationTest関数(lines 377-384)TEST_VIBRATION_INTERVAL_MS定数(line 82)sendVibrationSignalインポート(line 18)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/App.tsx` around lines 9 - 23, Remove the dead vibration-test code and its unused import: delete the TEST_VIBRATION_INTERVAL_MS constant, the vibrationTestIntervalRef variable, the isVibrationTestRunning state, and the helper functions handleSendVibrationSignal, clearVibrationTestInterval, and stopVibrationTest from App.tsx, and also remove sendVibrationSignal from the import list; ensure no remaining references to those symbols (e.g., handleSendVibrationSignal, vibrationTestIntervalRef, isVibrationTestRunning, clearVibrationTestInterval, stopVibrationTest, TEST_VIBRATION_INTERVAL_MS, sendVibrationSignal) remain in the file so the app compiles cleanly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src-tauri/src/commands/pairing_commands.rs`:
- Around line 17-25: emit_posture_signal currently calls
state.mark_posture_signal() and then separately broadcast_ws_state_event(&state,
event_type), creating a lock gap between mark_posture_signal and
state.build_ws_event() that can yield duplicate sequence numbers; fix by adding
an atomic method on PairingStateHandle (e.g., mark_and_build_ws_event(&self,
event_type: &str) -> WsEvent) that takes the mutex, increments last_sequence,
and returns the constructed WsEvent in a single locked section, then change
emit_posture_signal to call that new method and pass its returned WsEvent into
broadcast_ws_state_event (or adjust broadcast_ws_state_event to accept the
prebuilt event) so sequence increment and event construction occur under one
lock.
In `@src-tauri/src/pairing/server.rs`:
- Around line 26-47: The accept loop spawns unbounded threads and does not set
socket timeouts, allowing clients that send no data to occupy threads
indefinitely; fix by setting per-connection timeouts (call set_read_timeout and
set_write_timeout on the accepted TcpStream, e.g. Duration::from_secs(10) for
HTTP paths and a configurable idle timeout for WS), enforce an overall
connection limit before calling thread::spawn (reject or queue excess
connections), and implement WebSocket liveness (use ping/pong and idle
disconnect in the /ws handler where read_until_socket_closes is used); apply
these changes around the listener.incoming handling and inside handle_connection
and the /ws read_until_socket_closes logic.
- Around line 49-85: handle_connection currently reads at most 4096 bytes once,
which can truncate headers and break the WebSocket handshake; replace the single
stream.read(&mut buffer) with incremental buffered reads that accumulate until
the full HTTP header terminator "\r\n\r\n" is received (e.g., wrap stream in
std::io::BufReader and read lines or read_until CRLF sequences), then pass the
complete header string to parse_headers and the request line parsing used in
split_target/parse_query; ensure you preserve all header bytes (so
Sec-WebSocket-Key survives) and only stop reading headers once CRLFCRLF is seen
(or fail after a sensible byte limit), then dispatch to
handle_websocket/handle_pair/handle_disconnect as before.
- Around line 18-21: start_pairing_server is currently accepting the secret
token via a plaintext URL query (e.g. /pair?token=...), which leaks secrets;
change the pairing endpoint handler in start_pairing_server to read the token
from a non-URL channel (preferably the Authorization header or the POST request
body) instead of query params, validate it the same way, and update
PairingStateHandle usage accordingly; also update the client-side builder
buildPairingUrl / pairingLink.ts (and src/lib/desktopBridge.ts) to stop
embedding the token in the URL and instead send the token in the Authorization
header or as the body of a POST request; optionally add local-only protections
such as self-signed TLS or an HMAC challenge handshake around
start_pairing_server to harden LAN usage.
- Around line 264-278: validate_token currently relies on
PairingStateHandle::matches_token which performs a normal equality check
vulnerable to timing attacks and the token generation in generate_token is
predictable; change PairingStateHandle::matches_token to use a constant-time
comparison (e.g., subtle::ConstantTimeEq on the token bytes) and update
validate_token flow to apply a minimal abuse-mitigation (exponential backoff or
temporary lockout/counter on repeated failures for the /pair and /disconnect
endpoints) so repeated attempts are rate-limited; finally, replace the
SystemTime-based generate_token with a CSPRNG source (e.g., getrandom or
rand::rngs::OsRng) to produce unguessable tokens.
- Around line 16-24: WS_SINK is a OnceLock so calling start_pairing_server
multiple times silently fails to update WS_SINK (the `let _ = WS_SINK.set(...)`
is ignored), causing new listeners to use a new ws_sink while
broadcast_ws_state_event and emit_posture_signal still read the old WS_SINK;
change WS_SINK from OnceLock<WsSink> to a Mutex<Option<WsSink>> (or similar
shared Mutex-wrapped container), initialize it inside start_pairing_server only
when None and return an Err if already Some, and update all uses
(start_pairing_server, broadcast_ws_state_event, emit_posture_signal) to lock
and read the current Option to ensure they always reference the active ws_sink
or fail deterministically.
In `@src-tauri/src/pairing/state.rs`:
- Around line 206-212: The current generate_token function builds a predictable
token from SystemTime; replace it with a CSPRNG-based token (e.g., use
getrandom::getrandom or rand::thread_rng to produce 16+ bytes) and hex-encode
those bytes to produce the token string, then update Cargo.toml to include
getrandom or rand; ensure generate_token (and any callers such as the /pair
endpoint in pairing/server.rs) uses the new CSPRNG token so tokens are
cryptographically unguessable.
In `@src/App.tsx`:
- Around line 565-584: The current effect unconditionally calls
stopPairingServer() when isWebSocketConnected && isPairingServerRunning, which
removes the local reconnection path; instead, either stop removing the server
here (remove the stopHostAfterConnected/stopPairingServer call inside this
useEffect and keep the pairing server running while paired) or implement a
robust disconnection handler (add a WebSocket heartbeat/ping and on missed pings
or on socket close call resetToPairingMode) so that when the remote client
disconnects you can set isPairingServerRunning=false and paired=false reliably;
locate this logic around the useEffect that references
isWebSocketConnected/isPairingServerRunning and the functions stopPairingServer
and resetToPairingMode to apply the chosen fix.
In `@src/features/pairing/components/PairingDialog.tsx`:
- Around line 49-57: Cleanup in the useEffect currently only calls clearInterval
on vibeIntervalRef but never sends the stop signal, so when the dialog unmounts
the phone keeps vibrating; update the cleanup to call the same shutdown logic
used by stopVibeTest (or directly call emitPostureSignal(false)) before/after
clearing the interval, ensuring vibeIntervalRef.current is cleared and set to
null and the stop event is emitted to the server (referencing vibeIntervalRef,
stopVibeTest and emitPostureSignal to locate the code).
- Line 22: PairingDialog.tsx currently calls buildPairingQrImageUrl(pairingLink)
which sends the pairingLink (containing token) to an external QR service;
instead call the local QR generator generateQrDataUrl from src/lib/qrcode.ts to
produce a data URL on the client so the token is never transmitted externally.
Replace the buildPairingQrImageUrl usage with generateQrDataUrl(pairingLink),
await it if it returns a Promise, assign the result to qrImageUrl, and remove
any code paths that rely on the external API; keep references to pairingLink and
ensure error handling/logging around generateQrDataUrl just like other QR usage
in App.tsx.
In `@src/features/pairing/services/desktopBridge.ts`:
- Around line 5-21: App.tsx is importing the wrong desktopBridge module
(./lib/desktopBridge) which exposes a mismatched PairingStatus shape; replace
that import so App.tsx uses the correct API and types from
src/features/pairing/services/desktopBridge (use getDesktopPairingStatus and
DesktopPairingStatus) or remove the obsolete getPairingStatus export in
./lib/desktopBridge and update all references in App.tsx to call
getDesktopPairingStatus and access paired, deviceName, lastSeenAt (instead of
running, port, pairedPhoneIp, pairingToken) so the runtime types match the Rust
backend and the code paths that check pairingStatus.paired /
pairingStatus.deviceName / pairingStatus.lastSeenAt work correctly.
In `@src/features/pairing/services/pairingLink.ts`:
- Around line 11-19: buildPairingQrImageUrl currently embeds the full
pairingLink (including authentication token) into a third-party QR API URL; stop
sending secrets to external services by removing the external-API approach:
change buildPairingQrImageUrl to not call api.qrserver.com (return empty string
or throw) and add/replace with a function like
buildPairingQrDataForClient/getPairingQrPayload that either returns a sanitized
payload (strip token or expose only a one-time pairing ID) or otherwise signals
the client to generate the QR locally; update callers to use a client-side QR
generator (e.g., qrcode or qrcode.react) to render the QR from the sanitized
payload rather than relying on the external URL.
In `@src/lib/desktopBridge.ts`:
- Around line 8-14: PairingStatus 型がバックエンドの DesktopPairingStatus と不一致で、フロントの
App.tsx が参照する running/port/pairedPhoneIp/pairingToken が実行時 undefined
になっています。修正はどちらか一方で統一してください:1) Rust 側の DesktopPairingStatus に
running/port/paired_phone_ip/pairing_token 等を追加してバックエンドのレスポンスを拡張する、または 2) フロントの
PairingStatus 型をバックエンドのフィールド名に合わせて paired/deviceName/lastSeenAt
のみを持つように更新し、App.tsx
の参照(pairingStatus.running、pairingStatus.pairingToken、pairingStatus.pairedPhoneIp)を新フィールドに置き換える(例:paired
をサーバ稼働判定やトークン更新のロジックに適切にマップする)。対象シンボル:PairingStatus
型定義、DesktopPairingStatus(Rust側)、および App.tsx 内の pairingStatus.* 参照箇所を修正してください。
- Around line 3-6: The pairing URL builder currently hardcodes PAIRING_PORT =
47831 causing a mismatch with the server which binds to port 0; update
buildPairingUrl and remove/stop using the hardcoded PAIRING_PORT so the function
uses the actual port returned by the pairing service (the host/port/token from
get_pairing_info). Concretely, delete or stop exporting PAIRING_PORT and change
buildPairingUrl (or overload it) to accept a port argument (e.g.,
buildPairingUrl(deviceIp: string, port: number, token?: string)) and use that
port value when composing the vibeapp:// URL so callers can pass the runtime
port from get_pairing_info.
- Around line 16-98: The frontend calls several Tauri commands (getPrimaryIpv4,
getSavedPhoneIp, savePhoneIp, setBlackoutWindow, startPairingServer,
stopPairingServer, sendVibrationSignal) from src/lib/desktopBridge.ts that are
not registered in the Tauri invoke_handler (only greet, get_pairing_info,
get_pairing_status, emit_posture_signal exist), causing swallowed errors and
no-op behavior; fix by adding corresponding command functions (implementations
for e.g. get_primary_ipv4, get_saved_phone_ip, save_phone_ip,
set_blackout_window, start_pairing_server, stop_pairing_server,
send_vibration_signal) in src-tauri/src/commands/pairing_commands.rs (or the
appropriate backend module) and register them in invoke_handler! so invoke("…")
calls from getPrimaryIpv4(), getSavedPhoneIp(), savePhoneIp(),
setBlackoutWindow(), startPairingServer(), stopPairingServer(),
sendVibrationSignal() succeed, or alternatively remove/refactor those frontend
calls to use only the already-registered commands (get_pairing_status,
emit_posture_signal) if implementing backend handlers is not desired.
---
Minor comments:
In `@src-tauri/src/pairing/state.rs`:
- Around line 198-204: The timestamp_string() function currently returns epoch
seconds (e.g. "1761526023") which is not human-readable in the UI; update the
code so the UI shows a readable RFC3339/ISO8601 timestamp: either modify
timestamp_string() to return a RFC3339 string using chrono (e.g. use
chrono::Utc::now().to_rfc3339() inside timestamp_string) or keep
timestamp_string() as-is and change PairingDialog.tsx to detect numeric epoch
strings and render new Date(Number(s) * 1000).toISOString()/toLocaleString();
prefer the Rust change by replacing SystemTime-based logic in timestamp_string
with chrono::Utc::now().to_rfc3339() so status?.lastSeenAt can be displayed
directly.
- Around line 214-225: The local_ip_address() helper currently falls back to
"127.0.0.1" when the UDP-connect trick fails; change it to enumerate network
interfaces and prefer a non-loopback IPv4 (RFC1918) address first (e.g., via
get_if_addrs or similar) and only if none found attempt one or two explicit
reachability attempts (connect to a configured/known gateway IP or multiple
public IPs) before giving up; if still unavailable, return an Err or propagate
an explicit error to the caller/front-end instead of returning "127.0.0.1" so
pairing can surface a clear "LAN IP not found" message. Reference:
local_ip_address(), UdpSocket::bind/connect and the function's callers that
consume its String result.
In `@src/App.css`:
- Around line 422-427: CSS short-hand inset is overriding the explicit
left/right declarations in .blackout-panel causing stylelint's
declaration-block-no-shorthand-property-overrides error; remove the redundant
left: 12px; and right: 12px; lines and keep only inset: 72px 12px 12px; (or
alternatively expand inset and drop the shorthand) so the declarations are not
duplicated and the rule is satisfied.
In `@src/App.tsx`:
- Around line 463-503: Normalize the fallback pairing status before using it:
after calling getPairingStatus() in the useEffect, treat an empty
pairingStatus.pairingToken as null (e.g.
setPairingToken(pairingStatus.pairingToken && pairingStatus.pairingToken.trim()
? pairingStatus.pairingToken : null)) so you don't build a token= QR; also avoid
repeatedly starting the pairing server in non-Tauri/browser-preview environments
by gating the startPairingServer() call with an environment check (e.g.
isTauri() or equivalent) or by checking a real runtime indicator on
pairingStatus before calling startPairingServer(); update the code around
getPairingStatus, setPairingToken and the startPairingServer call to apply these
guards/normalization.
- Around line 587-610: The QR payload is only replaced when qrPayload ===
"取得中...", so later pairingToken rotations (via syncPairingState) don’t update
the QR; change the design so the server-returned packaged URL from
startPairingServer is stored in its own state (e.g., pairingUrlFromServer) and
compute the displayed qrPayload as a derived value (useMemo or inline) from
pairingUrlFromServer, deviceIp and pairingToken using buildPairingUrl(deviceIp,
pairingToken) when no server URL exists; update the effect that generates the QR
(generateQrDataUrl) to depend on the derived qrPayload (or on
pairingUrlFromServer, deviceIp, pairingToken) so new tokens cause regeneration,
remove the special-case check for "取得中..." and only keep startPairingServer’s
return assigned to pairingUrlFromServer while using the derived value for
display/QR.
- Around line 631-638: 現在の App コンポーネントの useEffect のクリーンアップから stopPairingServer()
を呼ぶと HMR や React StrictMode 下で不必要にサーバが停止し再起動失敗を招くので、useEffect の return ブロックから
stopPairingServer() を削除してクライアント側のアンマウントでサーバ停止を行わないようにし、代わりに Tauri
側のランタイム終了ハンドラ(tauri::Builder::default().build().run(|app, event| { ... }) の
RunEvent::ExitRequested)でペアリングサーバ停止処理を呼び出す実装に移行してください(参照: stopPairingServer(),
App コンポーネントの useEffect クリーンアップ、並びに src-tauri/src/pairing/server.rs の OnceLock
制約)。
- Around line 612-623: The effect in useEffect presently calls
getCurrentWindow() and appWindow.setFullscreen(shouldBlackoutScreen) without
protection, so a thrown exception or an unhandled rejected Promise can abort the
whole effect and later updates; wrap the window logic in a try/catch and await
(or attach .catch) the Promise from appWindow.setFullscreen to handle errors,
ensuring that setBlackoutWindow(...) and the posture broadcast
(lastBroadcastedPostureRef and sendPostureSignal) still run even if
getCurrentWindow() or setFullscreen fails; specifically modify the useEffect
body around getCurrentWindow(), appWindow.setFullscreen, and setBlackoutWindow
so errors are caught and logged instead of escaping.
In `@src/features/pairing/components/PairingDialog.tsx`:
- Around line 76-98: startVibeTest can register a second interval if
stopVibeTest is invoked while awaiting emitPostureSignal, so add a short-lived
"starting" guard to prevent concurrent start/stop races: set a flag (e.g.,
isStartingRef or setVibeState("starting")) before the first await
emitPostureSignal(true), check that flag/state after the await and only proceed
to setVibeState("running") and create window.setInterval if the flag still
indicates starting (and clear the flag on stopVibeTest); alternatively use an
AbortController to cancel the pending emitPostureSignal when stopVibeTest runs
or disable the toggle button while starting — reference startVibeTest,
stopVibeTest, vibeIntervalRef, emitPostureSignal, and vibeState when making the
change.
In `@src/features/pairing/hooks/usePairingState.ts`:
- Around line 107-135: In refresh() inside usePairingState.ts, start the fetch
by setting isLoading: true (and clear error) before awaiting readPairingSnapshot
so the UI can show a spinner/disable; implement this by calling setState(prev =>
({ ...prev, isLoading: true, error: null })) immediately after the isMountedRef
check at the top of refresh(), then keep the existing success and catch blocks
that set isLoading: false (and update pairingInfo/status or error) so loading is
correctly reset regardless of outcome.
In `@src/lib/desktopBridge.ts`:
- Around line 5-6: The current buildPairingUrl function injects a default
"pending" token which causes invalid QR codes during token race; modify
buildPairingUrl(deviceIp: string, token?: string) so it does NOT use the
"pending" fallback and instead returns null/undefined (or throws) when token is
null/empty, e.g., if (!token) return null; otherwise build the URL with the real
token using PAIRING_PORT; then update any callers that render QR codes to check
the buildPairingUrl return and show a loading state instead of generating a QR
when it returns null.
---
Nitpick comments:
In `@src-tauri/src/commands/pairing_commands.rs`:
- Around line 17-25: emit_posture_signal currently returns () so callers can't
know if delivery failed or no WS clients exist; change its signature (function
emit_posture_signal) to return a Result or an integer (e.g., Result<(), String>
or usize) and detect paired state / client count before broadcasting: call
state.mark_posture_signal(), inspect the WS client list or pairing flag (the
same source used by broadcast_ws_state_event), and if zero clients or not paired
return Err("no paired clients") (or return 0) otherwise call
broadcast_ws_state_event(&state, event_type) and return Ok(()) (or the number of
clients). Ensure callers in src/features/pairing/services/desktopBridge.ts are
updated to handle the Result/returned client count.
In `@src-tauri/src/lib.rs`:
- Around line 17-30: start_pairing_server(...) is being .expect(...)'d which
will panic and abort app startup if TcpListener::bind fails; instead catch the
Result from start_pairing_server(pairing_state.clone()), log or store the error
into the shared PairingStateHandle (so the UI/commands can surface it), and
continue building the tauri::Builder so the app still launches. Update the call
site that currently calls
start_pairing_server(pairing_state.clone()).expect(...) to handle Err(e) by
setting an error/status on pairing_state (or logging) and not panicking; keep
references to PairingStateHandle::new() and .manage(pairing_state) intact so
commands (greet, get_pairing_info, get_pairing_status, emit_posture_signal)
still share the same Arc/Mutex state.
In `@src-tauri/src/pairing/server.rs`:
- Around line 19-21: TcpListener::bind("0.0.0.0:0") exposes /health,/pair,/ws to
the whole network; change this by either binding to loopback for local-only use
(use 127.0.0.1 instead of 0.0.0.0) or make the bind address configurable, and if
LAN access must be supported implement an allowlist check on incoming
connections (validate peer_addr against the stored phone IP before
accepting/handling requests) and document the risk in README or a top-of-file
comment; update the TcpListener::bind call and keep state.set_port(port) logic
but ensure listeners only accept connections from permitted IPs when running in
non-local mode.
- Around line 181-191: read_until_socket_closes currently discards raw bytes and
must be replaced with proper WebSocket handling: either implement RFC6455 frame
parsing to validate client-side masking, reply to Ping with Pong, and send a
Close frame on receiving Close, or (recommended) replace the loop with a
tungstenite-based accept/handshake (e.g. call tungstenite::server::accept on the
TcpStream), then loop over the WebSocket object (read_message/send_message or
read/close APIs) to handle Message::Ping => send Message::Pong, Message::Close
=> send Message::Close and break, and let tungstenite enforce mask bits and
framing for you; update read_until_socket_closes to use the tungstenite
WebSocket type instead of raw TcpStream so ping/pong, close handshake, and mask
validation are correctly handled.
In `@src/App.tsx`:
- Around line 9-23: Remove the dead vibration-test code and its unused import:
delete the TEST_VIBRATION_INTERVAL_MS constant, the vibrationTestIntervalRef
variable, the isVibrationTestRunning state, and the helper functions
handleSendVibrationSignal, clearVibrationTestInterval, and stopVibrationTest
from App.tsx, and also remove sendVibrationSignal from the import list; ensure
no remaining references to those symbols (e.g., handleSendVibrationSignal,
vibrationTestIntervalRef, isVibrationTestRunning, clearVibrationTestInterval,
stopVibrationTest, TEST_VIBRATION_INTERVAL_MS, sendVibrationSignal) remain in
the file so the app compiles cleanly.
In `@src/features/pairing/components/PairingDialog.css`:
- Around line 147-165: Replace the use of !important on .pairing-vibe-test and
.pairing-vibe-stop by increasing selector specificity to target the buttons
inside the parent actions container (e.g., change selectors to .pairing-actions
button.pairing-vibe-test and .pairing-actions button.pairing-vibe-stop and
similarly for hover states like .pairing-actions
button.pairing-vibe-test:hover:not(:disabled)), then remove the !important
declarations so the styles override .pairing-actions button naturally while
remaining easier to override in the future.
In `@src/features/pairing/components/PairingDialog.tsx`:
- Around line 124-141: The Close button (button.pairing-close) in PairingDialog
lacks an aria-label and there’s no focus management; update the component
(PairingDialog) so the close button includes a descriptive aria-label (e.g.,
aria-label="閉じる"), and implement minimal focus handling: on mount save
document.activeElement, move focus to the close button (query/select
pairing-close) when the dialog opens, and onClose restore focus to the
previously focused element; optionally add a simple focus trap if needed to
prevent tabbing out of the dialog.
In `@src/features/pairing/hooks/usePairingState.ts`:
- Around line 71-97: The polling interval unconditionally clears error state
which can overwrite real errors from refresh() or initial load; in the interval
callback (the window.setInterval block using getDesktopPairingStatus,
POLL_INTERVAL_MS, isMountedRef) change the setState so it only clears the error
when the previous error is the polling-specific one (e.g., equals
STATUS_POLL_ERROR_MESSAGE) or when the error originated from a prior polling
attempt, otherwise preserve prev.error; additionally, prevent overlapping
requests by either wiring an AbortController into getDesktopPairingStatus calls
or replacing setInterval with a recursive setTimeout that awaits completion
before scheduling the next call to avoid concurrent invocations.
In `@src/features/pairing/services/pairingLink.ts`:
- Around line 3-9: The buildPairingLink function currently concatenates query
params manually which can be fragile; update buildPairingLink to use
URLSearchParams to encode the host, port and token from the PairingInfo object
(still return empty string when pairingInfo is null), e.g. construct a
URLSearchParams, set "host", "port", "token" from pairingInfo.host/port/token,
and return `vibeapp://pair?` + params.toString() so all values are properly
percent-encoded and readable.
In `@src/lib/qrcode.ts`:
- Around line 3-13: Add an explicit return type Promise<string> to the exported
function generateQrDataUrl to stabilize call-site type inference and
completions; leave the QRCode.toDataURL options as-is (errorCorrectionLevel:
"M", margin: 2, width: 240, color: { dark: "#020617", light: "#ffffff" }) since
current use-case doesn't require increasing to "Q". Ensure the function
signature reads generateQrDataUrl(payload: string): Promise<string>.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: a44d4bef-2769-46ef-8a5c-1b894f588d9c
⛔ Files ignored due to path filters (3)
bun.lockis excluded by!**/*.lockpackage-lock.jsonis excluded by!**/package-lock.jsonsrc-tauri/Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (23)
package.jsonsrc-tauri/Cargo.tomlsrc-tauri/Info.plistsrc-tauri/capabilities/default.jsonsrc-tauri/src/commands/mod.rssrc-tauri/src/commands/pairing_commands.rssrc-tauri/src/lib.rssrc-tauri/src/pairing/mod.rssrc-tauri/src/pairing/server.rssrc-tauri/src/pairing/state.rssrc-tauri/src/pairing/types.rssrc-tauri/tauri.conf.jsonsrc/App.csssrc/App.tsxsrc/features/pairing/components/PairingDialog.csssrc/features/pairing/components/PairingDialog.tsxsrc/features/pairing/hooks/usePairingState.tssrc/features/pairing/index.tssrc/features/pairing/services/desktopBridge.tssrc/features/pairing/services/pairingLink.tssrc/features/pairing/types/pairing.tssrc/lib/desktopBridge.tssrc/lib/qrcode.ts
| #[tauri::command] | ||
| pub fn emit_posture_signal( | ||
| is_bad: bool, | ||
| state: State<'_, PairingStateHandle>, | ||
| ) { | ||
| let event_type = if is_bad { "posture_bad" } else { "posture_good" }; | ||
| state.mark_posture_signal(); | ||
| broadcast_ws_state_event(&state, event_type); | ||
| } |
There was a problem hiding this comment.
mark_posture_signal と build_ws_event の間にロックギャップがあり、シーケンス番号と発生順が乖離する可能性があります
emit_posture_signal は state.mark_posture_signal() でロックを取得 → 解放 → broadcast_ws_state_event() 内の state.build_ws_event() でロックを再取得、という二段階構造です(src-tauri/src/pairing/state.rs:148-170)。複数スレッドから並行に呼ばれた場合、
- スレッド A:
mark→last_sequence = N(ロック解放) - スレッド B:
mark→last_sequence = N+1(ロック解放) - スレッド A:
build_ws_event→sequence = N+1を読み取る - スレッド B:
build_ws_event→sequence = N+1を読み取る
となり、両方の posture イベントが同じシーケンス番号 N+1 で配信されます。クライアントはシーケンスで重複/欠落を検出できなくなり、状態同期が壊れます。
PairingStateHandle に「インクリメントしながらイベントを返す」アトミックなメソッドを追加するのが最も安全です。
🔒️ 提案例: 単一ロック内でシーケンス更新と event 構築を行う
// state.rs
pub fn mark_and_build_ws_event(&self, event_type: &str) -> WsEvent {
let mut state = self.inner.lock().expect("pairing state poisoned");
state.last_sequence += 1;
WsEvent {
r#type: event_type.to_string(),
sequence: state.last_sequence,
paired: state.paired,
device_name: state.device_name.clone(),
last_seen_at: state.last_seen_at.clone(),
created_at: timestamp_string(),
}
} #[tauri::command]
pub fn emit_posture_signal(
is_bad: bool,
state: State<'_, PairingStateHandle>,
) {
let event_type = if is_bad { "posture_bad" } else { "posture_good" };
- state.mark_posture_signal();
- broadcast_ws_state_event(&state, event_type);
+ let event = state.mark_and_build_ws_event(event_type);
+ if let Some(ws_sink) = WS_SINK.get() {
+ broadcast_ws_event(ws_sink, &event);
+ }
}(または pair_device / disconnect_device も同様にイベントを返すように整える)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src-tauri/src/commands/pairing_commands.rs` around lines 17 - 25,
emit_posture_signal currently calls state.mark_posture_signal() and then
separately broadcast_ws_state_event(&state, event_type), creating a lock gap
between mark_posture_signal and state.build_ws_event() that can yield duplicate
sequence numbers; fix by adding an atomic method on PairingStateHandle (e.g.,
mark_and_build_ws_event(&self, event_type: &str) -> WsEvent) that takes the
mutex, increments last_sequence, and returns the constructed WsEvent in a single
locked section, then change emit_posture_signal to call that new method and pass
its returned WsEvent into broadcast_ws_state_event (or adjust
broadcast_ws_state_event to accept the prebuilt event) so sequence increment and
event construction occur under one lock.
| static WS_SINK: OnceLock<WsSink> = OnceLock::new(); | ||
|
|
||
| pub fn start_pairing_server(state: PairingStateHandle) -> Result<(), String> { | ||
| let listener = TcpListener::bind("0.0.0.0:0").map_err(|error| error.to_string())?; | ||
| let port = listener.local_addr().map_err(|error| error.to_string())?.port(); | ||
| state.set_port(port); | ||
|
|
||
| let ws_sink: WsSink = Arc::new(Mutex::new(Vec::new())); | ||
| let _ = WS_SINK.set(ws_sink.clone()); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# stop_pairing_server / start_pairing_server の Rust 実装と再起動可否を確認
rg -nP '(stop_pairing_server|start_pairing_server)' --type=rust -A 20
rg -nP 'WS_SINK' --type=rust -B2 -A 5Repository: vyuma/posture-app
Length of output: 5156
🏁 Script executed:
#!/bin/bash
# Check if stop_pairing_server exists and is exported
rg -n 'stop_pairing_server' --type=rust
# Check emit_posture_signal and its WS_SINK usage
rg -nP 'emit_posture_signal' --type=rust -B2 -A 10
# Check pairing_commands.rs for any related functions
find . -name 'pairing_commands.rs' -o -name '*pairing*command*' | head -20Repository: vyuma/posture-app
Length of output: 1868
🏁 Script executed:
#!/bin/bash
# Check TypeScript/JavaScript bindings and invoke handlers
find . -name 'desktopBridge.ts' -o -name '*Bridge.ts' | head -10
# Check for startPairingServer / stopPairingServer in TypeScript
rg -n 'startPairingServer\|stopPairingServer' --type=typescript --type=jsxRepository: vyuma/posture-app
Length of output: 190
🏁 Script executed:
#!/bin/bash
# Check the full broadcast_ws_state_event implementation
rg -n 'broadcast_ws_state_event' --type=rust -B5 -A15
# Check where WS_SINK is used to understand the full scope
rg -n 'WS_SINK' --type=rustRepository: vyuma/posture-app
Length of output: 6143
🏁 Script executed:
#!/bin/bash
# Search for TypeScript/JavaScript files using a different approach
find . -name '*.ts' -o -name '*.tsx' | grep -i bridge | head -5Repository: vyuma/posture-app
Length of output: 137
🏁 Script executed:
#!/bin/bash
# Check what's in desktopBridge.ts
cat ./src/lib/desktopBridge.tsRepository: vyuma/posture-app
Length of output: 2097
🏁 Script executed:
#!/bin/bash
# Check for App.tsx and how it uses pairing server
find . -name 'App.tsx' -o -name 'App.ts' | head -5Repository: vyuma/posture-app
Length of output: 75
🏁 Script executed:
#!/bin/bash
# Look for any references to startPairingServer in TypeScript
rg -i 'startPairingServer\|stopPairingServer' -A 3 -B 3Repository: vyuma/posture-app
Length of output: 43
🏁 Script executed:
#!/bin/bash
# Read App.tsx to understand usage pattern
cat ./src/App.tsxRepository: vyuma/posture-app
Length of output: 50373
🏁 Script executed:
#!/bin/bash
# Search for actual usage of startPairingServer in the codebase
rg -i 'startPairingServer' -B 5 -A 5Repository: vyuma/posture-app
Length of output: 2238
🏁 Script executed:
#!/bin/bash
# Verify once more that stop_pairing_server is truly not in Rust
rg 'stop_pairing_server' --type=rustRepository: vyuma/posture-app
Length of output: 43
start_pairing_server が複数回呼ばれると WS_SINK が古いままになります
WS_SINK は OnceLock のため、2 回目以降の start_pairing_server 呼び出しで line 24 の WS_SINK.set(ws_sink.clone()) はサイレントに失敗します(let _ = で結果が無視されている)。その一方で新しい TcpListener と ws_sink インスタンスは生成され、アクセプトループが回り始めるため、新規接続したクライアントは新しい ws_sink に登録されます。しかし broadcast_ws_state_event や emit_posture_signal は古い WS_SINK.get() を参照するため、新規接続クライアントには永遠にイベントが届きません。
App.tsx では複数の箇所(初期化、再接続ロジック)で startPairingServer() が呼ばれているため、この不整合が実際に発生する可能性があります。サーバー再起動時に確実に新しい ws_sink を使用するよう、OnceLock の代わりに Mutex<Option<...>> で一意のインスタンスを保持し、既に設定されている場合は早期に Err を返す設計に変更することを推奨します。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src-tauri/src/pairing/server.rs` around lines 16 - 24, WS_SINK is a OnceLock
so calling start_pairing_server multiple times silently fails to update WS_SINK
(the `let _ = WS_SINK.set(...)` is ignored), causing new listeners to use a new
ws_sink while broadcast_ws_state_event and emit_posture_signal still read the
old WS_SINK; change WS_SINK from OnceLock<WsSink> to a Mutex<Option<WsSink>> (or
similar shared Mutex-wrapped container), initialize it inside
start_pairing_server only when None and return an Err if already Some, and
update all uses (start_pairing_server, broadcast_ws_state_event,
emit_posture_signal) to lock and read the current Option to ensure they always
reference the active ws_sink or fail deterministically.
| pub fn start_pairing_server(state: PairingStateHandle) -> Result<(), String> { | ||
| let listener = TcpListener::bind("0.0.0.0:0").map_err(|error| error.to_string())?; | ||
| let port = listener.local_addr().map_err(|error| error.to_string())?.port(); | ||
| state.set_port(port); |
There was a problem hiding this comment.
プレーン HTTP/WS 経路でトークンが平文 + URL クエリ送信されています
ペアリングサーバは http://...:port/pair?token=... のように、秘密値であるはずの token を URL クエリ文字列 に乗せて平文で受け取ります。この設計には次のリスクがあります:
- LAN 上の他端末から WireShark などで token を傍受可能
- token が
eprintln!出力やフロントのqrserver.com(pairingLink.ts) 経由でログ・第三者サーバに残りやすい - ブラウザが Referrer や履歴に含めやすい
短期対策としては、(a) token を Authorization ヘッダ or リクエストボディに移す、(b) ペアリングは LAN ローカルでのみ動作するため少なくとも自己署名 TLS や HMAC チャレンジを併用する、を推奨します。buildPairingUrl (src/lib/desktopBridge.ts:5-6) のフォーマット側にも同じ修正が必要です。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src-tauri/src/pairing/server.rs` around lines 18 - 21, start_pairing_server
is currently accepting the secret token via a plaintext URL query (e.g.
/pair?token=...), which leaks secrets; change the pairing endpoint handler in
start_pairing_server to read the token from a non-URL channel (preferably the
Authorization header or the POST request body) instead of query params, validate
it the same way, and update PairingStateHandle usage accordingly; also update
the client-side builder buildPairingUrl / pairingLink.ts (and
src/lib/desktopBridge.ts) to stop embedding the token in the URL and instead
send the token in the Authorization header or as the body of a POST request;
optionally add local-only protections such as self-signed TLS or an HMAC
challenge handshake around start_pairing_server to harden LAN usage.
| thread::spawn(move || { | ||
| for stream in listener.incoming() { | ||
| match stream { | ||
| Ok(stream) => { | ||
| let request_state = state.clone(); | ||
| let request_ws_sink = ws_sink.clone(); | ||
| thread::spawn(move || { | ||
| if let Err(error) = handle_connection(stream, request_state, request_ws_sink) | ||
| { | ||
| eprintln!("pairing server error: {error}"); | ||
| } | ||
| }); | ||
| } | ||
| Err(error) => { | ||
| eprintln!("incoming connection error: {error}"); | ||
| } | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
接続にタイムアウトが無く、スレッドあたりの DoS 耐性がありません
stream には set_read_timeout / set_write_timeout が設定されておらず、/ws ハンドラの read_until_socket_closes (Line 181-191) は EOF まで永久にブロックします。さらにアクセプトループは接続ごとに無制限に thread::spawn するため、
- 何もデータを送らない TCP クライアントが永遠にスレッドを占有
- 大量接続でスレッドが青天井に増殖
といったローカル LAN からの単純な攻撃で容易にプロセスを劣化させられます。少なくとも HTTP 経路には set_read_timeout(Some(Duration::from_secs(10)))、WebSocket 経路にはアイドルタイムアウト + ping/pong による生存確認、接続上限を入れてください。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src-tauri/src/pairing/server.rs` around lines 26 - 47, The accept loop spawns
unbounded threads and does not set socket timeouts, allowing clients that send
no data to occupy threads indefinitely; fix by setting per-connection timeouts
(call set_read_timeout and set_write_timeout on the accepted TcpStream, e.g.
Duration::from_secs(10) for HTTP paths and a configurable idle timeout for WS),
enforce an overall connection limit before calling thread::spawn (reject or
queue excess connections), and implement WebSocket liveness (use ping/pong and
idle disconnect in the /ws handler where read_until_socket_closes is used);
apply these changes around the listener.incoming handling and inside
handle_connection and the /ws read_until_socket_closes logic.
| fn handle_connection( | ||
| mut stream: TcpStream, | ||
| state: PairingStateHandle, | ||
| ws_sink: WsSink, | ||
| ) -> Result<(), String> { | ||
| let mut buffer = [0_u8; 4096]; | ||
| let bytes_read = stream.read(&mut buffer).map_err(|error| error.to_string())?; | ||
|
|
||
| if bytes_read == 0 { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let request = String::from_utf8_lossy(&buffer[..bytes_read]); | ||
| let request_line = request | ||
| .lines() | ||
| .next() | ||
| .ok_or_else(|| "missing request line".to_string())?; | ||
| let mut parts = request_line.split_whitespace(); | ||
| let method = parts.next().unwrap_or_default(); | ||
| let target = parts.next().unwrap_or_default(); | ||
|
|
||
| if method != "GET" { | ||
| return write_internal_error_json(&mut stream, 405, "method not allowed"); | ||
| } | ||
|
|
||
| let (path, query) = split_target(target); | ||
| let query_map = parse_query(query); | ||
| let headers = parse_headers(&request); | ||
|
|
||
| match path { | ||
| "/health" => write_json(&mut stream, 200, &state.build_health_response()), | ||
| "/pair" => handle_pair(&mut stream, &state, &query_map), | ||
| "/disconnect" => handle_disconnect(&mut stream, &state, &query_map), | ||
| "/ws" => handle_websocket(stream, &state, &query_map, &headers, &ws_sink), | ||
| _ => write_internal_error_json(&mut stream, 404, "not found"), | ||
| } | ||
| } |
There was a problem hiding this comment.
4096 バイトを超えるリクエストでハンドシェイクが破綻します
stream.read(&mut buffer) (Line 55) は最大 4096 バイトを 1 回だけ読み込み、その内容で request line とヘッダをパースします。WebSocket クライアントによっては Cookie・User-Agent・拡張機能・Origin・追加ヘッダで容易に 1 KB を超えるため、Sec-WebSocket-Key ヘッダが切り詰められると handle_websocket で "missing sec-websocket-key" エラーになります。また \r\n\r\n を含む完全な HTTP リクエストが届く前に read が返る可能性もあり、TCP フラグメントがあった場合は parse_headers が不完全な入力で動作します。
最低でも BufReader::read_line で request line と各ヘッダ行を \r\n\r\n まで読む実装に置き換えるか、信頼できる HTTP/WS ライブラリ (tiny_http, tungstenite) の利用を検討してください。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src-tauri/src/pairing/server.rs` around lines 49 - 85, handle_connection
currently reads at most 4096 bytes once, which can truncate headers and break
the WebSocket handshake; replace the single stream.read(&mut buffer) with
incremental buffered reads that accumulate until the full HTTP header terminator
"\r\n\r\n" is received (e.g., wrap stream in std::io::BufReader and read lines
or read_until CRLF sequences), then pass the complete header string to
parse_headers and the request line parsing used in split_target/parse_query;
ensure you preserve all header bytes (so Sec-WebSocket-Key survives) and only
stop reading headers once CRLFCRLF is seen (or fail after a sensible byte
limit), then dispatch to handle_websocket/handle_pair/handle_disconnect as
before.
| export type DesktopPairingStatus = { | ||
| paired: boolean; | ||
| deviceName: string | null; | ||
| lastSeenAt: string | null; | ||
| }; | ||
|
|
||
| export async function getPairingInfo(): Promise<PairingInfo> { | ||
| return invoke<PairingInfo>("get_pairing_info"); | ||
| } | ||
|
|
||
| export async function getDesktopPairingStatus(): Promise<DesktopPairingStatus> { | ||
| return invoke<DesktopPairingStatus>("get_pairing_status"); | ||
| } | ||
|
|
||
| export async function emitPostureSignal(isBad: boolean): Promise<void> { | ||
| await invoke("emit_posture_signal", { isBad }); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# get_pairing_status のレスポンス形を Rust 側で確認
rg -nP 'fn\s+get_pairing_status' --type=rust -A 20
rg -nP 'pub\s+struct\s+(DesktopPairingStatus|PairingStatus)' --type=rust -A 10
# フロントの両モジュールの import 元を確認
rg -nP "from\s+['\"].*desktopBridge['\"]" --type=ts --type=tsxRepository: vyuma/posture-app
Length of output: 2894
🏁 Script executed:
# Check if src/lib/desktopBridge.ts exists and view its content
fd -t f "desktopBridge.ts" src/
# Check for PairingStatus type definition in TypeScript
rg -n "type\s+PairingStatus|interface\s+PairingStatus" --type=tsRepository: vyuma/posture-app
Length of output: 183
🏁 Script executed:
# View src/lib/desktopBridge.ts to see the PairingStatus type definition
cat -n src/lib/desktopBridge.ts
# Check App.tsx for usage of those fields
grep -n "running\|pairingToken\|pairedPhoneIp" src/App.tsxRepository: vyuma/posture-app
Length of output: 3766
🏁 Script executed:
# Check which desktopBridge module App.tsx imports
head -30 src/App.tsx | grep -n "desktopBridge"
# Also search for all imports of desktopBridge in the codebase
rg -n "from.*desktopBridge|import.*desktopBridge" --type=tsRepository: vyuma/posture-app
Length of output: 412
🏁 Script executed:
# See what App.tsx imports from lib/desktopBridge
rg -n "getPairingStatus\|from.*lib/desktopBridge" src/App.tsx -B 2 -A 2
# Search for any Rust command that might return the extended PairingStatus
rg -n "get_pairing_server_status|fn.*pairing.*status" src-tauri/src --type=rustRepository: vyuma/posture-app
Length of output: 295
🏁 Script executed:
# View the exact imports from lib/desktopBridge in App.tsx
sed -n '1,30p' src/App.tsx | grep -A 15 "from.*lib/desktopBridge"
# Check the full import statement context
rg -n "from.*lib/desktopBridge" src/App.tsx -B 5 -A 20Repository: vyuma/posture-app
Length of output: 761
🏁 Script executed:
# Get the full import statement from App.tsx, starting from the first import
rg -n "import.*from.*lib/desktopBridge" src/App.tsx -B 20Repository: vyuma/posture-app
Length of output: 43
🏁 Script executed:
# Show lines 1-25 of App.tsx to see the full imports
head -25 src/App.tsxRepository: vyuma/posture-app
Length of output: 687
App.tsx が間違った desktopBridge モジュールをインポートしており、型定義の不整合により実行時エラーが発生
src/App.tsx (行 22) は ./lib/desktopBridge からインポートしていますが、このモジュールの PairingStatus 型は running, port, pairedPhoneIp, pairingToken を定義しています。一方、実際の Rust バックエンド (src-tauri/src/pairing/types.rs:13-17) の get_pairing_status コマンドは paired, device_name, last_seen_at のみを返すため、これらの追加フィールドは常に undefined になります。
App.tsx はこれらの値に依存しており、特に以下の行でペアリングサーバーの状態判定と QR コード更新分岐を行っています:
- 行 414, 486, 522:
pairingStatus.runningの参照 - 行 479, 545-551:
pairingStatus.pairedPhoneIpの参照 - 行 413, 478, 518:
pairingStatus.pairingTokenの参照
正しい型定義は src/features/pairing/services/desktopBridge.ts に存在し、他のコンポーネント (usePairingState.ts, PairingDialog.tsx) はこちらを正しくインポートしています。src/lib/desktopBridge.ts の getPairingStatus 関数を削除するか、src/features/pairing/services/desktopBridge.ts の型と関数を使用するよう App.tsx を修正してください。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/features/pairing/services/desktopBridge.ts` around lines 5 - 21, App.tsx
is importing the wrong desktopBridge module (./lib/desktopBridge) which exposes
a mismatched PairingStatus shape; replace that import so App.tsx uses the
correct API and types from src/features/pairing/services/desktopBridge (use
getDesktopPairingStatus and DesktopPairingStatus) or remove the obsolete
getPairingStatus export in ./lib/desktopBridge and update all references in
App.tsx to call getDesktopPairingStatus and access paired, deviceName,
lastSeenAt (instead of running, port, pairedPhoneIp, pairingToken) so the
runtime types match the Rust backend and the code paths that check
pairingStatus.paired / pairingStatus.deviceName / pairingStatus.lastSeenAt work
correctly.
| export function buildPairingQrImageUrl(pairingLink: string): string { | ||
| if (!pairingLink) { | ||
| return ""; | ||
| } | ||
|
|
||
| return `https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent( | ||
| pairingLink, | ||
| )}`; | ||
| } |
There was a problem hiding this comment.
外部 QR サービスへのトークン送出はセキュリティ上の懸念があります。
buildPairingQrImageUrl は api.qrserver.com にペアリングリンク全体(=ホスト・ポート・認証トークン)を data クエリで送信しています。ペアリング認証の根拠であるトークンをサードパーティに渡すことになり、第三者のログ/CDN にトークンが記録されるリスクがあります。HTTPS であっても URL のクエリ文字列はサーバ側のアクセスログに残り得るため、ペアリング認証の前提が崩れる可能性があります。
QR コード生成はクライアント側のライブラリ(例: qrcode / qrcode.react など)でローカル生成することを推奨します。
♻️ 提案: クライアント生成への切り替え例
-export function buildPairingQrImageUrl(pairingLink: string): string {
- if (!pairingLink) {
- return "";
- }
-
- return `https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent(
- pairingLink,
- )}`;
-}
+// Use a client-side QR generator (e.g. `qrcode`) in the component instead of an external HTTP service.
+// Example (in PairingDialog.tsx):
+// import QRCode from "qrcode";
+// const dataUrl = await QRCode.toDataURL(pairingLink, { width: 240 });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/features/pairing/services/pairingLink.ts` around lines 11 - 19,
buildPairingQrImageUrl currently embeds the full pairingLink (including
authentication token) into a third-party QR API URL; stop sending secrets to
external services by removing the external-API approach: change
buildPairingQrImageUrl to not call api.qrserver.com (return empty string or
throw) and add/replace with a function like
buildPairingQrDataForClient/getPairingQrPayload that either returns a sanitized
payload (strip token or expose only a one-time pairing ID) or otherwise signals
the client to generate the QR locally; update callers to use a client-side QR
generator (e.g., qrcode or qrcode.react) to render the QR from the sanitized
payload rather than relying on the external URL.
| export const PAIRING_PORT = 47831; | ||
|
|
||
| export const buildPairingUrl = (deviceIp: string, token?: string) => | ||
| `vibeapp://pair?host=${deviceIp}&port=${PAIRING_PORT}&token=${token ?? "pending"}&httpProtocol=http&wsProtocol=ws`; |
There was a problem hiding this comment.
PAIRING_PORT = 47831 固定がサーバ実装(動的ポート)と矛盾します
src-tauri/src/pairing/server.rs:19 ではポート 0 でバインド (= OS が割り当てる任意の空きポート) し、その値を PairingStateHandle::set_port に書き戻しています。一方、本ファイル + buildPairingUrl は PAIRING_PORT = 47831 をハードコードし、QR の port=... パラメータも 47831 を返します。結果としてスマホは「47831」へ接続を試みますが、デスクトップ側は別ポートで Listen しているためまず接続できません。
QR 用 URL 構築は get_pairing_info の戻り値 (host/port/token) をそのまま使う方向に揃えるのが安全です(実際 src/features/pairing/services/pairingLink.ts 系はその設計のようなので、そちらに統一する形が自然)。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/lib/desktopBridge.ts` around lines 3 - 6, The pairing URL builder
currently hardcodes PAIRING_PORT = 47831 causing a mismatch with the server
which binds to port 0; update buildPairingUrl and remove/stop using the
hardcoded PAIRING_PORT so the function uses the actual port returned by the
pairing service (the host/port/token from get_pairing_info). Concretely, delete
or stop exporting PAIRING_PORT and change buildPairingUrl (or overload it) to
accept a port argument (e.g., buildPairingUrl(deviceIp: string, port: number,
token?: string)) and use that port value when composing the vibeapp:// URL so
callers can pass the runtime port from get_pairing_info.
| type PairingStatus = { | ||
| running: boolean; | ||
| port: number; | ||
| paired: boolean; | ||
| pairedPhoneIp: string | null; | ||
| pairingToken: string; | ||
| }; |
There was a problem hiding this comment.
PairingStatus 型がバックエンドの戻り値と一致していません
Rust 側 DesktopPairingStatus (src-tauri/src/pairing/types.rs:5-17) が返すのは paired / deviceName / lastSeenAt の 3 フィールドのみで、ここで宣言している running / port / pairedPhoneIp / pairingToken は実行時に常に undefined になります。
App.tsx ではこれらに依存して
pairingStatus.runningでサーバ稼働判定pairingStatus.pairingTokenで QR 用トークン更新pairingStatus.pairedPhoneIpでスマホ IP 表示
を行っているため、ペアリングフロー全体が成立しません。Rust 側で running/port/pairing_token 等を返す追加フィールドを定義するか、features/pairing 側の DesktopPairingStatus に揃えてフロントを修正してください。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/lib/desktopBridge.ts` around lines 8 - 14, PairingStatus 型がバックエンドの
DesktopPairingStatus と不一致で、フロントの App.tsx が参照する
running/port/pairedPhoneIp/pairingToken が実行時 undefined
になっています。修正はどちらか一方で統一してください:1) Rust 側の DesktopPairingStatus に
running/port/paired_phone_ip/pairing_token 等を追加してバックエンドのレスポンスを拡張する、または 2) フロントの
PairingStatus 型をバックエンドのフィールド名に合わせて paired/deviceName/lastSeenAt
のみを持つように更新し、App.tsx
の参照(pairingStatus.running、pairingStatus.pairingToken、pairingStatus.pairedPhoneIp)を新フィールドに置き換える(例:paired
をサーバ稼働判定やトークン更新のロジックに適切にマップする)。対象シンボル:PairingStatus
型定義、DesktopPairingStatus(Rust側)、および App.tsx 内の pairingStatus.* 参照箇所を修正してください。
| export async function getPrimaryIpv4() { | ||
| try { | ||
| return await invoke<string | null>("get_primary_ipv4"); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export async function getSavedPhoneIp() { | ||
| try { | ||
| return await invoke<string | null>("get_saved_phone_ip"); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export async function savePhoneIp(phoneIp: string) { | ||
| try { | ||
| await invoke("save_phone_ip", { phoneIp }); | ||
| } catch { | ||
| // Tauri以外では何もしない。将来のバック実装に差し替える前提。 | ||
| } | ||
| } | ||
|
|
||
| export async function setBlackoutWindow(active: boolean) { | ||
| try { | ||
| await invoke("set_blackout_window", { active }); | ||
| } catch { | ||
| // Tauri以外では何もしない。将来のバック実装に差し替える前提。 | ||
| } | ||
| } | ||
|
|
||
| export async function getPairingStatus(): Promise<PairingStatus> { | ||
| try { | ||
| return await invoke<PairingStatus>("get_pairing_status"); | ||
| } catch { | ||
| return { | ||
| running: false, | ||
| port: PAIRING_PORT, | ||
| paired: false, | ||
| pairedPhoneIp: null, | ||
| pairingToken: "", | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| export async function startPairingServer() { | ||
| try { | ||
| return await invoke<string>("start_pairing_server"); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| export async function stopPairingServer() { | ||
| try { | ||
| await invoke("stop_pairing_server"); | ||
| } catch { | ||
| // Tauri以外では何もしない。将来のバック実装に差し替える前提。 | ||
| } | ||
| } | ||
|
|
||
| export async function sendVibrationSignal(targetIp?: string) { | ||
| try { | ||
| await invoke("send_vibration_signal", { | ||
| targetIp: targetIp ?? null, | ||
| }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| export async function sendPostureSignal(isBad: boolean) { | ||
| try { | ||
| await invoke("emit_posture_signal", { | ||
| isBad, | ||
| }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } No newline at end of file |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Rust 側に登録済みのコマンドを列挙
rg -nP '#\[tauri::command\]' --type=rust -A 2
rg -nP 'tauri::generate_handler!' --type=rust -A 20
# 当該コマンド名の実装存在確認
for cmd in get_primary_ipv4 get_saved_phone_ip save_phone_ip set_blackout_window start_pairing_server stop_pairing_server send_vibration_signal; do
echo "=== $cmd ==="
rg -nP "fn\\s+${cmd}\\b" --type=rust
doneRepository: vyuma/posture-app
Length of output: 1790
🏁 Script executed:
#!/bin/bash
# Verify desktopBridge.ts exists and check line count
echo "=== desktopBridge.ts line count ==="
wc -l src/lib/desktopBridge.ts
# Check lines 16-98 to match the snippet
echo "=== desktopBridge.ts lines 16-98 ==="
sed -n '16,98p' src/lib/desktopBridge.ts
# Check App.tsx around the mentioned lines (463, 486)
echo "=== App.tsx around line 463 ==="
sed -n '460,470p' src/App.tsx
echo "=== App.tsx around line 486 ==="
sed -n '483,493p' src/App.tsx
# Verify pairing_commands.rs structure
echo "=== pairing_commands.rs ==="
cat src-tauri/src/commands/pairing_commands.rsRepository: vyuma/posture-app
Length of output: 3171
src/lib/desktopBridge.ts の複数のコマンドがバックエンドに未登録のため、主要機能が動作しません
src-tauri/src/lib.rs:25-30 の invoke_handler! に登録されているのは greet、get_pairing_info、get_pairing_status、emit_posture_signal の 4 つのみです。本ファイルの以下のコマンドは未登録のため、try/catch で例外が握りつぶされ、null/false/デフォルト値が返されます:
get_primary_ipv4、get_saved_phone_ip、save_phone_ipset_blackout_window、start_pairing_server、stop_pairing_serversend_vibration_signal
このため App.tsx の起動時処理で:
loadConnectionState(線 463)はgetPrimaryIpv4()、getSavedPhoneIp()がいずれも null を返すstartPairingServer()(線 486)も常に null を返すため、if (mounted && url)の条件を満たさず QR コード表示が実行されないsetBlackoutWindow、sendVibrationSignal、stopPairingServerも完全に no-op となる
つまりペアリング・QR 表示・バイブテスト・ブラックアウトなど PR 主要機能がほぼ動作しません。
src-tauri/src/commands/pairing_commands.rs に未登録の各コマンド実装を追加して invoke_handler! に登録するか、これらの呼び出しを削除して既存コマンドのみで再実装する必要があります。
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/lib/desktopBridge.ts` around lines 16 - 98, The frontend calls several
Tauri commands (getPrimaryIpv4, getSavedPhoneIp, savePhoneIp, setBlackoutWindow,
startPairingServer, stopPairingServer, sendVibrationSignal) from
src/lib/desktopBridge.ts that are not registered in the Tauri invoke_handler
(only greet, get_pairing_info, get_pairing_status, emit_posture_signal exist),
causing swallowed errors and no-op behavior; fix by adding corresponding command
functions (implementations for e.g. get_primary_ipv4, get_saved_phone_ip,
save_phone_ip, set_blackout_window, start_pairing_server, stop_pairing_server,
send_vibration_signal) in src-tauri/src/commands/pairing_commands.rs (or the
appropriate backend module) and register them in invoke_handler! so invoke("…")
calls from getPrimaryIpv4(), getSavedPhoneIp(), savePhoneIp(),
setBlackoutWindow(), startPairingServer(), stopPairingServer(),
sendVibrationSignal() succeed, or alternatively remove/refactor those frontend
calls to use only the already-registered commands (get_pairing_status,
emit_posture_signal) if implementing backend handlers is not desired.
Macで動作するように変更
Windowsの操作についてはまだ検証していないのでよろしくお願いします。
Summary by CodeRabbit
リリースノート
新機能
改善