Skip to content

Pair/add onboarding - #3

Merged
vyuma merged 4 commits into
mainfrom
pair/add-onboarding
May 7, 2026
Merged

Pair/add onboarding#3
vyuma merged 4 commits into
mainfrom
pair/add-onboarding

Conversation

@Kyoung9

@Kyoung9 Kyoung9 commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator
  • モバイル連携ボタンとペアリングダイアログを追加
  • QRコード/ディープリンク生成とクリップボードコピー機能を追加
  • デスクトップ-モバイルのペアリング状態の取得・手動更新機能を追加
  • Tauri 側のペアリングサーバー連携(/pair, /disconnect, /ws)とトークン検証を実装

Summary by CodeRabbit

  • 新機能

    • モバイル連携ダイアログを追加:QRコード/ペアリングリンクでモバイルと接続可能。
    • ペアリング状態の取得・自動更新(ポーリング)と状態表示を追加。
    • クリップボードコピー・リンク更新・閉じる操作をサポート。
    • 姿勢検出イベントを送信できるようになり、接続中のクライアントへリアルタイム通知を配信。
  • スタイル

    • ヒーロー領域とペアリングダイアログのレスポンシブなスタイルを追加。

@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown

Walkthrough

デスクトップとモバイルのペアリング機能を追加しました。Rust 側に TCP/HTTP/WebSocket ベースのペアリングサーバーとスレッド安全なペアリング状態を実装し、Tauri コマンドでフロントエンドと連携。React 側にペアリングダイアログ、状態フック、ブリッジを追加しました。

Changes

Cohort / File(s) Summary
Rust 依存関係
src-tauri/Cargo.toml
sha1base64 クレートを追加。
Tauri コマンド群
src-tauri/src/commands/mod.rs, src-tauri/src/commands/pairing_commands.rs
get_pairing_infoget_pairing_statusemit_posture_signal のコマンドを追加・公開。
アプリ初期化
src-tauri/src/lib.rs
ペアリング状態の作成、start_pairing_server 起動、.manage(pairing_state) 登録、invoke ハンドラへ新コマンド追加。
ペアリングモジュール公開面
src-tauri/src/pairing/mod.rs
server/state/types サブモジュールを宣言し再エクスポート。
ペアリングサーバー実装
src-tauri/src/pairing/server.rs
TCP バインド、HTTP ルーティング(/health,/pair,/disconnect,/ws)、手動 WebSocket upgrade、接続管理とブロードキャスト、token 検証、HTTP/JSON レスポンス生成を実装。
ペアリング状態管理
src-tauri/src/pairing/state.rs
PairingStateHandle(Arc/Mutex ラップ)を実装:ホスト/ポート/トークン/ペア情報、シーケンス更新、レスポンスビルダー等。
型定義
src-tauri/src/pairing/types.rs
PairingInfoDesktopPairingStatus の Serde 対応 struct を追加。
フロントエンドスタイル
src/App.css, src/features/pairing/components/PairingDialog.css
ヒーローのボタンとペアリングダイアログの CSS を追加。
App 統合
src/App.tsx
ペアリングダイアログ開閉 UI、姿勢変化に応じた emitPostureSignal 発行ロジックを追加。
ペアリング UI コンポーネント
src/features/pairing/components/PairingDialog.tsx
QR 表示、ペアリンク表示/コピー、リフレッシュ、閉じる操作を持つモーダルを実装。
状態フック
src/features/pairing/hooks/usePairingState.ts
初期読み込みと定期ポーリングで getPairingInfo/getDesktopPairingStatus を取得するフックを追加。
フロントサービス層
src/features/pairing/services/desktopBridge.ts, src/features/pairing/services/pairingLink.ts
Tauri invoke ブリッジ(get_pairing_info等)とペアリング URI・QR 画像 URL 生成ユーティリティを追加。
公開バレルと型
src/features/pairing/index.ts, src/features/pairing/types/pairing.ts
ペアリング機能の再エクスポートファイルと TypeScript の PairingInfo 型を追加。

Sequence Diagram(s)

sequenceDiagram
    participant User as User/UI
    participant Frontend as React App
    participant Tauri as Tauri Backend
    participant Server as Pairing Server
    participant Mobile as Mobile Client

    User->>Frontend: ペアリングダイアログを開く
    Frontend->>Tauri: invoke get_pairing_info()
    Tauri->>Frontend: PairingInfo {host, port, token}
    Frontend->>Frontend: QR を生成・表示
    User->>Mobile: QR をスキャン
    Mobile->>Server: HTTP GET /ws?token=...
    Server->>Server: token 検証、WebSocket upgrade
    Server->>Tauri: Pairing 状態更新(/pair)
    Server->>Frontend: WebSocket 経由でイベント配信
    Frontend->>User: ステータス更新を表示
Loading
sequenceDiagram
    participant App as React App
    participant Tauri as Tauri Backend
    participant Server as Pairing Server
    participant Clients as WebSocket Clients

    App->>Tauri: invoke emit_posture_signal(isBad)
    Tauri->>Server: mark_posture_signal + broadcast request
    Server->>Server: シーケンス更新、イベント生成
    Server->>Clients: posture_bad/posture_good イベント送信
    Clients->>Clients: イベント受信・処理
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 新しい扉をぴょん、と開けて、
QR でつながる小さな架け橋、
サーバーが奏でる合図を運んで、
ポストチャーの拍子もこだまする、
さあ、ペアリングの舞を始めよう!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed タイトルはペアリング機能とオンボーディングに関連する部分的な変更を示していますが、より包括的な内容(WebSocketサーバー、複数のUIコンポーネント、状態管理)を完全には反映していません。
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 pair/add-onboarding

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.

@Kyoung9
Kyoung9 requested a review from vyuma April 21, 2026 18:27

@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: 11

🧹 Nitpick comments (1)
src/features/pairing/hooks/usePairingState.ts (1)

70-96: ポーリングにおける多重実行と連続失敗時のバックオフを検討

window.setInterval + async コールバックの組み合わせでは、getDesktopPairingStatus() が 1500ms 以内に返らなかった場合にリクエストが重なって積み上がります。また、Tauri バックエンドや UI 側が一時的に落ちているケースでは、error メッセージを永続的に 1.5 秒周期で上書きし続けることになります。

以下のパターンを推奨します。

  • setInterval ではなく、前回の完了後に setTimeout で次回を予約する自己スケジューリング方式
  • 連続失敗時のエクスポネンシャルバックオフ(例: 1.5s → 3s → 6s … 上限 30s 程度)
