Skip to content

feat(camera): pairing and camera-list UI for both desktop apps - #78

Merged
masa-koz merged 2 commits into
mainfrom
feat/p2p-pairing-ui
Aug 2, 2026
Merged

feat(camera): pairing and camera-list UI for both desktop apps#78
masa-koz merged 2 commits into
mainfrom
feat/p2p-pairing-ui

Conversation

@masa-koz

@masa-koz masa-koz commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Until now, connecting the two apps meant reading a Listener ID, a Capability
and a Connection ID off one screen and typing them into another. Phases 1-3
put grants, pairing codes and discovery behind the proxy and gave the crates
methods for them; this wires those methods to the UI, so the manual exchange
stops being the way the apps are used.

camera-server grows a pairing panel: one button issues a code, the code is
shown with the time left on it and as a QR image, and the panel goes back to
the button when it lapses. The time left comes from the expires_at the proxy
set, not from a lifetime assumed at this end; a timestamp this cannot read
leaves the code up without a countdown rather than taking a working code off
the screen. The QR carries isekai://pair?code=... rather than the bare code,
because a scan of eight characters shows a phone user some text and leaves
them to find the app themselves — settled here rather than when the mobile
capture lands and has nothing to agree with. pairing_uri and
pairing_code_from_input live in isekai-p2p-core for the mobile side to
share.

Below that, the grants the listener has handed out, each with a revoke, and an
activity feed fed by the Phase 3 signalling broadcast, which is what tells the
operator that a peer bound, that one was displaced, or that a bind failed. The
grant list is read when the session comes up and again whenever a peer binds,
so a device that paired a moment ago is there without anyone pressing Refresh.

camera-client replaces the Listener ID field with a list of the cameras the
proxy says this Endpoint may reach, read as soon as there is a token to read
it with. Picking one fills in the listener and clears the Capability, which is
what makes the connect take the grant path. A pairing code typed or scanned in
next to Refresh adds a camera to that list, and the list is re-read on success
— which is also where the camera's name comes from, so the confirmation says
what the operator will recognise.

Neither list is a Vec. PeerDirectory::open issues an Endpoint Token and
opens a QUIC connection to the proxy, so the client holds one open across
button presses and replaces it when the token is nearly out; listing, pairing
and the grant connect all go over it, which is also what stops a listing and
the connect that acts on it from making two connections.

The old fields are not gone, only moved under a collapsing header, together
with the Endpoint ID button that was step 1 of the manual exchange. A proxy
without grants still works, and so does anything the automatic path gets
wrong.

Also folds the client's two P2pConfig constructions into one helper, which
moves the key load ahead of the channel wiring, so a bad key path now leaves
the app in the state it was already in rather than half-connected.

iOS (FFI, SwiftUI, QR capture) is the remainder of Phase 4 and is not here.

Verification

  • LIBCLANG_PATH=/usr/lib/llvm-18/lib RUSTFLAGS="-L /usr/lib/llvm-18/lib" cargo test --manifest-path rust/Cargo.toml --workspace — all passing, including new tests for the pairing URI round trip, the countdown against a fixed clock, and the QR's quiet zone
  • cargo clippy on the touched crates introduces no new warnings
  • cargo fmt on the touched files only; these crates are not rustfmt-clean on main, so running it per package would rewrite files this branch does not touch

Not yet done on hardware: typing a displayed code and connecting on the grant
it creates, and re-binding a displaced connection. For the second, what is
worth watching is whether A is still listed as relay after B leaves and
whether the proxy's relay edge is still alive at that point — those are what
decide whether the state.current.is_none() branch can do anything. I will
run both and report what happens.

🤖 Generated with Claude Code

Until now, connecting the two apps meant reading a Listener ID, a Capability
and a Connection ID off one screen and typing them into another. Phases 1-3
put grants, pairing codes and discovery behind the proxy and gave the crates
methods for them; this wires those methods to the UI, so the manual exchange
stops being the way the apps are used.

camera-server grows a pairing panel: one button issues a code, the code is
shown with the time left on it and as a QR image, and the panel goes back to
the button when it lapses. Below it, the grants the listener has handed out,
each with a revoke, and an activity feed fed by the Phase 3 signalling
broadcast, which is what tells the operator that a peer bound, that one was
displaced, or that a bind failed.

camera-client replaces the Listener ID field with a list of the cameras the
proxy says this Endpoint may reach. Picking one fills in the listener and
clears the Capability, which is what makes the connect take the grant path.
A pairing code typed in next to Refresh adds a camera to that list, and the
list is re-read on success so the new camera is there to pick.

The old fields are not gone, only moved under a collapsing header, together
with the Endpoint ID button that was step 1 of the manual exchange. A proxy
without grants still works, and so does anything the automatic path gets
wrong.

Also folds the client's two P2pConfig constructions into one helper, which
moves the key load ahead of the channel wiring, so a bad key path now leaves
the app in the state it was already in rather than half-connected.

iOS (FFI, SwiftUI, QR capture) is the remainder of Phase 4 and is not here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@masa-koz

masa-koz commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

