Skip to content

Commit 1926db9

Browse files
feat: control PC scanning through forwarding (#167)
* feat: control PC scanning through forwarding * fix: invalidate queued scanning edges and snapshot sync state --------- Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com>
1 parent 287c5fe commit 1926db9

9 files changed

Lines changed: 118 additions & 14 deletions

docs/accessibility.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,7 @@ The edit toggle is icon-only: a pencil labeled Edit layout, then a checkmark lab
5151
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.
5252

5353
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.
54+
55+
### PC scanning acceptance
56+
57+
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.

docs/physical-smoke-test.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,7 @@ The edit toggle is icon-only: a pencil labeled Edit layout, then a checkmark lab
6464
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.
6565

6666
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.
67+
68+
### Remote scanning
69+
70+
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.

docs/protocol-compatibility.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,9 @@ An optional discovery field `responseTransport: "read-v1"` selects the read-only
3434
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.
3535

3636
Fake tests cover negotiation, backward compatibility, response bounds, serial polling, timeout and cancellation. Physical Android interoperability is not yet qualified.
37+
38+
## PC scanning forwarding profile
39+
40+
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.
41+
42+
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.

src/domain/protocol/protocol.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,10 @@ describe('Switchify PC protocol v1', () => {
127127
expect(parseResponse(JSON.stringify({ type: 'error', error: { code, message: code } }))).toEqual({ kind: 'error', code, message: code });
128128
});
129129
});
130+
131+
it('accepts the opt-in scanning catalog and rejects unknown profile kinds', () => {
132+
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' }] }] } };
133+
expect(parseResponse(JSON.stringify(response)).kind).toBe('switchProfileCatalog');
134+
response.payload.profiles = response.payload.profiles.map((p) => ({ ...p, kind: 'unknown' }));
135+
expect(parseResponse(JSON.stringify(response)).kind).toBe('invalid');
136+
});

