Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/physical-smoke-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
115 changes: 104 additions & 11 deletions src/remote/RemoteSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
]);
});

Expand Down Expand Up @@ -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;
Expand All @@ -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<void>((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;
Expand All @@ -133,7 +159,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();
Expand All @@ -143,17 +169,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<boolean>(() => undefined) : Promise.resolve(true);
return true;
} } as unknown as ConnectionManager;
const session = new RemoteSession(manager, profile());

Expand All @@ -162,7 +190,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<boolean>((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'],
]);
});

Expand Down
34 changes: 25 additions & 9 deletions src/remote/RemoteSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class RemoteSession {
#repeatGeneration = 0;
#repeatArmAttempt = 0;
#repeatBridgeArmed = false;
#repeatStopGeneration = 0;
#bridgeUnsubscribe: () => void;

constructor(
Expand All @@ -27,7 +28,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) {
Expand All @@ -49,7 +50,11 @@ export class RemoteSession {

async #mouse(type: string, payload: JsonObject, repeatable: boolean): Promise<boolean> {
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);
Expand All @@ -62,20 +67,31 @@ export class RemoteSession {
return this.manager.send(type, payload, this.#supportsNoAck(type) ? 'none' : 'ack');
}

stopRepeat(responseMode: 'ack' | 'none' = 'none'): Promise<void> {
return this.#enqueueRepeat(() => this.#stopRepeat(responseMode));
stopRepeat(): Promise<void> {
const generation = this.#reserveRepeatStop();
if (generation === null) return this.#repeatQueue;
return this.#enqueueRepeat(() => this.#completeRepeatStop(generation));
}

async #stopRepeat(responseMode: 'ack' | 'none' = 'none'): Promise<void> {
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 });
if (generation > 0) await this.#setRepeatActiveBounded(generation, false);
return generation;
}

async #completeRepeatStop(generation: number): Promise<void> {
const [type, payload] = commandPayloads.repeatStop();
await this.manager.send(type, payload, responseMode);
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<void> {
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/remote/RemoteSurfaces.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } }],
]);
});
Expand All @@ -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'],
]);
});

Expand Down