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
5 changes: 5 additions & 0 deletions docs/accessibility.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Accessibility acceptance criteria

Web selector buttons must include their full current value in the accessible name.
Toggle controls expose pressed/unpressed state; ordinary action buttons must not be
announced as toggles. Busy and disabled controls expose those states to browser
assistive technology. Check the rendered DOM in addition to native prop tests.

On the web, live typing must submit from both keyboard Enter and the visible Enter
control, then restore input focus after success or failure. Failure retains text
and offers Retry Enter. Draft Enter remains multiline. Verify these using actual
Expand Down
25 changes: 25 additions & 0 deletions docs/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ Web Bluetooth is available in Chrome and Edge on Android, Windows, macOS and Chr

The protocol, framing limits, authentication and pairing approval are unchanged. See [protocol compatibility](protocol-compatibility.md).

First-time browser device identity creation uses an exclusive Web Lock, so tabs
share one persisted identity. Waiting for a lock is bounded to five seconds. If
locks are unavailable or persistence fails, initialization fails safely rather
than using an unsaved identity. Existing encrypted identities remain readable.
This keeps the existing keys, ciphertext format and native storage path unchanged.

Connection cancellation includes initial cleanup: a stopped or replaced attempt
must not reopen the picker or begin another connection after cleanup completes.

## Typing and probe recovery

Keyboard Enter in live typing sends one Enter, clears only after acknowledgement,
Expand All @@ -48,6 +57,22 @@ selection, and a late probe completion must not affect the chosen PC.

## Hardware validation still needed

Connection discovery now reads status before looking up command and notification
characteristics, one operation at a time. Only PCs advertising `read-v1` are asked
for the read-response characteristic. Discovery shares one ten-second budget and
checks cancellation between operations. Diagnostics identify each lookup using
fixed labels, never browser error text or Bluetooth identifiers.

Issue #182 follows a real Chrome failure after status discovery and before pairing.
The current-code hardware retry narrowed the failure to primary-service discovery
after reconnecting. Sequential characteristic discovery alone did not fix it.
The web transport now explicitly retains the selected completed discovery probe
across scan stop and connection preparation. It reuses that live service and
rereads status before pairing; a changed desktop identity still fails. Normal
scan stop, explicit disconnect, cancellation and failed setup close the probe.
Native transports keep their existing preparation path. A successful real pairing
with this handoff remains required before release.

Automated tests use fake Web Bluetooth devices. Before release, check on real hardware:

