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
1 change: 1 addition & 0 deletions docs/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Switchify Remote is designed for VoiceOver, TalkBack, iOS Switch Control, and An
- Connected Remote control screens add one "Scroll to top" scan stop after the Surface selector pins. The 48-point control stays above the PC switcher, announces "Top of Remote" once after use, and does not animate when Reduce Motion is enabled.
- Remote keeps a quick PC switcher immediately above the tab bar in every connection state. It announces the active connection, exposes selected state for the current saved PC, and restores focus after its modal closes.
- Headings, connection changes, failures, repeat state, pairing approval, and pointer-profile recovery transitions are announced once without moving focus unexpectedly.
- If the PC connection is lost, connected controls disappear immediately and Remote announces the first reconnect attempt once. Idle connections are checked in the background without adding focus or scanning stops.
- In live typing, Return and the visible Enter control restore the text field and software keyboard after delivery. Failed Enter attempts keep the text available for retry and return focus without sending it again.
- The pairing code is announced one digit at a time.
- Text scales with the operating system; controls grow rather than shrinking text below the user's chosen size.
Expand Down
2 changes: 2 additions & 0 deletions docs/physical-smoke-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Run the matrix on a physical Android phone and iPhone against current Switchify
2. Clear app data and repeat setup. Choose Allow Bluetooth, deny access once, verify the platform-appropriate explanation, then grant access from Settings and discover the desktop. Also test Bluetooth off, unsupported hardware where available, and a forced scan failure.
3. Pair and verify the six-digit code on both devices. Reject a second request and confirm the mobile error is sanitized.
4. Disconnect and reconnect from Saved PCs, select and clear a default PC, then press Unpair. Confirm Cancel, Android Back, and outside dismissal preserve the pairing. Confirm Unpair removes it and re-pairing is required.
- While Remote is idle and connected, quit Switchify PC, use its Disconnect action, disable Bluetooth, and move the phone out of range. Confirm connected controls disappear within nine seconds, attempt 1 is announced once, and the existing automatic retries either restore the correct PC or end in the sanitized failure state.
- 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.
Expand Down
154 changes: 127 additions & 27 deletions src/connection/ConnectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { JsonObject, PointerProfile, ProtocolResponse } from '@/domain/prot
import { DiagnosticLog } from '@/diagnostics/DiagnosticLog';
import type { PairingStorage, SavedPc } from '@/storage/PairingStore';
import type { BleAvailability, BleTransport, DiscoveredDesktop, Unsubscribe } from '@/transport/BleTransport';
import { ProtocolClient } from './ProtocolClient';
import { ProtocolClient, ProtocolWriteError } from './ProtocolClient';
import { pairingVerificationCode } from './verificationCode';