概要

フェーズ 1〜3 でプロキシとクレートに積んだものを、両デスクトップアプリの画面に繋ぐ変更。camera-server にペアリングパネル(コード発行・QR・残り時間・許可済みデバイス・アクティビティ)、camera-client に到達可能なカメラ一覧とコード入力。手作業の交換は畳んだヘッダの下に残す、という落とし方も妥当だと思います。

egui のコードとして気をつけるべきところが押さえられています。

  • 描画前にロックを外しているpairing_code / grants / activity をいずれも clone してから UI を組む)。UI スレッドが共有状態を握ったまま描かないので、非同期側と競合しない
  • request_repaint_after はコードを表示している間だけ。常時再描画にしていない
  • リスト内の削除を revoke に溜めてループ後に実行している
  • 非同期の結果は全て Arc<Mutex<..>> 越しに戻し、UI からは毎フレーム同じ読み方で見える
  • qrcodedefault-features = falseimage / svg レンダラを引き込まない)
  • feat(p2p): let the camera bind for itself #76 で挙げた next_to_bind の再 bind と、フィクスチャを実 JSON から起こす件が反映されているのを確認しました

指摘

1. 起動直後の一覧が空のままで、それが事実のように読める

refresh_grants() はボタンと revoke 後からしか呼ばれず、refresh_cameras() はボタンからしか呼ばれません。したがって:

  • camera-server: セッションが立ち上がっても「Allowed devices … none yet — pair a device to add one」のまま。実際には grant があっても、Refresh を押すまでそう見える
  • camera-client: 起動直後は「Cameras … none — pair with a camera, or refresh」。ペアリング済みでも同じ

どちらも「まだ無い」という断定の文言なので、実態と食い違うと操作者は「ペアリングが消えた」と受け取ります。camera-server は p2p_commands を格納した直後に一度、camera-client は最初に画面を出したとき(または接続設定が揃ったとき)に一度呼ぶだけで解消します。

併せて、camera-server 側は SignalingEvent::Bound を受けたときに grants を引き直すと、「ペアリングした直後に一覧へ出ない」状態も消えます。

2. Refresh / Pair のたびに Endpoint Token と制御プレーン接続を張り直す

refresh_cameraspair はどちらも PeerDirectory::open(&cfg) から始まります。open は Identity API へトークンを発行しに行き、さらにプロキシへ H3 接続を張ります。ボタンを 5 回押せば 5 回ぶんです(接続は drop 任せ)。#77 のレビューで挙げた「一覧と接続で接続が 2 本になる」が、UI から見える形で出ています。

PeerDirectory をアプリ側に 1 つ持って使い回す(トークンの残り寿命で作り直す)と、この経路はまとめて改善します。pair() が成功後の reachable() に同じ dir を使い回しているのは正しい形なので、それを外側に広げる形です。

3. ペアリングコードの残り時間が 300 秒のハードコード