- pairing and control from Chrome on Android against Switchify PC on Windows and on macOS
Expand Down
351 changes: 177 additions & 174 deletions package-lock.json

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,28 @@
"main": "expo-router/entry",
"version": "1.0.0-beta.28",
"dependencies": {
"@expo/ui": "~57.0.18",
"@expo/ui": "~57.0.22",
"@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.22",
"expo": "~57.0.27",
"expo-blur": "~57.0.3",
"expo-build-properties": "~57.0.17",
"expo-build-properties": "~57.0.22",
"expo-clipboard": "~57.0.2",
"expo-constants": "~57.0.18",
"expo-constants": "~57.0.21",
"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-glass-effect": "~57.0.4",
"expo-image": "~57.0.5",
"expo-linking": "~57.0.10",
"expo-router": "~57.0.21",
"expo-linking": "~57.0.12",
"expo-router": "~57.0.25",
"expo-secure-store": "~57.0.4",
"expo-sharing": "~57.0.19",
"expo-sharing": "~57.0.22",
"expo-splash-screen": "~57.0.9",
"expo-status-bar": "~57.0.1",
"expo-symbols": "~57.0.3",
Expand Down
5 changes: 3 additions & 2 deletions src/components/ActionButton.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import type { ComponentProps, Ref } from 'react';
import { ActivityIndicator, Animated, Pressable, Text, View, type View as NativeView } from 'react-native';
import { ActivityIndicator, Animated, Platform, Pressable, Text, View, type View as NativeView } from 'react-native';
import { webControlAccessibility } from './webControlAccessibility';
import { useTheme } from '@/theme/ThemeContext';
import { usePressScale } from './usePressScale';

Expand All @@ -14,7 +15,7 @@ export function ActionButton({ label, onPress, disabled = false, busy = false, s
const background = resolvedTone === 'primary' ? colors.brand : resolvedTone === 'danger' ? colors.dangerTint : resolvedTone === 'secondary' ? colors.surfaceRaised : 'transparent';
const foreground = resolvedTone === 'primary' ? colors.onBrand : resolvedTone === 'danger' ? colors.danger : resolvedTone === 'tertiary' ? colors.brandText : colors.text;
return (
<Animated.View style={{ transform: [{ scale: press.scale }], width: '100%' }}><Pressable ref={controlRef} accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ disabled, busy }} disabled={disabled} onPress={onPress} onPressIn={press.pressIn} onPressOut={press.pressOut} style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed && !disabled ? (resolvedTone === 'primary' ? colors.brandPressed : colors.surfacePressed) : background, borderColor: resolvedTone === 'tertiary' ? 'transparent' : resolvedTone === 'danger' ? colors.danger : colors.border, borderRadius: radii.md, borderWidth: resolvedTone === 'primary' ? 0 : 1, justifyContent: 'center', minHeight: 52, opacity: disabled ? 0.4 : 1, paddingHorizontal: spacing.lg })}>
<Animated.View style={{ transform: [{ scale: press.scale }], width: '100%' }}><Pressable ref={controlRef} accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ disabled, busy }} {...webControlAccessibility(Platform.OS, { disabled, busy })} disabled={disabled} onPress={onPress} onPressIn={press.pressIn} onPressOut={press.pressOut} style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed && !disabled ? (resolvedTone === 'primary' ? colors.brandPressed : colors.surfacePressed) : background, borderColor: resolvedTone === 'tertiary' ? 'transparent' : resolvedTone === 'danger' ? colors.danger : colors.border, borderRadius: radii.md, borderWidth: resolvedTone === 'primary' ? 0 : 1, justifyContent: 'center', minHeight: 52, opacity: disabled ? 0.4 : 1, paddingHorizontal: spacing.lg })}>
<View style={{ alignItems: 'center', flexDirection: 'row', gap: spacing.sm }}>
{busy ? <ActivityIndicator color={foreground} /> : icon ? <MaterialIcons color={foreground} importantForAccessibility="no" name={icon} size={20} /> : null}
<Text style={[typography.label, { color: foreground, flexShrink: 1, textAlign: 'center' }]}>{label}</Text>
Expand Down
7 changes: 5 additions & 2 deletions src/components/ControlButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import type { ComponentProps, Ref } from "react";
import {
Animated,
Platform,
Pressable,
Text,
type AccessibilityRole,
type View,
} from "react-native";
import { useTheme } from "@/theme/ThemeContext";
import { usePressScale } from "./usePressScale";
import { webControlAccessibility } from './webControlAccessibility';

type IconName = ComponentProps<typeof MaterialIcons>["name"];

