From fc95f7bdec4d718b01b35888a4db479c2302a1ec Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 13 Sep 2026 18:56:42 +0100 Subject: [PATCH 1/2] feat: control PC scanning through forwarding --- docs/accessibility.md | 4 ++++ docs/physical-smoke-test.md | 4 ++++ docs/protocol-compatibility.md | 6 ++++++ src/domain/protocol/protocol.test.ts | 7 +++++++ src/domain/protocol/responses.ts | 5 +++-- src/domain/protocol/types.ts | 3 ++- src/forwarding/ForwardingController.test.ts | 19 +++++++++++++++++++ src/forwarding/ForwardingController.ts | 12 +++++++++--- src/forwarding/ForwardingSurface.tsx | 4 +++- 9 files changed, 57 insertions(+), 7 deletions(-) diff --git a/docs/accessibility.md b/docs/accessibility.md index fb80b79..e4d86b5 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -51,3 +51,7 @@ The edit toggle is icon-only: a pencil labeled Edit layout, then a checkmark lab Saved grids and editor cells divide the available viewport width across the saved columns. Compact numbered row/column handles retain full accessible position labels and drag/tap hints. Labels scale and wrap into taller rows; icons and selected indicators stack above/below text. All targets remain at least 48 points. Horizontal scrolling is reserved for viewports too small for those targets, with an overflow indicator. No saved geometry changes. Verify three-column/four-row Movement layouts including Scroll up, Scroll down, and Enter on 320–430-point phone widths at 100%, 150%, and 200% text, portrait/landscape, and iOS maximum Dynamic Type. Check all columns, numbered handles, drag destinations, tap moves, and bottom editor actions; the right column must not clip when minimum targets fit. Test the genuine overflow fallback below that threshold. Cancel any drag on rotation/text/viewport changes. + +### PC scanning acceptance + +On an Android device with the Switchify bridge, select Switchify scanning in Forwarding. Verify the profile, Start/Stop controls, PC-assigned press/hold labels, pressed state and stop messages are announced and remain usable at large text in light/dark phone and tablet layouts. No new layout editing controls appear. After disconnect or safety stop, verify no automatic restart and that Start remains reachable. iOS switch capture remains unavailable, as with existing Forwarding. diff --git a/docs/physical-smoke-test.md b/docs/physical-smoke-test.md index 6019ddf..0f82acd 100644 --- a/docs/physical-smoke-test.md +++ b/docs/physical-smoke-test.md @@ -64,3 +64,7 @@ The edit toggle is icon-only: a pencil labeled Edit layout, then a checkmark lab Saved grids and editor cells divide the available viewport width across the saved columns. Compact numbered row/column handles retain full accessible position labels and drag/tap hints. Labels scale and wrap into taller rows; icons and selected indicators stack above/below text. All targets remain at least 48 points. Horizontal scrolling is reserved for viewports too small for those targets, with an overflow indicator. No saved geometry changes. Verify three-column/four-row Movement layouts including Scroll up, Scroll down, and Enter on 320–430-point phone widths at 100%, 150%, and 200% text, portrait/landscape, and iOS maximum Dynamic Type. Check all columns, numbered handles, drag destinations, tap moves, and bottom editor actions; the right column must not clip when minimum targets fit. Test the genuine overflow fallback below that threshold. Cancel any drag on rotation/text/viewport changes. + +### 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. diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index 7b957a6..da426d7 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -34,3 +34,9 @@ An optional discovery field `responseTransport: "read-v1"` selects the read-only One serial ATT read consumes one JSON/base64 frame (maximum 180 bytes), or returns an empty value when idle. ATT long reads must preserve a server snapshot for nonzero offsets. Poll immediately after data, or after 100 ms when idle. Existing protocol parsing/reassembly and request deadlines remain authoritative. Read failures terminate the receive path with a fixed error and poison writes until reconnect: consumed frames are never retried. Unsubscribe/disconnect cancels the native transaction, timers and late deliveries. Initial successful read replaces notification-descriptor readiness; no notification listener is registered in this mode. Fake tests cover negotiation, backward compatibility, response bounds, serial polling, timeout and cancellation. Physical Android interoperability is not yet qualified. + +## PC scanning forwarding profile + +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. diff --git a/src/domain/protocol/protocol.test.ts b/src/domain/protocol/protocol.test.ts index bc1dfc9..798b00b 100644 --- a/src/domain/protocol/protocol.test.ts +++ b/src/domain/protocol/protocol.test.ts @@ -127,3 +127,10 @@ describe('Switchify PC protocol v1', () => { expect(parseResponse(JSON.stringify({ type: 'error', error: { code, message: code } }))).toEqual({ kind: 'error', code, message: code }); }); }); + + it('accepts the opt-in scanning catalog and rejects unknown profile kinds', () => { + const response = { type: 'switch.profile.list', id: 'scan', ok: true, error: null, payload: { catalogRevision: 1, profiles: [{ id: 'builtin.switchify-scanning', version: 2, name: 'Switchify scanning', kind: 'scanning', bindings: [{ switchId: 1, label: 'Select; hold: Next', behavior: 'stateful' }] }] } }; + expect(parseResponse(JSON.stringify(response)).kind).toBe('switchProfileCatalog'); + response.payload.profiles[0].kind = 'unknown'; + expect(parseResponse(JSON.stringify(response)).kind).toBe('invalid'); + }); diff --git a/src/domain/protocol/responses.ts b/src/domain/protocol/responses.ts index d2cd3e3..bb8c276 100644 --- a/src/domain/protocol/responses.ts +++ b/src/domain/protocol/responses.ts @@ -68,7 +68,7 @@ function parseSwitchProfileCatalog(payload: JsonObject): SwitchProfileCatalog | const profile = object(entry); if (!profile) return null; const id = string(profile.id), version = number(profile.version), name = string(profile.name); - if (!id || !name || !version || !Number.isInteger(version) || (profile.kind !== 'grid3' && profile.kind !== 'mapped') || !Array.isArray(profile.bindings) || profile.bindings.length > 8) return null; + if (!id || !name || !version || !Number.isInteger(version) || (profile.kind !== 'grid3' && profile.kind !== 'mapped' && profile.kind !== 'scanning') || !Array.isArray(profile.bindings) || profile.bindings.length > 8) return null; const bindings = profile.bindings.map((entry) => { const binding = object(entry); if (!binding) return null; @@ -77,7 +77,7 @@ function parseSwitchProfileCatalog(payload: JsonObject): SwitchProfileCatalog | return { switchId, label, behavior: binding.behavior as 'stateful' | 'pulse' | 'unassigned' }; }); if (bindings.some((binding) => binding === null)) return null; - return { id, version, name, kind: profile.kind as 'grid3' | 'mapped', bindings: bindings as NonNullable<(typeof bindings)[number]>[] }; + return { id, version, name, kind: profile.kind as 'grid3' | 'mapped' | 'scanning', bindings: bindings as NonNullable<(typeof bindings)[number]>[] }; }); return profiles.some((profile) => profile === null) ? null : { catalogRevision: revision, profiles: profiles as NonNullable<(typeof profiles)[number]>[] }; } @@ -108,6 +108,7 @@ function parsePointerProfile(payload: JsonObject): PointerProfile | null { recommendedDeltas: { small: small!, medium: medium!, large: large! }, capabilities: { noAckMouseMove: bool(capabilities.noAckMouseMove), + switchScanning: bool(capabilities.switchScanning), noAckCommands: strings(capabilities.noAckCommands), supportedCommands: strings(capabilities.supportedCommands), mouseRepeat: { supported: bool(repeat.supported), enabled: bool(repeat.enabled), intervalMs: numeric(repeat.intervalMs, 250), minIntervalMs: numeric(repeat.minIntervalMs, 100), maxIntervalMs: numeric(repeat.maxIntervalMs, 2000) }, diff --git a/src/domain/protocol/types.ts b/src/domain/protocol/types.ts index 441fd7b..4e1dddc 100644 --- a/src/domain/protocol/types.ts +++ b/src/domain/protocol/types.ts @@ -12,6 +12,7 @@ export type PointerProfile = { recommendedDeltas: { small: number; medium: number; large: number }; capabilities: { noAckMouseMove: boolean; + switchScanning?: boolean; noAckCommands: string[]; supportedCommands: string[]; mouseRepeat: { supported: boolean; enabled: boolean; intervalMs: number; minIntervalMs: number; maxIntervalMs: number }; @@ -22,7 +23,7 @@ export type PointerProfile = { }; export type SwitchBinding = { switchId: number; label: string; behavior: 'stateful' | 'pulse' | 'unassigned' }; -export type SwitchProfile = { id: string; version: number; name: string; kind: 'grid3' | 'mapped'; bindings: SwitchBinding[] }; +export type SwitchProfile = { id: string; version: number; name: string; kind: 'grid3' | 'mapped' | 'scanning'; bindings: SwitchBinding[] }; export type SwitchProfileCatalog = { catalogRevision: number; profiles: SwitchProfile[] }; export type ProtocolResponse = diff --git a/src/forwarding/ForwardingController.test.ts b/src/forwarding/ForwardingController.test.ts index 577b268..cee0b53 100644 --- a/src/forwarding/ForwardingController.test.ts +++ b/src/forwarding/ForwardingController.test.ts @@ -17,6 +17,25 @@ 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) => { + 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 controller = new ForwardingController(connection, bridge, pc, 5000, fakeTimers(), () => 'session'); + await controller.loadProfiles(); + expect(connection.request).toHaveBeenCalledWith('switch.profile.list', { includeScanning: true }); + 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(); + 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()); + await controller.cleanup(); + }); it('maps eight switches, sends ordered edges, and cleans up', async () => { const bridge = new FakeBridge(); const connection = { request: jest.fn(async () => catalog), send: jest.fn(async () => true) } as ForwardingConnection; const controller = new ForwardingController(connection, bridge, profile(generic, ['switch.edge']), 5_000, fakeTimers(), () => 'session'); diff --git a/src/forwarding/ForwardingController.ts b/src/forwarding/ForwardingController.ts index ef7a005..6eb95a2 100644 --- a/src/forwarding/ForwardingController.ts +++ b/src/forwarding/ForwardingController.ts @@ -54,7 +54,7 @@ export class ForwardingController { async loadProfiles(remembered?: string): Promise { const supported = this.pointerProfile.capabilities.supportedCommands; if (genericCommands.every((command) => supported.includes(command))) { - const response = await this.connection.request('switch.profile.list', {}); + const response = await this.connection.request('switch.profile.list', this.pointerProfile.capabilities.switchScanning ? { includeScanning: true } : {}); if (response?.kind === 'switchProfileCatalog') { const selected = response.catalog.profiles.find((profile) => profile.id === remembered) ?? response.catalog.profiles[0] ?? null; this.#set({ profiles: response.catalog.profiles, selectedProfileId: selected?.id ?? null, message: selected ? null : 'This PC has no forwarding profiles.' }); @@ -131,7 +131,12 @@ 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); + 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) }); void this.#enqueue(async () => { if (replacement) await this.#edge(mapping.switchId, false); @@ -142,14 +147,15 @@ export class ForwardingController { #edge(switchId: number, down: boolean): Promise { this.#sequence += 1; - return this.connection.send(this.#legacy ? 'grid.switch.set' : 'switch.edge', { switchId, state: down ? 'down' : 'up', ...(!this.#legacy || this.pointerProfile.capabilities.supportedCommands.includes('grid.switch.sync') ? { sessionId: this.#sessionId, sequence: this.#sequence } : {}) }, this.pointerProfile.capabilities.noAckCommands.includes(this.#legacy ? 'grid.switch.set' : 'switch.edge') ? 'none' : 'ack'); + return this.connection.send(this.#legacy ? 'grid.switch.set' : 'switch.edge', { switchId, state: down ? 'down' : 'up', ...(!this.#legacy || this.pointerProfile.capabilities.supportedCommands.includes('grid.switch.sync') ? { sessionId: this.#sessionId, sequence: this.#sequence } : {}) }, this.selectedProfile()?.kind !== 'scanning' && this.pointerProfile.capabilities.noAckCommands.includes(this.#legacy ? 'grid.switch.set' : 'switch.edge') ? 'none' : 'ack').then((ok) => { if (!ok && this.selectedProfile()?.kind === 'scanning') void this.stop('PC scanning stopped. Start forwarding again.', true); return ok; }); } async #syncNow(): Promise { if (this.#state.phase !== 'active') return; if (this.#legacy && !this.pointerProfile.capabilities.supportedCommands.includes('grid.switch.sync')) return; this.#sequence += 1; - await this.connection.send(this.#legacy ? 'grid.switch.sync' : 'switch.sync', { sessionId: this.#sessionId, sequence: this.#sequence, pressedSwitchIds: this.#state.mappings.filter((item) => item.pressed).map((item) => item.switchId) }); + const ok = await this.connection.send(this.#legacy ? 'grid.switch.sync' : 'switch.sync', { sessionId: this.#sessionId, sequence: this.#sequence, pressedSwitchIds: this.#state.mappings.filter((item) => item.pressed).map((item) => item.switchId) }); + if (!ok && this.selectedProfile()?.kind === 'scanning') void this.stop('PC scanning stopped. Start forwarding again.', true); } async #stopPc(): Promise { diff --git a/src/forwarding/ForwardingSurface.tsx b/src/forwarding/ForwardingSurface.tsx index 93afb73..6b4658c 100644 --- a/src/forwarding/ForwardingSurface.tsx +++ b/src/forwarding/ForwardingSurface.tsx @@ -56,6 +56,7 @@ export function ForwardingSurface({ manager, bridge, profile, desktopId, prefere controller.report('The previously active forwarding profile changed. Start forwarding again to confirm it.'); return; } + if (selected.kind === 'scanning') { restore.clear(); return; } await controller.start(); }); const unregister = manager.registerCleanup(async () => { restore.clear(); await controller.cleanup(); }); @@ -72,11 +73,12 @@ export function ForwardingSurface({ manager, bridge, profile, desktopId, prefere return PC Switch Forwarding Forward configured external switches from Switchify to this PC. + {controller.selectedProfile()?.kind === 'scanning' ? Uses scanning settings and remote switch assignments from the PC. Press Select to begin. PC Escape, forwarding safety limits, or disconnect stop scanning. Start again after a stop. : null} { void select(profileId); }} onToggle={() => { if (state.phase === 'active') { restore.clear(); void controller.stop(); } else void controller.start().then((started) => { const selected = controller.selectedProfile(); - if (started && selected) restore.set({ desktopId, profileId: selected.id, profileVersion: selected.version }); + if (started && selected && selected.kind !== 'scanning') restore.set({ desktopId, profileId: selected.id, profileVersion: selected.version }); }); }} /> ; From efffa02536230eb38258e6dd532e87ca94f1539e Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Sun, 13 Sep 2026 19:03:47 +0100 Subject: [PATCH 2/2] fix: invalidate queued scanning edges and snapshot sync state --- src/domain/protocol/protocol.test.ts | 2 +- src/forwarding/ForwardingController.test.ts | 28 ++++++++++++- src/forwarding/ForwardingController.ts | 44 +++++++++++++++++---- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/domain/protocol/protocol.test.ts b/src/domain/protocol/protocol.test.ts index 798b00b..625f7e9 100644 --- a/src/domain/protocol/protocol.test.ts +++ b/src/domain/protocol/protocol.test.ts @@ -131,6 +131,6 @@ describe('Switchify PC protocol v1', () => { it('accepts the opt-in scanning catalog and rejects unknown profile kinds', () => { const response = { type: 'switch.profile.list', id: 'scan', ok: true, error: null, payload: { catalogRevision: 1, profiles: [{ id: 'builtin.switchify-scanning', version: 2, name: 'Switchify scanning', kind: 'scanning', bindings: [{ switchId: 1, label: 'Select; hold: Next', behavior: 'stateful' }] }] } }; expect(parseResponse(JSON.stringify(response)).kind).toBe('switchProfileCatalog'); - response.payload.profiles[0].kind = 'unknown'; + response.payload.profiles = response.payload.profiles.map((p) => ({ ...p, kind: 'unknown' })); expect(parseResponse(JSON.stringify(response)).kind).toBe('invalid'); }); diff --git a/src/forwarding/ForwardingController.test.ts b/src/forwarding/ForwardingController.test.ts index cee0b53..87b450c 100644 --- a/src/forwarding/ForwardingController.test.ts +++ b/src/forwarding/ForwardingController.test.ts @@ -36,6 +36,32 @@ describe('ForwardingController', () => { expect(connection.send).toHaveBeenCalledWith('switch.session.stop', expect.anything()); await controller.cleanup(); }); + it.each(['stop', 'sync'])('preserves queued edge semantics across delayed acknowledgement and %s', async (scenario) => { + const bridge = new FakeBridge(); let resolveDown: (ok: boolean) => void = () => undefined; + const down = new Promise((resolve) => { resolveDown = resolve; }); + 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 send = jest.fn((command: string, payload?: import('@/domain/protocol/types').JsonObject) => command === 'switch.edge' && payload?.state === 'down' ? down : Promise.resolve(true)); + const connection = { request: jest.fn(async () => scanCatalog), send }; + const timers = { ...fakeTimers(), interval: jest.fn((callback: () => void, _ms: number) => { void callback; return 1 as never; }) }; let interval: () => void = () => undefined; + timers.interval.mockImplementation((callback) => { interval = callback; return 1 as never; }); + const controller = new ForwardingController(connection, bridge, profile(generic), 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(); + if (scenario === 'sync') interval(); + bridge.emit({ type: 'switchEdge', generation: 41, sequence: 2, keyCode: 20, down: false, downTimeMs: 0, eventTimeMs: 50, cancelled: false }); + const stopped = scenario === 'stop' ? controller.stop() : null; + resolveDown(true); for (let i = 0; i < 30; i++) await Promise.resolve(); + if (scenario === 'stop') { + await stopped; + expect(send.mock.calls.filter(([command, payload]) => command === 'switch.edge' && payload?.state === 'up')).toHaveLength(0); + } else { + const syncs = send.mock.calls.filter(([command]) => command === 'switch.sync'); + expect(syncs[1]?.[1]?.pressedSwitchIds).toEqual([1]); + expect(send.mock.calls.filter(([command, payload]) => command === 'switch.edge' && payload?.state === 'up')).toHaveLength(1); + } + await controller.cleanup(); + }); it('maps eight switches, sends ordered edges, and cleans up', async () => { const bridge = new FakeBridge(); const connection = { request: jest.fn(async () => catalog), send: jest.fn(async () => true) } as ForwardingConnection; const controller = new ForwardingController(connection, bridge, profile(generic, ['switch.edge']), 5_000, fakeTimers(), () => 'session'); @@ -63,7 +89,7 @@ describe('ForwardingController', () => { await controller.loadProfiles(); await controller.start(); bridge.emit({ type: 'switchEdge', generation: 41, sequence: 1, keyCode: 20, down: true, downTimeMs: 10, eventTimeMs: 10, cancelled: false }); bridge.emit({ type: 'switchEdge', generation: 41, sequence: 2, keyCode: 20, down: true, downTimeMs: 20, eventTimeMs: 20, cancelled: false }); - for (let index = 0; index < 10; index += 1) await Promise.resolve(); + for (let index = 0; index < 30; index += 1) await Promise.resolve(); const edges = (connection.send as jest.Mock).mock.calls.filter(([command]) => command === 'switch.edge').map(([, payload]) => payload.state); expect(edges).toEqual(['down', 'up', 'down']); }); diff --git a/src/forwarding/ForwardingController.ts b/src/forwarding/ForwardingController.ts index 6eb95a2..c0c4757 100644 --- a/src/forwarding/ForwardingController.ts +++ b/src/forwarding/ForwardingController.ts @@ -33,6 +33,9 @@ export class ForwardingController { #legacy = false; #attempt = 0; #disposed = false; + #stopping: Promise | null = null; + #starting: Promise | null = null; + #pending = 0; #expectedSwitches: { keyCode: number; name: string }[] = []; #sync: ReturnType | null = null; #idle: ReturnType | null = null; @@ -75,6 +78,19 @@ export class ForwardingController { report(message: string): void { this.#set({ message }); } async start(): Promise { + if (this.#stopping) await this.#stopping; + if (this.#starting) { + if (this.#state.phase === 'starting' || this.#state.phase === 'active') return false; + await this.#starting; + } + if (this.#state.phase === 'starting' || this.#state.phase === 'active') return false; + const starting = this.#start(); + this.#starting = starting; + try { return await starting; } finally { if (this.#starting === starting) this.#starting = null; } + } + + async #start(): Promise { + if (this.#state.phase === 'starting' || this.#state.phase === 'active') return false; if (this.#disposed) return false; const attempt = ++this.#attempt; const snapshot = this.bridge.snapshot(); @@ -95,22 +111,27 @@ export class ForwardingController { if (!await this.bridge.setForwardingActive(this.#generation, true)) { await this.#stopPc(); if (attempt === this.#attempt && !this.#disposed) this.#set({ phase: 'failed', message: 'Switchify is not available for forwarding.' }); return false; } if (attempt !== this.#attempt || this.#disposed) { await this.bridge.setForwardingActive(this.#generation, false); await this.#stopPc(); return false; } this.#set({ phase: 'active', mappings, overflow: external.slice(8).map((item) => item.name), message: null }); - await this.#syncNow(); + await this.#syncNow(this.#heldIds(), attempt); if (attempt !== this.#attempt || this.#disposed) return false; - this.#sync = this.timers.interval(() => { void this.#enqueue(() => this.#syncNow()); }, 1_000); + this.#sync = this.timers.interval(() => { if (this.#pending >= 64) { void this.stop('Remote input queue is full. Start forwarding again.', true); return; } const held = this.#heldIds(); void this.#enqueue(() => this.#syncNow(held, attempt)); }, 1_000); this.#resetIdle(); return true; } async stop(message: string | null = null, safety = false): Promise { + if (this.#stopping) return this.#stopping; this.#attempt += 1; if (this.#state.phase !== 'active' && this.#state.phase !== 'starting') return; if (safety) this.onSafetyStop(); const generation = this.#generation; this.#clearTimers(); this.#set({ phase: 'idle', mappings: this.#state.mappings.map((mapping) => ({ ...mapping, pressed: false, downTimeMs: null })), message }); - await this.bridge.setForwardingActive(generation, false); - await this.#enqueue(() => this.#stopPc()); + const stopping = (async () => { + await this.bridge.setForwardingActive(generation, false); + await this.#enqueue(() => this.#stopPc()); + })(); + this.#stopping = stopping; + try { await stopping; } finally { if (this.#stopping === stopping) this.#stopping = null; } } async cleanup(): Promise { this.#disposed = true; await this.stop(); this.#unsubscribe(); } @@ -125,6 +146,7 @@ export class ForwardingController { } } if (event.type !== 'switchEdge' || event.generation !== this.#generation || this.#state.phase !== 'active') return; + if (this.#pending >= 64) { void this.stop('Remote input queue is full. Start forwarding again.', true); return; } if (event.sequence !== this.#bridgeSequence + 1) { void this.stop('A switch event was missed. Forwarding stopped safely.', true); return; } this.#bridgeSequence = event.sequence; const mapping = this.#state.mappings.find((item) => item.keyCode === event.keyCode); @@ -138,8 +160,11 @@ export class ForwardingController { 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 () => { + if (attempt !== this.#attempt || this.#state.phase !== 'active') return; 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); }); @@ -150,12 +175,15 @@ export class ForwardingController { return this.connection.send(this.#legacy ? 'grid.switch.set' : 'switch.edge', { switchId, state: down ? 'down' : 'up', ...(!this.#legacy || this.pointerProfile.capabilities.supportedCommands.includes('grid.switch.sync') ? { sessionId: this.#sessionId, sequence: this.#sequence } : {}) }, this.selectedProfile()?.kind !== 'scanning' && this.pointerProfile.capabilities.noAckCommands.includes(this.#legacy ? 'grid.switch.set' : 'switch.edge') ? 'none' : 'ack').then((ok) => { if (!ok && this.selectedProfile()?.kind === 'scanning') void this.stop('PC scanning stopped. Start forwarding again.', true); return ok; }); } - async #syncNow(): Promise { + #heldIds(): number[] { return this.#state.mappings.filter((item) => item.pressed).map((item) => item.switchId); } + + async #syncNow(held: number[], attempt: number): Promise { + if (attempt !== this.#attempt) return; if (this.#state.phase !== 'active') return; if (this.#legacy && !this.pointerProfile.capabilities.supportedCommands.includes('grid.switch.sync')) return; this.#sequence += 1; - const ok = await this.connection.send(this.#legacy ? 'grid.switch.sync' : 'switch.sync', { sessionId: this.#sessionId, sequence: this.#sequence, pressedSwitchIds: this.#state.mappings.filter((item) => item.pressed).map((item) => item.switchId) }); - if (!ok && this.selectedProfile()?.kind === 'scanning') void this.stop('PC scanning stopped. Start forwarding again.', true); + const ok = await this.connection.send(this.#legacy ? 'grid.switch.sync' : 'switch.sync', { sessionId: this.#sessionId, sequence: this.#sequence, pressedSwitchIds: held }); + if (attempt === this.#attempt && !ok && this.selectedProfile()?.kind === 'scanning') void this.stop('PC scanning stopped. Start forwarding again.', true); } async #stopPc(): Promise { @@ -168,6 +196,6 @@ export class ForwardingController { #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); } #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 { const next = this.#queue.then(operation, operation); this.#queue = next.then(() => undefined, () => undefined); return next; } + #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()); } }