Skip to content

Commit a04b229

Browse files
enaboappsOwen McGirr
andauthored
feat: add a quick PC switcher to Remote (#86)
* feat: add quick PC switcher * fix: cancel stale PC switches * fix: make quick switching intent-safe * fix: reconnect when reversing a switch --------- Co-authored-by: Owen McGirr <owenmcgirr@Owens-Mac-Studio-2.local>
1 parent c1104f0 commit a04b229

10 files changed

Lines changed: 432 additions & 14 deletions

File tree

docs/accessibility.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Switchify Remote is designed for VoiceOver, TalkBack, iOS Switch Control, and An
44

55
- Every interactive target is at least 48 by 48 logical points and has a concise accessible name.
66
- Surface selection is one button that announces its current value and opens a modal option list. Options expose selected state, scanning stays inside the modal, and focus returns to the selector after selection or dismissal. Toggles expose selected or disabled state.
7+
- 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.
78
- Headings, connection changes, failures, repeat state, pairing approval, and pointer-profile recovery transitions are announced once without moving focus unexpectedly.
89
- The pairing code is announced one digit at a time.
910
- Text scales with the operating system; controls grow rather than shrinking text below the user's chosen size.
@@ -22,4 +23,4 @@ Switchify Remote is designed for VoiceOver, TalkBack, iOS Switch Control, and An
2223

2324
## Manual smoke test
2425

25-
On both platforms, enable the screen reader and then the platform switch-access feature. Navigate PCs, pairing, every available remote screen, Settings, and Diagnostics in logical order. Confirm every action can be selected, read-only text does not become a switch-scan stop, pointer repeat exposes Stop movement, held modifiers and drag expose state, disabled controls are announced, and backgrounding returns the desktop to a neutral input state. On Android, confirm Forwarding keeps its profile choices available before Start but adds mapped external switches to scanning only while forwarding is active. The mapped switches and overflow notice must disappear after manual or safety stops. In Settings, edit the Remote name and confirm validation and save status are announced without adding read-only scan stops. Confirm Use device model restores the model-based name. Confirm Privacy policy is one scan stop, announces that it opens in a browser, opens the Remote-specific policy, and returns safely if the browser cannot open. During a delayed pointer-profile response, confirm "Restoring controls" is announced once, followed by either restored controls or "Controls unavailable" after all retries. Open the Surface selector and confirm the underlying screen and bottom tabs are not scannable, options scan linearly, Close works, Android back and VoiceOver escape dismiss the modal, and focus returns to the selector after selection or dismissal. Press Unpair for a saved computer and confirm the native alert reads the full computer name. Verify Cancel, Android Back, and outside dismissal preserve the pairing, then confirm Unpair removes it. Repeat in light and dark appearance, at 200% text scaling, and with reduced motion enabled.
26+
On both platforms, enable the screen reader and then the platform switch-access feature. Navigate PCs, pairing, every available remote screen, Settings, and Diagnostics in logical order. Confirm every action can be selected, read-only text does not become a switch-scan stop, pointer repeat exposes Stop movement, held modifiers and drag expose state, disabled controls are announced, and backgrounding returns the desktop to a neutral input state. On Android, confirm Forwarding keeps its profile choices available before Start but adds mapped external switches to scanning only while forwarding is active. The mapped switches and overflow notice must disappear after manual or safety stops. In Settings, edit the Remote name and confirm validation and save status are announced without adding read-only scan stops. Confirm Use device model restores the model-based name. Confirm Privacy policy is one scan stop, announces that it opens in a browser, opens the Remote-specific policy, and returns safely if the browser cannot open. During a delayed pointer-profile response, confirm "Restoring controls" is announced once, followed by either restored controls or "Controls unavailable" after all retries. Open the Surface selector and confirm the underlying screen and bottom tabs are not scannable, options scan linearly, Close works, Android back and VoiceOver escape dismiss the modal, and focus returns to the selector after selection or dismissal. Repeat those checks with the Remote PC switcher, confirm it stays above the tabs while content scrolls, and switch between two saved PCs. Press Unpair for a saved computer and confirm the native alert reads the full computer name. Verify Cancel, Android Back, and outside dismissal preserve the pairing, then confirm Unpair removes it. Repeat in light and dark appearance, at 200% text scaling, and with reduced motion enabled.

src/app/(tabs)/remote.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useConnectionManager, useConnectionState } from '@/connection/Connectio
88
import { usePreferredPcConnection } from '@/connection/usePreferredPcConnection';
99
import { MouseSurface } from '@/remote/MouseSurface';
1010
import { DisconnectedRemote } from '@/remote/DisconnectedRemote';
11+
import { RemoteDeviceSwitcher } from '@/remote/RemoteDeviceSwitcher';
1112
import { RemoteSession } from '@/remote/RemoteSession';
1213
import { SurfaceSelector } from '@/remote/SurfaceSelector';
1314
import { TypingSurface } from '@/remote/TypingSurface';
@@ -40,13 +41,14 @@ export default function RemoteScreen() {
4041
useEffect(() => {
4142
if (shouldClearForwardingRestore(preferences.surface, connection.kind)) forwardingRestore.clear();
4243
}, [connection.kind, forwardingRestore, preferences.surface]);
43-
if (connection.kind !== 'connected') return <DisconnectedRemote connection={connection} selectedSurface={preferences.surface} retry={() => void manager.connectPreferred()} choose={() => router.navigate('/')} />;
44+
const deviceSwitcher = <RemoteDeviceSwitcher connection={connection} manager={manager} managePcs={() => router.navigate('/')} />;
45+
if (connection.kind !== 'connected') return <DisconnectedRemote connection={connection} selectedSurface={preferences.surface} retry={() => void manager.connectPreferred()} choose={() => router.navigate('/')} bottomAccessory={deviceSwitcher} />;
4446
if (!connection.profile) {
4547
const unavailablePresentation = profilePresentation(connection.profileStatus);
46-
return <Screen title="Remote"><EmptyState icon={unavailablePresentation.icon} title={unavailablePresentation.title} body={unavailablePresentation.body} /></Screen>;
48+
return <Screen title="Remote" bottomAccessory={deviceSwitcher}><EmptyState icon={unavailablePresentation.icon} title={unavailablePresentation.title} body={unavailablePresentation.body} /></Screen>;
4749
}
4850
return (
49-
<Screen title="Remote" headerAccessory={<StatusBadge icon="check-circle" label={`Connected · ${connection.desktop.displayName}`} tone="success" />}>
51+
<Screen title="Remote" headerAccessory={<StatusBadge icon="check-circle" label={`Connected · ${connection.desktop.displayName}`} tone="success" />} bottomAccessory={deviceSwitcher}>
5052
<SurfaceSelector selected={preferences.surface} />
5153
{preferences.surface === 'mouse' ? <MouseSurface session={session} state={sessionState} physicalSwitchStopAvailable={bridgeSnapshot.captureAvailable && bridgeSnapshot.externalSwitches.length > 0} /> : null}
5254
{preferences.surface === 'typing' ? <TypingSurface session={session} mode={preferences.typingMode} draft={preferences.draft} /> : null}

src/components/ActionButton.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
2-
import type { ComponentProps } from 'react';
3-
import { ActivityIndicator, Animated, Pressable, Text, View } from 'react-native';
2+
import type { ComponentProps, Ref } from 'react';
3+
import { ActivityIndicator, Animated, Pressable, Text, View, type View as NativeView } from 'react-native';
44
import { useTheme } from '@/theme/ThemeContext';
55
import { usePressScale } from './usePressScale';
66

77
type Tone = 'primary' | 'secondary' | 'tertiary' | 'danger';
88
type IconName = ComponentProps<typeof MaterialIcons>['name'];
99

10-
export function ActionButton({ label, onPress, disabled = false, busy = false, secondary = false, tone, icon }: { label: string; onPress: () => void; disabled?: boolean; busy?: boolean; secondary?: boolean; tone?: Tone; icon?: IconName }) {
10+
export function ActionButton({ label, onPress, disabled = false, busy = false, secondary = false, tone, icon, controlRef }: { label: string; onPress: () => void; disabled?: boolean; busy?: boolean; secondary?: boolean; tone?: Tone; icon?: IconName; controlRef?: Ref<NativeView> }) {
1111
const { colors, radii, spacing, typography } = useTheme();
1212
const press = usePressScale();
1313
const resolvedTone = tone ?? (secondary ? 'secondary' : 'primary');
1414
const background = resolvedTone === 'primary' ? colors.brand : resolvedTone === 'danger' ? colors.dangerTint : resolvedTone === 'secondary' ? colors.surfaceRaised : 'transparent';
1515
const foreground = resolvedTone === 'primary' ? colors.onBrand : resolvedTone === 'danger' ? colors.danger : resolvedTone === 'tertiary' ? colors.brandText : colors.text;
1616
return (
17-
<Animated.View style={{ transform: [{ scale: press.scale }], width: '100%' }}><Pressable 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 })}>
17+
<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 })}>
1818
<View style={{ alignItems: 'center', flexDirection: 'row', gap: spacing.sm }}>
1919
{busy ? <ActivityIndicator color={foreground} /> : icon ? <MaterialIcons color={foreground} importantForAccessibility="no" name={icon} size={20} /> : null}
2020
<Text style={[typography.label, { color: foreground, flexShrink: 1, textAlign: 'center' }]}>{label}</Text>

