Skip to content

Commit f4dc48e

Browse files
enaboappsOwenMcGirrclaude
authored
fix(forwarding): restore hold-to-stop for scanning profiles (#177)
* fix(forwarding): restore hold-to-stop for scanning profiles Removing it in #173 left switch users no way to end a session from a switch. A release at or past the Forwarding hold-to-stop stops forwarding without an actionable release again; shorter holds still deliver their release so the PC can run hold actions. The idle stop stays off for scanning, and cancelled or replaced presses keep their sync-based handling. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(forwarding): pin safety stop ordering after a hold-to-stop Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Owen McGirr <o.a.mcgirr@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 5bd4236 commit f4dc48e

5 files changed

Lines changed: 44 additions & 13 deletions

File tree

docs/physical-smoke-test.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,4 @@ Verify three-column/four-row Movement layouts including Scroll up, Scroll down,
6767

6868
### Remote scanning
6969

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 and switch replacement never click on release and keep the session running; confirm a hold longer than the Forwarding hold-to-stop still delivers its release and the PC offers hold actions; confirm no idle stop occurs while scanning waits; confirm navigation away, backgrounding and disconnect never click on release and always release an active drag. Changing PC assignments applies to a live session; reload profiles only to refresh the labels shown in Remote. Reconnect must require explicit Start. Use a disposable target application for real-input checks; keep these checks separate from automated tests.
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 and switch replacement never click on release and keep the session running; confirm holds shorter than the Forwarding hold-to-stop deliver their release and the PC offers hold actions, and that reaching the hold-to-stop stops forwarding without clicking; confirm no idle stop occurs while scanning waits; confirm navigation away, backgrounding and disconnect never click on release and always release an active drag. Changing PC assignments applies to a live session; reload profiles only to refresh the labels shown in Remote. 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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,4 @@ Fake tests cover negotiation, backward compatibility, response bounds, serial po
3939

4040
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.
4141

42-
Scanning edges request acknowledgements. The PC owns hold timing for scanning profiles: Remote's hold-to-stop and 60-second idle stop apply to keyboard forwarding only, and a long hold still delivers its release. A cancelled press is withdrawn with a `switch.sync` that omits it, so the PC drops the gesture without selecting; a replacement press is withdrawn with a `switch.sync` and then sent as a fresh press, never as a 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; the PC applies its current assignments at start and no longer rejects a stale profile revision, so labels in Remote may lag until profiles are reloaded.
42+
Scanning edges request acknowledgements. The PC owns hold-action timing for scanning profiles, so holds shorter than the Forwarding hold-to-stop deliver their release; a hold that reaches the hold-to-stop sends session stop without an actionable release, which is the switch user's way out. The 60-second idle stop applies to keyboard forwarding only. A cancelled press is withdrawn with a `switch.sync` that omits it, so the PC drops the gesture without selecting; a replacement press is withdrawn with a `switch.sync` and then sent as a fresh press, never as a 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; the PC applies its current assignments at start and no longer rejects a stale profile revision, so labels in Remote may lag until profiles are reloaded.

src/app/(tabs)/settings.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export default function SettingsScreen() {
4141
<View style={cardWidth}><SettingCard title="Typing mode" description="Choose whether text sends live or as a draft."><View style={{ flexDirection: 'row', gap: spacing.sm }}><ControlButton label="Live" selected={preferences.typingMode === 'live'} onPress={() => void preferencesStore.update({ typingMode: 'live' })} /><ControlButton label="Draft" selected={preferences.typingMode === 'draft'} onPress={() => void preferencesStore.update({ typingMode: 'draft' })} /></View></SettingCard></View>
4242
<View style={cardWidth}><SettingCard title="Default PC" description="Select the computer used for automatic connection.">{saved.length === 0 ? <EmptyState icon="computer" title="No saved PCs" body="Pair a PC before choosing a default." /> : <View style={{ gap: spacing.sm }}><ControlButton label="Most recently connected" selected={defaultId === null} onPress={() => void setDefault(null)} />{saved.map((pc) => <ControlButton key={pc.desktopId} label={pc.displayName} selected={defaultId === pc.desktopId} onPress={() => void setDefault(pc.desktopId)} />)}</View>}</SettingCard></View>
4343
<View style={cardWidth}><SettingCard title="Pointer controls" description="Capabilities reported by the connected computer.">{connection.kind === 'connected' && connection.profile ? <View><ListRow icon="speed" title="Pointer speed" description={`${connection.profile.capabilities.pointerSpeed.scalePercent}%`} /><ListRow icon="repeat" title="Movement repeat" description={connection.profile.capabilities.mouseRepeat.enabled ? 'On' : 'Off'} /><ListRow icon="desktop-windows" title="Displays" description={`${connection.profile.capabilities.displayNavigation.displayCount}`} /></View> : <EmptyState icon="link" title="Connect a PC" body="Supported pointer controls are provided by the connected computer." />}</SettingCard></View>
44-
{Platform.OS === 'android' ? <View style={cardWidth}><SettingCard title="Forwarding hold to stop" description="How long a physical switch must be held to stop keyboard forwarding. Switchify scanning uses the PC's hold limit instead."><SelectorField label="Hold to stop" options={[3_000, 5_000, 8_000].map((milliseconds) => ({ key: milliseconds, label: `${milliseconds / 1000} seconds` }))} selectedKey={preferences.forwardingHoldToStopMs} onSelect={(forwardingHoldToStopMs) => preferencesStore.update({ forwardingHoldToStopMs })} /></SettingCard></View> : null}
44+
{Platform.OS === 'android' ? <View style={cardWidth}><SettingCard title="Forwarding hold to stop" description="How long a physical switch must be held to stop forwarding. With Switchify scanning, shorter holds run the PC's hold actions."><SelectorField label="Hold to stop" options={[3_000, 5_000, 8_000].map((milliseconds) => ({ key: milliseconds, label: `${milliseconds / 1000} seconds` }))} selectedKey={preferences.forwardingHoldToStopMs} onSelect={(forwardingHoldToStopMs) => preferencesStore.update({ forwardingHoldToStopMs })} /></SettingCard></View> : null}
4545
<View style={cardWidth}><Card><PrivacyPolicyLink /><DiagnosticsLink /></Card></View>
4646
</View>
4747
</Screen>;

src/forwarding/ForwardingController.test.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,36 @@ 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'])('keeps a scanning session alive and never selects on its own when %s', async (reason) => {
20+
it('stops a scanning session on hold-to-stop without sending a selecting release', async () => {
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 onSafetyStop = jest.fn();
26+
const controller = new ForwardingController(connection, bridge, pc, 5000, fakeTimers(), () => 'session', onSafetyStop);
27+
await controller.loadProfiles(); await controller.start();
28+
bridge.emit({ type: 'switchEdge', generation: 41, sequence: 1, keyCode: 20, down: true, downTimeMs: 0, eventTimeMs: 0, cancelled: false });
29+
for (let i = 0; i < 10; i++) await Promise.resolve();
30+
// A shorter hold is the PC's business and delivers its release.
31+
bridge.emit({ type: 'switchEdge', generation: 41, sequence: 2, keyCode: 20, down: false, downTimeMs: 0, eventTimeMs: 4999, cancelled: false });
32+
for (let i = 0; i < 10; i++) await Promise.resolve();
33+
expect(controller.snapshot().phase).toBe('active');
34+
bridge.emit({ type: 'switchEdge', generation: 41, sequence: 3, keyCode: 20, down: true, downTimeMs: 6000, eventTimeMs: 6000, cancelled: false });
35+
for (let i = 0; i < 10; i++) await Promise.resolve();
36+
bridge.emit({ type: 'switchEdge', generation: 41, sequence: 4, keyCode: 20, down: false, downTimeMs: 6000, eventTimeMs: 11000, cancelled: false });
37+
for (let i = 0; i < 20; i++) await Promise.resolve();
38+
expect(controller.snapshot().phase).toBe('idle');
39+
const edges = (connection.send as jest.Mock).mock.calls.filter(([command]) => command === 'switch.edge').map(([, payload]) => payload.state);
40+
expect(edges).toEqual(['down', 'up', 'down']);
41+
// The stop is a safety stop and follows the last edge, so the PC never
42+
// sees an edge after the session has ended.
43+
expect(onSafetyStop).toHaveBeenCalledTimes(1);
44+
const commands = (connection.send as jest.Mock).mock.calls.map(([command]) => command).filter((command) => command === 'switch.edge' || command === 'switch.session.stop');
45+
expect(commands.at(-1)).toBe('switch.session.stop');
46+
expect(commands.filter((command) => command === 'switch.session.stop')).toHaveLength(1);
47+
await controller.cleanup();
48+
});
49+
it.each(['cancelled', 'replaced'])('keeps a scanning session alive and never selects on its own when %s', async (reason) => {
2150
const bridge = new FakeBridge();
2251
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' }] }] } };
2352
const connection = { request: jest.fn(async () => scanCatalog), send: jest.fn(async () => true) };
@@ -28,7 +57,7 @@ describe('ForwardingController', () => {
2857
await controller.start();
2958
bridge.emit({ type: 'switchEdge', generation: 41, sequence: 1, keyCode: 20, down: true, downTimeMs: 0, eventTimeMs: 0, cancelled: false });
3059
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' });
60+
bridge.emit({ type: 'switchEdge', generation: 41, sequence: 2, keyCode: 20, down: reason === 'replaced', downTimeMs: reason === 'replaced' ? 1 : 0, eventTimeMs: 20, cancelled: reason === 'cancelled' });
3261
for (let i = 0; i < 20; i++) await Promise.resolve();
3362
expect(controller.snapshot().phase).toBe('active');
3463
expect(connection.send).not.toHaveBeenCalledWith('switch.session.stop', expect.anything());
@@ -39,9 +68,6 @@ describe('ForwardingController', () => {
3968
const syncs = (connection.send as jest.Mock).mock.calls.filter(([command]) => command === 'switch.sync').map(([, payload]) => payload.pressedSwitchIds);
4069
expect(syncs.at(-1)).toEqual([]);
4170
expect(controller.snapshot().mappings.find((mapping) => mapping.keyCode === 20)?.pressed).toBe(false);
42-
} else if (reason === 'held') {
43-
// The PC owns hold timing, so a long hold still delivers its release.
44-
expect(edges).toEqual(['down', 'up']);
4571
} else {
4672
// A replacement withdraws the old press with a sync, then presses again;
4773
// the PC never receives a release it could act on.

src/forwarding/ForwardingController.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,16 @@ export class ForwardingController {
153153
if (!mapping) return;
154154
this.#resetIdle();
155155
const duration = Math.max(0, event.eventTimeMs - event.downTimeMs);
156-
// Scanning profiles leave hold limits to the PC, which owns the hold-action
157-
// timing and its own emergency hold. A cancelled press is withdrawn with a
158-
// sync so the PC drops the gesture without selecting, and the session
159-
// continues; nothing here ends a scanning session on the user's behalf.
156+
// The PC owns hold-action timing for scanning profiles, so shorter holds
157+
// deliver their release. The Forwarding hold-to-stop still applies: it is
158+
// the switch user's way out of a session, and it stops without sending an
159+
// actionable release. A cancelled press is withdrawn with a sync so the PC
160+
// drops the gesture without selecting, and the session continues.
160161
const scanning = this.selectedProfile()?.kind === 'scanning';
162+
if (scanning && !event.down && !event.cancelled && duration >= this.holdToStopMs) {
163+
void this.stop('Forwarding stopped after the switch was held.', true);
164+
return;
165+
}
161166
if (scanning && event.cancelled) {
162167
this.#set({ mappings: this.#state.mappings.map((item) => item.keyCode === event.keyCode ? { ...item, pressed: false, downTimeMs: null } : item) });
163168
const attempt = this.#attempt; const held = this.#heldIds();
@@ -176,7 +181,7 @@ export class ForwardingController {
176181
else if (replacement) await this.#edge(mapping.switchId, false);
177182
if (attempt !== this.#attempt || this.#state.phase !== 'active') return;
178183
await this.#edge(mapping.switchId, event.down);
179-
if (!scanning && !event.down && !event.cancelled && duration >= this.holdToStopMs) void this.stop('Forwarding stopped after the switch was held.', true);
184+
if (!event.down && !event.cancelled && duration >= this.holdToStopMs) void this.stop('Forwarding stopped after the switch was held.', true);
180185
});
181186
}
182187

0 commit comments

Comments
 (0)