From 066f1b942d0eb37a739e66f361e34fde861be27f Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 25 Aug 2026 19:10:05 +0100 Subject: [PATCH 1/2] fix: acknowledge repeat stop commands --- docs/accessibility.md | 2 +- docs/physical-smoke-test.md | 2 +- src/remote/RemoteSession.test.ts | 89 ++++++++++++++++++++++++++---- src/remote/RemoteSession.ts | 12 ++-- src/remote/RemoteSurfaces.test.tsx | 4 +- 5 files changed, 88 insertions(+), 21 deletions(-) diff --git a/docs/accessibility.md b/docs/accessibility.md index 98a2b17..6a68fac 100644 --- a/docs/accessibility.md +++ b/docs/accessibility.md @@ -20,7 +20,7 @@ Switchify Remote is designed for VoiceOver, TalkBack, iOS Switch Control, and An - The primary tab bar contains PCs, Remote, and Settings. Diagnostics is available from Settings and uses a standard back action. Settings also exposes the Remote privacy policy as one clearly named browser action. - Settings exposes a Remote name text field with complete validation and synchronization status. Save and Use device model remain separate 48-point controls at large text sizes. - Selected controls combine color with a check icon and selected accessibility state. -- Repeating pointer movement exposes a dedicated Stop movement button alongside the existing stop-on-control behavior. +- Repeating pointer movement exposes a dedicated Stop movement button alongside the existing stop-on-control behavior. Both send the PC an acknowledged stop command before Android switch-capture cleanup. - Switch scanning stops on actionable controls and scroll containers, not read-only headings, descriptions, status badges, summaries, or capability values. - Unpairing a saved computer opens a native confirmation alert. Cancel and dismissal leave the pairing unchanged, and the destructive action names the computer before removing access. - Scroll content clears the bottom tab bar, gesture area, and home indicator at maximum text size. diff --git a/docs/physical-smoke-test.md b/docs/physical-smoke-test.md index 7b245ed..dd46bf1 100644 --- a/docs/physical-smoke-test.md +++ b/docs/physical-smoke-test.md @@ -14,7 +14,7 @@ Run the matrix on a physical Android phone and iPhone against current Switchify - Repeat while sending a non-sensitive fixture command. Confirm a failed native write shows reconnection immediately, while one deliberately dropped response remains connected when the active health check succeeds. - Confirm a new pairing shows the phone model rather than a generic Remote name. Change Remote name while connected and confirm both desktop platforms update without re-pairing. Change it while offline, reconnect, and confirm the new name appears. Restart the PC application and confirm the name persists. - On the OPD2403 against Windows, complete at least 20 connections and reconnects. Confirm controls become ready every time. Simulate one dropped pointer-profile response and confirm the app shows "Restoring controls" before controls appear without another reconnect. -5. Exercise eight-way movement, repeat/stop, all clicks, both scroll directions, drag cleanup, speed limits, and monitor movement. +5. Exercise eight-way movement, all clicks, both scroll directions, drag cleanup, speed limits, and monitor movement. Start movement and scroll repeats, then stop each with Stop movement, another Remote control, and an Android physical switch. Repeat after backgrounding, reconnecting, and replacing the active Remote session. Confirm the PC stops before the Android switch-capture state clears. 6. Exercise live typing, backspace/replacement, stream recovery, draft persistence/send/clear, and every displayed PC key using non-sensitive fixture text. Send repeated live lines with both software Return and the visible Enter control; confirm each sends one Enter, clears only after success, and restores the text field and software keyboard. Simulate a failed Enter and confirm the text remains focused for Retry Enter without duplicate text or backspaces. 7. Exercise held modifiers, shortcuts, app switching, task view, desktop, minimize, maximize, and close. Confirm labels follow Windows/macOS conventions. - On Android, open Forwarding and confirm profile choices remain available while stopped. Confirm mapped switches and the overflow notice appear only after forwarding starts and disappear after manual, hold-to-stop, configuration-change, and inactivity stops. diff --git a/src/remote/RemoteSession.test.ts b/src/remote/RemoteSession.test.ts index 2358613..fc4cd17 100644 --- a/src/remote/RemoteSession.test.ts +++ b/src/remote/RemoteSession.test.ts @@ -35,14 +35,14 @@ describe('RemoteSession', () => { }); it('uses the desktop-compatible PC-side repeat payload and the next control stops it', async () => { - const calls: [string, unknown][] = []; - const manager = { send: async (type: string, payload: unknown) => { calls.push([type, payload]); return true; } } as unknown as ConnectionManager; + const calls: [string, unknown, string | undefined][] = []; + const manager = { send: async (type: string, payload: unknown, responseMode?: string) => { calls.push([type, payload, responseMode]); return true; } } as unknown as ConnectionManager; const session = new RemoteSession(manager, profile()); await session.mouse('mouse.move', { dx: 10, dy: 0 }, true); await session.mouse('mouse.click'); expect(calls).toEqual([ - ['mouse.repeat.start', { command: { type: 'mouse.move', payload: { dx: 10, dy: 0 } } }], - ['mouse.repeat.stop', {}], + ['mouse.repeat.start', { command: { type: 'mouse.move', payload: { dx: 10, dy: 0 } } }, undefined], + ['mouse.repeat.stop', {}, 'ack'], ]); }); @@ -110,7 +110,7 @@ describe('RemoteSession', () => { await Promise.resolve(); await Promise.resolve(); const next = session.mouse('mouse.move', { dx: -10, dy: 0 }, true); await Promise.resolve(); await Promise.resolve(); - expect(calls).toEqual(['mouse.repeat.start']); + expect(calls).toEqual(['mouse.repeat.start', 'mouse.repeat.stop']); releaseDeactivation(); await next; @@ -133,7 +133,7 @@ describe('RemoteSession', () => { expect(calls).toEqual(['mouse.repeat.start', 'mouse.repeat.stop']); }); - it('still stops the PC when bridge deactivation never settles', async () => { + it('sends the PC stop before bridge deactivation and does not let deactivation block cleanup', async () => { const calls: string[] = []; const manager = { send: async (type: string) => { calls.push(type); return true; } } as unknown as ConnectionManager; const host = fakeBridge(); @@ -143,17 +143,19 @@ describe('RemoteSession', () => { const session = new RemoteSession(manager, profile(), undefined, null, host.bridge, 1); await session.mouse('mouse.move', { dx: 10, dy: 0 }, true); - await session.cleanup(); + const cleanup = session.cleanup(); + await Promise.resolve(); await Promise.resolve(); expect(session.snapshot().repeat).toBeNull(); expect(calls).toEqual(['mouse.repeat.start', 'mouse.repeat.stop']); + await cleanup; }); - it('uses no-ack repeat stop during lifecycle cleanup', async () => { + it('uses an acknowledged repeat stop during lifecycle cleanup', async () => { const calls: [string, string | undefined][] = []; - const manager = { send: (type: string, _payload: unknown, responseMode?: string) => { + const manager = { send: async (type: string, _payload: unknown, responseMode?: string) => { calls.push([type, responseMode]); - return responseMode === 'ack' ? new Promise(() => undefined) : Promise.resolve(true); + return true; } } as unknown as ConnectionManager; const session = new RemoteSession(manager, profile()); @@ -162,7 +164,72 @@ describe('RemoteSession', () => { expect(calls).toEqual([ ['mouse.repeat.start', undefined], - ['mouse.repeat.stop', 'none'], + ['mouse.repeat.stop', 'ack'], + ]); + }); + + it('stops the old session with acknowledgement before a replacement starts repeating', async () => { + const calls: [string, string | undefined][] = []; + const manager = { send: async (type: string, _payload: unknown, responseMode?: string) => { + calls.push([type, responseMode]); + return true; + } } as unknown as ConnectionManager; + const oldSession = new RemoteSession(manager, profile(), undefined, 'pc-1'); + await oldSession.mouse('mouse.move', { dx: 10, dy: 0 }, true); + + const replacement = new RemoteSession(manager, profile(), undefined, 'pc-2'); + await oldSession.cleanup(); + await replacement.mouse('mouse.move', { dx: -10, dy: 0 }, true); + + expect(calls).toEqual([ + ['mouse.repeat.start', undefined], + ['mouse.repeat.stop', 'ack'], + ['mouse.repeat.start', undefined], + ]); + expect(oldSession.snapshot().repeat).toBeNull(); + expect(replacement.snapshot().repeat).toBe('mouse.move'); + oldSession.dispose(); + replacement.dispose(); + }); + + it('clears repeat once and keeps it cleared when the stop acknowledgement is missing', async () => { + let finishStop!: (sent: boolean) => void; + const stopResult = new Promise((resolve) => { finishStop = resolve; }); + const calls: string[] = []; + const manager = { send: (type: string) => { + calls.push(type); + return type === 'mouse.repeat.stop' ? stopResult : Promise.resolve(true); + } } as unknown as ConnectionManager; + const session = new RemoteSession(manager, profile()); + await session.mouse('mouse.move', { dx: 10, dy: 0 }, true); + + const first = session.stopRepeat(); + const duplicate = session.stopRepeat(); + await Promise.resolve(); await Promise.resolve(); + expect(session.snapshot().repeat).toBeNull(); + expect(calls).toEqual(['mouse.repeat.start', 'mouse.repeat.stop']); + + finishStop(false); + await Promise.all([first, duplicate]); + expect(session.snapshot().repeat).toBeNull(); + expect(calls).toEqual(['mouse.repeat.start', 'mouse.repeat.stop']); + }); + + it('keeps repeat cleared when the acknowledged stop reports a write failure', async () => { + const calls: [string, string | undefined][] = []; + const manager = { send: async (type: string, _payload: unknown, responseMode?: string) => { + calls.push([type, responseMode]); + return type !== 'mouse.repeat.stop'; + } } as unknown as ConnectionManager; + const session = new RemoteSession(manager, profile()); + await session.mouse('mouse.move', { dx: 10, dy: 0 }, true); + + await session.stopRepeat(); + + expect(session.snapshot().repeat).toBeNull(); + expect(calls).toEqual([ + ['mouse.repeat.start', undefined], + ['mouse.repeat.stop', 'ack'], ]); }); diff --git a/src/remote/RemoteSession.ts b/src/remote/RemoteSession.ts index f6ba491..4044f8a 100644 --- a/src/remote/RemoteSession.ts +++ b/src/remote/RemoteSession.ts @@ -27,7 +27,7 @@ export class RemoteSession { private readonly bridgeTimeoutMs = 1_000, ) { this.#bridgeUnsubscribe = bridge.subscribe((event) => { - if (event.type === 'repeatStop' && event.generation === this.#repeatGeneration && this.#state.repeat) void this.stopRepeat('ack'); + if (event.type === 'repeatStop' && event.generation === this.#repeatGeneration && this.#state.repeat) void this.stopRepeat(); if (event.type === 'snapshot') { const available = event.captureAvailable && event.externalSwitches.length > 0; if (!available) { @@ -62,20 +62,20 @@ export class RemoteSession { return this.manager.send(type, payload, this.#supportsNoAck(type) ? 'none' : 'ack'); } - stopRepeat(responseMode: 'ack' | 'none' = 'none'): Promise { - return this.#enqueueRepeat(() => this.#stopRepeat(responseMode)); + stopRepeat(): Promise { + return this.#enqueueRepeat(() => this.#stopRepeat()); } - async #stopRepeat(responseMode: 'ack' | 'none' = 'none'): Promise { + async #stopRepeat(): Promise { if (!this.#state.repeat) return; const generation = this.#repeatGeneration; this.#repeatArmAttempt += 1; this.#repeatGeneration = 0; this.#repeatBridgeArmed = false; this.#set({ repeat: null }); - if (generation > 0) await this.#setRepeatActiveBounded(generation, false); const [type, payload] = commandPayloads.repeatStop(); - await this.manager.send(type, payload, responseMode); + await this.manager.send(type, payload, 'ack'); + if (generation > 0) await this.#setRepeatActiveBounded(generation, false); } async #armRepeatBridge(): Promise { diff --git a/src/remote/RemoteSurfaces.test.tsx b/src/remote/RemoteSurfaces.test.tsx index ea36c03..2a846a6 100644 --- a/src/remote/RemoteSurfaces.test.tsx +++ b/src/remote/RemoteSurfaces.test.tsx @@ -259,7 +259,7 @@ describe('capability-driven remote surfaces', () => { expect(repeatSend.mock.calls).toEqual([ ['mouse.repeat.start', { command: { type: 'mouse.scroll', payload: { dx: 0, dy: 5 } } }], - ['mouse.repeat.stop', {}, 'none'], + ['mouse.repeat.stop', {}, 'ack'], ['mouse.repeat.start', { command: { type: 'mouse.scroll', payload: { dx: 0, dy: -5 } } }], ]); }); @@ -276,7 +276,7 @@ describe('capability-driven remote surfaces', () => { await act(async () => { fireEvent.press(mouse.getByRole('button', { name: 'Stop movement' })); await Promise.resolve(); }); expect(send.mock.calls).toEqual([ ['mouse.repeat.start', { command: { type: 'mouse.move', payload: { dx: 0, dy: -64 } } }], - ['mouse.repeat.stop', {}, 'none'], + ['mouse.repeat.stop', {}, 'ack'], ]); }); From 9b4ac32783470797cc25a552a8006789d407d9a3 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Tue, 25 Aug 2026 19:21:18 +0100 Subject: [PATCH 2/2] fix: clear repeat state when stop is requested --- src/remote/RemoteSession.test.ts | 26 ++++++++++++++++++++++++++ src/remote/RemoteSession.ts | 30 +++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/remote/RemoteSession.test.ts b/src/remote/RemoteSession.test.ts index fc4cd17..6c5f5e7 100644 --- a/src/remote/RemoteSession.test.ts +++ b/src/remote/RemoteSession.test.ts @@ -119,6 +119,32 @@ describe('RemoteSession', () => { expect(host.bridge.setRepeatActive).toHaveBeenLastCalledWith(102, true); }); + it('clears repeat immediately while bridge activation is pending and stops the PC before bridge cleanup', async () => { + const order: string[] = []; + let releaseActivation!: () => void; + const activation = new Promise((resolve) => { releaseActivation = resolve; }); + const host = fakeBridge(); + (host.bridge.setRepeatActive as jest.Mock).mockImplementation(async (_generation: number, active: boolean) => { + order.push(active ? 'bridge:on' : 'bridge:off'); + if (active) await activation; + return true; + }); + const manager = { send: async (type: string) => { order.push(type); return true; } } as unknown as ConnectionManager; + const session = new RemoteSession(manager, profile(), undefined, null, host.bridge); + const starting = session.mouse('mouse.move', { dx: 10, dy: 0 }, true); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(session.snapshot().repeat).toBe('mouse.move'); + + const stopping = session.stopRepeat(); + const duplicate = session.stopRepeat(); + expect(session.snapshot().repeat).toBeNull(); + expect(order).toEqual(['mouse.repeat.start', 'bridge:on']); + + releaseActivation(); + await Promise.all([starting, stopping, duplicate]); + expect(order).toEqual(['mouse.repeat.start', 'bridge:on', 'mouse.repeat.stop', 'bridge:off']); + }); + it('does not let a hung bridge activation block repeat cleanup', async () => { const calls: string[] = []; const manager = { send: async (type: string) => { calls.push(type); return true; } } as unknown as ConnectionManager; diff --git a/src/remote/RemoteSession.ts b/src/remote/RemoteSession.ts index 4044f8a..389a689 100644 --- a/src/remote/RemoteSession.ts +++ b/src/remote/RemoteSession.ts @@ -16,6 +16,7 @@ export class RemoteSession { #repeatGeneration = 0; #repeatArmAttempt = 0; #repeatBridgeArmed = false; + #repeatStopGeneration = 0; #bridgeUnsubscribe: () => void; constructor( @@ -49,7 +50,11 @@ export class RemoteSession { async #mouse(type: string, payload: JsonObject, repeatable: boolean): Promise { if (!this.supports(type)) return false; - if (this.#state.repeat) { await this.#stopRepeat(); return true; } + if (this.#state.repeat) { + const generation = this.#reserveRepeatStop(); + if (generation !== null) await this.#completeRepeatStop(generation); + return true; + } if (repeatable && this.supports('mouse.repeat.start') && this.supports('mouse.repeat.stop') && this.profile?.capabilities.mouseRepeat.supported && this.profile.capabilities.mouseRepeat.enabled) { const [repeatType, repeatPayload] = commandPayloads.repeatStart({ type: type as 'mouse.move' | 'mouse.scroll', dx: Number(payload.dx), dy: Number(payload.dy) }); const ok = await this.manager.send(repeatType, repeatPayload); @@ -63,19 +68,30 @@ export class RemoteSession { } stopRepeat(): Promise { - return this.#enqueueRepeat(() => this.#stopRepeat()); + const generation = this.#reserveRepeatStop(); + if (generation === null) return this.#repeatQueue; + return this.#enqueueRepeat(() => this.#completeRepeatStop(generation)); } - async #stopRepeat(): Promise { - if (!this.#state.repeat) return; + #reserveRepeatStop(): number | null { + if (!this.#state.repeat) return null; const generation = this.#repeatGeneration; this.#repeatArmAttempt += 1; this.#repeatGeneration = 0; this.#repeatBridgeArmed = false; + this.#repeatStopGeneration = generation; this.#set({ repeat: null }); + return generation; + } + + async #completeRepeatStop(generation: number): Promise { const [type, payload] = commandPayloads.repeatStop(); - await this.manager.send(type, payload, 'ack'); - if (generation > 0) await this.#setRepeatActiveBounded(generation, false); + try { + await this.manager.send(type, payload, 'ack'); + } finally { + if (generation > 0) await this.#setRepeatActiveBounded(generation, false); + if (this.#repeatStopGeneration === generation) this.#repeatStopGeneration = 0; + } } async #armRepeatBridge(): Promise { @@ -85,7 +101,7 @@ export class RemoteSession { this.#repeatGeneration = generation; const accepted = await this.#setRepeatActiveBounded(generation, true); if (attempt !== this.#repeatArmAttempt || !this.#state.repeat) { - if (accepted) await this.#setRepeatActiveBounded(generation, false); + if (accepted && this.#repeatStopGeneration !== generation) await this.#setRepeatActiveBounded(generation, false); return; } this.#repeatBridgeArmed = accepted;