Skip to content
This repository was archived by the owner on Oct 11, 2026. It is now read-only.
Open
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
20 changes: 20 additions & 0 deletions docs/physical-smoke-test.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,26 @@ Run the matrix on a physical Android phone and iPhone against current Switchify
9. While repeat, drag, modifiers, and a text stream are active, background and terminate the app, disconnect Bluetooth, and quit Switchify PC. Confirm the desktop returns to neutral input state each time.
10. Export diagnostics and verify that no typed content, token, authentication proof, nonce, or verification code appears.

### Correlating connection attempts

Connection events and BLE stages include local labels such as `[attempt-3 PC-2 switch]`.
Origins distinguish nearby selection, saved selection, preferred auto-connect, explicit switching,
and reconnect retries. `ble_peer_observed` records an anonymous candidate and its advertised reply
mode (`read-v1` or `notifications`); compare this with the selected PC label and `selected_match`.
Read-based readiness has its own `response_read_ready` stage.

Teardown events record the observed trigger: explicit disconnect, scan, switch, focus cancellation,
native disconnect, response failure, write failure, health failure, or failed connection setup.
These are triggers, not inferred radio/OS root causes. A superseded attempt and a new attempt have
different labels, even when they target the same PC. Confirm that foregrounding does not replace
an active manual connection with the preferred PC, and that quick switches cannot revive an old attempt.

Labels are not names, addresses, credentials, or persistent desktop identifiers. The log keeps
200 entries and at most 128 identity-to-label mappings in memory; eviction can assign a later label
to an old PC. Clear removes the mappings and invalidates old correlation contexts; restart also
starts a new label namespace. Do not compare these labels across exports separated by clear/restart.
No new telemetry or persistence is added. Physical reconnect success must still be recorded separately.

The development-preview PR remains draft until all four platform pairings are recorded successfully.

### Movement width regression
Expand Down
96 changes: 59 additions & 37 deletions src/connection/ConnectionManager.ts

Large diffs are not rendered by default.