src/domain/protocol/responses.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ function parseSwitchProfileCatalog(payload: JsonObject): SwitchProfileCatalog |
6868
const profile = object(entry);
6969
if (!profile) return null;
7070
const id = string(profile.id), version = number(profile.version), name = string(profile.name);
71-
if (!id || !name || !version || !Number.isInteger(version) || (profile.kind !== 'grid3' && profile.kind !== 'mapped') || !Array.isArray(profile.bindings) || profile.bindings.length > 8) return null;
71+
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;
7272
const bindings = profile.bindings.map((entry) => {
7373
const binding = object(entry);
7474
if (!binding) return null;
@@ -77,7 +77,7 @@ function parseSwitchProfileCatalog(payload: JsonObject): SwitchProfileCatalog |
7777
return { switchId, label, behavior: binding.behavior as 'stateful' | 'pulse' | 'unassigned' };
7878
});
7979
if (bindings.some((binding) => binding === null)) return null;
80-
return { id, version, name, kind: profile.kind as 'grid3' | 'mapped', bindings: bindings as NonNullable<(typeof bindings)[number]>[] };
80+
return { id, version, name, kind: profile.kind as 'grid3' | 'mapped' | 'scanning', bindings: bindings as NonNullable<(typeof bindings)[number]>[] };
8181
});
8282
return profiles.some((profile) => profile === null) ? null : { catalogRevision: revision, profiles: profiles as NonNullable<(typeof profiles)[number]>[] };
8383
}
@@ -108,6 +108,7 @@ function parsePointerProfile(payload: JsonObject): PointerProfile | null {
108108
recommendedDeltas: { small: small!, medium: medium!, large: large! },
109109
capabilities: {
110110
noAckMouseMove: bool(capabilities.noAckMouseMove),
111+
switchScanning: bool(capabilities.switchScanning),
111112
noAckCommands: strings(capabilities.noAckCommands),
112113
supportedCommands: strings(capabilities.supportedCommands),
113114
mouseRepeat: { supported: bool(repeat.supported), enabled: bool(repeat.enabled), intervalMs: numeric(repeat.intervalMs, 250), minIntervalMs: numeric(repeat.minIntervalMs, 100), maxIntervalMs: numeric(repeat.maxIntervalMs, 2000) },

src/domain/protocol/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type PointerProfile = {
1212
recommendedDeltas: { small: number; medium: number; large: number };
1313
capabilities: {
1414
noAckMouseMove: boolean;
15+
switchScanning?: boolean;
1516
noAckCommands: string[];
1617
supportedCommands: string[];
1718
mouseRepeat: { supported: boolean; enabled: boolean; intervalMs: number; minIntervalMs: number; maxIntervalMs: number };
@@ -22,7 +23,7 @@ export type PointerProfile = {
2223
};
2324

2425
export type SwitchBinding = { switchId: number; label: string; behavior: 'stateful' | 'pulse' | 'unassigned' };
25-
export type SwitchProfile = { id: string; version: number; name: string; kind: 'grid3' | 'mapped'; bindings: SwitchBinding[] };
26+
export type SwitchProfile = { id: string; version: number; name: string; kind: 'grid3' | 'mapped' | 'scanning'; bindings: SwitchBinding[] };
2627
export type SwitchProfileCatalog = { catalogRevision: number; profiles: SwitchProfile[] };
2728

2829
export type ProtocolResponse =

src/forwarding/ForwardingController.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,51 @@ const catalog: ProtocolResponse = { kind: 'switchProfileCatalog', id: 'catalog',
1717
describe('ForwardingController', () => {
1818
const generic = ['switch.profile.list', 'switch.session.start', 'switch.edge', 'switch.sync', 'switch.session.stop'];
1919
const fakeTimers = () => ({ interval: jest.fn(() => 1 as never), timeout: jest.fn(() => 2 as never), clear: jest.fn() });
20+
it.each(['cancelled', 'held', 'replaced'])('stops scanning without sending a selecting release when %s', async (reason) => {
21+
const bridge = new FakeBridge();
22+
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' }] }] } };
23+
const connection = { request: jest.fn(async () => scanCatalog), send: jest.fn(async () => true) };
24+
const pc = profile(generic, ['switch.edge']); pc.capabilities.switchScanning = true;
25+
const controller = new ForwardingController(connection, bridge, pc, 5000, fakeTimers(), () => 'session');
26+
await controller.loadProfiles();
27+
expect(connection.request).toHaveBeenCalledWith('switch.profile.list', { includeScanning: true });
28+
await controller.start();
29+
bridge.emit({ type: 'switchEdge', generation: 41, sequence: 1, keyCode: 20, down: true, downTimeMs: 0, eventTimeMs: 0, cancelled: false });
30+
for (let i = 0; i < 10; i++) await Promise.resolve();
31+
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' });
32+
for (let i = 0; i < 20; i++) await Promise.resolve();
33+
expect(controller.snapshot().phase).toBe('idle');
34+
expect(connection.send).toHaveBeenCalledWith('switch.edge', expect.objectContaining({ state: 'down' }), 'ack');
35+
expect(connection.send).not.toHaveBeenCalledWith('switch.edge', expect.objectContaining({ state: 'up' }), expect.anything());
36+
expect(connection.send).toHaveBeenCalledWith('switch.session.stop', expect.anything());
37+
await controller.cleanup();
38+
});
39+
it.each(['stop', 'sync'])('preserves queued edge semantics across delayed acknowledgement and %s', async (scenario) => {
40+
const bridge = new FakeBridge(); let resolveDown: (ok: boolean) => void = () => undefined;
41+
const down = new Promise<boolean>((resolve) => { resolveDown = resolve; });
42+
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' }] }] } };
43+
const send = jest.fn((command: string, payload?: import('@/domain/protocol/types').JsonObject) => command === 'switch.edge' && payload?.state === 'down' ? down : Promise.resolve(true));
44+
const connection = { request: jest.fn(async () => scanCatalog), send };
45+
const timers = { ...fakeTimers(), interval: jest.fn((callback: () => void, _ms: number) => { void callback; return 1 as never; }) }; let interval: () => void = () => undefined;
46+
timers.interval.mockImplementation((callback) => { interval = callback; return 1 as never; });
47+
const controller = new ForwardingController(connection, bridge, profile(generic), 5000, timers, () => 'session');
48+
await controller.loadProfiles(); await controller.start();
49+
bridge.emit({ type: 'switchEdge', generation: 41, sequence: 1, keyCode: 20, down: true, downTimeMs: 0, eventTimeMs: 0, cancelled: false });
50+
for (let i = 0; i < 10; i++) await Promise.resolve();
51+
if (scenario === 'sync') interval();
52+
bridge.emit({ type: 'switchEdge', generation: 41, sequence: 2, keyCode: 20, down: false, downTimeMs: 0, eventTimeMs: 50, cancelled: false });
53+
const stopped = scenario === 'stop' ? controller.stop() : null;
54+
resolveDown(true); for (let i = 0; i < 30; i++) await Promise.resolve();
55+
if (scenario === 'stop') {
56+
await stopped;
57+
expect(send.mock.calls.filter(([command, payload]) => command === 'switch.edge' && payload?.state === 'up')).toHaveLength(0);
58+
} else {
59+
const syncs = send.mock.calls.filter(([command]) => command === 'switch.sync');
60+
expect(syncs[1]?.[1]?.pressedSwitchIds).toEqual([1]);
61+
expect(send.mock.calls.filter(([command, payload]) => command === 'switch.edge' && payload?.state === 'up')).toHaveLength(1);
62+
}
63+
await controller.cleanup();
64+
});
2065
it('maps eight switches, sends ordered edges, and cleans up', async () => {
2166
const bridge = new FakeBridge(); const connection = { request: jest.fn(async () => catalog), send: jest.fn(async () => true) } as ForwardingConnection;
2267
const controller = new ForwardingController(connection, bridge, profile(generic, ['switch.edge']), 5_000, fakeTimers(), () => 'session');
@@ -44,7 +89,7 @@ describe('ForwardingController', () => {
4489
await controller.loadProfiles(); await controller.start();
4590
bridge.emit({ type: 'switchEdge', generation: 41, sequence: 1, keyCode: 20, down: true, downTimeMs: 10, eventTimeMs: 10, cancelled: false });
4691
bridge.emit({ type: 'switchEdge', generation: 41, sequence: 2, keyCode: 20, down: true, downTimeMs: 20, eventTimeMs: 20, cancelled: false });
47-
for (let index = 0; index < 10; index += 1) await Promise.resolve();
92+
for (let index = 0; index < 30; index += 1) await Promise.resolve();
4893
const edges = (connection.send as jest.Mock).mock.calls.filter(([command]) => command === 'switch.edge').map(([, payload]) => payload.state);
4994
expect(edges).toEqual(['down', 'up', 'down']);
5095
});

0 commit comments

Comments
 (0)