🤖 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 70 - 96, Replace
the window.setInterval polling in usePairingState with a self-scheduling async
loop: stop using setInterval and instead create an async function (e.g.,
pollStatus) that awaits getDesktopPairingStatus(), updates state via setState
(status and error) only if active, then schedules the next run with setTimeout;
on errors implement exponential backoff (multiply lastDelay starting from
POLL_INTERVAL_MS by 2 up to a max like 30000ms and set state.error to
error.message or STATUS_POLL_ERROR_MESSAGE) and reset backoff on a successful
call; ensure you keep the active flag checks and clear any pending timeout on
cleanup so polling stops when unmounted.
🤖 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 runs synchronously on the
main thread and calls broadcast_ws_state_event which locks the
Arc<Mutex<Vec<TcpStream>>> and does blocking write_all calls; change
emit_posture_signal to avoid blocking the main thread by either (A) making it
async (async fn emit_posture_signal) and moving the blocking socket writes into
tauri::async_runtime::spawn_blocking inside broadcast_ws_state_event, or (B)
implement an internal send queue: add a channel (mpsc) and a dedicated sender
task/thread owned by PairingStateHandle that performs the locked writes, and
have emit_posture_signal only mark_posture_signal() and enqueue the event;
update references to PairingStateHandle and broadcast_ws_state_event accordingly
so no synchronous write_all is performed while holding the Mutex on the main
thread.

In `@src-tauri/src/pairing/server.rs`:
- Around line 181-191: The current read_until_socket_closes function just
discards bytes and doesn't handle WebSocket frames, causing missed Close/Ping
semantics and stale entries in WS_SINK; replace the loop in
read_until_socket_closes with proper WebSocket frame handling: either upgrade to
a real WS library (e.g., use tungstenite::accept on the TcpStream and read
frames) or minimally parse the first frame byte to detect opcode 0x8 (Close) and
0x9 (Ping), reply with a Close or a Pong (opcode 0xA) respectively and then
return Ok on Close; ensure any write failures feed back into
WS_SINK/broadcast_ws_event cleanup so stale connections are removed.
- Around line 161-179: broadcast_ws_event currently holds the WS_SINK mutex
while synchronously writing to each client, which can block; change it to first
lock WS_SINK and collect clones/copies of the client streams or a lightweight
send-handle (use the same client type used in WsSink) into a local Vec, then
drop the lock before performing writes in a loop so slow clients don't block
registration; apply a write timeout on each TcpStream via
set_write_timeout(Some(duration)) before writing to avoid indefinite blocking,
and after the write pass re-lock WS_SINK to remove failed clients (use the
existing write_websocket_text_frame and WS_SINK symbols and keep
broadcast_ws_state_event unchanged).
- Around line 49-85: The single read in handle_connection can miss HTTP headers;
change the logic so you consume from TcpStream with a buffered reader (e.g.,
wrap stream in a BufReader or use an HTTP parser like httparse) and read until
the full header terminator CRLFCRLF is received (or let httparse parse
incrementally) before calling parse_headers and dispatching to
handle_websocket/handle_pair/handle_disconnect; update parse_headers usage to
accept a complete header string or bytes and ensure handle_websocket checks use
the fully parsed headers (e.g., references: handle_connection, parse_headers,
handle_websocket, split_target, parse_query).

In `@src-tauri/src/pairing/state.rs`:
- Around line 115-130: pair_device unconditionally overwrites existing pairing;
change it to first check state.paired and state.device_name under the same lock
and, if already paired, return a failure PairResponse (ok: false) or trigger the
disconnect/cleanup flow before accepting a new pair; only update last_sequence,
last_seen_at, device_name and set paired = true when performing the actual swap.
Locate the pair_device function and modify its early return logic so it either
rejects when state.paired is true (returning a clear PairResponse with ok: false
and a message) or calls the existing disconnect/emit logic for the current
device first, then proceeds to set state.paired, device_name, last_seen_at and
last_sequence and return success.
- Around line 110-113: matches_token currently uses short-circuit equality
(state.token == token) which can leak timing information; change it to a
constant-time comparison by reading the locked value (state.token) into a local
variable and performing a length-checked, constant-time byte-wise compare (e.g.,
iterating bytes, XOR-ing into an accumulator and returning true only if lengths
equal and accumulator == 0). Alternatively, use subtle::ConstantTimeEq if you
prefer an external helper; ensure the function still acquires the lock via
self.inner.lock() as before and compares the stored token and the input in
constant time.
- Around line 214-225: The local_ip_address function currently returns bare IPv6
strings and falls back to "127.0.0.1" on any socket error; update
local_ip_address (the UdpSocket::bind .. connect .. map chain) so that when
matching IpAddr::V6 you return the address wrapped in brackets (e.g.,
"[<ipv6>]") to produce valid host tokens for URL construction, and change the
error fallback from a hardcoded "127.0.0.1" to a non-misleading value (e.g., an
empty string or propagate an error) so the caller/front-end does not get a false
localhost address; ensure you only modify the mapping/unwrap_or_else behavior in
local_ip_address and not other networking code.
- Around line 206-212: The current generate_token() is predictable because it
derives from SystemTime; replace it with a CSPRNG-based implementation (e.g.,
use the getrandom crate) so pairing tokens are cryptographically random: add
getrandom to Cargo.toml, modify generate_token() to fill a fixed-length byte
buffer (eg. 32 bytes) with getrandom(), and return a URL-safe encoded string
(hex or base64url) of those bytes; keep the function name generate_token() and
ensure callers for /pair, /disconnect, and /ws continue to use the new return
value.

In `@src/features/pairing/components/PairingDialog.tsx`:
- Around line 42-59: The PairingDialog currently lacks focus management; when
mounted, save document.activeElement, then move focus into the dialog (e.g.,
call .focus() on the "閉じる" button with class pairing-close or the first
focusable element inside PairingDialog), and on unmount restore focus to the
saved trigger element; additionally implement a focus trap inside the
PairingDialog (handle Tab and Shift+Tab to cycle focus within elements in the
dialog or replace with a library like focus-trap-react) so keyboard and
screen-reader users cannot tab out of the dialog while role="dialog" and
aria-modal="true" are active.

In `@src/features/pairing/services/pairingLink.ts`:
- Around line 16-18: The current implementation encodes the full pairingLink
(which contains host, port and token) into an external QR service URL, exposing
sensitive pairing tokens; update the code that builds the QR to stop calling
api.qrserver.com and instead generate the QR locally using a library (e.g.,
qrcode or equivalent) inside the same module: take the pairingLink value
produced in this file (the pairingLink variable / buildPairingLink function),
pass it to a local QR generator to produce a data URL or Buffer, and return that
local QR output (or save/serve it locally) so the token and host/port are never
sent to an external service. Ensure the change replaces the external service URL
construction and keeps the same return type contract (data URL or Buffer) used
by callers.
- Line 8: The return builds a deep-link by interpolating raw values (in
pairingLink.ts) which can break if pairingInfo.host/token contain reserved
characters; change the implementation of the function that returns the string to
build the query via URLSearchParams (or encodeURIComponent) using keys "host",
"port", "token" from pairingInfo and append the resulting serialized search
string to "vibeapp://pair?" so all parameters (especially pairingInfo.token and
pairingInfo.host) are properly URL-encoded before returning.

---

