Skip to content

Feat/mac device - #8

Merged
kazu-1234 merged 2 commits into
mainfrom
feat/mac_device
Apr 27, 2026
Merged

Feat/mac device#8
kazu-1234 merged 2 commits into
mainfrom
feat/mac_device

Conversation

@vyuma

@vyuma vyuma commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Macで動作するように変更

Windowsの操作についてはまだ検証していないのでよろしくお願いします。

Summary by CodeRabbit

リリースノート

  • 新機能

    • デスクトップからのモバイル連携機能を追加しました。QRコードによるペアリング画面が利用可能になります
    • ウィンドウのフルスクリーン表示機能を有効化しました
    • ポスチャー検出時の振動テスト機能を追加しました
  • 改善

    • UIレイアウトを更新し、ペアリング関連の新しい画面要素を追加しました
    • ポスチャーキャリブレーションモデルを改善しました

@coderabbitai

coderabbitai Bot commented Apr 27, 2026

Copy link
Copy Markdown

Walkthrough

デスクトップアプリに対するモバイルペアリング機能を実装します。Tauri バックエンドではペアリングサーバーと状態管理を追加し、フロントエンドではペアリングダイアログとQRコード生成機能を実装。同時に姿勢トラッキングロジックを強化します。

Changes

Cohort / File(s) Summary
依存関係追加
package.json, src-tauri/Cargo.toml
QRコード生成(qrcode、@types/qrcode)、SHA-1ハッシング(sha1)、Base64エンコーディング(base64)の依存関係を追加。
ペアリングサーバー実装
src-tauri/src/pairing/server.rs
TCP リスナーのバインド、HTTP/WebSocketリクエストハンドリング、ペアリング状態管理、WebSocketクライアントへのイベントブロードキャスト機能を実装(398行)。
ペアリング状態・型定義
src-tauri/src/pairing/state.rs, src-tauri/src/pairing/types.rs
ペアリング状態管理(ホスト、ポート、トークン、ペアリング状態)、ユーティリティメソッド、JSON レスポンス生成機能を追加。
Tauri コマンド層
src-tauri/src/commands/pairing_commands.rs, src-tauri/src/commands/mod.rs, src-tauri/src/lib.rs
ペアリング情報・状態取得、姿勢シグナル発行コマンドを追加し、アプリ起動時にペアリングサーバーを初期化。
macOS設定
src-tauri/Info.plist, src-tauri/tauri.conf.json, src-tauri/capabilities/default.json
カメラ使用許可表示、フルスクリーン権限、Info.plist バンドル設定を追加。
フロントエンド:ペアリング UI コンポーネント
src/features/pairing/components/PairingDialog.tsx, src/features/pairing/components/PairingDialog.css
モーダルダイアログでペアリング情報(QR、リンク、接続状態)を表示し、バイブテストと更新機能を提供。
フロントエンド:ペアリングロジック
src/features/pairing/hooks/usePairingState.ts, src/features/pairing/services/desktopBridge.ts, src/features/pairing/services/pairingLink.ts, src/features/pairing/types/pairing.ts, src/features/pairing/index.ts
ペアリング状態ポーリング、Tauri ブリッジ、ペアリングリンク/QR生成ユーティリティを実装。
フロントエンド:姿勢トラッキング強化
src/App.tsx, src/App.css, src/lib/qrcode.ts
キャリブレーション基準の更新、顔・肩中心座標の追跡、ブラックアウト/アラート表示、QRコード生成、WebSocket統合、姿勢シグナルブロードキャスト。

Sequence Diagrams

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Optimize posture detection and fix slider range display #2: src/App.tsx の姿勢キャリブレーション・追跡ロジック(適応ベースライン、閾値、状態参照)の同じセクションを修正しており、関連度が高い。
  • 視線推定機能の追加 #1: src/App.tsx とスタイル実装における姿勢トラッキング UI・ロジック変更を含み、Mediapipe ランドマーク推定との関連がある。

Poem

🐰 ペアリングのQR コード光りて、
モバイルとデスクトップ、今つながる。
姿勢の信号、WebSocket を舞う、
トークンが守る、信頼の絆 ✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.85% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive プルリクエストのタイトル「Feat/mac device」は、変更内容の主要な目的であるmacOS対応の実装を示唆していますが、「mac device」という表現は曖昧で具体性に欠けており、正確な変更内容の把握が難しいです。実際の変更には、ペアリング機能、WebSocketサーバー、UI更新など複数の機能が含まれており、タイトルはこれらの複雑さを反映していません。 より具体的で説明的なタイトルに変更してください。例:「Add macOS support with mobile pairing server and improved UI」など、主要な機能追加を明確に説明するタイトルが望ましいです。
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mac_device

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::bindconnect("8.8.8.8:80")local_addr() のトリックは便利ですが、

  • インターネット非接続(社内 LAN のみ・オフラインデモ)
  • DNS や 8.8.8.8 をブロックするルータ

の環境では connectErr になり 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 エラーになります。pairingTokennull/空のときは 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-60getPairingStatus は失敗時に { 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) に反映されません。

qrPayloaduseMemo/派生値にして 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

startVibeTestawait 中に 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" にしても isBadPosturetrue なら shouldBlackoutScreenfalse に変わるので、appWindow.setFullscreen(false) が呼ばれて意図通りですが、逆に false の場合は依存値も変わらないため OK。実害はほぼないものの、

  1. lastBroadcastedPostureRef の比較は OK ですが
  2. 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

📥 Commits

Reviewing files that changed from the base of the PR and between f579826 and 695376d.

