Pair/add onboarding - #3
Conversation
Walkthroughデスクトップとモバイルのペアリング機能を追加しました。Rust 側に TCP/HTTP/WebSocket ベースのペアリングサーバーとスレッド安全なペアリング状態を実装し、Tauri コマンドでフロントエンドと連携。React 側にペアリングダイアログ、状態フック、ブリッジを追加しました。 Changes
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: ステータス更新を表示
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: イベント受信・処理
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 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
⛔ Files ignored due to path filters (1)
src-tauri/Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (17)
src-tauri/Cargo.tomlsrc-tauri/src/commands/mod.rssrc-tauri/src/commands/pairing_commands.rssrc-tauri/src/lib.rssrc-tauri/src/pairing/mod.rssrc-tauri/src/pairing/server.rssrc-tauri/src/pairing/state.rssrc-tauri/src/pairing/types.rssrc/App.csssrc/App.tsxsrc/features/pairing/components/PairingDialog.csssrc/features/pairing/components/PairingDialog.tsxsrc/features/pairing/hooks/usePairingState.tssrc/features/pairing/index.tssrc/features/pairing/services/desktopBridge.tssrc/features/pairing/services/pairingLink.tssrc/features/pairing/types/pairing.ts
| #[tauri::command] | ||
| pub fn emit_posture_signal( | ||
| is_bad: bool, | ||
| state: State<'_, PairingStateHandle>, | ||
| ) { | ||
| let event_type = if is_bad { "posture_bad" } else { "posture_good" }; | ||
| state.mark_posture_signal(); | ||
| broadcast_ws_state_event(&state, event_type); | ||
| } |
There was a problem hiding this comment.
🛠️ 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:
- 1: https://v2.tauri.app/develop/calling-rust/
- 2: https://github.com/tauri-apps/tauri-docs/blob/v2/src/content/docs/develop/calling-rust.mdx
- 3: https://v2.tauri.app/develop/calling-rust
- 4: [feat] Ability to execute async commands on main thread | non-send code support for async commands tauri-apps/tauri#15065
- 5: Window and webview commands are all async functions tauri-apps/tauri#12032
- 6: enhance(core): use sync commands for window and webview getters tauri-apps/tauri#12162
- 7: https://tauritutorials.com/blog/tauri-command-fundamentals
- 8: https://github.com/tauri-apps/tauri/blob/dev/examples/commands/main.rs
🏁 Script executed:
# Find the broadcast_ws_state_event function
rg "broadcast_ws_state_event" --type rust -B 2 -A 10Repository: 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 5Repository: 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.rsRepository: 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 20Repository: 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 2Repository: 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 10Repository: vyuma/posture-app
Length of output: 2635
🏁 Script executed:
# Find write_websocket_text_frame implementation
rg "fn write_websocket_text_frame" --type rust -A 20Repository: 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 2Repository: 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 3Repository: 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 -20Repository: 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 -20Repository: 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 -50Repository: 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 25Repository: 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 -30Repository: 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/nullRepository: 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.rsRepository: 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 -100Repository: vyuma/posture-app
Length of output: 666
🏁 Script executed:
# Look for posture-related calls and frequency
rg "posture|emit" ./src --type rust -lRepository: 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 2Repository: 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.
| fn handle_connection( | ||
| mut stream: TcpStream, | ||
| state: PairingStateHandle, | ||
| ws_sink: WsSink, | ||
| ) -> Result<(), String> { | ||
| let mut buffer = [0_u8; 4096]; | ||
| let bytes_read = stream.read(&mut buffer).map_err(|error| error.to_string())?; | ||
|
|
||
| if bytes_read == 0 { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let request = String::from_utf8_lossy(&buffer[..bytes_read]); | ||
| let request_line = request | ||
| .lines() | ||
| .next() | ||
| .ok_or_else(|| "missing request line".to_string())?; | ||
| let mut parts = request_line.split_whitespace(); | ||
| let method = parts.next().unwrap_or_default(); | ||
| let target = parts.next().unwrap_or_default(); | ||
|
|
||
| if method != "GET" { | ||
| return write_internal_error_json(&mut stream, 405, "method not allowed"); | ||
| } | ||
|
|
||
| let (path, query) = split_target(target); | ||
| let query_map = parse_query(query); | ||
| let headers = parse_headers(&request); | ||
|
|
||
| match path { | ||
| "/health" => write_json(&mut stream, 200, &state.build_health_response()), | ||
| "/pair" => handle_pair(&mut stream, &state, &query_map), | ||
| "/disconnect" => handle_disconnect(&mut stream, &state, &query_map), | ||
| "/ws" => handle_websocket(stream, &state, &query_map, &headers, &ws_sink), | ||
| _ => write_internal_error_json(&mut stream, 404, "not found"), | ||
| } | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's examine the specific file and lines mentioned
cd src-tauri && head -100 src/pairing/server.rs | cat -nRepository: 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.rsRepository: 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 -nRepository: 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.rsRepository: 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 -nRepository: 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 -nRepository: 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.rsRepository: 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.rsRepository: 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.rsRepository: 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 -nRepository: 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 -20Repository: 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.txtRepository: 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.rsRepository: 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 -10Repository: 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 -5Repository: vyuma/posture-app
Length of output: 205
stream.read() の単一呼び出しではHTTPヘッダの欠落が発生し得ます
TCP/IP はメッセージ境界を保証しないため、ヘッダが複数の TCP セグメントに分割される場合、特に WebSocket ハンドシェイク時の Sec-WebSocket-Key や Upgrade といった必須ヘッダが読み込み範囲に含まれない可能性があります。通常の 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).
| 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()); | ||
| } |
There was a problem hiding this comment.
ブロードキャストがロック保持下の同期 write で詰まり得ます
broadcast_ws_event は ws_sink の Mutex を保持したまま全クライアントへ順番に write_all を行っています。TCP は送信バッファが満杯になると write_all がブロックするため、以下の問題が発生します。
- 応答の遅いクライアント 1 つで全体のブロードキャストが停止
- その間、他スレッドからの
ws_sink.lock()も待たされ、新規/ws接続の登録までブロック emit_posture_signalが頻繁に呼ばれるため症状が顕在化しやすい
対策としては以下が有効です。
- 各
TcpStreamにset_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).
| 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()), | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
read_until_socket_closes は WebSocket フレームを解釈していません
このループは受信バイトを単に捨てているため、クライアントからの Close フレーム(opcode 0x8)や Ping フレームに対応できません。結果として以下の不都合が起こり得ます。
- クライアントが正常クローズを試みても、サーバー側は FIN を受け取るまで解放されない
- 中継機器のアイドルタイムアウトで TCP 半オープン状態になった場合、
WS_SINKにゴミ接続が溜まり、次のbroadcast_ws_eventでwriteが失敗して初めて除去される(ヘルスチェックが無いため長時間放置される可能性あり) - 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.
| pub fn matches_token(&self, token: &str) -> bool { | ||
| let state = self.inner.lock().expect("pairing state poisoned"); | ||
| state.token == token | ||
| } |
There was a problem hiding this comment.
トークン比較は定数時間で行ってください
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.
| 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.
| 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()) | ||
| } |
There was a problem hiding this comment.
🧩 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:
- 1: https://github.com/rust-random/getrandom/blob/master/CHANGELOG.md
- 2: https://crates.io/crates/getrandom
- 3: https://docs.rs/crate/getrandom/0.2.16
- 4: https://crates.io/crates/getrandom/0.1.16
- 5: https://docs.rs/getrandom/latest
- 6: https://github.com/rust-random/getrandom
🏁 Script executed:
cat -n src-tauri/src/pairing/state.rsRepository: vyuma/posture-app
Length of output: 7448
🏁 Script executed:
find src-tauri -type f -name "*.rs" | head -20Repository: vyuma/posture-app
Length of output: 318
🏁 Script executed:
cat -n src-tauri/src/pairing/server.rsRepository: vyuma/posture-app
Length of output: 14288
🏁 Script executed:
cat -n src-tauri/src/pairing/types.rsRepository: vyuma/posture-app
Length of output: 553
🏁 Script executed:
find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \) | head -20Repository: vyuma/posture-app
Length of output: 394
🏁 Script executed:
cat -n ./src/features/pairing/services/pairingLink.tsRepository: vyuma/posture-app
Length of output: 704
🏁 Script executed:
cat -n ./src/features/pairing/services/desktopBridge.tsRepository: vyuma/posture-app
Length of output: 813
🏁 Script executed:
grep -r "host.*port.*token" src --include="*.ts" --include="*.tsx" -nRepository: vyuma/posture-app
Length of output: 211
🏁 Script executed:
grep -r "http.*host\|host.*http" src --include="*.ts" --include="*.tsx" -nRepository: vyuma/posture-app
Length of output: 43
🏁 Script executed:
rg "host.*port|fetch|http://" src --type ts --type tsx -A 2 | head -50Repository: vyuma/posture-app
Length of output: 88
🏁 Script executed:
rg "http|fetch|ws" src/features/pairing -A 2 | head -80Repository: vyuma/posture-app
Length of output: 544
🏁 Script executed:
rg "vibeapp" src -A 5 -B 5Repository: 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.toml に getrandom の追加が必要です(最新安定版は 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.
| 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()) | ||
| } |
There was a problem hiding this comment.
🧩 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 -20Repository: 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 -100Repository: 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
fiRepository: 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 -50Repository: 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.
| 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> |
There was a problem hiding this comment.
アクセシビリティ: モーダル内のフォーカス管理が未実装
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}`; |
There was a problem hiding this comment.
🧩 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'));
NODERepository: vyuma/posture-app
Length of output: 116
🏁 Script executed:
cat -n src/features/pairing/services/pairingLink.tsRepository: 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 -20Repository: vyuma/posture-app
Length of output: 392
🏁 Script executed:
# Check for type definitions in the same directory
fd -e ts src/features/pairingRepository: vyuma/posture-app
Length of output: 446
🏁 Script executed:
fd . src/features/pairingRepository: vyuma/posture-app
Length of output: 486
🏁 Script executed:
cat -n src/features/pairing/types/pairing.tsRepository: vyuma/posture-app
Length of output: 175
ディープリンクのクエリパラメータをURLエンコードしてください。
現在のコードはクエリパラメータを直接埋め込んでいますが、tokenやhostに+、&、=、%などの予約文字が含まれると、ディープリンク側で正しく解釈されません。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.
| 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.
| return `https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent( | ||
| pairingLink, | ||
| )}`; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# 説明: トークン入りリンクを外部 QR サービス URL に含めている呼び出し箇所を確認します。
rg -n -C3 'buildPairingQrImageUrl|api\.qrserver\.com|create-qr-code' --type=ts --type=tsxRepository: 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=tsRepository: 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 -30Repository: vyuma/posture-app
Length of output: 704
🏁 Script executed:
#!/bin/bash
# Search for usage of buildPairingQrImageUrl function
rg -n 'buildPairingQrImageUrl' --type=tsRepository: 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 -lRepository: 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.
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
src/features/pairing/components/PairingDialog.tsxsrc/features/pairing/hooks/usePairingState.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- src/features/pairing/components/PairingDialog.tsx
| const readPairingSnapshot = () => | ||
| Promise.all([getPairingInfo(), getDesktopPairingStatus()]); |
There was a problem hiding this comment.
ペアリング情報取得とステータス取得の失敗を分離してください。
Promise.all だと片方の IPC 失敗で成功した値も反映されません。さらに Line 82 で status ポーリング成功時に error を常に消すため、pairingInfo が null のままでもエラーが非表示になり、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.
Summary by CodeRabbit
新機能
スタイル