Nitpick comments:
In `@src/features/pairing/hooks/usePairingState.ts`:
- Around line 70-96: Replace the window.setInterval polling in usePairingState
with a self-scheduling async loop: stop using setInterval and instead create an
async function (e.g., pollStatus) that awaits getDesktopPairingStatus(), updates
state via setState (status and error) only if active, then schedules the next
run with setTimeout; on errors implement exponential backoff (multiply lastDelay
starting from POLL_INTERVAL_MS by 2 up to a max like 30000ms and set state.error
to error.message or STATUS_POLL_ERROR_MESSAGE) and reset backoff on a successful
call; ensure you keep the active flag checks and clear any pending timeout on
cleanup so polling stops when unmounted.
🪄 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: 2b65dc4f-0549-4cb2-b7e4-a7e747fd70c1

📥 Commits

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

⛔ Files ignored due to path filters (1)
  • src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • src-tauri/Cargo.toml
  • 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/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

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.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Tauri v2 synchronous command thread execution model

💡 Result:

In Tauri v2, commands annotated with #[tauri::command] have distinct execution models based on whether they are synchronous or asynchronous. All commands execute in the Tauri core process (Rust side), which runs on the main thread handling the event loop. Synchronous commands (non-async functions): - Execute directly on the main thread synchronously. - Can block the UI/event loop if they perform long-running CPU-bound work, which is discouraged. - Supported for quick operations, but async is preferred to avoid UI freezes. Asynchronous commands (async functions or #[tauri::command(async)] on sync functions): - Executed by spawning a new async task using tauri::async_runtime::spawn (Tokio multi-threaded runtime). - The task runs on a worker thread from the Tokio thread pool (not main thread), requiring all captured data to be Send. - Allows non-blocking execution; the main thread awaits the task result without blocking. - For non-Send code in async commands, workarounds like tauri::async_runtime::block_on or std::sync::Exclusive may be used, but running async commands on main thread is a requested feature (open issue). There is also #[tauri::command(async)] attribute to force async execution even on sync functions, spawning them similarly. Window/webview built-in commands are async to avoid deadlocks from event emissions during sync execution on main thread. Official docs recommend async commands for heavy work to prevent UI freezes, confirming sync commands run on main thread while async use separate threads/tasks.

Citations:


🏁 Script executed:

# Find the broadcast_ws_state_event function
rg "broadcast_ws_state_event" --type rust -B 2 -A 10

Repository: vyuma/posture-app

Length of output: 4010


🏁 Script executed:

# Search for the WebSocket write implementation
rg "write_websocket_text_frame\|write.*tcp\|write.*socket" --type rust -B 2 -A 5

Repository: vyuma/posture-app

Length of output: 43


🏁 Script executed:

# Check the overall structure of pairing_commands.rs and related modules
head -100 src-tauri/src/commands/pairing_commands.rs

Repository: vyuma/posture-app

Length of output: 730


🏁 Script executed:

# Get the full broadcast_ws_event and related implementation
rg "fn broadcast_ws_event|struct WsSink|type WsSink" --type rust -A 20

Repository: vyuma/posture-app

Length of output: 3072


🏁 Script executed:

# Check for write timeout configuration
rg "timeout|Timeout|time_out|set_read_timeout|set_write_timeout" src-tauri/src/pairing/ --type rust -B 2 -A 2

Repository: vyuma/posture-app

Length of output: 43


🏁 Script executed:

# Look for the WS_SINK definition and how it stores sinks
rg "WS_SINK|OnceCell" src-tauri/src/pairing/ --type rust -B 3 -A 10

Repository: vyuma/posture-app

Length of output: 2635


🏁 Script executed:

# Find write_websocket_text_frame implementation
rg "fn write_websocket_text_frame" --type rust -A 20

Repository: vyuma/posture-app

Length of output: 1367


🏁 Script executed:

# Search for any timeout settings or non-blocking writes
rg "set_read_timeout|set_write_timeout|nonblocking|non_blocking" src-tauri/src/ --type rust -B 2 -A 2

Repository: vyuma/posture-app

Length of output: 43


🏁 Script executed:

# Check how frequently emit_posture_signal is called from frontend
rg "emit_posture_signal" --type rust --type typescript --type javascript -B 3 -A 3

Repository: vyuma/posture-app

Length of output: 95


🏁 Script executed:

# Search for emit_posture_signal calls in TypeScript/JavaScript with different file type
rg "emit_posture_signal" --type rust -l | head -20

Repository: vyuma/posture-app

Length of output: 124


🏁 Script executed:

# Find TypeScript/JS files that might call emit_posture_signal
fd "\\.(ts|js|tsx|jsx)$" -type f | head -20

Repository: vyuma/posture-app

Length of output: 231


🏁 Script executed:

# Check frequency - look for posture signal emission patterns
rg "posture.*signal\|signal.*posture" --type rust -B 3 -A 3 | head -50

Repository: vyuma/posture-app

Length of output: 43


🏁 Script executed:

# Check the full write_websocket_text_frame to see complete implementation
rg "fn write_websocket_text_frame" --type rust -A 25

Repository: vyuma/posture-app

Length of output: 1755


🏁 Script executed:

# Find all files in src directory (frontend)
find . -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" | grep -v node_modules | head -30

Repository: vyuma/posture-app

Length of output: 394


🏁 Script executed:

# Search for emit_posture_signal in frontend code
find . -path ./node_modules -prune -o -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \) -print0 | xargs -0 grep -l "emit_posture_signal" 2>/dev/null

Repository: vyuma/posture-app

Length of output: 109


🏁 Script executed:

# Look at the complete broadcast_ws_event function to understand the full blocking pattern
sed -n '/^fn broadcast_ws_event/,/^}/p' src-tauri/src/pairing/server.rs

Repository: vyuma/posture-app

Length of output: 507


🏁 Script executed:

# Check how emit_posture_signal is called in the frontend
cat ./src/features/pairing/services/desktopBridge.ts | head -100

Repository: vyuma/posture-app

Length of output: 666


🏁 Script executed:

# Look for posture-related calls and frequency
rg "posture|emit" ./src --type rust -l

Repository: vyuma/posture-app

Length of output: 43


🏁 Script executed:

# Check if there's any rate limiting or throttling of posture signals
rg "throttle|debounce|rate" ./src -A 2 -B 2

Repository: vyuma/posture-app

Length of output: 43


emit_posture_signal を非同期化するか別スレッドに逃がすことを推奨

Tauri v2 の同期コマンドはメインスレッド上で実行されます。このコマンドは broadcast_ws_state_event を呼び出し、その内部で Arc<Mutex<Vec<TcpStream>>> をロックして全 WebSocket クライアントに対して stream.write_all() による同期 TCP write を実行します。ロック中の write がタイムアウト設定されていないため、応答の遅いクライアントが 1 つでもいると、ロック解放まで無期限にメインスレッドが ブロックされ、その間に他の invoke 呼び出しは全て詰まります。

フロントエンド側では throttle/debounce が無く、高頻度で呼び出される可能性があります。以下のいずれかを推奨します。

  • コマンドを async fn にし、送信処理を tauri::async_runtime::spawn_blocking に逃がす
  • 内部に送信用チャンネル + 専用スレッド/タスクを置き、コマンドからは enqueue のみ行う
🤖 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 runs synchronously on the main thread and calls
broadcast_ws_state_event which locks the Arc<Mutex<Vec<TcpStream>>> and does
blocking write_all calls; change emit_posture_signal to avoid blocking the main
thread by either (A) making it async (async fn emit_posture_signal) and moving
the blocking socket writes into tauri::async_runtime::spawn_blocking inside
broadcast_ws_state_event, or (B) implement an internal send queue: add a channel
(mpsc) and a dedicated sender task/thread owned by PairingStateHandle that
performs the locked writes, and have emit_posture_signal only
mark_posture_signal() and enqueue the event; update references to
PairingStateHandle and broadcast_ws_state_event accordingly so no synchronous
write_all is performed while holding the Mutex on the main thread.

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

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the specific file and lines mentioned
cd src-tauri && head -100 src/pairing/server.rs | cat -n

Repository: vyuma/posture-app

Length of output: 3997


🏁 Script executed:

# Get file size to determine if we can read it fully
wc -l src-tauri/src/pairing/server.rs

Repository: vyuma/posture-app

Length of output: 96


🏁 Script executed:

# Read the handle_connection function and related code
sed -n '49,85p' src-tauri/src/pairing/server.rs | cat -n

Repository: vyuma/posture-app

Length of output: 1615


🏁 Script executed:

# Also check the handle_websocket function to see how it handles WebSocket
rg -A 20 "fn handle_websocket" src-tauri/src/pairing/

Repository: vyuma/posture-app

Length of output: 1379


🏁 Script executed:

# Look for any BufReader or httparse usage in the project
rg "BufReader|httparse|read_until|read_line" src-tauri/src/

Repository: vyuma/posture-app

Length of output: 236


🏁 Script executed:

# Check if there's any looping to read full HTTP request
rg -B 3 -A 10 "read.*buffer|parse.*request" src-tauri/src/pairing/server.rs

Repository: vyuma/posture-app

Length of output: 1811


🏁 Script executed:

# Check the parse_headers implementation more carefully
sed -n '320,360p' src-tauri/src/pairing/server.rs | cat -n

Repository: vyuma/posture-app

Length of output: 1297


🏁 Script executed:

# Look for any retry or looping logic after handle_connection
sed -n '49,150p' src-tauri/src/pairing/server.rs | cat -n

Repository: vyuma/posture-app

Length of output: 3989


🏁 Script executed:

# Check if there are any TCP socket configuration like read timeout
rg -B 5 -A 5 "read_timeout|set_read_timeout|BufReader" src-tauri/src/pairing/server.rs

Repository: vyuma/posture-app

Length of output: 43


🏁 Script executed:

# Verify the exact parse_headers function to see how it handles incomplete headers
rg -B 2 -A 15 "^fn parse_headers" src-tauri/src/pairing/server.rs

Repository: vyuma/posture-app

Length of output: 449


🏁 Script executed:

# Check if there's any looping or retry mechanism after parse_headers fails
sed -n '1,150p' src-tauri/src/pairing/server.rs | rg -A 10 "parse_headers|handle_websocket"

Repository: vyuma/posture-app

Length of output: 980


🏁 Script executed:

# Verify the exact websocket validation - check is_websocket_upgrade
rg -B 2 -A 10 "fn is_websocket_upgrade" src-tauri/src/pairing/server.rs

Repository: vyuma/posture-app

Length of output: 477


🏁 Script executed:

# Check if there's multiple connections or retry in the connection handling loop
sed -n '26,44p' src-tauri/src/pairing/server.rs | cat -n

Repository: vyuma/posture-app

Length of output: 901


🏁 Script executed:

# Check if there's any evidence of actual issues or if the code has been working
git log --oneline src-tauri/src/pairing/server.rs | head -20

Repository: vyuma/posture-app

Length of output: 73


🏁 Script executed:

# Verify the actual maximum size of HTTP WebSocket upgrade request in practice
# Let's see what a typical WebSocket upgrade request looks like
cat > /tmp/test_ws_request.txt << 'EOF'
GET /ws?token=abc HTTP/1.1
Host: 192.168.1.1:12345
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
User-Agent: Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36
Accept-Language: ja,en;q=0.9
Origin: http://192.168.1.1:12345

EOF
wc -c /tmp/test_ws_request.txt

Repository: vyuma/posture-app

Length of output: 89


🏁 Script executed:

# Check if parse_headers is robust to missing headers
# It should gracefully handle incomplete header sets
sed -n '360,380p' src-tauri/src/pairing/server.rs

Repository: vyuma/posture-app

Length of output: 722


🏁 Script executed:

# Look for any test cases that might show if fragmentation has been tested
fd -type f "test" src-tauri/src/pairing/ | head -10

Repository: vyuma/posture-app

Length of output: 231


🏁 Script executed:

# Check the actual project size and whether it's still in development
git log --all --oneline | tail -5

Repository: vyuma/posture-app

Length of output: 205


stream.read() の単一呼び出しではHTTPヘッダの欠落が発生し得ます

TCP/IP はメッセージ境界を保証しないため、ヘッダが複数の TCP セグメントに分割される場合、特に WebSocket ハンドシェイク時の Sec-WebSocket-KeyUpgrade といった必須ヘッダが読み込み範囲に含まれない可能性があります。通常の WebSocket アップグレードリクエストは約 286 バイトですが、カスタムヘッダやプロキシを経由する場合に 4096 バイト を超えたり、パケット分割が発生したりするシナリオは存在します。

現在の実装では parse_headers() は欠落ヘッダを単に無視し、handle_websocket() で "missing sec-websocket-key" エラーが返されますが、これは散発的な接続失敗につながります。BufReader で確実に \r\n\r\n まで読み切る、または httparse 等の HTTP パーサライブラリの利用を推奨します。

🤖 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, The single read in
handle_connection can miss HTTP headers; change the logic so you consume from
TcpStream with a buffered reader (e.g., wrap stream in a BufReader or use an
HTTP parser like httparse) and read until the full header terminator CRLFCRLF is
received (or let httparse parse incrementally) before calling parse_headers and
dispatching to handle_websocket/handle_pair/handle_disconnect; update
parse_headers usage to accept a complete header string or bytes and ensure
handle_websocket checks use the fully parsed headers (e.g., references:
handle_connection, parse_headers, handle_websocket, split_target, parse_query).

Comment on lines +161 to +179
pub fn broadcast_ws_state_event(state: &PairingStateHandle, event_type: &str) {
if let Some(ws_sink) = WS_SINK.get() {
let event = state.build_ws_event(event_type);
broadcast_ws_event(ws_sink, &event);
}
}