let ttl = Duration::from_secs(300);
match code_remaining(shown_at, ttl) {

一方 ShowPairingCode { ttl: None } で発行しており、実際の期限は応答の expires_at(RFC 3339)に入っています。今はサーバ既定が 300 秒なので一致しますが、

  • 計測の起点が応答を受け取った時刻なので、往復ぶん常に多めに出る
  • サーバ側の既定やクランプ(60〜300)が変われば、画面の数字は黙って嘘になる

パネルの目的が「切れかけていることを操作者に見せる(誰かがペアリングに失敗して初めて気づく、を避ける)」ことなので、ここは expires_at から出したいところです。日付パースを増やしたくなければ、ttl: Some(N) を明示して同じ定数を使う形でも、少なくとも仮定が 1 か所になります。

4. QR の中身が生のコードなので、スキャンしても何も起きない

qr_image(&code.code) なので、QR には K7M2-QX4P だけが入ります。スマートフォンのカメラで読むと文字列が表示されるだけで、アプリには繋がりません。iOS の QR 取り込みが次フェーズであることを考えると、今この形式を決めておく価値があります(isekai://pair?code=K7M2-QX4P のようなカスタムスキーム、あるいは universal link になる https URL)。デスクトップ側の Pair 欄は、生コードと URI の両方を受け付ければ移行も要りません。

5. 細かい点

  • qr_image の一辺は (modules.len() as f64).sqrt() で求めていますが、QrCode::width() がそのまま使えます(完全平方数なので現状の計算も正しく出ますが、意図が明示できます)
  • Cargo.tomlqrcodeegui_plotfutures の間に入っていて、周囲のおおよそのアルファベット順から外れています
  • ペアリング成功時のメッセージが paired with pl_AbC... と listener_id を出します。直後に一覧を引き直しているので、そこで得た metadata.label があれば人が読める名前を出せます
  • describe()SignalingEvent::Waiting を扱っていますが、AutoNotify では発生しません(列挙を網羅する意味では妥当なので、そのままでも構いません)

検証について

「表示されたコードを打ってその grant で繋ぐ」「displaced な接続を再 bind する」が未実施と書かれていますが、後者は #76 のレビュー後に入った

if state.current.is_none() { return connections.first(); }

の分岐を通る経路です。B が去ったあと A を拾い直せるか(A がまだ relay で listing に載っていること、プロキシ側のリレーエッジが接続 TTL まで生きていること)が確認したい点になります。実機で見るときはこの 2 点を合わせて確認すると、分岐が意図どおり効いているか判定できます。

総評

指摘 1 は操作者の誤解を生むので直しておきたいところ、2 は #77 からの持ち越しでまとめて解消できる話、3 は表示の正しさ、4 は次フェーズの形を決める判断です。5 は任意。UI としての作りは素直で、egui で壊れやすい部分(ロック保持、毎フレームの再描画・通信)を避けられているのは良いと思います。

🤖 Generated with Claude Code

Five things, in the order they matter.

An empty list is shown as empty before anything has been asked. Both apps
said "none yet" from the moment they started, so an operator who paired a
device yesterday was told, in as many words, that it was gone. Neither list
is a `Vec` any more: `None` means the proxy has not answered yet and reads as
"not read yet" on screen. The server also asks once when its session comes
up, and again whenever a peer binds — the moment the list is most likely to
be one device out of date. The client asks once as soon as there is a token
to ask with, and not while it is still being typed.

Every listing and pairing opened its own control plane. `PeerDirectory::open`
issues an Endpoint Token and opens a QUIC connection to the proxy, so five
button presses were five of each, and the connect that followed made a sixth.
The client now holds one open and replaces it when its token is nearly out;
listing, pairing and the grant connect all go over it. That closes the
"listing and connecting make two connections" point from #77 at the same
time.

The countdown on the pairing code was 300 seconds hardcoded, started when the
response arrived. It was a round trip late, and would have gone on being
confident about a number the proxy is free to change. It now comes from the
`expires_at` the proxy set. An unreadable timestamp leaves the code up
without a countdown rather than taking a working code off the screen.

The QR carried the bare code, so scanning it showed a phone user eight
characters and left them to find the app. It now carries
`isekai://pair?code=...`, decided here rather than when the mobile capture
lands and has nothing to agree with. `pairing_uri` and
`pairing_code_from_input` are in `isekai-p2p-core` for the mobile side to
share, and the client's field takes the URI or the bare code, so nothing has
to migrate.

Smaller: the QR side comes from `QrCode::width()` rather than a square root;
`qrcode` moved to where the workspace manifest is otherwise alphabetical;
pairing success names the camera rather than its listener id, from the
listing it re-reads anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@masa-koz

masa-koz commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all five addressed.

1. An empty list read as an assertion. Both lists are now Option<Vec<..>>: None renders as "not read yet", Some([]) as "none". On top of that the server reads its grants once when the session comes up and again on every SignalingEvent::Bound (a peer that just bound is the case most likely to have paired a moment ago), and the client reads its camera list once as soon as auth0_token is non-empty and the field does not have focus — otherwise it would fire on the first keystroke and report an auth failure at someone mid-paste.

2. A control plane per button press. The client now holds one PeerDirectory behind a tokio::sync::Mutex and replaces it when its Endpoint Token is within 30s of expiry. Listing, pairing and the grant connect all go through it — dir.connect(..) for the last one, so the #77 "listing and connecting make two connections" case is closed too. The capability path is unchanged; it stays on connect_with_options.

3. A hardcoded 300s countdown. Gone. code_remaining(&code.expires_at, OffsetDateTime::now_utc()) returns Left/Expired/Unknown; Unknown (unparsable timestamp) keeps the code on screen and shows the raw expires_at instead of a made-up number, since dropping a working code over an unfamiliar timestamp is the worse failure. time was already a workspace dep. A code exactly at its deadline counts as expired — the first version of the test caught that returning Left(0).

4. QR content. Now isekai://pair?code=K7M2-QX4P. pairing_uri / pairing_code_from_input are in isekai-p2p-core so the iOS side shares them, and the client's Pair field takes either form (plus a trailing &…/#… a scanner might append), so no migration is needed when capture lands.

5.

  • QrCode::width() — done.
  • qrcode moved to where rust/Cargo.toml is otherwise alphabetical.
  • Pairing success now names the camera from the listing it re-reads anyway, falling back to the listener id.
  • describe()'s Waiting arm left as is, for the reason you gave.

New tests: the URI round trip (including bare code and scanner-appended parameters), the countdown against a fixed clock, and that the QR comes out square with its quiet zone intact.

On verification — noted, and thank you for pinning down what actually decides it. I will watch whether A is still listed as relay after B leaves, and whether the proxy's relay edge is still up at that point, rather than only whether the re-bind happens.

One note on process: these crates are not rustfmt-clean on main, so cargo fmt -p camera-core -p isekai-p2p-core rewrites files this branch does not touch. I reverted those; the diff is only files this branch actually changes.

@masa-koz
masa-koz merged commit 8d22da7 into main Aug 2, 2026
6 checks passed
@masa-koz
masa-koz deleted the feat/p2p-pairing-ui branch August 2, 2026 12:34
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.

1 participant