⛔ Files ignored due to path filters (3)
  • bun.lock is excluded by !**/*.lock
  • package-lock.json is excluded by !**/package-lock.json
  • src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (23)
  • package.json
  • src-tauri/Cargo.toml
  • src-tauri/Info.plist
  • src-tauri/capabilities/default.json
  • src-tauri/src/commands/mod.rs
  • src-tauri/src/commands/pairing_commands.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/pairing/mod.rs
  • src-tauri/src/pairing/server.rs
  • src-tauri/src/pairing/state.rs
  • src-tauri/src/pairing/types.rs
  • src-tauri/tauri.conf.json
  • src/App.css
  • src/App.tsx
  • src/features/pairing/components/PairingDialog.css
  • src/features/pairing/components/PairingDialog.tsx
  • src/features/pairing/hooks/usePairingState.ts
  • src/features/pairing/index.ts
  • src/features/pairing/services/desktopBridge.ts
  • src/features/pairing/services/pairingLink.ts
  • src/features/pairing/types/pairing.ts
  • src/lib/desktopBridge.ts
  • src/lib/qrcode.ts

Comment on lines +17 to +25
#[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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

mark_posture_signalbuild_ws_event の間にロックギャップがあり、シーケンス番号と発生順が乖離する可能性があります

emit_posture_signalstate.mark_posture_signal() でロックを取得 → 解放 → broadcast_ws_state_event() 内の state.build_ws_event() でロックを再取得、という二段階構造です(src-tauri/src/pairing/state.rs:148-170)。複数スレッドから並行に呼ばれた場合、

  1. スレッド A: marklast_sequence = N(ロック解放)
  2. スレッド B: marklast_sequence = N+1(ロック解放)
  3. スレッド A: build_ws_eventsequence = N+1 を読み取る
  4. スレッド B: build_ws_eventsequence = 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.

Comment on lines +16 to +24
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());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 5

Repository: 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 -20

Repository: 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=jsx

Repository: 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=rust

Repository: 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 -5

Repository: vyuma/posture-app

Length of output: 137


🏁 Script executed:

#!/bin/bash
# Check what's in desktopBridge.ts
cat ./src/lib/desktopBridge.ts

Repository: 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 -5

Repository: 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 3

Repository: vyuma/posture-app

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Read App.tsx to understand usage pattern
cat ./src/App.tsx

Repository: 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 5

Repository: 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=rust

Repository: vyuma/posture-app

Length of output: 43


start_pairing_server が複数回呼ばれると WS_SINK が古いままになります

WS_SINKOnceLock のため、2 回目以降の start_pairing_server 呼び出しで line 24 の WS_SINK.set(ws_sink.clone()) はサイレントに失敗します(let _ = で結果が無視されている)。その一方で新しい TcpListenerws_sink インスタンスは生成され、アクセプトループが回り始めるため、新規接続したクライアントは新しい ws_sink に登録されます。しかし broadcast_ws_state_eventemit_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.

Comment on lines +18 to +21
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

プレーン 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.

Comment on lines +26 to +47
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(())
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

接続にタイムアウトが無く、スレッドあたりの 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.

Comment on lines +49 to +85
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"),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines +5 to +21
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 });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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=tsx

Repository: 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=ts

Repository: 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.tsx

Repository: 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=ts

Repository: 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=rust

Repository: 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 20

Repository: 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 20

Repository: 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.tsx

Repository: 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.tsgetPairingStatus 関数を削除するか、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.

Comment on lines +11 to +19
export function buildPairingQrImageUrl(pairingLink: string): string {
if (!pairingLink) {
return "";
}

return `https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent(
pairingLink,
)}`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

外部 QR サービスへのトークン送出はセキュリティ上の懸念があります。

buildPairingQrImageUrlapi.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.

Comment thread src/lib/desktopBridge.ts
Comment on lines +3 to +6
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`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

PAIRING_PORT = 47831 固定がサーバ実装(動的ポート)と矛盾します

src-tauri/src/pairing/server.rs:19 ではポート 0 でバインド (= OS が割り当てる任意の空きポート) し、その値を PairingStateHandle::set_port に書き戻しています。一方、本ファイル + buildPairingUrlPAIRING_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.

Comment thread src/lib/desktopBridge.ts
Comment on lines +8 to +14
type PairingStatus = {
running: boolean;
port: number;
paired: boolean;
pairedPhoneIp: string | null;
pairingToken: string;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

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.* 参照箇所を修正してください。

Comment thread src/lib/desktopBridge.ts
Comment on lines +16 to +98
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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
done

Repository: 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.rs

Repository: vyuma/posture-app

Length of output: 3171


src/lib/desktopBridge.ts の複数のコマンドがバックエンドに未登録のため、主要機能が動作しません

src-tauri/src/lib.rs:25-30invoke_handler! に登録されているのは greetget_pairing_infoget_pairing_statusemit_posture_signal の 4 つのみです。本ファイルの以下のコマンドは未登録のため、try/catch で例外が握りつぶされ、null/false/デフォルト値が返されます:

  • get_primary_ipv4get_saved_phone_ipsave_phone_ip
  • set_blackout_windowstart_pairing_serverstop_pairing_server
  • send_vibration_signal

このため App.tsx の起動時処理で:

  • loadConnectionState(線 463)は getPrimaryIpv4()getSavedPhoneIp() がいずれも null を返す
  • startPairingServer()(線 486)も常に null を返すため、if (mounted && url) の条件を満たさず QR コード表示が実行されない
  • setBlackoutWindowsendVibrationSignalstopPairingServer も完全に 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.

@kazu-1234
kazu-1234 merged commit 2eae34d into main Apr 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants