From 60c83d8b7bc63ef0dbf3fa6b0f61a114a7605193 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 13 Sep 2026 21:03:25 +0100 Subject: [PATCH 1/3] docs: require milestones when creating issues --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 18fec66..be2679d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ The application uses TypeScript, React 19, React Native 0.86, Expo Router, and n Follow this workflow for every change: -1. Create a GitHub issue that defines the change and acceptance criteria. +1. Create a GitHub issue that defines the change and acceptance criteria. Never create a GitHub issue without a milestone. Select or create the appropriate milestone first and include it in the issue creation request; assigning it afterward is not allowed. This applies to every issue, including bugs, chores, release preparation, and follow-up work. Verify the milestone on the created issue before continuing. 2. Fetch the intended base branch and create a focused `codex/-` branch from it. Use `main` unless the issue or user identifies another base. 3. Inspect neighboring code, tests, documentation, and configuration before editing. 4. Make the smallest coherent change that satisfies the issue. From d492ac20a0b3ee0433f7f3839c584984c802cda3 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 14 Sep 2026 17:28:26 +0100 Subject: [PATCH 2/3] feat(forwarding): let the PC own hold timing for scanning profiles Hold-to-stop and the idle stop now apply to keyboard forwarding only, so a long hold on a scanning profile still delivers its release and the PC can offer hold actions past five seconds. Cancelled presses are withdrawn with a sync instead of ending the session; replacements send release then press. Closes #172 Co-Authored-By: Claude Fable 5.1 --- docs/physical-smoke-test.md | 2 +- docs/protocol-compatibility.md | 2 +- src/app/(tabs)/settings.tsx | 2 +- src/forwarding/ForwardingController.test.ts | 35 ++++++++++++++++++--- src/forwarding/ForwardingController.ts | 18 ++++++++--- 5 files changed, 46 insertions(+), 13 deletions(-) diff --git a/docs/physical-smoke-test.md b/docs/physical-smoke-test.md index 0f82acd..8486fad 100644 --- a/docs/physical-smoke-test.md +++ b/docs/physical-smoke-test.md @@ -67,4 +67,4 @@ Verify three-column/four-row Movement layouts including Scroll up, Scroll down, ### Remote scanning -With a compatible Windows or macOS PC, assign remote slots in PC settings and select Switchify scanning in Remote Forwarding. Start with one Select switch in automatic mode, then test three slots in manual mode. Check row escape, icon menus, scroll repetition, confirmed drag and shared colour/timing. Verify local mapped keys do nothing while Remote owns scanning, but PC Escape stops it. Check hold action labels on PC. Confirm cancellation, switch replacement, hold-to-stop, navigation away, backgrounding and disconnect never click on release and always release an active drag. Reload profiles after changing PC assignments/settings. Reconnect must require explicit Start. Use a disposable target application for real-input checks; keep these checks separate from automated tests. +With a compatible Windows or macOS PC, assign remote slots in PC settings and select Switchify scanning in Remote Forwarding. Start with one Select switch in automatic mode, then test three slots in manual mode. Check row escape, icon menus, scroll repetition, confirmed drag and shared colour/timing. Verify local mapped keys do nothing while Remote owns scanning, but PC Escape stops it. Check hold action labels on PC. Confirm cancellation and switch replacement never click on release and keep the session running; confirm a hold longer than the Forwarding hold-to-stop still delivers its release and the PC offers hold actions; confirm no idle stop occurs while scanning waits; confirm navigation away, backgrounding and disconnect never click on release and always release an active drag. Changing PC assignments applies to a live session; reload profiles only to refresh the labels shown in Remote. Reconnect must require explicit Start. Use a disposable target application for real-input checks; keep these checks separate from automated tests. diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index da426d7..74dbeec 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -39,4 +39,4 @@ Fake tests cover negotiation, backward compatibility, response bounds, serial po PCs may advertise `capabilities.switchScanning: true`. Only then request `switch.profile.list` with `{ "includeScanning": true }`; older PCs retain the empty-object request. The opt-in catalog adds kind `scanning`, ID `builtin.switchify-scanning`, a revision and up to eight stateful/unassigned switch labels. Older clients receive the unchanged catalog. Protocol v1, authentication and existing forwarding command payloads are unchanged. -Scanning edges request acknowledgements. Cancelled events, replacement presses and hold-to-stop send session stop without an actionable release. A PC refusal stops forwarding and clears restoration intent. Scanning profiles are never automatically restored after reconnect. A fresh start receives a new session ID; PC profile revisions reject stale assignments. +Scanning edges request acknowledgements. The PC owns hold timing for scanning profiles: Remote's hold-to-stop and 60-second idle stop apply to keyboard forwarding only, and a long hold still delivers its release. A cancelled press is withdrawn with a `switch.sync` that omits it, so the PC drops the gesture without selecting; a replacement press sends a synthesized release then press. A PC refusal stops forwarding and clears restoration intent. Scanning profiles are never automatically restored after reconnect. A fresh start receives a new session ID; the PC applies its current assignments at start and no longer rejects a stale profile revision, so labels in Remote may lag until profiles are reloaded. diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index d63035b..250aaa6 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -41,7 +41,7 @@ export default function SettingsScreen() { void preferencesStore.update({ typingMode: 'live' })} /> void preferencesStore.update({ typingMode: 'draft' })} /> {saved.length === 0 ? : void setDefault(null)} />{saved.map((pc) => void setDefault(pc.desktopId)} />)}} {connection.kind === 'connected' && connection.profile ? : } - {Platform.OS === 'android' ? ({ key: milliseconds, label: `${milliseconds / 1000} seconds` }))} selectedKey={preferences.forwardingHoldToStopMs} onSelect={(forwardingHoldToStopMs) => preferencesStore.update({ forwardingHoldToStopMs })} /> : null} + {Platform.OS === 'android' ? ({ key: milliseconds, label: `${milliseconds / 1000} seconds` }))} selectedKey={preferences.forwardingHoldToStopMs} onSelect={(forwardingHoldToStopMs) => preferencesStore.update({ forwardingHoldToStopMs })} /> : null} ; diff --git a/src/forwarding/ForwardingController.test.ts b/src/forwarding/ForwardingController.test.ts index 87b450c..491b469 100644 --- a/src/forwarding/ForwardingController.test.ts +++ b/src/forwarding/ForwardingController.test.ts @@ -17,7 +17,7 @@ const catalog: ProtocolResponse = { kind: 'switchProfileCatalog', id: 'catalog', describe('ForwardingController', () => { const generic = ['switch.profile.list', 'switch.session.start', 'switch.edge', 'switch.sync', 'switch.session.stop']; const fakeTimers = () => ({ interval: jest.fn(() => 1 as never), timeout: jest.fn(() => 2 as never), clear: jest.fn() }); - it.each(['cancelled', 'held', 'replaced'])('stops scanning without sending a selecting release when %s', async (reason) => { + it.each(['cancelled', 'held', 'replaced'])('keeps a scanning session alive and never selects on its own when %s', async (reason) => { const bridge = new FakeBridge(); const scanCatalog: ProtocolResponse = { kind: 'switchProfileCatalog', id: 'catalog', catalog: { catalogRevision: 1, profiles: [{ id: 'builtin.switchify-scanning', version: 1, name: 'Switchify scanning', kind: 'scanning', bindings: [{ switchId: 1, label: 'Select', behavior: 'stateful' }] }] } }; const connection = { request: jest.fn(async () => scanCatalog), send: jest.fn(async () => true) }; @@ -30,10 +30,35 @@ describe('ForwardingController', () => { for (let i = 0; i < 10; i++) await Promise.resolve(); bridge.emit({ type: 'switchEdge', generation: 41, sequence: 2, keyCode: 20, down: reason === 'replaced', downTimeMs: reason === 'replaced' ? 1 : 0, eventTimeMs: reason === 'held' ? 5000 : 20, cancelled: reason === 'cancelled' }); for (let i = 0; i < 20; i++) await Promise.resolve(); - expect(controller.snapshot().phase).toBe('idle'); - expect(connection.send).toHaveBeenCalledWith('switch.edge', expect.objectContaining({ state: 'down' }), 'ack'); - expect(connection.send).not.toHaveBeenCalledWith('switch.edge', expect.objectContaining({ state: 'up' }), expect.anything()); - expect(connection.send).toHaveBeenCalledWith('switch.session.stop', expect.anything()); + expect(controller.snapshot().phase).toBe('active'); + expect(connection.send).not.toHaveBeenCalledWith('switch.session.stop', expect.anything()); + const edges = (connection.send as jest.Mock).mock.calls.filter(([command]) => command === 'switch.edge').map(([, payload]) => payload.state); + if (reason === 'cancelled') { + // Withdrawn with a sync so the PC drops the gesture without selecting. + expect(edges).toEqual(['down']); + const syncs = (connection.send as jest.Mock).mock.calls.filter(([command]) => command === 'switch.sync').map(([, payload]) => payload.pressedSwitchIds); + expect(syncs.at(-1)).toEqual([]); + expect(controller.snapshot().mappings.find((mapping) => mapping.keyCode === 20)?.pressed).toBe(false); + } else if (reason === 'held') { + // The PC owns hold timing, so a long hold still delivers its release. + expect(edges).toEqual(['down', 'up']); + } else { + expect(edges).toEqual(['down', 'up', 'down']); + } + expect(scanCatalog).toBeTruthy(); + await controller.cleanup(); + }); + it('does not arm the idle stop for scanning profiles', async () => { + const bridge = new FakeBridge(); + const scanCatalog: ProtocolResponse = { kind: 'switchProfileCatalog', id: 'catalog', catalog: { catalogRevision: 1, profiles: [{ id: 'builtin.switchify-scanning', version: 1, name: 'Switchify scanning', kind: 'scanning', bindings: [{ switchId: 1, label: 'Select', behavior: 'stateful' }] } ] } }; + const connection = { request: jest.fn(async () => scanCatalog), send: jest.fn(async () => true) }; + const pc = profile(generic, ['switch.edge']); pc.capabilities.switchScanning = true; + const timers = fakeTimers(); + const controller = new ForwardingController(connection, bridge, pc, 5000, timers, () => 'session'); + await controller.loadProfiles(); await controller.start(); + bridge.emit({ type: 'switchEdge', generation: 41, sequence: 1, keyCode: 20, down: true, downTimeMs: 0, eventTimeMs: 0, cancelled: false }); + for (let i = 0; i < 10; i++) await Promise.resolve(); + expect(timers.timeout).not.toHaveBeenCalled(); await controller.cleanup(); }); it.each(['stop', 'sync'])('preserves queued edge semantics across delayed acknowledgement and %s', async (scenario) => { diff --git a/src/forwarding/ForwardingController.ts b/src/forwarding/ForwardingController.ts index c0c4757..d5794ce 100644 --- a/src/forwarding/ForwardingController.ts +++ b/src/forwarding/ForwardingController.ts @@ -153,12 +153,18 @@ export class ForwardingController { if (!mapping) return; this.#resetIdle(); const duration = Math.max(0, event.eventTimeMs - event.downTimeMs); - if (this.selectedProfile()?.kind === 'scanning' && (event.cancelled || (!event.down && duration >= this.holdToStopMs))) { - void this.stop(event.cancelled ? 'Switch input cancelled. Start forwarding again.' : 'Forwarding stopped after the switch was held.', true); + // Scanning profiles leave hold limits to the PC, which owns the hold-action + // timing and its own emergency hold. A cancelled press is withdrawn with a + // sync so the PC drops the gesture without selecting, and the session + // continues; nothing here ends a scanning session on the user's behalf. + const scanning = this.selectedProfile()?.kind === 'scanning'; + if (scanning && event.cancelled) { + this.#set({ mappings: this.#state.mappings.map((item) => item.keyCode === event.keyCode ? { ...item, pressed: false, downTimeMs: null } : item) }); + const attempt = this.#attempt; const held = this.#heldIds(); + void this.#enqueue(() => this.#syncNow(held, attempt)); return; } const replacement = event.down && mapping.pressed && mapping.downTimeMs !== event.downTimeMs; - if (replacement && this.selectedProfile()?.kind === 'scanning') { void this.stop('A switch press was replaced. Start forwarding again.', true); return; } this.#set({ mappings: this.#state.mappings.map((item) => item.keyCode === event.keyCode ? { ...item, pressed: event.down, downTimeMs: event.down ? event.downTimeMs : null } : item) }); const attempt = this.#attempt; void this.#enqueue(async () => { @@ -166,7 +172,7 @@ export class ForwardingController { if (replacement) await this.#edge(mapping.switchId, false); if (attempt !== this.#attempt || this.#state.phase !== 'active') return; await this.#edge(mapping.switchId, event.down); - if (!event.down && !event.cancelled && duration >= this.holdToStopMs) void this.stop('Forwarding stopped after the switch was held.', true); + if (!scanning && !event.down && !event.cancelled && duration >= this.holdToStopMs) void this.stop('Forwarding stopped after the switch was held.', true); }); } @@ -194,7 +200,9 @@ export class ForwardingController { } else await this.connection.send('switch.session.stop', { sessionId: this.#sessionId, sequence: this.#sequence }); } - #resetIdle(): void { if (this.#idle) this.timers.clear(this.#idle); this.#idle = this.timers.timeout(() => { void this.stop('Forwarding stopped after 60 seconds without switch activity.', true); }, 60_000); } + // A scanning user may watch the PC screen for a long time between presses, + // so the idle stop applies to keyboard forwarding only. + #resetIdle(): void { if (this.#idle) this.timers.clear(this.#idle); this.#idle = null; if (this.selectedProfile()?.kind === 'scanning') return; this.#idle = this.timers.timeout(() => { void this.stop('Forwarding stopped after 60 seconds without switch activity.', true); }, 60_000); } #clearTimers(): void { if (this.#sync) this.timers.clear(this.#sync); if (this.#idle) this.timers.clear(this.#idle); this.#sync = null; this.#idle = null; } #enqueue(operation: () => Promise): Promise { this.#pending++; const next = this.#queue.then(operation, operation).finally(() => { this.#pending--; }); this.#queue = next.then(() => undefined, () => undefined); return next; } #set(patch: Partial): void { this.#state = { ...this.#state, ...patch }; this.#listeners.forEach((listener) => listener()); } From 36ed346a5d1610c2e468ac22f5b688e83df1eb56 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Mon, 14 Sep 2026 17:39:50 +0100 Subject: [PATCH 3/3] fix(forwarding): withdraw a replaced scanning press instead of releasing it A synthesized release would reach the PC as an up edge and could select. For scanning profiles the old press is withdrawn with a sync that omits the switch, then the new press is sent. Co-Authored-By: Claude Fable 5.1 --- docs/protocol-compatibility.md | 2 +- src/forwarding/ForwardingController.test.ts | 9 ++++++++- src/forwarding/ForwardingController.ts | 6 +++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index 74dbeec..04aeb20 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -39,4 +39,4 @@ Fake tests cover negotiation, backward compatibility, response bounds, serial po PCs may advertise `capabilities.switchScanning: true`. Only then request `switch.profile.list` with `{ "includeScanning": true }`; older PCs retain the empty-object request. The opt-in catalog adds kind `scanning`, ID `builtin.switchify-scanning`, a revision and up to eight stateful/unassigned switch labels. Older clients receive the unchanged catalog. Protocol v1, authentication and existing forwarding command payloads are unchanged. -Scanning edges request acknowledgements. The PC owns hold timing for scanning profiles: Remote's hold-to-stop and 60-second idle stop apply to keyboard forwarding only, and a long hold still delivers its release. A cancelled press is withdrawn with a `switch.sync` that omits it, so the PC drops the gesture without selecting; a replacement press sends a synthesized release then press. A PC refusal stops forwarding and clears restoration intent. Scanning profiles are never automatically restored after reconnect. A fresh start receives a new session ID; the PC applies its current assignments at start and no longer rejects a stale profile revision, so labels in Remote may lag until profiles are reloaded. +Scanning edges request acknowledgements. The PC owns hold timing for scanning profiles: Remote's hold-to-stop and 60-second idle stop apply to keyboard forwarding only, and a long hold still delivers its release. A cancelled press is withdrawn with a `switch.sync` that omits it, so the PC drops the gesture without selecting; a replacement press is withdrawn with a `switch.sync` and then sent as a fresh press, never as a release. A PC refusal stops forwarding and clears restoration intent. Scanning profiles are never automatically restored after reconnect. A fresh start receives a new session ID; the PC applies its current assignments at start and no longer rejects a stale profile revision, so labels in Remote may lag until profiles are reloaded. diff --git a/src/forwarding/ForwardingController.test.ts b/src/forwarding/ForwardingController.test.ts index 491b469..5525012 100644 --- a/src/forwarding/ForwardingController.test.ts +++ b/src/forwarding/ForwardingController.test.ts @@ -43,7 +43,14 @@ describe('ForwardingController', () => { // The PC owns hold timing, so a long hold still delivers its release. expect(edges).toEqual(['down', 'up']); } else { - expect(edges).toEqual(['down', 'up', 'down']); + // A replacement withdraws the old press with a sync, then presses again; + // the PC never receives a release it could act on. + expect(edges).toEqual(['down', 'down']); + const calls = (connection.send as jest.Mock).mock.calls.filter(([command]) => command === 'switch.sync' || command === 'switch.edge'); + const withdraw = calls.findIndex(([command, payload]) => command === 'switch.sync' && Array.isArray(payload?.pressedSwitchIds) && payload.pressedSwitchIds.length === 0 && payload.sequence > 2); + expect(withdraw).toBeGreaterThan(0); + expect(calls[withdraw + 1]?.[0]).toBe('switch.edge'); + expect(calls[withdraw + 1]?.[1]?.state).toBe('down'); } expect(scanCatalog).toBeTruthy(); await controller.cleanup(); diff --git a/src/forwarding/ForwardingController.ts b/src/forwarding/ForwardingController.ts index d5794ce..e15e603 100644 --- a/src/forwarding/ForwardingController.ts +++ b/src/forwarding/ForwardingController.ts @@ -165,11 +165,15 @@ export class ForwardingController { return; } const replacement = event.down && mapping.pressed && mapping.downTimeMs !== event.downTimeMs; + // For scanning, a replacement's missing release must never reach the PC as + // an up edge, which would select. Withdraw it with a sync first. + const withdrawn = replacement && scanning ? this.#heldIds().filter((id) => id !== mapping.switchId) : null; this.#set({ mappings: this.#state.mappings.map((item) => item.keyCode === event.keyCode ? { ...item, pressed: event.down, downTimeMs: event.down ? event.downTimeMs : null } : item) }); const attempt = this.#attempt; void this.#enqueue(async () => { if (attempt !== this.#attempt || this.#state.phase !== 'active') return; - if (replacement) await this.#edge(mapping.switchId, false); + if (withdrawn) await this.#syncNow(withdrawn, attempt); + else if (replacement) await this.#edge(mapping.switchId, false); if (attempt !== this.#attempt || this.#state.phase !== 'active') return; await this.#edge(mapping.switchId, event.down); if (!scanning && !event.down && !event.cancelled && duration >= this.holdToStopMs) void this.stop('Forwarding stopped after the switch was held.', true);