Skip to content
This repository was archived by the owner on Oct 11, 2026. It is now read-only.
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
7 changes: 7 additions & 0 deletions docs/protocol-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ Authenticated `connection.ping` commands may include an optional `deviceName`. C
## Shared remote actions

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.
# Linux read-response transport

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.

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.
291 changes: 148 additions & 143 deletions package-lock.json

Large diffs are not rendered by default.

40 changes: 20 additions & 20 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,33 @@
"main": "expo-router/entry",
"version": "1.0.0-beta.23",
"dependencies": {
"@expo/ui": "~57.0.17",
"@expo/ui": "~57.0.18",
"@expo/vector-icons": "^15.0.2",
"@noble/hashes": "^2.3.0",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/native": "^7.3.16",
"base64-js": "^1.5.1",
"expo": "~57.0.21",
"expo-blur": "~57.0.2",
"expo": "~57.0.22",
"expo-blur": "~57.0.3",
"expo-build-properties": "~57.0.17",
"expo-clipboard": "~57.0.1",
"expo-constants": "~57.0.16",
"expo-crypto": "~57.0.2",
"expo-dev-client": "~57.0.18",
"expo-device": "~57.0.1",
"expo-file-system": "~57.0.6",
"expo-font": "~57.0.2",
"expo-glass-effect": "~57.0.2",
"expo-image": "~57.0.4",
"expo-linking": "~57.0.9",
"expo-router": "~57.0.20",
"expo-secure-store": "~57.0.3",
"expo-sharing": "~57.0.18",
"expo-splash-screen": "~57.0.8",
"expo-clipboard": "~57.0.2",
"expo-constants": "~57.0.18",
"expo-crypto": "~57.0.3",
"expo-dev-client": "~57.0.19",
"expo-device": "~57.0.2",
"expo-file-system": "~57.0.7",
"expo-font": "~57.0.4",
"expo-glass-effect": "~57.0.3",
"expo-image": "~57.0.5",
"expo-linking": "~57.0.10",
"expo-router": "~57.0.21",
"expo-secure-store": "~57.0.4",
"expo-sharing": "~57.0.19",
"expo-splash-screen": "~57.0.9",
"expo-status-bar": "~57.0.1",
"expo-symbols": "~57.0.2",
"expo-system-ui": "~57.0.3",
"expo-web-browser": "~57.0.2",
"expo-symbols": "~57.0.3",
"expo-system-ui": "~57.0.4",
"expo-web-browser": "~57.0.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.3",
Expand Down
1 change: 1 addition & 0 deletions src/domain/protocol/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const BLE_UUIDS = {
receive: '7a78f7e9-1d6d-4d92-9ef0-1f89d3db21f4',
transmit: '7a78f7ea-1d6d-4d92-9ef0-1f89d3db21f4',
status: '7a78f7eb-1d6d-4d92-9ef0-1f89d3db21f4',
response: '7a78f7ec-1d6d-4d92-9ef0-1f89d3db21f4',
} as const;

export const BLE_DESCRIPTORS = {
Expand Down
5 changes: 4 additions & 1 deletion src/domain/protocol/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ export function parseStatus(raw: string): PcStatus | null {
if (value.protocolVersion !== 1) return null;
const desktopId = string(value.desktopId);
if (!desktopId) return null;
if (value.responseTransport !== undefined && value.responseTransport !== 'read-v1') return null;
const platform = value.platform === 'windows' || value.platform === 'macos' ? value.platform : null;
return { desktopId, displayName: string(value.displayName)?.trim() || 'Switchify PC', platform };
return { desktopId, displayName: string(value.displayName)?.trim() || 'Switchify PC', platform,
...(value.responseTransport === 'read-v1' ? { responseTransport: 'read-v1' as const } : {}),
};
} catch {
return null;
}
Expand Down
2 changes: 1 addition & 1 deletion src/domain/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ export type ProtocolResponse =
| { kind: 'error'; id?: string; code: string; message: string }
| { kind: 'invalid' };

export type PcStatus = { desktopId: string; displayName: string; platform: PcPlatform };
export type PcStatus = { desktopId: string; displayName: string; platform: PcPlatform; responseTransport?: 'read-v1' };
36 changes: 33 additions & 3 deletions src/transport/ReactNativeBleTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,33 @@ function manager(overrides: Record<string, unknown> = {}): BleManager {
}

describe('ReactNativeBleTransport', () => {
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 stop = transport.subscribe(jest.fn(), jest.fn());
await transport.notificationsReady();
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));
stop();
expect(() => transport.subscribe(jest.fn(), jest.fn())).toThrow('Reconnect');
await transport.disconnect();
expect(native.cancelTransaction).toHaveBeenCalled();
});

