Skip to content

Commit 178d9da

Browse files
authored
Merge pull request #157 from switchifyapp/codex/linux-read-replies
feat: support negotiated Linux read replies
2 parents b3fe910 + 43590fb commit 178d9da

8 files changed

Lines changed: 198 additions & 5 deletions

File tree

docs/protocol-compatibility.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,10 @@ Authenticated `connection.ping` commands may include an optional `deviceName`. C
2727
## Shared remote actions
2828

2929
The catalog contains only stable IDs, descriptive searchable metadata, placement rules, and declarative action definitions. The runtime resolver attaches current platform labels, selected/disabled states, explanations, and callbacks from the active RemoteSession. Both original and customized grids use it. Monitor behavior is defined once; keys in live Typing use its stream and Enter controller, while keys elsewhere use normal PC key commands. Draft actions require draft mode and applicable text/capabilities. Picker selection handles IDs only. Layout storage contains no handlers, search text, typing content, or command payloads. Reconnection refreshes runtime state without rewriting layouts.
30+
# Linux read-response transport
31+
32+
An optional discovery field `responseTransport: "read-v1"` selects the read-only response characteristic `7a78f7ec-1d6d-4d92-9ef0-1f89d3db21f4` under the existing service. Absent fields retain the existing notification path; unknown values reject discovery/connection. Existing UUIDs, protocol-v1 frames, authentication and stored pairing schemas are unchanged. A Linux server using this mode never sends sensitive responses through notifications; older Remotes must update to pair with it.
33+
34+
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.
35+
36+
Fake tests cover negotiation, backward compatibility, response bounds, serial polling, timeout and cancellation. Physical Android interoperability is not yet qualified.

src/domain/protocol/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const BLE_UUIDS = {
99
receive: '7a78f7e9-1d6d-4d92-9ef0-1f89d3db21f4',
1010
transmit: '7a78f7ea-1d6d-4d92-9ef0-1f89d3db21f4',
1111
status: '7a78f7eb-1d6d-4d92-9ef0-1f89d3db21f4',
12+
response: '7a78f7ec-1d6d-4d92-9ef0-1f89d3db21f4',
1213
} as const;
1314

1415
export const BLE_DESCRIPTORS = {

src/domain/protocol/responses.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ export function parseStatus(raw: string): PcStatus | null {
1818
if (value.protocolVersion !== 1) return null;
1919
const desktopId = string(value.desktopId);
2020
if (!desktopId) return null;
21+
if (value.responseTransport !== undefined && value.responseTransport !== 'read-v1') return null;
2122
const platform = value.platform === 'windows' || value.platform === 'macos' ? value.platform : null;
22-
return { desktopId, displayName: string(value.displayName)?.trim() || 'Switchify PC', platform };
23+
return { desktopId, displayName: string(value.displayName)?.trim() || 'Switchify PC', platform,
24+
...(value.responseTransport === 'read-v1' ? { responseTransport: 'read-v1' as const } : {}),
25+
};
2326
} catch {
2427
return null;
2528
}

src/domain/protocol/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,4 @@ export type ProtocolResponse =
3333
| { kind: 'error'; id?: string; code: string; message: string }
3434
| { kind: 'invalid' };
3535

36-
export type PcStatus = { desktopId: string; displayName: string; platform: PcPlatform };
36+
export type PcStatus = { desktopId: string; displayName: string; platform: PcPlatform; responseTransport?: 'read-v1' };

src/transport/ReactNativeBleTransport.test.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,33 @@ function manager(overrides: Record<string, unknown> = {}): BleManager {
3030
}
3131

3232
describe('ReactNativeBleTransport', () => {
33+
it('uses peer-specific reads for negotiated Linux replies, never notifications', async () => {
34+
const connected = device({ readCharacteristicForService: jest.fn(async (_service, characteristic) => ({
35+
value: characteristic.endsWith('7eb-1d6d-4d92-9ef0-1f89d3db21f4')
36+
? fromByteArray(new TextEncoder().encode(JSON.stringify({ protocolVersion: 1, desktopId: 'pc-1', responseTransport: 'read-v1' })))
37+
: '',
38+
} as Characteristic)) });
39+
const native = manager({ connectToDevice: jest.fn(async () => connected) });
40+
const transport = new ReactNativeBleTransport(native, 'ios');
41+
await transport.connect('ble-1');
42+
const stop = transport.subscribe(jest.fn(), jest.fn());
43+
await transport.notificationsReady();
44+
expect(connected.monitorCharacteristicForService).not.toHaveBeenCalled();
45+
expect(connected.readDescriptorForService).not.toHaveBeenCalled();
46+
expect(connected.readCharacteristicForService).toHaveBeenCalledWith(expect.any(String), '7a78f7ec-1d6d-4d92-9ef0-1f89d3db21f4', expect.any(String));
47+
stop();
48+
expect(() => transport.subscribe(jest.fn(), jest.fn())).toThrow('Reconnect');
49+
await transport.disconnect();
50+
expect(native.cancelTransaction).toHaveBeenCalled();
51+
});
52+
53+
it('rejects unknown response transports without notification fallback', async () => {
54+
const connected = device({ readCharacteristicForService: jest.fn(async () => ({ value: fromByteArray(new TextEncoder().encode('{"protocolVersion":1,"desktopId":"pc","responseTransport":"future"}')) } as Characteristic)) });
55+
const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios');
56+
await expect(transport.connect('ble-1')).rejects.toThrow('status is invalid');
57+
expect(connected.monitorCharacteristicForService).not.toHaveBeenCalled();
58+
expect(connected.cancelConnection).toHaveBeenCalled();
59+
});
3360
it.each(['', 'private malformed', '{"protocolVersion":2}', '{"protocolVersion":1,"desktopId":"other-private"}'])('diagnoses rejected or nonmatching status (%s)', async (raw) => {
3461
const log = new DiagnosticLog();
3562
let callback!: (error: Error | null, value: Device | null) => void;
@@ -372,6 +399,7 @@ describe('ReactNativeBleTransport', () => {
372399
const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios');
373400
await transport.connect('ble-1');
374401
(connected.discoverAllServicesAndCharacteristics as jest.Mock).mockClear();
402+
(connected.readCharacteristicForService as jest.Mock).mockClear();
375403

376404
await expect(transport.verifyConnection('pc-1')).resolves.toBe(true);
377405

@@ -388,19 +416,20 @@ describe('ReactNativeBleTransport', () => {
388416
['malformed status', { readCharacteristicForService: jest.fn(async () => ({ value: fromByteArray(new TextEncoder().encode('not json')) })) }],
389417
['wrong desktop', { readCharacteristicForService: jest.fn(async () => ({ value: fromByteArray(new TextEncoder().encode('{"protocolVersion":1,"desktopId":"other","displayName":"Desk","platform":"windows"}')) })) }],
390418
] as const)('reports a failed health check for %s', async (_label, overrides) => {
391-
const connected = device(overrides as Partial<Device>);
419+
const connected = device();
392420
const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios');
393421
await transport.connect('ble-1');
394-
422+
Object.assign(connected, overrides);
395423
await expect(transport.verifyConnection('pc-1')).resolves.toBe(false);
396424
});
397425

398426
it('bounds a connection health check to four seconds', async () => {
399427
jest.useFakeTimers();
400428
try {
401-
const connected = device({ readCharacteristicForService: jest.fn(() => new Promise<Characteristic>(() => undefined)) });
429+
const connected = device();
402430
const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios');
403431
await transport.connect('ble-1');
432+
(connected.readCharacteristicForService as jest.Mock).mockImplementation(() => new Promise<Characteristic>(() => undefined));
404433
let settled = false;
405434
const result = transport.verifyConnection('pc-1').then((value) => { settled = true; return value; });
406435
await Promise.resolve();
@@ -422,6 +451,7 @@ describe('ReactNativeBleTransport', () => {
422451
const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios');
423452
await transport.connect('ble-1');
424453
const result = transport.verifyConnection('pc-1');
454+
(connected.readCharacteristicForService as jest.Mock).mockClear();
425455

426456
await jest.advanceTimersByTimeAsync(4_000);
427457
await expect(result).resolves.toBe(false);

src/transport/ReactNativeBleTransport.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { parseStatus } from '@/domain/protocol/responses';
77
import type { ConnectionStage, ConnectionStageOutcome, DiagnosticLog } from '@/diagnostics/DiagnosticLog';
88
import type { BleAvailability, BleTransport, DiscoveredDesktop, Unsubscribe } from './BleTransport';
99
import { bluetoothDeviceDisplayName, desktopDisplayName } from './desktopDisplayName';
10+
import { ReadResponsePoller } from './ReadResponsePoller';
1011

1112
export class ReactNativeBleTransport implements BleTransport {
1213
#manager: BleManager | null;
@@ -22,6 +23,8 @@ export class ReactNativeBleTransport implements BleTransport {
2223
#resolutionCancel: ((error: Error) => Promise<void>) | null = null;
2324
#writeSequence = 0;
2425
#writePoisoned = false;
26+
#readReplies = false;
27+
#responsePoller: ReadResponsePoller | null = null;
2528

2629
constructor(
2730
manager: BleManager | null = null,
@@ -162,6 +165,7 @@ export class ReactNativeBleTransport implements BleTransport {
162165
connected = await this.#stage('services', () => this.#bounded(connected!.discoverAllServicesAndCharacteristics()), operation);
163166
if (!active || operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.');
164167
this.#device = connected;
168+
this.#readReplies = desktop.responseTransport === 'read-v1';
165169
this.#writePoisoned = false;
166170
succeed(desktop);
167171
}).catch((probeError: unknown) => {
@@ -211,6 +215,11 @@ export class ReactNativeBleTransport implements BleTransport {
211215
connected = await this.#stage('services', () => this.#bounded(connected!.discoverAllServicesAndCharacteristics()), operation);
212216
if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.');
213217
this.#device = connected;
218+
const statusValue = await this.#bounded(connected.readCharacteristicForService(BLE_UUIDS.service, BLE_UUIDS.status));
219+
if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.');
220+
const status = statusValue.value ? parseStatus(new TextDecoder().decode(toByteArray(statusValue.value))) : null;
221+
if (!status) throw new Error('Bluetooth discovery status is invalid.');
222+
this.#readReplies = status.responseTransport === 'read-v1';
214223
this.#writePoisoned = false;
215224
} catch (error) {
216225
connectCancelled = true;
@@ -225,6 +234,9 @@ export class ReactNativeBleTransport implements BleTransport {
225234

226235
async disconnect(): Promise<void> {
227236
this.#operation += 1;
237+
this.#responsePoller?.stop();
238+
this.#responsePoller = null;
239+
this.#readReplies = false;
228240
const cancelResolution = this.#resolutionCancel;
229241
if (cancelResolution) await cancelResolution(new Error('Bluetooth operation was cancelled.'));
230242
this.#cancelNativeOperations();
@@ -295,6 +307,21 @@ export class ReactNativeBleTransport implements BleTransport {
295307
}
296308

297309
subscribe(onFrame: (frameBase64: string) => void, onError: (error: Error) => void): Unsubscribe {
310+
if (this.#readReplies) {
311+
if (this.#responsePoller || this.#writePoisoned) throw new Error('Reconnect before starting response reads again.');
312+
const device = this.#requireDevice();
313+
const operation = this.#operation;
314+
const transaction = `switchify-read-${operation}`;
315+
const poller = new ReadResponsePoller(
316+
async () => (await device.readCharacteristicForService(BLE_UUIDS.service, BLE_UUIDS.response, transaction)).value,
317+
async () => { await this.#managerOrCreate().cancelTransaction(transaction); },
318+
(frame) => { if (operation === this.#operation) onFrame(frame); },
319+
(error) => { if (operation === this.#operation) { this.#writePoisoned = true; onError(error); } },
320+
this.nativeTimeoutMs,
321+
);
322+
this.#responsePoller = poller;
323+
return () => { poller.stop(); if (this.#responsePoller === poller) this.#writePoisoned = true; };
324+
}
298325
const operation = this.#operation;
299326
let active = true;
300327
let failed = false;
@@ -317,6 +344,11 @@ export class ReactNativeBleTransport implements BleTransport {
317344
}
318345

319346
async notificationsReady(): Promise<void> {
347+
if (this.#readReplies) {
348+
if (!this.#responsePoller) throw new Error('Bluetooth response reader has not started.');
349+
await this.#responsePoller.ready;
350+
return;
351+
}
320352
if (this.platform !== 'android') return;
321353
await this.#stage('notification_ready', async () => {
322354
const descriptor = await this.#bounded(this.#requireDevice().readDescriptorForService(
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { ReadResponsePoller } from './ReadResponsePoller';
2+
import { parseStatus } from '@/domain/protocol/responses';
3+
4+
describe('Linux read responses', () => {
5+
beforeEach(() => jest.useFakeTimers());
6+
afterEach(() => jest.useRealTimers());
7+
8+
it('negotiates explicitly and preserves old desktops', () => {
9+
const base = { protocolVersion: 1, desktopId: 'pc', platform: 'linux' };
10+
expect(parseStatus(JSON.stringify(base))?.responseTransport).toBeUndefined();
11+
expect(parseStatus(JSON.stringify({ ...base, responseTransport: 'read-v1' }))?.responseTransport).toBe('read-v1');
12+
for (const responseTransport of ['future', null, true]) expect(parseStatus(JSON.stringify({ ...base, responseTransport }))).toBeNull();
13+
});
14+
15+
it('polls serially, idles on empty values and stops without late delivery', async () => {
16+
let finish!: (value: string) => void;
17+
const read = jest.fn().mockResolvedValueOnce('').mockImplementationOnce(() => new Promise<string>((resolve) => { finish = resolve; }));
18+
const cancel = jest.fn(async () => undefined), frame = jest.fn(), error = jest.fn();
19+
const poller = new ReadResponsePoller(read, cancel, frame, error, 1000);
20+
await poller.ready;
21+
await jest.advanceTimersByTimeAsync(99);
22+
expect(read).toHaveBeenCalledTimes(1);
23+
await jest.advanceTimersByTimeAsync(401);
24+
expect(read).toHaveBeenCalledTimes(2);
25+
poller.stop();
26+
finish('e30=');
27+
await jest.advanceTimersByTimeAsync(2000);
28+
expect(frame).not.toHaveBeenCalled();
29+
expect(error).not.toHaveBeenCalled();
30+
expect(cancel).toHaveBeenCalledTimes(1);
31+
expect(read).toHaveBeenCalledTimes(2);
32+
});
33+
34+
it('delivers nonempty frames without the idle delay', async () => {
35+
const read = jest.fn().mockResolvedValueOnce('e30=').mockResolvedValue('');
36+
const frame = jest.fn();
37+
const poller = new ReadResponsePoller(read, async () => undefined, frame, jest.fn(), 1000);
38+
await poller.ready;
39+
expect(frame).toHaveBeenCalledWith('e30=');
40+
await jest.advanceTimersByTimeAsync(1);
41+
expect(read).toHaveBeenCalledTimes(2);
42+
poller.stop();
43+
});
44+
45+
it.each([null, '!', 'x'.repeat(244)])('fails closed on malformed or oversized value', async (value) => {
46+
const error = jest.fn();
47+
const poller = new ReadResponsePoller(async () => value, async () => undefined, jest.fn(), error, 1000);
48+
await expect(poller.ready).rejects.toThrow();
49+
expect(error).toHaveBeenCalledTimes(1);
50+
await jest.advanceTimersByTimeAsync(2000);
51+
expect(error).toHaveBeenCalledTimes(1);
52+
});
53+
54+
it('times out once, cancels the native read and ignores its late completion', async () => {
55+
let finish!: (value: string) => void;
56+
const cancel = jest.fn(async () => undefined), frame = jest.fn(), error = jest.fn();
57+
const poller = new ReadResponsePoller(() => new Promise((resolve) => { finish = resolve; }), cancel, frame, error, 1000);
58+
const rejected = expect(poller.ready).rejects.toThrow();
59+
await jest.advanceTimersByTimeAsync(1000);
60+
await rejected;
61+
finish('e30=');
62+
await jest.advanceTimersByTimeAsync(2000);
63+
expect(error).toHaveBeenCalledTimes(1);
64+
expect(cancel).toHaveBeenCalledTimes(1);
65+
expect(frame).not.toHaveBeenCalled();
66+
});
67+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { toByteArray } from 'base64-js';
2+
3+
/** One consuming ATT read at a time. Any failure requires a fresh connection. */
4+
export class ReadResponsePoller {
5+
readonly ready: Promise<void>;
6+
#active = true;
7+
#timer: ReturnType<typeof setTimeout> | undefined;
8+
#resolveReady!: () => void;
9+
#rejectReady!: (error: Error) => void;
10+
11+
constructor(
12+
private readonly read: () => Promise<string | null>,
13+
private readonly cancelRead: () => Promise<unknown>,
14+
private readonly onFrame: (frame: string) => void,
15+
private readonly onError: (error: Error) => void,
16+
private readonly timeoutMs: number,
17+
) {
18+
this.ready = new Promise<void>((resolve, reject) => { this.#resolveReady = resolve; this.#rejectReady = reject; });
19+
void this.ready.catch(() => undefined);
20+
void this.#poll();
21+
}
22+
23+
stop(): void {
24+
if (!this.#active) return;
25+
this.#active = false;
26+
clearTimeout(this.#timer);
27+
this.#rejectReady(new Error('Bluetooth response reading stopped.'));
28+
void this.cancelRead().catch(() => undefined);
29+
}
30+
31+
async #poll(): Promise<void> {
32+
if (!this.#active) return;
33+
const fail = () => {
34+
if (!this.#active) return;
35+
this.stop();
36+
this.onError(new Error('Bluetooth response reading failed. Reconnect to the PC.'));
37+
};
38+
this.#timer = setTimeout(fail, this.timeoutMs);
39+
try {
40+
const value = await this.read();
41+
if (!this.#active) return;
42+
clearTimeout(this.#timer);
43+
if (value === null || value.length > 240 || (value !== '' && (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value) || toByteArray(value).length > 180))) {
44+
fail(); return;
45+
}
46+
this.#resolveReady();
47+
if (value) this.onFrame(value);
48+
if (this.#active) this.#timer = setTimeout(() => { void this.#poll(); }, value ? 0 : 100);
49+
} catch {
50+
fail();
51+
}
52+
}
53+
}

0 commit comments

Comments
 (0)