Expand All @@ -17,7 +19,7 @@ export function ControlButton({
accessibilityLabel = label,
hint,
onPress,
selected = false,
selected,
disabled = false,
danger = false,
role = "button",
Expand Down Expand Up @@ -60,7 +62,8 @@ export function ControlButton({
accessibilityRole={role}
accessibilityLabel={accessibilityLabel}
accessibilityHint={hint}
accessibilityState={{ selected, disabled }}
accessibilityState={{ selected: selected ?? false, disabled }}
{...webControlAccessibility(Platform.OS, { selected, disabled, role })}
disabled={disabled}
onPress={onPress}
onPressIn={press.pressIn}
Expand Down
8 changes: 5 additions & 3 deletions src/components/IconButton.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import type { ComponentProps } from "react";
import { Animated, Pressable } from "react-native";
import { Animated, Platform, Pressable } from "react-native";
import { useTheme } from "@/theme/ThemeContext";
import { usePressScale } from "./usePressScale";
import { webControlAccessibility } from './webControlAccessibility';

export function IconButton({
icon,
accessibilityLabel,
hint,
selected = false,
selected,
disabled = false,
onPress,
}: {
Expand All @@ -34,7 +35,8 @@ export function IconButton({
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityHint={hint}
accessibilityState={{ selected, disabled }}
accessibilityState={{ selected: selected ?? false, disabled }}
{...webControlAccessibility(Platform.OS, { selected, disabled })}
disabled={disabled}
onPress={onPress}
onPressIn={press.pressIn}
Expand Down
3 changes: 2 additions & 1 deletion src/components/SelectorField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AppText } from './AppText';
import { ControlButton } from './ControlButton';
import { focusAccessibilityTarget } from './accessibilityFocus';
import { useTheme } from '@/theme/ThemeContext';
import { webControlAccessibility } from './webControlAccessibility';

export type SelectorOption<T extends string | number> = { key: T; label: string; disabled?: boolean };

Expand Down Expand Up @@ -91,7 +92,7 @@ export function SelectorField<T extends string | number>({ label, options, selec
};

return <>
<Pressable ref={fieldRef} accessibilityRole="button" accessibilityLabel={label} accessibilityValue={{ text: selected?.label ?? '' }} accessibilityHint={hint} onPress={() => { setSelectionError(null); setVisible(true); }} style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed ? colors.surfacePressed : colors.surfaceRaised, borderColor: colors.border, borderRadius: radii.lg, borderWidth: 1, flexDirection: 'row', gap: spacing.sm, minHeight: 48, padding: spacing.md })}>
<Pressable ref={fieldRef} accessibilityRole="button" accessibilityLabel={label} accessibilityValue={{ text: selected?.label ?? '' }} {...webControlAccessibility(Platform.OS, { label, value: selected?.label ?? '', expanded: visible })} accessibilityHint={hint} onPress={() => { setSelectionError(null); setVisible(true); }} style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed ? colors.surfacePressed : colors.surfaceRaised, borderColor: colors.border, borderRadius: radii.lg, borderWidth: 1, flexDirection: 'row', gap: spacing.sm, minHeight: 48, padding: spacing.md })}>
<AppText style={{ flex: 1, flexShrink: 1 }} variant="label">{label}: {selected?.label ?? ''}</AppText>
<MaterialIcons color={colors.textMuted} importantForAccessibility="no" name="unfold-more" size={20} />
</Pressable>
Expand Down
19 changes: 19 additions & 0 deletions src/components/webControlAccessibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** Native state/value props are not forwarded by React Native Web. */
export function webControlAccessibility(platform: string, { label, value, selected, disabled, busy, expanded, role = 'button' }: {
label?: string;
value?: string;
selected?: boolean | undefined;
disabled?: boolean;
busy?: boolean;
expanded?: boolean;
role?: string;
}) {
if (platform !== 'web') return {};
return {
...(label !== undefined && value !== undefined ? { 'aria-label': `${label}: ${value}` } : {}),
...(selected !== undefined ? role === 'button' ? { 'aria-pressed': selected } : { 'aria-selected': selected } : {}),
...(disabled !== undefined ? { 'aria-disabled': disabled } : {}),
...(busy !== undefined ? { 'aria-busy': busy } : {}),
...(expanded !== undefined ? { 'aria-expanded': expanded } : {}),
};
}
35 changes: 35 additions & 0 deletions src/components/webControlAccessibility.web.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/** @jest-environment jsdom */
/* global describe, it, expect */
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server.node';
import Pressable from 'react-native-web/dist/cjs/exports/Pressable';
import { webControlAccessibility } from './webControlAccessibility';

function render(props) {
const host = document.createElement('div');
host.innerHTML = renderToStaticMarkup(createElement(Pressable, {
accessibilityRole: 'button', accessibilityLabel: 'Surface',
disabled: props.disabled,
...webControlAccessibility('web', props),
}));
return host.querySelector('button');
}

describe('browser control accessibility', () => {
it('includes the complete current value in the accessible name', () => {
const button = render({ label: 'Surface', value: 'Typing', expanded: true });
expect(button.getAttribute('aria-label')).toBe('Surface: Typing');
expect(button.getAttribute('aria-expanded')).toBe('true');
expect(button.hasAttribute('aria-pressed')).toBe(false);
});
it.each([true, false])('exposes toggle state %s and disabled/busy state', (selected) => {
const button = render({ selected, disabled: true, busy: true });
expect(button.getAttribute('aria-pressed')).toBe(String(selected));
expect(button.getAttribute('aria-disabled')).toBe('true');
expect(button.getAttribute('aria-busy')).toBe('true');
expect(button.hasAttribute('aria-selected')).toBe(false);
});
it.each(['android', 'ios'])('does not override native props on %s', (platform) => {
expect(webControlAccessibility(platform, { label: 'Surface', value: 'Mouse', selected: true })).toEqual({});
});
});
7 changes: 4 additions & 3 deletions src/connection/ConnectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ export class ConnectionManager {

async connect(desktop: DiscoveredDesktop): Promise<void> {
const operation = ++this.#operation;
const retained = !this.#client && this.transport.retainDiscoveredConnection?.(desktop.desktopId) === true;
this.#scanStop?.(); this.#scanStop = null;
await this.#teardownConnection();
await this.#teardownConnection(retained);
if (!this.#current(operation)) return;
this.#set({ kind: 'connecting', desktop });
this.diagnostics.add('connecting');
Expand Down Expand Up @@ -496,14 +497,14 @@ export class ConnectionManager {
this.#set({ kind: 'failed', message, saved });
}

async #teardownConnection(): Promise<void> {
async #teardownConnection(preserveDiscovery = false): 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);
if (!preserveDiscovery) await this.transport.disconnect().catch(() => undefined);
}

#scheduleHealth(delay = 5_000): void {
Expand Down
21 changes: 21 additions & 0 deletions src/connection/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,27 @@ const waitFor = async (condition: () => boolean): Promise<void> => {
};

describe('connection lifecycle', () => {
it('claims a discovered connection before stopping the scan and skips intermediate disconnect', async () => {
const transport = new FakeTransport();
const manager = new ConnectionManager(transport, new FakeStorage(), new DiagnosticLog(), async () => true);
const desktop = { ...pc('desk-1'), rssi: null };
await manager.scan();
const disconnect = jest.spyOn(transport, 'disconnect');
const retained = jest.fn(() => {
expect(transport.scanStops).toBe(0);
return true;
});
Object.assign(transport, { retainDiscoveredConnection: retained });
transport.resolveGate = new Promise(() => undefined);
void manager.connect(desktop);
await waitFor(() => transport.resolveDesktopIds.length === 1);
expect(retained).toHaveBeenCalledWith('desk-1');
expect(transport.scanStops).toBe(1);
expect(disconnect).not.toHaveBeenCalled();
await manager.disconnect();
expect(disconnect).toHaveBeenCalled();
});

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
6 changes: 6 additions & 0 deletions src/diagnostics/DiagnosticLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ const connectionStages = {
priority: 'Request Android connection priority (optional)',
mtu: 'Negotiate Bluetooth MTU',
services: 'Discover connection services',
primary_service: 'Discover PC primary service',
discovery_handoff: 'Reuse selected discovery connection',
status_characteristic: 'Discover PC status characteristic',
receive_characteristic: 'Discover PC command characteristic',
transmit_characteristic: 'Discover PC notification characteristic',
response_characteristic: 'Discover PC read-response characteristic',
probe_connect: 'Connect for discovery status',
probe_services: 'Discover status services',
status_read: 'Read discovery status',
Expand Down
2 changes: 2 additions & 0 deletions src/remote/RemoteDeviceSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AppText } from '@/components/AppText';
import { ControlButton } from '@/components/ControlButton';
import { EmptyState } from '@/components/EmptyState';
import { focusAccessibilityTarget } from '@/components/accessibilityFocus';
import { webControlAccessibility } from '@/components/webControlAccessibility';
import type { ConnectionManager, ConnectionState } from '@/connection/ConnectionManager';
import type { SavedPc } from '@/storage/PairingStore';
import { useTheme } from '@/theme/ThemeContext';
Expand Down Expand Up @@ -94,6 +95,7 @@ export function RemoteDeviceSwitcher({ connection, manager, managePcs }: { conne
accessibilityRole="button"
accessibilityLabel="Switch PC"
accessibilityValue={{ text: `${presentation.status}, ${presentation.name}` }}
{...webControlAccessibility(Platform.OS, { label: 'Switch PC', value: `${presentation.status}, ${presentation.name}`, expanded: visible })}
accessibilityHint="Opens your saved PCs."
onPress={open}
style={({ pressed }) => ({ alignItems: 'center', backgroundColor: pressed ? colors.surfacePressed : colors.surfaceRaised, borderColor: colors.border, borderRadius: radii.lg, borderWidth: 1, flexDirection: 'row', gap: spacing.md, minHeight: 56, paddingHorizontal: spacing.md, paddingVertical: spacing.sm })}
Expand Down
11 changes: 11 additions & 0 deletions src/storage/PairingStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ function fixture() {
}

describe('PairingStore transactions', () => {
it('uses platform atomic identity initialization when available', async () => {
const secrets = new SecretMemory();
const atomic = jest.fn(async (_name: string, _create: () => string) => 'shared-device');
const storage = Object.assign(secrets, { getOrCreateItemAsync: atomic });
const first = new PairingStore(new PublicMemory(), storage);
const second = new PairingStore(new PublicMemory(), storage);
expect(await Promise.all([first.getDeviceId(), second.getDeviceId()])).toEqual(['shared-device', 'shared-device']);
expect(atomic).toHaveBeenCalledWith('switchify.remote.device-id.v1', expect.any(Function));
expect(secrets.values.size).toBe(0);
});

it('rolls back a token update when saving the public index fails', async () => {
const { store, publicStorage } = fixture();
publicStorage.failSet = 1;
Expand Down
3 changes: 3 additions & 0 deletions src/storage/PairingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export class PairingStore implements PairingStorage {
constructor(private readonly publicStorage: PublicStorage = AsyncStorage, private readonly secretStorage: SecretStorage = platformSecretStorage) {}

async getDeviceId(): Promise<string> {
if (this.secretStorage.getOrCreateItemAsync) {
return this.secretStorage.getOrCreateItemAsync(DEVICE_ID_KEY, () => `remote-${Crypto.randomUUID()}`);
}
const existing = await this.secretStorage.getItemAsync(DEVICE_ID_KEY);
if (existing) return existing;
const created = `remote-${Crypto.randomUUID()}`;
Expand Down
4 changes: 3 additions & 1 deletion src/storage/secretStorage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as SecureStore from 'expo-secure-store';

export type SecretStorage = Pick<typeof SecureStore, 'getItemAsync' | 'setItemAsync' | 'deleteItemAsync'>;
export type SecretStorage = Pick<typeof SecureStore, 'getItemAsync' | 'setItemAsync' | 'deleteItemAsync'> & {
getOrCreateItemAsync?: (name: string, create: () => string) => Promise<string>;
};

export const secretStorage: SecretStorage = SecureStore;
Loading
Loading