fn broadcast_ws_event(ws_sink: &WsSink, event: &impl serde::Serialize) {
let payload = match serde_json::to_string(event) {
Ok(payload) => payload,
Err(error) => {
eprintln!("failed to serialize websocket event: {error}");
return;
}
};

let mut clients = ws_sink.lock().expect("ws sink poisoned");
clients.retain_mut(|client| write_websocket_text_frame(client, &payload).is_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

ブロードキャストがロック保持下の同期 write で詰まり得ます

broadcast_ws_eventws_sinkMutex を保持したまま全クライアントへ順番に write_all を行っています。TCP は送信バッファが満杯になると write_all がブロックするため、以下の問題が発生します。

  • 応答の遅いクライアント 1 つで全体のブロードキャストが停止
  • その間、他スレッドからの ws_sink.lock() も待たされ、新規 /ws 接続の登録までブロック
  • emit_posture_signal が頻繁に呼ばれるため症状が顕在化しやすい

対策としては以下が有効です。

  • TcpStreamset_write_timeout(Some(...)) を設定し、write タイムアウトを保証する
  • クライアントごとに送信用 mpsc::channel を持たせ、書き込みはロック外のワーカースレッドで行う
  • 最低でも、ロック中に stream のクローンを集めてロック解放 → 解放後に書き込みを行い、失敗分を再度ロックを取って除去
🤖 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 161 - 179, broadcast_ws_event
currently holds the WS_SINK mutex while synchronously writing to each client,
which can block; change it to first lock WS_SINK and collect clones/copies of
the client streams or a lightweight send-handle (use the same client type used
in WsSink) into a local Vec, then drop the lock before performing writes in a
loop so slow clients don't block registration; apply a write timeout on each
TcpStream via set_write_timeout(Some(duration)) before writing to avoid
indefinite blocking, and after the write pass re-lock WS_SINK to remove failed
clients (use the existing write_websocket_text_frame and WS_SINK symbols and
keep broadcast_ws_state_event unchanged).

Comment on lines +181 to +191
fn read_until_socket_closes(mut stream: TcpStream) -> Result<(), String> {
let mut buffer = [0_u8; 1024];

loop {
match stream.read(&mut buffer) {
Ok(0) => return Ok(()),
Ok(_) => continue,
Err(error) => return Err(error.to_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 | 🟡 Minor

read_until_socket_closes は WebSocket フレームを解釈していません

このループは受信バイトを単に捨てているため、クライアントからの Close フレーム(opcode 0x8)や Ping フレームに対応できません。結果として以下の不都合が起こり得ます。

  • クライアントが正常クローズを試みても、サーバー側は FIN を受け取るまで解放されない
  • 中継機器のアイドルタイムアウトで TCP 半オープン状態になった場合、WS_SINK にゴミ接続が溜まり、次の broadcast_ws_eventwrite が失敗して初めて除去される(ヘルスチェックが無いため長時間放置される可能性あり)
  • Ping/Pong が無いため NAT/Proxy 側のアイドル切断でサイレントに接続が失われる

1 機能の足場としては許容できますが、中期的には tungstenite 等の WebSocket 実装に乗せ替えるか、最低限 0x8 (Close) / 0x9 (Ping) を判別して Pong/Close を返すループに差し替えることを推奨します。

🤖 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, The current
read_until_socket_closes function just discards bytes and doesn't handle
WebSocket frames, causing missed Close/Ping semantics and stale entries in
WS_SINK; replace the loop in read_until_socket_closes with proper WebSocket
frame handling: either upgrade to a real WS library (e.g., use
tungstenite::accept on the TcpStream and read frames) or minimally parse the
first frame byte to detect opcode 0x8 (Close) and 0x9 (Ping), reply with a Close
or a Pong (opcode 0xA) respectively and then return Ok on Close; ensure any
write failures feed back into WS_SINK/broadcast_ws_event cleanup so stale
connections are removed.

Comment on lines +110 to +113
pub fn matches_token(&self, token: &str) -> bool {
let state = self.inner.lock().expect("pairing state poisoned");
state.token == token
}

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

トークン比較は定数時間で行ってください

state.token == token は早期リターンのショートサーキット比較のため、理論上タイミング側チャネルでトークンを推測される余地があります。LAN 内リモートでは実用的な脅威になりにくいですが、認証トークン比較では定数時間比較が定石です。

🔒 提案修正(依存を増やさず自前の定数時間比較)
 pub fn matches_token(&self, token: &str) -> bool {
     let state = self.inner.lock().expect("pairing state poisoned");
-    state.token == token
+    let a = state.token.as_bytes();
+    let b = token.as_bytes();
+    if a.len() != b.len() {
+        return false;
+    }
+    let mut diff: u8 = 0;
+    for (x, y) in a.iter().zip(b.iter()) {
+        diff |= x ^ y;
+    }
+    diff == 0
 }

subtle クレートの ConstantTimeEq が利用できればより簡潔です。

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn matches_token(&self, token: &str) -> bool {
let state = self.inner.lock().expect("pairing state poisoned");
state.token == token
}
pub fn matches_token(&self, token: &str) -> bool {
let state = self.inner.lock().expect("pairing state poisoned");
let a = state.token.as_bytes();
let b = token.as_bytes();
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
🤖 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 110 - 113, matches_token
currently uses short-circuit equality (state.token == token) which can leak
timing information; change it to a constant-time comparison by reading the
locked value (state.token) into a local variable and performing a
length-checked, constant-time byte-wise compare (e.g., iterating bytes, XOR-ing
into an accumulator and returning true only if lengths equal and accumulator ==
0). Alternatively, use subtle::ConstantTimeEq if you prefer an external helper;
ensure the function still acquires the lock via self.inner.lock() as before and
compares the stored token and the input in constant time.

Comment on lines +206 to +212
fn generate_token() -> String {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time before unix epoch");

format!("{:x}{:x}", duration.as_secs(), duration.subsec_nanos())
}

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

🌐 Web query:

Rust getrandom crate latest stable version 2026

💡 Result:

The latest stable version of the Rust getrandom crate is 0.4.2, released on 2026-03-03.

Citations:


🏁 Script executed:

cat -n src-tauri/src/pairing/state.rs

Repository: vyuma/posture-app

Length of output: 7448


🏁 Script executed:

find src-tauri -type f -name "*.rs" | head -20

Repository: vyuma/posture-app

Length of output: 318


🏁 Script executed:

cat -n src-tauri/src/pairing/server.rs

Repository: vyuma/posture-app

Length of output: 14288


🏁 Script executed:

cat -n src-tauri/src/pairing/types.rs

Repository: vyuma/posture-app

Length of output: 553


🏁 Script executed:

find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \) | head -20

Repository: vyuma/posture-app

Length of output: 394


🏁 Script executed:

cat -n ./src/features/pairing/services/pairingLink.ts

Repository: vyuma/posture-app

Length of output: 704


🏁 Script executed:

cat -n ./src/features/pairing/services/desktopBridge.ts

Repository: vyuma/posture-app

Length of output: 813


🏁 Script executed:

grep -r "host.*port.*token" src --include="*.ts" --include="*.tsx" -n

Repository: vyuma/posture-app

Length of output: 211


🏁 Script executed:

grep -r "http.*host\|host.*http" src --include="*.ts" --include="*.tsx" -n

Repository: vyuma/posture-app

Length of output: 43


🏁 Script executed:

rg "host.*port|fetch|http://" src --type ts --type tsx -A 2 | head -50

Repository: vyuma/posture-app

Length of output: 88


🏁 Script executed:

rg "http|fetch|ws" src/features/pairing -A 2 | head -80

Repository: vyuma/posture-app

Length of output: 544


🏁 Script executed:

rg "vibeapp" src -A 5 -B 5

Repository: vyuma/posture-app

Length of output: 887


ペアリングトークンが予測可能です(重大なセキュリティ欠陥)

generate_token()SystemTime::now() の秒とナノ秒だけをhexで連結しています。これは CSPRNG ではなく、以下の点で認証トークンとして不適切です。

  • アプリ起動時刻を大まかに把握できる攻撃者(LAN 上に同居、ログ、UI 表示タイミング観測など)は探索空間が極めて狭く、総当たりが現実的です。
  • subsec_nanos() は OS のタイマ解像度に依存し、実効エントロピーは名目より大幅に低い場合があります。
  • このトークンは /pair/disconnect/ws の唯一の認証材料として使われており、突破されるとペアリングの乗っ取りや任意デバイスの接続・切断が可能になります。

暗号学的乱数ベースに置き換えてください。

🔒 提案修正(`getrandom` を使用)
-fn generate_token() -> String {
-    let duration = SystemTime::now()
-        .duration_since(UNIX_EPOCH)
-        .expect("system time before unix epoch");
-
-    format!("{:x}{:x}", duration.as_secs(), duration.subsec_nanos())
-}
+fn generate_token() -> String {
+    let mut bytes = [0u8; 16];
+    getrandom::getrandom(&mut bytes).expect("failed to generate secure token");
+    bytes.iter().map(|b| format!("{:02x}", b)).collect()
+}

Cargo.tomlgetrandom の追加が必要です(最新安定版は 0.4.2)。

🤖 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 206 - 212, The current
generate_token() is predictable because it derives from SystemTime; replace it
with a CSPRNG-based implementation (e.g., use the getrandom crate) so pairing
tokens are cryptographically random: add getrandom to Cargo.toml, modify
generate_token() to fill a fixed-length byte buffer (eg. 32 bytes) with
getrandom(), and return a URL-safe encoded string (hex or base64url) of those
bytes; keep the function name generate_token() and ensure callers for /pair,
/disconnect, and /ws continue to use the new return value.

Comment on lines +214 to +225
fn local_ip_address() -> String {
UdpSocket::bind("0.0.0.0:0")
.and_then(|socket| {
socket.connect("8.8.8.8:80")?;
socket.local_addr()
})
.map(|addr| match addr.ip() {
IpAddr::V4(ipv4) => ipv4.to_string(),
IpAddr::V6(ipv6) => ipv6.to_string(),
})
.unwrap_or_else(|_| "127.0.0.1".to_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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# host フィールドが URL 組み立てに使われているかを確認
rg -nP -C3 '\b(host|pairingInfo|PairingInfo)\b' --type=ts --type=tsx --type=rs
rg -nP -C2 'http://\$\{.*host'

Repository: vyuma/posture-app

Length of output: 88


🏁 Script executed:

#!/bin/bash
# ファイルタイプの問題を修正してリサーチ
# まず、レポジトリのファイル構造を確認
echo "=== Repository structure ==="
git ls-files | head -30

echo -e "\n=== state.rs の実装確認 ==="
if [ -f "src-tauri/src/pairing/state.rs" ]; then
  wc -l src-tauri/src/pairing/state.rs
fi

echo -e "\n=== TypeScript/JS ファイルを探す ==="
find . -type f \( -name "*.ts" -o -name "*.js" \) | grep -E "(pairing|QR|qr)" | head -20

Repository: vyuma/posture-app

Length of output: 1179


🏁 Script executed:

#!/bin/bash
# host や pairingInfo の使用パターンを検索(ファイルタイプを正しく指定)
echo "=== host フィールドの使用パターン ==="
rg -n 'host' --type=ts -C2 2>/dev/null | head -100

echo -e "\n=== pairingInfo の使用パターン ==="
rg -n 'pairingInfo|PairingInfo' -C2 2>/dev/null | head -100

echo -e "\n=== URL 組み立てパターン ==="
rg -n 'http://' --type=ts -C2 2>/dev/null | head -100

Repository: vyuma/posture-app

Length of output: 7611


🏁 Script executed:

#!/bin/bash
# state.rs の実装を直接確認
echo "=== state.rs lines around 214-225 ==="
if [ -f "src-tauri/src/pairing/state.rs" ]; then
  sed -n '200,240p' src-tauri/src/pairing/state.rs
fi

echo -e "\n=== state.rs 全体を確認 ==="
if [ -f "src-tauri/src/pairing/state.rs" ]; then
  head -100 src-tauri/src/pairing/state.rs
fi

Repository: vyuma/posture-app

Length of output: 3038


🏁 Script executed:

#!/bin/bash
# QR コード生成パターンを確認
echo "=== QR コード生成パターン ==="
rg -n 'buildPairingQrImageUrl|qr' -i --type=ts -C2 2>/dev/null

echo -e "\n=== pairingLink.ts の全体確認 ==="
cat -n src/features/pairing/services/pairingLink.ts

echo -e "\n=== state.rs での host の初期化確認 ==="
sed -n '60,100p' src-tauri/src/pairing/state.rs

echo -e "\n=== Cargo.toml で依存ライブラリを確認 ==="
grep -A5 -B5 'socket' src-tauri/Cargo.toml 2>/dev/null || echo "Dependencies section"

Repository: vyuma/posture-app

Length of output: 5355


🏁 Script executed:

#!/bin/bash
# QR コード URL 構築パターンを確認
echo "=== QR 画像 URL 構築パターン ==="
rg -n 'buildPairingQrImageUrl|qrImageUrl' -A5 --type=ts 2>/dev/null

echo -e "\n=== HTTP URL 構築パターン ==="
rg -n 'http://' --type=ts -B2 -A2 2>/dev/null | head -50

Repository: vyuma/posture-app

Length of output: 2358


IPv6 アドレスを含む場合、URL 構築でブラケットが必須です

IpAddr::V6(ipv6) => ipv6.to_string()fe80::1 のようなブラケット無しの文字列を返します。フロント側で QR コードをデコード後、モバイルアプリが http://{host}:{port} 形式に組み立てるとき、IPv6 環境では不正な URL になります。

同時に、UDP connect による 8.8.8.8:80 へのネットワーク接続試行は、オフライン環境や社内ネットワーク、DNS/UDP がフィルタリングされた環境では失敗し、127.0.0.1 へのフォールバックが発生します。ローカルホストへのフォールバックはリモートペアリングを事実上不能にするため、この動作が許容可能か確認をお願いします。

修正案
         .map(|addr| match addr.ip() {
             IpAddr::V4(ipv4) => ipv4.to_string(),
-            IpAddr::V6(ipv6) => ipv6.to_string(),
+            IpAddr::V6(ipv6) => format!("[{}]", ipv6),
         })
🤖 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
function currently returns bare IPv6 strings and falls back to "127.0.0.1" on
any socket error; update local_ip_address (the UdpSocket::bind .. connect .. map
chain) so that when matching IpAddr::V6 you return the address wrapped in
brackets (e.g., "[<ipv6>]") to produce valid host tokens for URL construction,
and change the error fallback from a hardcoded "127.0.0.1" to a non-misleading
value (e.g., an empty string or propagate an error) so the caller/front-end does
not get a false localhost address; ensure you only modify the
mapping/unwrap_or_else behavior in local_ip_address and not other networking
code.

Comment on lines +42 to +59
return (
<div className="pairing-overlay" onClick={onClose} role="presentation">
<section
className="pairing-dialog"
onClick={(event) => event.stopPropagation()}
aria-modal="true"
role="dialog"
aria-label="モバイルペアリング"
>
<div className="pairing-dialog-header">
<div>
<p className="pairing-eyebrow">モバイル連携</p>
<h2>ペアリングQR</h2>
</div>
<button type="button" className="pairing-close" onClick={onClose}>
閉じる
</button>
</div>

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

アクセシビリティ: モーダル内のフォーカス管理が未実装

role="dialog" / aria-modal="true" を付与していますが、ダイアログを開いた際にフォーカスがダイアログ内に移動せず、Tab でダイアログ外のアプリ本体側(スライダーやボタン)にフォーカスが抜けてしまいます。キーボード/スクリーンリーダー利用者がダイアログの操作を完了できない可能性があります。

最低限、以下を検討してください。

  • マウント時にダイアログ内の最初のフォーカス可能要素(「閉じる」ボタン等)へ .focus()
  • アンマウント時に開く前のトリガー要素へフォーカスを戻す
  • Tab / Shift+Tab をダイアログ内で循環させるフォーカストラップ(focus-trap-react などの利用も可)
🤖 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 42 - 59, The
PairingDialog currently lacks focus management; when mounted, save
document.activeElement, then move focus into the dialog (e.g., call .focus() on
the "閉じる" button with class pairing-close or the first focusable element inside
PairingDialog), and on unmount restore focus to the saved trigger element;
additionally implement a focus trap inside the PairingDialog (handle Tab and
Shift+Tab to cycle focus within elements in the dialog or replace with a library
like focus-trap-react) so keyboard and screen-reader users cannot tab out of the
dialog while role="dialog" and aria-modal="true" are active.

return "";
}

return `vibeapp://pair?host=${pairingInfo.host}&port=${pairingInfo.port}&token=${pairingInfo.token}`;

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 説明: 未エンコードの予約文字が query parse を壊すことを確認します。
node - <<'NODE'
const token = 'abc+def&deviceName=spoof';
const raw = `vibeapp://pair?host=192.168.0.2&port=1234&token=${token}`;
const safe = `vibeapp://pair?${new URLSearchParams({
  host: '192.168.0.2',
  port: '1234',
  token,
}).toString()}`;

console.log('raw token:', new URL(raw).searchParams.get('token'));
console.log('safe token:', new URL(safe).searchParams.get('token'));
NODE

Repository: vyuma/posture-app

Length of output: 116


🏁 Script executed:

cat -n src/features/pairing/services/pairingLink.ts

Repository: vyuma/posture-app

Length of output: 704


🏁 Script executed:

# Check the pairingInfo type definition
rg -A 5 "pairingInfo" src/features/pairing/services/pairingLink.ts | head -20

Repository: vyuma/posture-app

Length of output: 392


🏁 Script executed:

# Check for type definitions in the same directory
fd -e ts src/features/pairing

Repository: vyuma/posture-app

Length of output: 446


🏁 Script executed:

fd . src/features/pairing

Repository: vyuma/posture-app

Length of output: 486


🏁 Script executed:

cat -n src/features/pairing/types/pairing.ts

Repository: vyuma/posture-app

Length of output: 175


ディープリンクのクエリパラメータをURLエンコードしてください。

現在のコードはクエリパラメータを直接埋め込んでいますが、tokenhost+&=%などの予約文字が含まれると、ディープリンク側で正しく解釈されません。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()}`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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` at line 8, The return builds a
deep-link by interpolating raw values (in pairingLink.ts) which can break if
pairingInfo.host/token contain reserved characters; change the implementation of
the function that returns the string to build the query via URLSearchParams (or
encodeURIComponent) using keys "host", "port", "token" from pairingInfo and
append the resulting serialized search string to "vibeapp://pair?" so all
parameters (especially pairingInfo.token and pairingInfo.host) are properly
URL-encoded before returning.

Comment on lines +16 to +18
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 説明: トークン入りリンクを外部 QR サービス URL に含めている呼び出し箇所を確認します。
rg -n -C3 'buildPairingQrImageUrl|api\.qrserver\.com|create-qr-code' --type=ts --type=tsx

Repository: vyuma/posture-app

Length of output: 88


🏁 Script executed:

#!/bin/bash
# Search for the function and external service calls using TypeScript files only
rg -n -C3 'buildPairingQrImageUrl|api\.qrserver\.com|create-qr-code' --type=ts

Repository: vyuma/posture-app

Length of output: 2227


🏁 Script executed:

#!/bin/bash
# Also check the pairingLink.ts file directly
cat -n src/features/pairing/services/pairingLink.ts | head -30

Repository: vyuma/posture-app

Length of output: 704


🏁 Script executed:

#!/bin/bash
# Search for usage of buildPairingQrImageUrl function
rg -n 'buildPairingQrImageUrl' --type=ts

Repository: vyuma/posture-app

Length of output: 425


🏁 Script executed:

#!/bin/bash
# Check package.json for QR code libraries
cat package.json | grep -i qr || echo "No QR mentions in package.json"

Repository: vyuma/posture-app

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Look for any other QR code generation approaches in the codebase
rg -i 'qr|qrcode' --type=ts --type=json -l

Repository: vyuma/posture-app

Length of output: 155


ペアリングトークンを外部 QR サービスへ送信しないでください。

buildPairingLink() で生成される vibeapp://pair?host=...&port=...&token=... の完全な URL が api.qrserver.com の data パラメータに URL エンコードされて送信されるため、ペアリングトークンと接続先情報が外部サービスに露出します。ローカルで QR コードを生成するライブラリの導入に切り替えてください。

🤖 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 16 - 18, The
current implementation encodes the full pairingLink (which contains host, port
and token) into an external QR service URL, exposing sensitive pairing tokens;
update the code that builds the QR to stop calling api.qrserver.com and instead
generate the QR locally using a library (e.g., qrcode or equivalent) inside the
same module: take the pairingLink value produced in this file (the pairingLink
variable / buildPairingLink function), pass it to a local QR generator to
produce a data URL or Buffer, and return that local QR output (or save/serve it
locally) so the token and host/port are never sent to an external service.
Ensure the change replaces the external service URL construction and keeps the
same return type contract (data URL or Buffer) used by callers.

@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: 1

🧹 Nitpick comments (1)
src/features/pairing/hooks/usePairingState.ts (1)

71-97: 非同期ポーリングの多重実行を防いでください。

setInterval は async callback の完了を待たないため、getDesktopPairingStatus() が 1500ms を超えると重複実行されます。遅い応答が後から返って状態を上書きする race condition が発生するため、in-flight ガードの追加を推奨します。

修正イメージ
+    let isPolling = false;
+
     const intervalId = window.setInterval(async () => {
+      if (isPolling) {
+        return;
+      }
+
+      isPolling = true;
+
       try {
         const status = await getDesktopPairingStatus();

         if (!isMountedRef.current) {
           return;
         }

         setState((prev) => ({
           ...prev,
           status,
           error: null,
         }));
       } catch (error) {
         if (!isMountedRef.current) {
           return;
         }

         setState((prev) => ({
           ...prev,
           error:
             error instanceof Error
               ? error.message
               : STATUS_POLL_ERROR_MESSAGE,
         }));
+      } finally {
+        isPolling = false;
       }
     }, POLL_INTERVAL_MS);
🤖 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
interval callback using setInterval can start a new async
getDesktopPairingStatus() before the prior call finishes, causing overlapping
calls and race conditions; add an in-flight guard (e.g., a ref like
isFetchingRef) in usePairingState so the interval handler returns immediately if
a fetch is already running, set the guard to true before awaiting
getDesktopPairingStatus() and clear it in a finally block, preserve existing
isMountedRef checks and setState usage, and ensure the guard is checked/cleared
around both success and error paths for proper behavior with intervalId and
POLL_INTERVAL_MS.
🤖 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/features/pairing/hooks/usePairingState.ts`:
- Around line 29-30: Replace Promise.all in readPairingSnapshot so each IPC
call's success/failure is handled independently: use
Promise.allSettled([getPairingInfo(), getDesktopPairingStatus()]) and, for the
settled results, only assign pairingInfo when the getPairingInfo promise is
fulfilled and only assign status when getDesktopPairingStatus is fulfilled; if
either is rejected set the store error state with errorSource: "snapshot" (do
not overwrite the other successful value). Also update the status-polling
success path (the handler that processes getDesktopPairingStatus results around
the existing status polling logic) so a successful status poll clears only the
status-related error (not the whole error/state), leaving pairingInfo errors
visible until getPairingInfo succeeds. Use the symbols readPairingSnapshot,
getPairingInfo, getDesktopPairingStatus, pairingInfo, status, errorSource, and
error to locate and implement these changes.

---

Nitpick comments:
In `@src/features/pairing/hooks/usePairingState.ts`:
- Around line 71-97: The interval callback using setInterval can start a new
async getDesktopPairingStatus() before the prior call finishes, causing
overlapping calls and race conditions; add an in-flight guard (e.g., a ref like
isFetchingRef) in usePairingState so the interval handler returns immediately if
a fetch is already running, set the guard to true before awaiting
getDesktopPairingStatus() and clear it in a finally block, preserve existing
isMountedRef checks and setState usage, and ensure the guard is checked/cleared
around both success and error paths for proper behavior with intervalId and
POLL_INTERVAL_MS.
🪄 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: 7307202e-ade2-4659-998e-389125fed707

📥 Commits

Reviewing files that changed from the base of the PR and between febfc6c and 85e4ccb.

📒 Files selected for processing (2)
  • src/features/pairing/components/PairingDialog.tsx
  • src/features/pairing/hooks/usePairingState.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/features/pairing/components/PairingDialog.tsx

Comment on lines +29 to +30
const readPairingSnapshot = () =>
Promise.all([getPairingInfo(), getDesktopPairingStatus()]);

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

ペアリング情報取得とステータス取得の失敗を分離してください。

Promise.all だと片方の IPC 失敗で成功した値も反映されません。さらに Line 82 で status ポーリング成功時に error を常に消すため、pairingInfonull のままでもエラーが非表示になり、QR/ディープリンクを出せない理由がユーザーに伝わらない可能性があります。Promise.allSettled で成功分は反映し、status ポーリングの成功時は status 用エラーだけをクリアする形が安全です。

修正イメージ
 type PairingState = {
   pairingInfo: PairingInfo | null;
   status: DesktopPairingStatus | null;
   isLoading: boolean;
   error: string | null;
+  errorSource: "snapshot" | "statusPoll" | null;
 };

 const defaultState: PairingState = {
   pairingInfo: null,
   status: null,
   isLoading: true,
   error: null,
+  errorSource: null,
 };

-const readPairingSnapshot = () =>
-  Promise.all([getPairingInfo(), getDesktopPairingStatus()]);
+const readPairingSnapshot = () =>
+  Promise.allSettled([getPairingInfo(), getDesktopPairingStatus()]);
         setState((prev) => ({
           ...prev,
           status,
-          error: null,
+          error: prev.errorSource === "statusPoll" ? null : prev.error,
+          errorSource: prev.errorSource === "statusPoll" ? null : prev.errorSource,
         }));
         setState((prev) => ({
           ...prev,
           error:
             error instanceof Error
               ? error.message
               : STATUS_POLL_ERROR_MESSAGE,
+          errorSource: "statusPoll",
         }));

snapshot 側の成功/失敗処理では、fulfilled の値だけ pairingInfo / status に反映し、失敗時は errorSource: "snapshot" を設定してください。

Also applies to: 79-95

🤖 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 29 - 30, Replace
Promise.all in readPairingSnapshot so each IPC call's success/failure is handled
independently: use Promise.allSettled([getPairingInfo(),
getDesktopPairingStatus()]) and, for the settled results, only assign
pairingInfo when the getPairingInfo promise is fulfilled and only assign status
when getDesktopPairingStatus is fulfilled; if either is rejected set the store
error state with errorSource: "snapshot" (do not overwrite the other successful
value). Also update the status-polling success path (the handler that processes
getDesktopPairingStatus results around the existing status polling logic) so a
successful status poll clears only the status-related error (not the whole
error/state), leaving pairingInfo errors visible until getPairingInfo succeeds.
Use the symbols readPairingSnapshot, getPairingInfo, getDesktopPairingStatus,
pairingInfo, status, errorSource, and error to locate and implement these
changes.

@vyuma
vyuma merged commit 85e4ccb into main May 7, 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