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
4 changes: 4 additions & 0 deletions docs/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 4 additions & 0 deletions docs/physical-smoke-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions docs/protocol-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions src/domain/protocol/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = response.payload.profiles.map((p) => ({ ...p, kind: 'unknown' }));
expect(parseResponse(JSON.stringify(response)).kind).toBe('invalid');
});
5 changes: 3 additions & 2 deletions src/domain/protocol/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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]>[] };
}
Expand Down Expand Up @@ -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) },
Expand Down
3 changes: 2 additions & 1 deletion src/domain/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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 =
Expand Down
47 changes: 46 additions & 1 deletion src/forwarding/ForwardingController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,51 @@ 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.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<boolean>((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');
Expand Down Expand Up @@ -44,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']);
});
Expand Down
Loading