export type ConnectionState =
Expand Down Expand Up @@ -37,6 +37,10 @@ export class ConnectionManager {
#switchIntent = 0;
#invalidSavedDesktopIds = new Set<string>();
#profileRecoveryTimers = new Map<ReturnType<typeof setTimeout>, (active: boolean) => void>();
#profileRecoveryDeadline: ReturnType<typeof setTimeout> | null = null;
#healthTimer: ReturnType<typeof setTimeout> | null = null;
#healthProbe: Promise<boolean> | null = null;
#protocolOperations = 0;

constructor(
private readonly transport: BleTransport,
Expand Down Expand Up @@ -252,12 +256,23 @@ export class ConnectionManager {
}

async request(type: string, payload: JsonObject = {}, responseMode: 'ack' | 'none' = 'ack'): Promise<ProtocolResponse | null> {
if (!this.#client || !this.#token || !this.#deviceId) return null;
await this.#healthProbe;
const client = this.#client;
const token = this.#token;
const deviceId = this.#deviceId;
const sourceOperation = this.#operation;
const desktop = this.#state.kind === 'connected' ? this.#state.desktop : null;
if (!client || !token || !deviceId || !desktop) return null;
this.#cancelHealthTimer();
this.#protocolOperations += 1;
let healthyActivity = false;
let shouldProbe = false;
const id = this.id();
const message = authenticatedCommand({ id, deviceId: this.#deviceId, token: this.#token, timestamp: this.now(), type, payload, responseMode });
const message = authenticatedCommand({ id, deviceId, token, timestamp: this.now(), type, payload, responseMode });
try {
if (responseMode === 'none') { await this.#client.send(message); return { kind: 'ack', id }; }
const response = await this.#client.request(message, id, 5_000);
if (responseMode === 'none') { await client.send(message); healthyActivity = true; return { kind: 'ack', id }; }
const response = await client.request(message, id, 5_000);
healthyActivity = true;
if (response.kind === 'ack') {
if (type === 'pointer.speed.set' && typeof payload.scalePercent === 'number' && this.#state.kind === 'connected' && this.#state.profile) {
this.#set({ ...this.#state, profile: { ...this.#state.profile, capabilities: { ...this.#state.profile.capabilities, pointerSpeed: { ...this.#state.profile.capabilities.pointerSpeed, scalePercent: payload.scalePercent } } } });
Expand All @@ -270,7 +285,18 @@ export class ConnectionManager {
this.diagnostics.add('remote_name_sync_failed', 'warning');
return response;
}
} catch { /* sanitized below */ }
} catch (error) {
if (error instanceof ProtocolWriteError || responseMode === 'none') {
void this.#unexpectedDisconnect(desktop, sourceOperation);
} else {
shouldProbe = true;
}
} finally {
this.#protocolOperations -= 1;
if (this.#protocolOperations === 0 && this.#current(sourceOperation) && this.#state.kind === 'connected') {
this.#scheduleHealth(healthyActivity ? 5_000 : shouldProbe ? 0 : 5_000);
}
}
this.diagnostics.add('command_failed', 'warning');
return null;
}
Expand Down Expand Up @@ -318,7 +344,7 @@ export class ConnectionManager {
}
if (response.kind === 'error') this.diagnostics.add('remote_name_sync_failed', 'warning');
this.#token = token;
const profile = await this.#requestPointerProfile(token, operation);
const profile = await this.#requestPointerProfile(token, desktop, operation);
if (!this.#current(operation)) return;
const saved = { desktopId: desktop.desktopId, displayName: desktop.displayName, platform: desktop.platform, peripheralId: desktop.peripheralId, lastConnectedAt: this.now() };
await this.storage.save(saved, token);
Expand All @@ -327,50 +353,81 @@ export class ConnectionManager {
this.diagnostics.add('connected');
if (profile) {
this.#set({ kind: 'connected', desktop, profile, profileStatus: 'ready' });
this.#scheduleHealth();
} else {
this.#set({ kind: 'connected', desktop, profile: null, profileStatus: 'recovering' });
this.diagnostics.add('profile_recovery_started');
void this.#recoverPointerProfile(token, operation);
void this.#recoverPointerProfile(token, desktop, operation);
}
}

async #requestPointerProfile(token: string, operation: number): Promise<PointerProfile | null> {
async #requestPointerProfile(token: string, desktop: DiscoveredDesktop, operation: number): Promise<PointerProfile | null> {
for (let attempt = 0; attempt < 2; attempt += 1) {
const profile = await this.#requestPointerProfileAttempt(token, operation);
const profile = await this.#requestPointerProfileAttempt(token, desktop, operation);
if (profile) return profile;
if (!this.#current(operation)) return null;
}
return null;
}

async #recoverPointerProfile(token: string, operation: number): Promise<void> {
for (const delay of [1_000, 2_000, 4_000]) {
if (!await this.#waitForProfileRecovery(delay, operation)) return;
const profile = await this.#requestPointerProfileAttempt(token, operation);
if (!this.#current(operation)) return;
if (profile) {
if (this.#state.kind === 'connected' && this.#state.profileStatus === 'recovering') {
this.#set({ ...this.#state, profile, profileStatus: 'ready' });
this.diagnostics.add('profile_recovered');
async #recoverPointerProfile(token: string, desktop: DiscoveredDesktop, operation: number): Promise<void> {
const deadline = setTimeout(() => this.#markProfileUnavailable(operation), 22_000);
this.#profileRecoveryDeadline = deadline;
try {
for (const delay of [1_000, 2_000, 4_000]) {
if (!await this.#waitForProfileRecovery(delay, operation)) return;
if (!await this.#probeHealth(desktop, operation, false) || !this.#profileRecovering(operation)) return;
const profile = await this.#requestPointerProfileAttempt(token, desktop, operation);
if (!this.#current(operation)) return;
if (profile) {
if (this.#state.kind === 'connected' && this.#state.profileStatus === 'recovering') {
this.#set({ ...this.#state, profile, profileStatus: 'ready' });
this.diagnostics.add('profile_recovered');
this.#scheduleHealth();
}
return;
}
return;
// A missing profile response is not itself a disconnect signal. Probe
// immediately so a lost PC is still detected within the request's
// five-second timeout plus the four-second health-check bound.
if (!await this.#probeHealth(desktop, operation, false)) return;
if (!this.#profileRecovering(operation)) return;
}
}
if (this.#current(operation) && this.#state.kind === 'connected' && this.#state.profileStatus === 'recovering') {
this.#set({ ...this.#state, profileStatus: 'unavailable' });
this.diagnostics.add('profile_recovery_exhausted', 'warning');
this.#markProfileUnavailable(operation);
} finally {
if (this.#profileRecoveryDeadline === deadline) {
clearTimeout(deadline);
this.#profileRecoveryDeadline = null;
}
if (this.#current(operation) && this.#state.kind === 'connected' && this.#state.profileStatus === 'unavailable') this.#scheduleHealth();
}
}

async #requestPointerProfileAttempt(token: string, operation: number): Promise<PointerProfile | null> {
async #requestPointerProfileAttempt(token: string, desktop: DiscoveredDesktop, operation: number): Promise<PointerProfile | null> {
if (!this.#current(operation)) return null;
const [type, payload] = commandPayloads.pointerProfile();
const id = this.id();
const response = await this.#client!.request(authenticatedCommand({ id, deviceId: this.#deviceId!, token, timestamp: this.now(), type, payload }), id, 5_000).catch(() => null);
let response: ProtocolResponse | null = null;
try {
response = await this.#client!.request(authenticatedCommand({ id, deviceId: this.#deviceId!, token, timestamp: this.now(), type, payload }), id, 5_000);
} catch (error) {
if (error instanceof ProtocolWriteError && this.#current(operation)) void this.#unexpectedDisconnect(desktop, operation);
}
if (!this.#current(operation)) return null;
return response?.kind === 'pointerProfile' ? response.profile : null;
}

#profileRecovering(operation: number): boolean {
return this.#current(operation) && this.#state.kind === 'connected' && this.#state.profileStatus === 'recovering';
}

#markProfileUnavailable(operation: number): void {
if (!this.#current(operation) || this.#state.kind !== 'connected' || this.#state.profileStatus !== 'recovering') return;
const state = this.#state;
this.#set({ ...state, profileStatus: 'unavailable' });
this.diagnostics.add('profile_recovery_exhausted', 'warning');
}

#waitForProfileRecovery(milliseconds: number, operation: number): Promise<boolean> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
Expand All @@ -382,6 +439,8 @@ export class ConnectionManager {
}

#cancelProfileRecovery(): void {
if (this.#profileRecoveryDeadline !== null) clearTimeout(this.#profileRecoveryDeadline);
this.#profileRecoveryDeadline = null;
for (const [timer, resolve] of this.#profileRecoveryTimers) {
clearTimeout(timer);
resolve(false);
Expand All @@ -390,8 +449,11 @@ export class ConnectionManager {
}

async #unexpectedDisconnect(desktop: DiscoveredDesktop, sourceOperation: number): Promise<void> {
if (!this.#current(sourceOperation) || this.#state.kind === 'idle' || this.#state.kind === 'reconnecting') return;
if (!this.#current(sourceOperation) || this.#state.kind === 'idle' || this.#state.kind === 'reconnecting' || this.#state.kind === 'failed') return;
const operation = ++this.#operation;
this.#cancelHealthTimer();
this.#set({ kind: 'reconnecting', desktop, attempt: 1 });
this.diagnostics.add('connection_lost', 'warning');
await this.#teardownConnection();
const token = await this.storage.token(desktop.desktopId);
if (!token || !this.#current(operation)) { if (this.#current(operation)) await this.#fail('Connection to the PC was lost.', operation); return; }
Expand Down Expand Up @@ -432,13 +494,51 @@ export class ConnectionManager {

async #teardownConnection(): Promise<void> {
this.#cancelProfileRecovery();
this.#cancelHealthTimer();
this.#disconnectStop?.(); this.#disconnectStop = null;
const client = this.#client;
this.#client = null; this.#token = null;
if (client) await client.close();
await this.transport.disconnect().catch(() => undefined);
}

#scheduleHealth(delay = 5_000): void {
this.#cancelHealthTimer();
if (this.#state.kind !== 'connected' || this.#state.profileStatus === 'recovering') return;
const operation = this.#operation;
const desktop = this.#state.desktop;
this.#healthTimer = setTimeout(() => {
this.#healthTimer = null;
if (!this.#current(operation) || this.#state.kind !== 'connected') return;
if (this.#protocolOperations > 0 || this.#healthProbe) { this.#scheduleHealth(); return; }
void this.#probeHealth(desktop, operation, true);
}, delay);
(this.#healthTimer as unknown as { unref?: () => void }).unref?.();
}

#probeHealth(desktop: DiscoveredDesktop, operation: number, scheduleOnSuccess: boolean): Promise<boolean> {
if (this.#healthProbe) return this.#healthProbe;
const probe = (async () => {
const healthy = await this.transport.verifyConnection(desktop.desktopId).catch(() => false);
if (!this.#current(operation) || this.#state.kind !== 'connected') return false;
if (healthy) {
if (scheduleOnSuccess) this.#scheduleHealth();
return true;
}
this.diagnostics.add('connection_health_failed', 'warning');
void this.#unexpectedDisconnect(desktop, operation);
return false;
})();
this.#healthProbe = probe;
void probe.finally(() => { if (this.#healthProbe === probe) this.#healthProbe = null; });
return probe;
}

#cancelHealthTimer(): void {
if (this.#healthTimer !== null) clearTimeout(this.#healthTimer);
this.#healthTimer = null;
}

async #orderedSaved(): Promise<SavedPc[]> {
const saved = (await this.storage.list()).filter((pc) => !this.#invalidSavedDesktopIds.has(pc.desktopId));
const defaultId = await this.storage.defaultDesktopId().catch(() => null);
Expand Down
7 changes: 4 additions & 3 deletions src/connection/ProtocolClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { toByteArray } from 'base64-js';
import { createFrames, encodeFrame, FrameReassembler } from '@/domain/protocol/framing';
import type { BleTransport, Unsubscribe } from '@/transport/BleTransport';
import { ProtocolClient } from './ProtocolClient';
import { ProtocolClient, ProtocolResponseTimeoutError, ProtocolWriteError } from './ProtocolClient';

class FakeTransport implements BleTransport {
frames: string[] = [];
Expand All @@ -16,6 +16,7 @@ class FakeTransport implements BleTransport {
resolveAndConnect = async () => { throw new Error('not used'); };
connect = async () => undefined; disconnect = async () => undefined;
cancelPendingWrites = async () => undefined;
verifyConnection = async () => true;
notificationsReady = async () => undefined;
async writeFrame(frame: string) { if (this.fail) throw new Error('write failed'); this.inFlight += 1; this.maxInFlight = Math.max(this.maxInFlight, this.inFlight); await this.writeGate; this.frames.push(frame); this.inFlight -= 1; }
subscribe(listener: (value: string) => void): Unsubscribe { this.listener = listener; return () => { this.listener = null; }; }
Expand Down Expand Up @@ -68,9 +69,9 @@ describe('ProtocolClient', () => {
const transport = new FakeTransport();
transport.fail = true;
const client = new ProtocolClient(transport);
await expect(client.request('{"fixture":true}', 'write', 10)).rejects.toThrow('Could not write');
await expect(client.request('{"fixture":true}', 'write', 10)).rejects.toBeInstanceOf(ProtocolWriteError);
transport.fail = false;
await expect(client.request('{"fixture":true}', 'timeout', 1)).rejects.toThrow('timed out');
await expect(client.request('{"fixture":true}', 'timeout', 1)).rejects.toBeInstanceOf(ProtocolResponseTimeoutError);
});

it('bounds a native GATT write that never settles', async () => {
Expand Down
14 changes: 11 additions & 3 deletions src/connection/ProtocolClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export class ProtocolClient {

async start(onFailure: () => void): Promise<void> {
this.#unsubscribe?.();
this.#unsubscribe = this.transport.subscribe((raw) => this.#accept(raw), () => { void this.close().finally(onFailure); });
this.#unsubscribe = this.transport.subscribe((raw) => this.#accept(raw), () => { onFailure(); void this.close(); });
try {
await this.transport.notificationsReady();
} catch (error) {
Expand All @@ -32,9 +32,9 @@ export class ProtocolClient {
});
void response.catch(() => undefined);
try { await this.send(message); }
catch { this.#reject(requestId, new Error('Could not write to PC.')); }
catch { this.#reject(requestId, new ProtocolWriteError()); }
const pending = this.#pending.get(requestId);
if (pending) pending.timer = setTimeout(() => { this.#pending.delete(requestId); pending.reject(new Error('PC response timed out.')); }, timeoutMs);
if (pending) pending.timer = setTimeout(() => { this.#pending.delete(requestId); pending.reject(new ProtocolResponseTimeoutError()); }, timeoutMs);
return await response;
}

Expand Down Expand Up @@ -106,3 +106,11 @@ export class ProtocolClient {
pending.reject(error);
}
}

export class ProtocolWriteError extends Error {
constructor() { super('Could not write to PC.'); }
}

export class ProtocolResponseTimeoutError extends Error {
constructor() { super('PC response timed out.'); }
}
1 change: 1 addition & 0 deletions src/connection/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class FakeTransport implements BleTransport {
return this.resolvedDesktop;
};
cancelPendingWrites = async () => undefined;
verifyConnection = async () => true;
notificationsReady = async () => { if (this.failReadiness) throw new Error('readiness failed'); };
subscribe(): Unsubscribe { return () => undefined; } subscribeDisconnect(): Unsubscribe { return () => undefined; }
}
Expand Down
Loading