it('rejects unknown response transports without notification fallback', async () => {
const connected = device({ readCharacteristicForService: jest.fn(async () => ({ value: fromByteArray(new TextEncoder().encode('{"protocolVersion":1,"desktopId":"pc","responseTransport":"future"}')) } as Characteristic)) });
const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios');
await expect(transport.connect('ble-1')).rejects.toThrow('status is invalid');
expect(connected.monitorCharacteristicForService).not.toHaveBeenCalled();
expect(connected.cancelConnection).toHaveBeenCalled();
});
it.each(['', 'private malformed', '{"protocolVersion":2}', '{"protocolVersion":1,"desktopId":"other-private"}'])('diagnoses rejected or nonmatching status (%s)', async (raw) => {
const log = new DiagnosticLog();
let callback!: (error: Error | null, value: Device | null) => void;
Expand Down Expand Up @@ -372,6 +399,7 @@ describe('ReactNativeBleTransport', () => {
const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios');
await transport.connect('ble-1');
(connected.discoverAllServicesAndCharacteristics as jest.Mock).mockClear();
(connected.readCharacteristicForService as jest.Mock).mockClear();

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

Expand All @@ -388,19 +416,20 @@ describe('ReactNativeBleTransport', () => {
['malformed status', { readCharacteristicForService: jest.fn(async () => ({ value: fromByteArray(new TextEncoder().encode('not json')) })) }],
['wrong desktop', { readCharacteristicForService: jest.fn(async () => ({ value: fromByteArray(new TextEncoder().encode('{"protocolVersion":1,"desktopId":"other","displayName":"Desk","platform":"windows"}')) })) }],
] as const)('reports a failed health check for %s', async (_label, overrides) => {
const connected = device(overrides as Partial<Device>);
const connected = device();
const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios');
await transport.connect('ble-1');

Object.assign(connected, overrides);
await expect(transport.verifyConnection('pc-1')).resolves.toBe(false);
});

it('bounds a connection health check to four seconds', async () => {
jest.useFakeTimers();
try {
const connected = device({ readCharacteristicForService: jest.fn(() => new Promise<Characteristic>(() => undefined)) });
const connected = device();
const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios');
await transport.connect('ble-1');
(connected.readCharacteristicForService as jest.Mock).mockImplementation(() => new Promise<Characteristic>(() => undefined));
let settled = false;
const result = transport.verifyConnection('pc-1').then((value) => { settled = true; return value; });
await Promise.resolve();
Expand All @@ -422,6 +451,7 @@ describe('ReactNativeBleTransport', () => {
const transport = new ReactNativeBleTransport(manager({ connectToDevice: jest.fn(async () => connected) }), 'ios');
await transport.connect('ble-1');
const result = transport.verifyConnection('pc-1');
(connected.readCharacteristicForService as jest.Mock).mockClear();

await jest.advanceTimersByTimeAsync(4_000);
await expect(result).resolves.toBe(false);
Expand Down
32 changes: 32 additions & 0 deletions src/transport/ReactNativeBleTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { parseStatus } from '@/domain/protocol/responses';
import type { ConnectionStage, ConnectionStageOutcome, DiagnosticLog } from '@/diagnostics/DiagnosticLog';
import type { BleAvailability, BleTransport, DiscoveredDesktop, Unsubscribe } from './BleTransport';
import { bluetoothDeviceDisplayName, desktopDisplayName } from './desktopDisplayName';
import { ReadResponsePoller } from './ReadResponsePoller';

export class ReactNativeBleTransport implements BleTransport {
#manager: BleManager | null;
Expand All @@ -22,6 +23,8 @@ export class ReactNativeBleTransport implements BleTransport {
#resolutionCancel: ((error: Error) => Promise<void>) | null = null;
#writeSequence = 0;
#writePoisoned = false;
#readReplies = false;
#responsePoller: ReadResponsePoller | null = null;

constructor(
manager: BleManager | null = null,
Expand Down Expand Up @@ -162,6 +165,7 @@ export class ReactNativeBleTransport implements BleTransport {
connected = await this.#stage('services', () => this.#bounded(connected!.discoverAllServicesAndCharacteristics()), operation);
if (!active || operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.');
this.#device = connected;
this.#readReplies = desktop.responseTransport === 'read-v1';
this.#writePoisoned = false;
succeed(desktop);
}).catch((probeError: unknown) => {
Expand Down Expand Up @@ -211,6 +215,11 @@ export class ReactNativeBleTransport implements BleTransport {
connected = await this.#stage('services', () => this.#bounded(connected!.discoverAllServicesAndCharacteristics()), operation);
if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.');
this.#device = connected;
const statusValue = await this.#bounded(connected.readCharacteristicForService(BLE_UUIDS.service, BLE_UUIDS.status));
if (operation !== this.#operation) throw new Error('Bluetooth connection was cancelled.');
const status = statusValue.value ? parseStatus(new TextDecoder().decode(toByteArray(statusValue.value))) : null;
if (!status) throw new Error('Bluetooth discovery status is invalid.');
this.#readReplies = status.responseTransport === 'read-v1';
this.#writePoisoned = false;
} catch (error) {
connectCancelled = true;
Expand All @@ -225,6 +234,9 @@ export class ReactNativeBleTransport implements BleTransport {

async disconnect(): Promise<void> {
this.#operation += 1;
this.#responsePoller?.stop();
this.#responsePoller = null;
this.#readReplies = false;
const cancelResolution = this.#resolutionCancel;
if (cancelResolution) await cancelResolution(new Error('Bluetooth operation was cancelled.'));
this.#cancelNativeOperations();
Expand Down Expand Up @@ -295,6 +307,21 @@ export class ReactNativeBleTransport implements BleTransport {
}

subscribe(onFrame: (frameBase64: string) => void, onError: (error: Error) => void): Unsubscribe {
if (this.#readReplies) {
if (this.#responsePoller || this.#writePoisoned) throw new Error('Reconnect before starting response reads again.');
const device = this.#requireDevice();
const operation = this.#operation;
const transaction = `switchify-read-${operation}`;
const poller = new ReadResponsePoller(
async () => (await device.readCharacteristicForService(BLE_UUIDS.service, BLE_UUIDS.response, transaction)).value,
async () => { await this.#managerOrCreate().cancelTransaction(transaction); },
(frame) => { if (operation === this.#operation) onFrame(frame); },
(error) => { if (operation === this.#operation) { this.#writePoisoned = true; onError(error); } },
this.nativeTimeoutMs,
);
this.#responsePoller = poller;
return () => { poller.stop(); if (this.#responsePoller === poller) this.#writePoisoned = true; };
}
const operation = this.#operation;
let active = true;
let failed = false;
Expand All @@ -317,6 +344,11 @@ export class ReactNativeBleTransport implements BleTransport {
}

async notificationsReady(): Promise<void> {
if (this.#readReplies) {
if (!this.#responsePoller) throw new Error('Bluetooth response reader has not started.');
await this.#responsePoller.ready;
return;
}
if (this.platform !== 'android') return;
await this.#stage('notification_ready', async () => {
const descriptor = await this.#bounded(this.#requireDevice().readDescriptorForService(
Expand Down
67 changes: 67 additions & 0 deletions src/transport/ReadResponsePoller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ReadResponsePoller } from './ReadResponsePoller';
import { parseStatus } from '@/domain/protocol/responses';

describe('Linux read responses', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

it('negotiates explicitly and preserves old desktops', () => {
const base = { protocolVersion: 1, desktopId: 'pc', platform: 'linux' };
expect(parseStatus(JSON.stringify(base))?.responseTransport).toBeUndefined();
expect(parseStatus(JSON.stringify({ ...base, responseTransport: 'read-v1' }))?.responseTransport).toBe('read-v1');
for (const responseTransport of ['future', null, true]) expect(parseStatus(JSON.stringify({ ...base, responseTransport }))).toBeNull();
});

it('polls serially, idles on empty values and stops without late delivery', async () => {
let finish!: (value: string) => void;
const read = jest.fn().mockResolvedValueOnce('').mockImplementationOnce(() => new Promise<string>((resolve) => { finish = resolve; }));
const cancel = jest.fn(async () => undefined), frame = jest.fn(), error = jest.fn();
const poller = new ReadResponsePoller(read, cancel, frame, error, 1000);
await poller.ready;
await jest.advanceTimersByTimeAsync(99);
expect(read).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(401);
expect(read).toHaveBeenCalledTimes(2);
poller.stop();
finish('e30=');
await jest.advanceTimersByTimeAsync(2000);
expect(frame).not.toHaveBeenCalled();
expect(error).not.toHaveBeenCalled();
expect(cancel).toHaveBeenCalledTimes(1);
expect(read).toHaveBeenCalledTimes(2);
});

it('delivers nonempty frames without the idle delay', async () => {
const read = jest.fn().mockResolvedValueOnce('e30=').mockResolvedValue('');
const frame = jest.fn();
const poller = new ReadResponsePoller(read, async () => undefined, frame, jest.fn(), 1000);
await poller.ready;
expect(frame).toHaveBeenCalledWith('e30=');
await jest.advanceTimersByTimeAsync(1);
expect(read).toHaveBeenCalledTimes(2);
poller.stop();
});

it.each([null, '!', 'x'.repeat(244)])('fails closed on malformed or oversized value', async (value) => {
const error = jest.fn();
const poller = new ReadResponsePoller(async () => value, async () => undefined, jest.fn(), error, 1000);
await expect(poller.ready).rejects.toThrow();
expect(error).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(2000);
expect(error).toHaveBeenCalledTimes(1);
});

it('times out once, cancels the native read and ignores its late completion', async () => {
let finish!: (value: string) => void;
const cancel = jest.fn(async () => undefined), frame = jest.fn(), error = jest.fn();
const poller = new ReadResponsePoller(() => new Promise((resolve) => { finish = resolve; }), cancel, frame, error, 1000);
const rejected = expect(poller.ready).rejects.toThrow();
await jest.advanceTimersByTimeAsync(1000);
await rejected;
finish('e30=');
await jest.advanceTimersByTimeAsync(2000);
expect(error).toHaveBeenCalledTimes(1);
expect(cancel).toHaveBeenCalledTimes(1);
expect(frame).not.toHaveBeenCalled();
});
});
53 changes: 53 additions & 0 deletions src/transport/ReadResponsePoller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { toByteArray } from 'base64-js';

/** One consuming ATT read at a time. Any failure requires a fresh connection. */
export class ReadResponsePoller {
readonly ready: Promise<void>;
#active = true;
#timer: ReturnType<typeof setTimeout> | undefined;
#resolveReady!: () => void;
#rejectReady!: (error: Error) => void;

constructor(
private readonly read: () => Promise<string | null>,
private readonly cancelRead: () => Promise<unknown>,
private readonly onFrame: (frame: string) => void,
private readonly onError: (error: Error) => void,
private readonly timeoutMs: number,
) {
this.ready = new Promise<void>((resolve, reject) => { this.#resolveReady = resolve; this.#rejectReady = reject; });
void this.ready.catch(() => undefined);
void this.#poll();
}

stop(): void {
if (!this.#active) return;
this.#active = false;
clearTimeout(this.#timer);
this.#rejectReady(new Error('Bluetooth response reading stopped.'));
void this.cancelRead().catch(() => undefined);
}

async #poll(): Promise<void> {
if (!this.#active) return;
const fail = () => {
if (!this.#active) return;
this.stop();
this.onError(new Error('Bluetooth response reading failed. Reconnect to the PC.'));
};
this.#timer = setTimeout(fail, this.timeoutMs);
try {
const value = await this.read();
if (!this.#active) return;
clearTimeout(this.#timer);
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))) {
fail(); return;
}
this.#resolveReady();
if (value) this.onFrame(value);
if (this.#active) this.#timer = setTimeout(() => { void this.#poll(); }, value ? 0 : 100);
} catch {
fail();
}
}
}