src/components/Screen.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
55
import { AppText } from './AppText';
66
import { useLayout, useTheme } from '@/theme/ThemeContext';
77

8-
type ScreenProps = PropsWithChildren<{ title: string; description?: string; headerAccessory?: ReactNode; nativeHeader?: boolean }>;
8+
type ScreenProps = PropsWithChildren<{ title: string; description?: string; headerAccessory?: ReactNode; bottomAccessory?: ReactNode; nativeHeader?: boolean }>;
99

10-
export function Screen({ title, description, headerAccessory, nativeHeader = false, children }: ScreenProps) {
10+
export function Screen({ title, description, headerAccessory, bottomAccessory, nativeHeader = false, children }: ScreenProps) {
1111
const { colors, spacing } = useTheme();
1212
const { isCompact, isExpanded, isLargeText } = useLayout();
1313
const insets = useSafeAreaInsets();
1414
const stackHeader = isCompact || isLargeText;
1515
return (
1616
<SafeAreaView edges={nativeHeader ? [] : ['top']} style={{ backgroundColor: colors.background, flex: 1 }}>
17-
<ScrollView testID="screen-scroll" contentContainerStyle={{ alignItems: 'center', flexGrow: 1, paddingBottom: spacing.xxxl + insets.bottom, paddingHorizontal: isExpanded ? spacing.xxl : spacing.xl }}>
17+
<ScrollView testID="screen-scroll" contentContainerStyle={{ alignItems: 'center', flexGrow: 1, paddingBottom: bottomAccessory ? spacing.xl : spacing.xxxl + insets.bottom, paddingHorizontal: isExpanded ? spacing.xxl : spacing.xl }}>
1818
<View testID="screen-content" style={{ gap: spacing.xl, maxWidth: isExpanded ? 960 : 640, paddingTop: nativeHeader ? spacing.xl : 0, width: '100%' }}>
1919
{!nativeHeader ? <View testID="screen-header" style={{ alignItems: 'flex-start', flexDirection: stackHeader ? 'column' : 'row', gap: stackHeader ? spacing.sm : spacing.md, justifyContent: 'space-between' }}>
2020
<View style={{ flex: 1 }}>
@@ -26,6 +26,9 @@ export function Screen({ title, description, headerAccessory, nativeHeader = fal
2626
{children}
2727
</View>
2828
</ScrollView>
29+
{bottomAccessory ? <View testID="screen-bottom-accessory" style={{ alignItems: 'center', backgroundColor: colors.surface, borderTopColor: colors.border, borderTopWidth: 1, paddingHorizontal: isExpanded ? spacing.xxl : spacing.xl, paddingVertical: spacing.sm }}>
30+
<View testID="screen-bottom-accessory-content" style={{ maxWidth: isExpanded ? 960 : 640, width: '100%' }}>{bottomAccessory}</View>
31+
</View> : null}
2932
</SafeAreaView>
3033
);
3134
}

src/connection/ConnectionManager.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export class ConnectionManager {
3434
#operation = 0;
3535
#preferredConnect: Promise<void> | null = null;
3636
#disconnecting: Promise<void> | null = null;
37+
#switchIntent = 0;
3738
#invalidSavedDesktopIds = new Set<string>();
3839
#profileRecoveryTimers = new Map<ReturnType<typeof setTimeout>, (active: boolean) => void>();
3940

@@ -140,6 +141,14 @@ export class ConnectionManager {
140141
}
141142
}
142143

144+
async switchSaved(pc: SavedPc): Promise<void> {
145+
const intent = ++this.#switchIntent;
146+
if ('desktop' in this.#state && this.#state.desktop.desktopId === pc.desktopId && !this.#disconnecting) return;
147+
await this.#beginDisconnect(false);
148+
if (intent !== this.#switchIntent) return;
149+
await this.connectSaved(pc);
150+
}
151+
143152
async connectPreferred(): Promise<void> {
144153
if (this.#preferredConnect) return this.#preferredConnect;
145154
const attempt = (async () => {
@@ -210,6 +219,11 @@ export class ConnectionManager {
210219
}
211220

212221
async disconnect(record = true): Promise<void> {
222+
this.#switchIntent += 1;
223+
await this.#beginDisconnect(record);
224+
}
225+
226+
async #beginDisconnect(record: boolean): Promise<void> {
213227
if (this.#disconnecting) return this.#disconnecting;
214228
this.#preferredConnect = null;
215229
const attempt = this.#disconnect(record);

0 commit comments

Comments
 (0)