84 changes: 82 additions & 2 deletions src/connection/connection.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DiagnosticLog } from '@/diagnostics/DiagnosticLog';
import { DiagnosticLog, type DiagnosticAttempt } from '@/diagnostics/DiagnosticLog';
import type { PairingStorage, SavedPc } from '@/storage/PairingStore';
import type { BleAvailability, BleTransport, DiscoveredDesktop, Unsubscribe } from '@/transport/BleTransport';
import { ConnectionManager } from './ConnectionManager';
Expand Down Expand Up @@ -29,6 +29,7 @@ class FakeTransport implements BleTransport {
resolveGate: Promise<void> | null = null;
resolveGates = new Map<string, Promise<void>>();
resolveDesktopIds: string[] = [];
attempts: DiagnosticAttempt[] = [];
resolveStarted: ((desktopId: string) => void) | null = null;
failConnect = false;
failReadiness = false;
Expand All @@ -42,7 +43,8 @@ class FakeTransport implements BleTransport {
await this.connectGate;
if (this.failConnect) throw new Error('connect failed');
}; disconnect = async () => undefined; writeFrame = async () => undefined;
resolveAndConnect = async (desktopId: string) => {
resolveAndConnect = async (desktopId: string, attempt?: DiagnosticAttempt) => {
if (attempt) this.attempts.push(attempt);
this.resolveDesktopIds.push(desktopId);
this.resolveStarted?.(desktopId);
await (this.resolveGates.get(desktopId) ?? this.resolveGate);
Expand All @@ -64,6 +66,84 @@ const waitFor = async (condition: () => boolean): Promise<void> => {
};

describe('connection lifecycle', () => {
it('keeps overlapping manual attempts separate and ignores late completion', async () => {
const storage = new FakeStorage();
storage.saved = [pc('private-first'), pc('private-second')];
storage.saved.forEach((p) => storage.tokens.set(p.desktopId, 'private-token'));
const transport = new FakeTransport();
let release!: () => void;
transport.resolveGates.set('private-first', new Promise<void>((resolve) => { release = resolve; }));
transport.resolveError = new Error('private native error');
const log = new DiagnosticLog();
const manager = new ConnectionManager(transport, storage, log, async () => true);
const first = manager.connectSaved(storage.saved[0]!);
await waitFor(() => transport.resolveDesktopIds.length === 1);
await manager.connectSaved(storage.saved[1]!);
const completed = log.export();
release();
await first;
expect(log.export()).toBe(completed);
expect(transport.attempts).toMatchObject([{ attempt: 1, pc: 1, source: 'saved' }, { attempt: 2, pc: 2, source: 'saved' }]);
expect(completed).toContain('[attempt-1 PC-1 saved] A new connection request');
expect(completed).toContain('[attempt-2 PC-2 saved] Connection attempt failed');
expect(completed).not.toContain('private');
});

it('does not let preferred auto-connect replace a pending manual target', async () => {
const storage = new FakeStorage();
storage.saved = [pc('preferred-private'), pc('manual-private')];
storage.saved.forEach((p) => storage.tokens.set(p.desktopId, 'private-token'));
const transport = new FakeTransport();
let release!: () => void;
transport.resolveGate = new Promise<void>((resolve) => { release = resolve; });
transport.resolveError = new Error('private error');
const manager = new ConnectionManager(transport, storage, new DiagnosticLog(), async () => true);
const manual = manager.connectSaved(storage.saved[1]!);
await waitFor(() => transport.resolveDesktopIds.length === 1);
await manager.connectPreferred();
expect(transport.resolveDesktopIds).toEqual(['manual-private']);
release();
await manual;
});

it('distinguishes automatic preferred requests from explicit switches', async () => {
const storage = new FakeStorage();
storage.saved = [pc('preferred-private'), pc('switched-private')];
storage.saved.forEach((p) => storage.tokens.set(p.desktopId, 'private-token'));
const transport = new FakeTransport();
transport.resolveError = new Error('private error');
const log = new DiagnosticLog();
const manager = new ConnectionManager(transport, storage, log, async () => true);
await manager.connectPreferred();
await manager.switchSaved(storage.saved[1]!);
expect(transport.attempts).toMatchObject([{ source: 'preferred', pc: 1 }, { source: 'switch', pc: 2 }]);
expect(log.export()).toContain('teardown_switch');
expect(log.export()).not.toContain('private');
});

it('does not cancel a manual replacement when an older preferred attempt loses focus', async () => {
const storage = new FakeStorage();
storage.saved = [pc('preferred-private'), pc('manual-private')];
storage.saved.forEach((p) => storage.tokens.set(p.desktopId, 'private-token'));
const transport = new FakeTransport();
let releasePreferred!: () => void;
let releaseManual!: () => void;
transport.resolveGates.set('preferred-private', new Promise<void>((resolve) => { releasePreferred = resolve; }));
transport.resolveGates.set('manual-private', new Promise<void>((resolve) => { releaseManual = resolve; }));
transport.resolveError = new Error('private error');
const log = new DiagnosticLog();
const manager = new ConnectionManager(transport, storage, log, async () => true);
const preferred = manager.connectPreferred();
await waitFor(() => transport.resolveDesktopIds.length === 1);
const manual = manager.connectSaved(storage.saved[1]!);
await waitFor(() => transport.resolveDesktopIds.length === 2);
await manager.cancelPreferredConnection();
const state = manager.snapshot();
releasePreferred(); releaseManual();
await Promise.all([preferred, manual]);
expect(state).toMatchObject({ kind: 'connecting', desktop: { desktopId: 'manual-private' } });
expect(log.snapshot().some((entry) => entry.code === 'teardown_focus')).toBe(false);
});
it('matches the Android pairing verification algorithm', () => {
expect(pairingVerificationCode('desktop-1', 'device-1', 'nonce-1')).toBe('215918');
expect(pairingVerificationCode('0:0:1280:720:1.5', 'android-device-id', 'random-request-nonce')).toBe('735258');
Expand Down
28 changes: 28 additions & 0 deletions src/diagnostics/DiagnosticLog.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,34 @@
import { DiagnosticLog } from './DiagnosticLog';

describe('connection-stage diagnostics', () => {
it('correlates attempts with anonymous PCs without exporting identifying input', () => {
const log = new DiagnosticLog();
const first = log.beginAttempt('private-desktop-id', 'saved');
const second = log.beginAttempt('another-private-id', 'preferred');
const third = log.beginAttempt('private-desktop-id', 'switch');
expect(first.pc).toBe(third.pc);
expect(second.pc).not.toBe(first.pc);
log.addConnectionStage('resolution', 'started', first);
log.peerObserved('another-private-id', true, first);
log.add('teardown_native', 'warning', third);
expect(log.export()).toContain('[attempt-1 PC-1 saved]');
expect(log.export()).toContain('Observed PC-2; replies=read-v1');
expect(log.export()).toContain('[attempt-3 PC-1 switch]');
expect(log.export()).not.toMatch(/private|desktop-id/);
});

it('bounds aliases, clears correlation state, and isolates throwing observers', () => {
const log = new DiagnosticLog();
log.subscribe(() => { throw new Error('private observer error'); });
const first = log.beginAttempt('first-private-id', 'saved');
for (let n = 0; n < 130; n++) log.beginAttempt(`private-${n}`, 'nearby');
expect(log.beginAttempt('first-private-id', 'saved').pc).not.toBe(first.pc);
log.clear();
expect(log.export()).toBe('');
log.add('connected', 'info', first);
expect(log.export()).not.toContain('attempt-1');
expect(log.export()).not.toContain('private');
});
it('uses the existing bounded, observable and clearable local log', () => {
const log = new DiagnosticLog();
const listener = jest.fn();
Expand Down
54 changes: 47 additions & 7 deletions src/diagnostics/DiagnosticLog.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export type DiagnosticLevel = 'info' | 'warning' | 'error';
export type DiagnosticEntry = { id: number; timestamp: number; level: DiagnosticLevel; code: string; message: string };
export type AttemptSource = 'nearby' | 'saved' | 'preferred' | 'switch' | 'reconnect';
export type DiagnosticAttempt = Readonly<{ attempt: number; pc: number; source: AttemptSource }>;

const connectionStages = {
connect: 'Connect to the selected PC',
Expand All @@ -14,11 +16,23 @@ const connectionStages = {
resolution: 'Resolve and prepare the selected PC connection',
notifications: 'Register notification listener',
notification_ready: 'Verify Android notification descriptor',
response_read_ready: 'Prepare read-based responses',
} as const;
export type ConnectionStage = keyof typeof connectionStages;
export type ConnectionStageOutcome = 'started' | 'succeeded' | 'failed' | 'not_matched' | 'timed_out';

const messages = {
attempt_started: 'Connection attempt started.',
attempt_superseded: 'A new connection request superseded this attempt.',
teardown_requested: 'Explicit disconnect requested.',
teardown_switch: 'Switch to another saved PC requested.',
teardown_scan: 'Disconnect to start discovery.',
teardown_focus: 'Preferred connection cancelled when leaving the screen.',
teardown_failure: 'Connection attempt failed; cleaning up.',
teardown_native: 'Native Bluetooth disconnect observed.',
teardown_response: 'Response channel failed.',
teardown_write: 'Bluetooth write failed.',
teardown_health: 'Connection health check failed.',
scan_started: 'Looking for nearby PCs.',
scan_failed: 'Bluetooth discovery could not start.',
connecting: 'Connecting to a PC.',
Expand All @@ -42,19 +56,45 @@ export class DiagnosticLog {
#entries: DiagnosticEntry[] = [];
#listeners = new Set<() => void>();
#nextId = 1;
#nextAttempt = 1;
#nextPc = 1;
#pcs = new Map<string, number>();
#attempts = new WeakSet<DiagnosticAttempt>();
beginAttempt(desktopId: string, source: AttemptSource): DiagnosticAttempt {
const safeSource: AttemptSource = ['nearby', 'saved', 'preferred', 'switch', 'reconnect'].includes(source) ? source : 'saved';
const attempt = Object.freeze({ attempt: this.#nextAttempt++, pc: this.#pc(desktopId), source: safeSource });
this.#attempts.add(attempt);
this.add('attempt_started', 'info', attempt);
return attempt;
}
#pc(desktopId: string): number {
const existing = this.#pcs.get(desktopId);
if (existing !== undefined) return existing;
const pc = this.#nextPc++;
if (desktopId.length <= 128) {
if (this.#pcs.size >= 128) this.#pcs.delete(this.#pcs.keys().next().value!);
this.#pcs.set(desktopId, pc);
Comment on lines +74 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Keep long IDs correlated

Desktop IDs longer than 128 characters are never retained in the session alias map. Each later observation of the same peer therefore receives a new anonymous PC label, so exported diagnostics cannot correlate that peer's events. This is a non-blocking diagnostic-quality concern that makes support investigations less reliable.

Artifacts

Evidence from the check

  • Authored Jest test that invokes the real DiagnosticLog path twice with one 129-character desktop ID and asserts a stable anonymous label; it is the executed reproducer.

Command output from the check

  • Captured Jest output from the focused reproducer shows the two observations received PC-2 and PC-3, proving correlation is broken.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/diagnostics/DiagnosticLog.ts
Line: 74-76

Comment:
**Keep long IDs correlated**

Desktop IDs longer than 128 characters are never retained in the session alias map. Each later observation of the same peer therefore receives a new anonymous PC label, so exported diagnostics cannot correlate that peer's events. This is a non-blocking diagnostic-quality concern that makes support investigations less reliable.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}
return pc;
}
peerObserved(desktopId: string, readReplies: boolean, attempt?: DiagnosticAttempt, matchesSelected = false): void {
if (!attempt || !this.#attempts.has(attempt)) return;
this.#append('ble_peer_observed', `Observed PC-${matchesSelected ? attempt.pc : this.#pc(desktopId)}; replies=${readReplies ? 'read-v1' : 'notifications'}.`, 'info', attempt);
}
subscribe = (listener: () => void) => { this.#listeners.add(listener); return () => this.#listeners.delete(listener); };
snapshot = () => this.#entries;
add(code: keyof typeof messages, level: DiagnosticLevel = 'info'): void {
this.#append(code, messages[code], level);
add(code: keyof typeof messages, level: DiagnosticLevel = 'info', attempt?: DiagnosticAttempt): void {
this.#append(code, messages[code], level, attempt);
}
addConnectionStage(stage: ConnectionStage, outcome: ConnectionStageOutcome): void {
addConnectionStage(stage: ConnectionStage, outcome: ConnectionStageOutcome, attempt?: DiagnosticAttempt): void {
// Only fixed vocabulary crosses this boundary: no native error, address or payload.
this.#append(`ble_${stage}_${outcome}`, `${connectionStages[stage]}: ${outcome}.`, outcome === 'failed' || outcome === 'timed_out' ? 'warning' : 'info');
this.#append(`ble_${stage}_${outcome}`, `${connectionStages[stage]}: ${outcome}.`, outcome === 'failed' || outcome === 'timed_out' ? 'warning' : 'info', attempt);
}
#append(code: string, message: string, level: DiagnosticLevel): void {
#append(code: string, message: string, level: DiagnosticLevel, attempt?: DiagnosticAttempt): void {
if (attempt && this.#attempts.has(attempt)) message = `[attempt-${attempt.attempt} PC-${attempt.pc} ${attempt.source}] ${message}`;
this.#entries = [{ id: this.#nextId++, timestamp: Date.now(), level, code, message }, ...this.#entries].slice(0, 200);
this.#listeners.forEach((listener) => listener());
this.#listeners.forEach((listener) => { try { listener(); } catch { /* Diagnostics must not affect connections. */ } });
}
clear(): void { this.#entries = []; this.#listeners.forEach((listener) => listener()); }
clear(): void { this.#entries = []; this.#pcs.clear(); this.#attempts = new WeakSet(); this.#listeners.forEach((listener) => { try { listener(); } catch { /* Observer isolation. */ } }); }
export(): string { return this.#entries.map((entry) => `${new Date(entry.timestamp).toISOString()} [${entry.level}] ${entry.code}: ${entry.message}`).join('\n'); }
}
5 changes: 3 additions & 2 deletions src/transport/BleTransport.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PcStatus } from '@/domain/protocol/types';
import type { DiagnosticAttempt } from '@/diagnostics/DiagnosticLog';

export type DiscoveredDesktop = PcStatus & { peripheralId: string; rssi: number | null };
export type Unsubscribe = () => void;
Expand All @@ -7,8 +8,8 @@ export type BleAvailability = 'ready' | 'unauthorized' | 'poweredOff' | 'unsuppo
export interface BleTransport {
availability(): Promise<BleAvailability>;
scan(onDesktop: (desktop: DiscoveredDesktop) => void, onError: (error: Error) => void): Unsubscribe;
connect(peripheralId: string): Promise<void>;
resolveAndConnect(desktopId: string): Promise<DiscoveredDesktop>;
connect(peripheralId: string, attempt?: DiagnosticAttempt): Promise<void>;
resolveAndConnect(desktopId: string, attempt?: DiagnosticAttempt): Promise<DiscoveredDesktop>;
disconnect(): Promise<void>;
maxWriteValueBytes(): number;
writeFrame(frameBase64: string): Promise<void>;
Expand Down
21 changes: 19 additions & 2 deletions src/transport/ReactNativeBleTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,34 @@ function manager(overrides: Record<string, unknown> = {}): BleManager {
}

describe('ReactNativeBleTransport', () => {
it('links observed peers and BLE stages to the selected attempt without identifiers', async () => {
const log = new DiagnosticLog();
let callback!: (error: Error | null, value: Device | null) => void;
const transport = new ReactNativeBleTransport(manager({ startDeviceScan: jest.fn((_u, _o, cb) => { callback = cb; }) }), 'ios', 1000, undefined, log);
const attempt = log.beginAttempt('pc-1', 'saved');
const result = transport.resolveAndConnect('pc-1', attempt);
await waitFor(() => !!callback);
callback(null, device({ id: 'private-address', name: 'private-name' }));
await result;
expect(log.export()).toContain('ble_peer_observed: [attempt-1 PC-1 saved] Observed PC-1; replies=notifications.');
expect(log.export()).toContain('ble_resolution_succeeded: [attempt-1 PC-1 saved]');
expect(log.export()).not.toMatch(/private|pc-1/);
await transport.disconnect();
});

it('uses peer-specific reads for negotiated Linux replies, never notifications', async () => {
const connected = device({ readCharacteristicForService: jest.fn(async (_service, characteristic) => ({
value: characteristic.endsWith('7eb-1d6d-4d92-9ef0-1f89d3db21f4')
? fromByteArray(new TextEncoder().encode(JSON.stringify({ protocolVersion: 1, desktopId: 'pc-1', responseTransport: 'read-v1' })))
: '',
} as Characteristic)) });
const native = manager({ connectToDevice: jest.fn(async () => connected) });
const transport = new ReactNativeBleTransport(native, 'ios');
await transport.connect('ble-1');
const log = new DiagnosticLog();
const transport = new ReactNativeBleTransport(native, 'ios', 10_000, undefined, log);
await transport.connect('ble-1', log.beginAttempt('pc-1', 'saved'));
const stop = transport.subscribe(jest.fn(), jest.fn());
await transport.notificationsReady();
expect(log.export()).toContain('ble_response_read_ready_succeeded: [attempt-1 PC-1 saved]');
expect(connected.monitorCharacteristicForService).not.toHaveBeenCalled();
expect(connected.readDescriptorForService).not.toHaveBeenCalled();
expect(connected.readCharacteristicForService).toHaveBeenCalledWith(expect.any(String), '7a78f7ec-1d6d-4d92-9ef0-1f89d3db21f4', expect.any(String));
Expand Down
Loading