Onboarding latches denpa.onboarded with no reset path #37

Closed
opened 2026-07-30 10:20:10 +00:00 by aiko · 3 comments
Owner

Onboarding writes denpa.onboarded to localStorage on 開局 (src/renderer/src/hooks/receiver/use-onboarding.ts:48, key at components/receiver/onboarding-spec.ts:103) and nothing anywhere can clear it. grep -rn ONBOARDED_KEY src/ returns exactly two call sites: the constant and that one hook. The 設定 sheet has no reset.

App.tsx:116 gates the entire application on that flag — onboarding is not a screen the app can return to, it is the app until the flag flips. So a device whose flag is true while it is not paired shows the receiver chrome over an inert connection: no code, no 周波数, no way back, forever.

This is reachable, not theoretical

aiko/ollvt-hermes-bridge#23 describes the bypass that latched exactly this flag: an unpaired device that tripped MAX_PENDING_PER_PLATFORM received chain-start, concluded it was paired, and wrote denpa.onboarded=true. The client half was fixed in #28 (c57af9b) — but the fix does not un-write the flag on devices that already ran an affected build. The Android APK and the desktop dev shell both ran one.

What is needed

  • A reset path. Minimum: a 設定 key that clears denpa.onboarded and returns to 待受. It is also the only way to re-pair a device deliberately.
  • Consider deriving the gate from pairing state rather than from the flag alone. The flag records "the human finished setup"; it should not also be trusted to mean "this device is paired", which is what App.tsx:116 currently makes it mean.

Also in this slice, same file family

  • isAndroid resolves async. App.tsx:58-63 starts false and fills in from getTauriPlatform(). stepsFor(android) therefore grows 5→6 mid-flow, and last (components/receiver/onboarding.tsx:330) shifts with it. Narrow window, real: on a slow resolve the finish key stops being on 開局.
  • Dead WebSocketProvider (src/renderer/src/context/websocket-context.tsx:58-82) hard-codes wsState: 'CLOSED' and upstream: null. It is exported and never mounted — WebSocketHandler provides the real value (services/websocket-handler.tsx:320-335). Mount it by mistake and canAdvance can never open. Delete it.

Acceptance

  • denpa.onboarded can be cleared from the running app, and clearing it returns the app to 待受
  • A device with the flag set but pairing unproven does not present a chrome it cannot use
  • Step list identity is stable once the platform is known, or last is derived so a late resolve cannot move the finish key
  • WebSocketProvider is gone, or is the real provider
  • Test: flag set + pairing unknown does not render the receiver chrome as if working
Onboarding writes `denpa.onboarded` to localStorage on 開局 (`src/renderer/src/hooks/receiver/use-onboarding.ts:48`, key at `components/receiver/onboarding-spec.ts:103`) and nothing anywhere can clear it. `grep -rn ONBOARDED_KEY src/` returns exactly two call sites: the constant and that one hook. The 設定 sheet has no reset. `App.tsx:116` gates the entire application on that flag — onboarding is not a screen the app can return to, it is the app until the flag flips. So a device whose flag is `true` while it is *not* paired shows the receiver chrome over an inert connection: no code, no 周波数, no way back, forever. ## This is reachable, not theoretical `aiko/ollvt-hermes-bridge#23` describes the bypass that latched exactly this flag: an unpaired device that tripped `MAX_PENDING_PER_PLATFORM` received chain-start, concluded it was paired, and wrote `denpa.onboarded=true`. The client half was fixed in `#28` (`c57af9b`) — but the fix does not un-write the flag on devices that already ran an affected build. The Android APK and the desktop dev shell both ran one. ## What is needed - A reset path. Minimum: a 設定 key that clears `denpa.onboarded` and returns to 待受. It is also the only way to re-pair a device deliberately. - Consider deriving the gate from pairing state rather than from the flag alone. The flag records "the human finished setup"; it should not also be trusted to mean "this device is paired", which is what `App.tsx:116` currently makes it mean. ## Also in this slice, same file family - **`isAndroid` resolves async.** `App.tsx:58-63` starts `false` and fills in from `getTauriPlatform()`. `stepsFor(android)` therefore grows 5→6 mid-flow, and `last` (`components/receiver/onboarding.tsx:330`) shifts with it. Narrow window, real: on a slow resolve the finish key stops being on 開局. - **Dead `WebSocketProvider`** (`src/renderer/src/context/websocket-context.tsx:58-82`) hard-codes `wsState: 'CLOSED'` and `upstream: null`. It is exported and never mounted — `WebSocketHandler` provides the real value (`services/websocket-handler.tsx:320-335`). Mount it by mistake and `canAdvance` can never open. Delete it. ## Acceptance - [ ] `denpa.onboarded` can be cleared from the running app, and clearing it returns the app to 待受 - [ ] A device with the flag set but pairing unproven does not present a chrome it cannot use - [ ] Step list identity is stable once the platform is known, or `last` is derived so a late resolve cannot move the finish key - [ ] `WebSocketProvider` is gone, or is the real provider - [ ] Test: flag set + pairing `unknown` does not render the receiver chrome as if working
Author
Owner

Ruled out of scope for v1 on the wayfinder map, the conversation works (v1). Labelled v2; staying open rather than closed, because the scope call lives on the map and this is still real work.

v1 is: a real voice conversation — you speak, she answers in her own voice, Live2D renders, the log records it — on the Windows desktop, the OnePlus Pad and an Android phone, for one operator on hardware they already own.

**Ruled out of scope for v1** on the wayfinder map, [the conversation works (v1)](https://git.aiko.works/aiko/denpa/issues/41). Labelled `v2`; staying open rather than closed, because the scope call lives on the map and this is still real work. v1 is: a real voice conversation — you speak, she answers in her own voice, Live2D renders, the log records it — on the Windows desktop, the OnePlus Pad and an Android phone, for one operator on hardware they already own.
Author
Owner

This was generated by AI during triage.

Three of the five are done and merged. The other two are addressed rather than outstanding — details below, since one of them was solved by a different ticket.

  • denpa.onboarded can be cleared from the running app, and clearing it returns the app to 待受. 最初からやる (start) now clears the flag alongside the tuning and address keys. It already forgot the credential Rust holds and reloaded, and setupNeeded reopens setup when the credential is gone — so the return to 待受 worked before this; what was left was stored state going on claiming the device had been through setup when it had not. Level one still keeps the flag: that is tuning, not enrolment.
  • A device with the flag set but pairing unproven does not present a chrome it cannot use. Done by #66, not here. setupNeeded(flag, credentialed, refusal) reads the credential Rust holds and a device refusal, so a latched flag alone opens nothing.
  • Step list identity is stable, or last is derived so a late resolve cannot move the finish key. Taken the second way: last was state.index === steps.length - 1 and the list grows 5 → 6 when isAndroid resolves, so a slow resolve moved the finish key off 開局 mid-flow. It now reads the step's own key.
  • WebSocketProvider is gone. It hard-coded wsState: 'CLOSED' and upstream: null, was exported, and was mounted nowhere — mounting it by mistake would have left canAdvance unable to open, ever. Deleted with the imports and the eslint-disable that existed only for it.
  • Test: flag set + pairing unknown does not render the receiver chrome as if working.

The last one is the only real gap. setupNeeded distinguishes credentialed === null (not answered yet) from false, which is the state that acceptance item names — but nothing pins the render against it. That test belongs with the hook that owns the third state rather than bolted onto this batch, and it wants a decision I did not want to make alone: what the app should draw during that window, given it is the same window #67 deliberately holds the titlebar back for.

Leaving this open for it. Everything else here is merged: 66 test files, 907 tests, npm run typecheck exits 0.

Still v2 — the scope call on the map is unchanged.

> *This was generated by AI during triage.* **Three of the five are done and merged.** The other two are addressed rather than outstanding — details below, since one of them was solved by a different ticket. - [x] **`denpa.onboarded` can be cleared from the running app, and clearing it returns the app to 待受.** 最初からやる (`start`) now clears the flag alongside the tuning and address keys. It already forgot the credential Rust holds and reloaded, and `setupNeeded` reopens setup when the credential is gone — so the *return to 待受* worked before this; what was left was stored state going on claiming the device had been through setup when it had not. Level one still keeps the flag: that is tuning, not enrolment. - [x] **A device with the flag set but pairing unproven does not present a chrome it cannot use.** Done by `#66`, not here. `setupNeeded(flag, credentialed, refusal)` reads the credential Rust holds and a `device` refusal, so a latched flag alone opens nothing. - [x] **Step list identity is stable, or `last` is derived so a late resolve cannot move the finish key.** Taken the second way: `last` was `state.index === steps.length - 1` and the list grows 5 → 6 when `isAndroid` resolves, so a slow resolve moved the finish key off 開局 mid-flow. It now reads the step's own key. - [x] **`WebSocketProvider` is gone.** It hard-coded `wsState: 'CLOSED'` and `upstream: null`, was exported, and was mounted nowhere — mounting it by mistake would have left `canAdvance` unable to open, ever. Deleted with the imports and the eslint-disable that existed only for it. - [ ] **Test: flag set + pairing `unknown` does not render the receiver chrome as if working.** The last one is the only real gap. `setupNeeded` distinguishes `credentialed === null` (not answered yet) from `false`, which is the state that acceptance item names — but nothing pins the *render* against it. That test belongs with the hook that owns the third state rather than bolted onto this batch, and it wants a decision I did not want to make alone: what the app should draw during that window, given it is the same window `#67` deliberately holds the titlebar back for. Leaving this open for it. Everything else here is merged: 66 test files, 907 tests, `npm run typecheck` exits 0. Still `v2` — the scope call on the map is unchanged.
Author
Owner

Closed by 6d620a3. 最初からやる now clears the latch: settings-spec.ts:301-313's resetKeys drops denpa.onboarded at the session level and names this issue in the comment above it. A device can be walked through setup again.

Closed by `6d620a3`. 最初からやる now clears the latch: `settings-spec.ts:301-313`'s `resetKeys` drops `denpa.onboarded` at the session level and names this issue in the comment above it. A device can be walked through setup again.
aiko closed this issue 2026-08-10 22:51:48 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiko/denpa#37
No description provided.