Add mobile terminal shortcut bar customization (#3012)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-05-29 16:39:52 -07:00
committed by GitHub
co-authored by Orca
parent 431b336f1b
commit 30a09f3bd9
28 changed files with 4103 additions and 2423 deletions
+1
View File
@@ -81,6 +81,7 @@ docs/**
!docs/reference/
!docs/reference/**
!docs/STYLEGUIDE.md
!docs/mobile-terminal-shortcut-bar.md
# Stably CLI (only docs/ are tracked)
.stably/*
+8 -22
View File
@@ -10,6 +10,7 @@ import { OrcaLogo } from '../src/components/OrcaLogo'
import { RpcClientProvider } from '../src/transport/client-context'
import { getNotificationNavigationPath } from '../src/notifications/notification-routing'
import { loadHosts } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
// Why: keeps the native splash screen visible until the React tree is mounted
// and ready to render. Without this the user sees a blank white/black frame
@@ -30,39 +31,23 @@ Notifications.setNotificationHandler({
})
})
// Why: extract the path+payload that follows the orca://pair anchor so we
// can route it to the confirm screen. Accept either a hash payload
// (`orca://pair#<base64>`, the QR / shared form) or a query param
// (`orca://pair?code=<...>`, future-proof for share sheets that strip
// fragments).
function extractPairCode(url: string): string | null {
if (!url.startsWith('orca://pair')) return null
const hashIndex = url.indexOf('#')
if (hashIndex !== -1) {
return url.slice(hashIndex + 1) || null
}
const queryIndex = url.indexOf('?')
if (queryIndex !== -1) {
const params = new URLSearchParams(url.slice(queryIndex + 1))
return params.get('code')
}
return null
}
export default function RootLayout() {
const router = useRouter()
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
// Why: route `orca://pair#<code>` deep links to the confirm screen so
// Why: route `orca://pair?...` deep links to the confirm screen so
// the same pairing flow runs whether the link arrived via QR scan,
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
// covers cold-start (link tapped while app was closed); the listener
// covers warm-start (link tapped while app is in memory).
useEffect(() => {
function handleUrl(url: string) {
const code = extractPairCode(url)
const code = extractPairingCodeFromUrl(url)
if (code) {
router.push({ pathname: '/pair-confirm', params: { code } })
// Why: Android camera launches can leave Expo Router's unmatched
// `orca://pair` route underneath this screen; replacing keeps cancel
// and edge-back from revealing the router error page.
router.replace({ pathname: '/pair-confirm', params: { code } })
}
}
@@ -169,6 +154,7 @@ export default function RootLayout() {
}}
/>
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
<Stack.Screen name="pair" options={{ headerShown: false }} />
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
+68 -36
View File
@@ -18,13 +18,14 @@ import {
type TextStyle
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
AlertTriangle,
ArrowUp,
ChevronLeft,
ChevronRight,
ChevronsRight,
Eraser,
Folder,
File,
@@ -59,7 +60,11 @@ import {
type TerminalModes,
type TerminalWebViewHandle
} from '../../../../src/terminal/TerminalWebView'
import { TERMINAL_ACCESSORY_KEYS } from '../../../../src/terminal/terminal-accessory-keys'
import {
getDefaultTerminalAccessoryBuiltInIds,
getVisibleTerminalAccessoryKeys,
loadTerminalAccessoryLayout
} from '../../../../src/terminal/terminal-accessory-layout'
import {
getTerminalLiveSpecialKeyBytes,
isTerminalLiveInputWithinByteLimit
@@ -724,8 +729,15 @@ export default function SessionScreen() {
const [leaveDrafts, setLeaveDrafts] = useState<DirtyMarkdownDraft[] | null>(null)
const [renameTarget, setRenameTarget] = useState<Terminal | null>(null)
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [visibleBuiltInIds, setVisibleBuiltInIds] = useState<string[]>(
getDefaultTerminalAccessoryBuiltInIds
)
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
const [deleteKeyTarget, setDeleteKeyTarget] = useState<CustomKey | null>(null)
const visibleBuiltInAccessoryKeys = useMemo(
() => getVisibleTerminalAccessoryKeys(visibleBuiltInIds),
[visibleBuiltInIds]
)
// Why: in Expo SDK 55 edge-to-edge mode the OS does NOT resize the window when
// the IME opens — the keyboard draws on top of the app. We track the keyboard
// height ourselves and translate the input/accessory area above the IME without
@@ -1716,6 +1728,34 @@ export default function SessionScreen() {
void loadCustomKeys().then(setCustomKeys)
}, [])
useFocusEffect(
useCallback(() => {
let stale = false
void loadTerminalAccessoryLayout().then((layout) => {
if (!stale) setVisibleBuiltInIds(layout.visibleBuiltInIds)
})
return () => {
stale = true
}
}, [])
)
useEffect(() => {
let mounted = true
const refresh = () => {
void loadTerminalAccessoryLayout().then((layout) => {
if (mounted) setVisibleBuiltInIds(layout.visibleBuiltInIds)
})
}
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') refresh()
})
return () => {
mounted = false
sub.remove()
}
}, [])
// Why: re-measure when non-keyboard layout-affecting state changes
// (e.g. tab strip toggling visibility when the terminal count crosses
// 0↔1 — without this, a freshly-created 2nd tab subscribes with a
@@ -1815,6 +1855,11 @@ export default function SessionScreen() {
[customKeys]
)
const handleManageShortcuts = useCallback(() => {
setShowCustomKeyModal(false)
router.push('/terminal-settings')
}, [router])
useEffect(() => {
clearTerminalCache()
activeHandleRef.current = null
@@ -3279,15 +3324,16 @@ export default function SessionScreen() {
: 'Switch to live terminal input'
}
>
<Text
style={[
styles.accessoryKeyText,
liveInputEnabled && styles.accessoryKeyTextActive,
!canSend && styles.accessoryKeyTextDisabled
]}
>
Live
</Text>
<ChevronsRight
size={14}
color={
liveInputEnabled
? colors.bgBase
: canSend
? colors.textSecondary
: colors.textMuted
}
/>
</Pressable>
{canPaste && (
<Pressable
@@ -3307,9 +3353,9 @@ export default function SessionScreen() {
</Text>
</Pressable>
)}
{TERMINAL_ACCESSORY_KEYS.map((key) => (
{visibleBuiltInAccessoryKeys.map((key) => (
<Pressable
key={key.label}
key={key.id}
style={({ pressed }) => [
styles.accessoryKey,
pressed && styles.accessoryKeyPressed,
@@ -3383,12 +3429,9 @@ export default function SessionScreen() {
onPress={focusLiveInput}
accessibilityLabel="Focus live terminal input"
>
<View style={styles.liveInputBadge}>
<KeyboardIcon size={13} color={colors.textPrimary} strokeWidth={2.2} />
<Text style={styles.liveInputBadgeText}>Live</Text>
</View>
<KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
<Text style={styles.liveInputHint} numberOfLines={1}>
Keyboard input goes to terminal
Keyboard input directly goes to terminal
</Text>
<TextInput
ref={liveInputRef}
@@ -3775,6 +3818,7 @@ export default function SessionScreen() {
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={setCustomKeys}
onManageShortcuts={handleManageShortcuts}
/>
<ActionSheetModal
visible={deleteKeyTarget != null}
@@ -4194,7 +4238,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.borderSubtle
},
accessoryKeyActive: {
backgroundColor: colors.accentBlue
backgroundColor: colors.textPrimary
},
customAccessoryKey: {
borderWidth: 1,
@@ -4209,7 +4253,7 @@ const styles = StyleSheet.create({
fontFamily: typography.monoFamily
},
accessoryKeyTextActive: {
color: colors.textPrimary,
color: colors.bgBase,
fontWeight: '700'
},
accessoryKeyTextDisabled: {
@@ -4218,6 +4262,7 @@ const styles = StyleSheet.create({
inputBar: {
flexDirection: 'row',
alignItems: 'center',
minHeight: 46,
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.md,
borderTopWidth: 1,
@@ -4226,11 +4271,12 @@ const styles = StyleSheet.create({
},
textInput: {
flex: 1,
height: 34,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
paddingVertical: 0,
fontSize: 14,
fontFamily: typography.monoFamily,
marginRight: spacing.sm
@@ -4238,21 +4284,7 @@ const styles = StyleSheet.create({
liveInputBar: {
gap: spacing.sm
},
liveInputBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.accentBlue,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radii.button
},
liveInputBadgeText: {
color: colors.textPrimary,
fontSize: 12,
fontWeight: '700',
fontFamily: typography.monoFamily
},
liveInputHint: {
flex: 1,
color: colors.textSecondary,
+17 -7
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
import { useCallback, useEffect, useRef, useState } from 'react'
import { View, Text, StyleSheet, Pressable, ActivityIndicator, BackHandler } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { parsePairingCode } from '../src/transport/pairing'
import { connect } from '../src/transport/rpc-client'
@@ -32,6 +32,20 @@ export default function PairConfirmScreen() {
// batch fewer setState calls when entries arrive in bursts.
const logsRef = useRef<ConnectionLogEntry[]>([])
const cancel = useCallback(() => {
router.replace('/')
}, [router])
useFocusEffect(
useCallback(() => {
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
cancel()
return true
})
return () => subscription.remove()
}, [cancel])
)
useEffect(() => {
if (!params.code) {
setStatus('error')
@@ -118,10 +132,6 @@ export default function PairConfirmScreen() {
}
}
function cancel() {
router.replace('/')
}
const containerPadding = { paddingTop: insets.top + spacing.sm }
return (
+2 -2
View File
@@ -210,7 +210,7 @@ export default function PairScanScreen() {
visible={pasteVisible}
title="Paste pairing code"
message="Copy the code shown under the QR on your computer."
placeholder="orca://pair#... or paste the code"
placeholder="orca://pair?code=... or paste the code"
onSubmit={handlePasteSubmit}
onCancel={() => setPasteVisible(false)}
/>
@@ -306,7 +306,7 @@ export default function PairScanScreen() {
visible={pasteVisible}
title="Paste pairing code"
message="Copy the code shown under the QR on your computer."
placeholder="orca://pair#... or paste the code"
placeholder="orca://pair?code=... or paste the code"
onSubmit={handlePasteSubmit}
onCancel={() => setPasteVisible(false)}
/>
+87
View File
@@ -0,0 +1,87 @@
import { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
export default function PairRedirectScreen() {
const router = useRouter()
const params = useLocalSearchParams<{ code?: string }>()
const [missingCode, setMissingCode] = useState(false)
const goHome = useCallback(() => {
router.replace('/')
}, [router])
useEffect(() => {
let disposed = false
async function redirectToConfirm() {
const codeParam = Array.isArray(params.code) ? params.code[0] : params.code
if (codeParam) {
router.replace({ pathname: '/pair-confirm', params: { code: codeParam } })
return
}
const initialUrl = await Linking.getInitialURL().catch(() => null)
const code = initialUrl ? extractPairingCodeFromUrl(initialUrl) : null
if (disposed) {
return
}
if (code) {
router.replace({ pathname: '/pair-confirm', params: { code } })
return
}
setMissingCode(true)
}
void redirectToConfirm()
return () => {
disposed = true
}
}, [params.code, router])
return (
<View style={styles.container}>
{missingCode ? (
<>
<Text style={styles.errorText}>Missing pairing code</Text>
<Pressable style={styles.primaryButton} onPress={goHome}>
<Text style={styles.primaryButtonText}>Back to home</Text>
</Pressable>
</>
) : (
<ActivityIndicator size="large" color={colors.textSecondary} />
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgBase,
padding: spacing.lg
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
lineHeight: 20,
marginBottom: spacing.xl,
textAlign: 'center'
},
primaryButton: {
alignItems: 'center',
backgroundColor: colors.textPrimary,
borderRadius: radii.button,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2
},
primaryButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
}
})
+246 -6
View File
@@ -1,14 +1,40 @@
import { useEffect, useMemo, useState } from 'react'
import { View, Text, StyleSheet, Pressable, ScrollView } from 'react-native'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
AppState,
View,
Text,
StyleSheet,
Pressable,
ScrollView,
Switch,
type AppStateStatus
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone } from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { useFocusEffect, useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone, X } from 'lucide-react-native'
import {
CustomKeyModal,
loadCustomKeys,
saveCustomKeys,
type CustomKey
} from '../src/components/CustomKeyModal'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useAllHostClients } from '../src/transport/client-context'
import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
import {
TERMINAL_ACCESSORY_KEYS,
type TerminalAccessoryKey
} from '../src/terminal/terminal-accessory-keys'
import {
getDefaultTerminalAccessoryBuiltInIds,
loadTerminalAccessoryLayout,
resetTerminalAccessoryBuiltInIds,
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible
} from '../src/terminal/terminal-accessory-layout'
type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
@@ -74,6 +100,33 @@ function HostFitRow({
)
}
function ShortcutBarRow({
shortcutKey,
visible,
onToggle
}: {
shortcutKey: TerminalAccessoryKey
visible: boolean
onToggle: (visible: boolean) => void
}): React.JSX.Element {
return (
<View style={styles.row}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{shortcutKey.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{shortcutKey.accessibilityLabel ?? shortcutKey.label}</Text>
</View>
<Switch
value={visible}
onValueChange={onToggle}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
/>
</View>
)
}
export default function TerminalSettingsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
@@ -84,6 +137,9 @@ export default function TerminalSettingsScreen() {
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const hostClients = useAllHostClients(hostIds)
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
// Why: per-host current value, lazily fetched. We keep state at the
// screen level rather than per-row so the picker can render at root
// level — embedding PickerModal inside a row clipped its BottomDrawer
@@ -91,6 +147,79 @@ export default function TerminalSettingsScreen() {
// drawer appear cut-off.
const [hostMs, setHostMs] = useState<Record<string, number | null | undefined>>({})
const [pickerHostId, setPickerHostId] = useState<string | null>(null)
const [visibleBuiltInIds, setVisibleBuiltInIds] = useState<string[]>(
getDefaultTerminalAccessoryBuiltInIds
)
const layoutWriteChainRef = useRef<Promise<void>>(Promise.resolve())
const layoutWriteSeqRef = useRef(0)
const pendingLayoutWritesRef = useRef(0)
const persistLayout = useCallback((nextIds: string[]) => {
layoutWriteSeqRef.current += 1
pendingLayoutWritesRef.current += 1
layoutWriteChainRef.current = layoutWriteChainRef.current
.catch(() => {})
.then(() => saveTerminalAccessoryLayout(nextIds))
.catch(() => {})
.finally(() => {
pendingLayoutWritesRef.current -= 1
})
}, [])
const refreshShortcutLayout = useCallback(() => {
const refreshSeq = layoutWriteSeqRef.current
void loadTerminalAccessoryLayout().then((layout) => {
if (pendingLayoutWritesRef.current > 0 || refreshSeq !== layoutWriteSeqRef.current) return
setVisibleBuiltInIds(layout.visibleBuiltInIds)
})
}, [])
const refreshCustomKeys = useCallback(() => {
void loadCustomKeys().then(setCustomKeys)
}, [])
const handleDeleteCustomKey = useCallback(
async (key: CustomKey) => {
const updated = customKeys.filter((k) => k.id !== key.id)
setCustomKeys(updated)
await saveCustomKeys(updated)
},
[customKeys]
)
useFocusEffect(
useCallback(() => {
refreshShortcutLayout()
refreshCustomKeys()
}, [refreshShortcutLayout, refreshCustomKeys])
)
useEffect(() => {
const sub = AppState.addEventListener('change', (s: AppStateStatus) => {
if (s === 'active') {
refreshShortcutLayout()
refreshCustomKeys()
}
})
return () => sub.remove()
}, [refreshShortcutLayout, refreshCustomKeys])
const toggleBuiltInKey = useCallback(
(id: string, visible: boolean) => {
setVisibleBuiltInIds((current) => {
const next = setTerminalAccessoryBuiltInVisible(current, id, visible)
persistLayout(next)
return next
})
},
[persistLayout]
)
const resetBuiltInKeys = useCallback(() => {
const next = resetTerminalAccessoryBuiltInIds()
setVisibleBuiltInIds(next)
persistLayout(next)
}, [persistLayout])
useEffect(() => {
let cancelled = false
@@ -143,6 +272,7 @@ export default function TerminalSettingsScreen() {
}
const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null
const visibleBuiltInSet = useMemo(() => new Set(visibleBuiltInIds), [visibleBuiltInIds])
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
@@ -186,6 +316,76 @@ export default function TerminalSettingsScreen() {
})}
</View>
)}
<Text style={[styles.groupHeading, styles.groupTopGap]}>SHORTCUT BAR</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{TERMINAL_ACCESSORY_KEYS.map((shortcutKey, idx) => (
<View key={shortcutKey.id}>
{idx > 0 && <View style={styles.separator} />}
<ShortcutBarRow
shortcutKey={shortcutKey}
visible={visibleBuiltInSet.has(shortcutKey.id)}
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
/>
</View>
))}
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={resetBuiltInKeys}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Reset Defaults</Text>
<Text style={styles.rowSublabel}>Show every built-in shortcut key</Text>
</View>
</Pressable>
</View>
<Text style={[styles.groupHeading, styles.groupTopGap]}>CUSTOM SHORTCUTS</Text>
<View style={[styles.section, styles.sectionTopGap]}>
{customKeys.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>No custom shortcuts defined yet.</Text>
</View>
) : (
customKeys.map((key, idx) => (
<View key={key.id}>
{idx > 0 && <View style={styles.separator} />}
<View style={styles.row}>
<View style={styles.keycap}>
<Text style={styles.keycapText}>{key.label}</Text>
</View>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>{key.label}</Text>
<Text style={styles.rowSublabel} numberOfLines={1} ellipsizeMode="tail">
{key.bytes.replace(/\r/g, ' ↵')}
</Text>
</View>
<Pressable
style={({ pressed }) => [
styles.deleteButton,
pressed && styles.deleteButtonPressed
]}
onPress={() => handleDeleteCustomKey(key)}
>
<X size={16} color={colors.statusRed} />
</Pressable>
</View>
</View>
))
)}
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => setShowCustomKeyModal(true)}
>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Add Custom Shortcut</Text>
<Text style={styles.rowSublabel}>Create key combo or text macro</Text>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
</ScrollView>
<PickerModal<RestoreValue>
@@ -198,6 +398,14 @@ export default function TerminalSettingsScreen() {
}}
onClose={() => setPickerHostId(null)}
/>
<CustomKeyModal
visible={showCustomKeyModal}
onClose={() => setShowCustomKeyModal(false)}
onKeysChanged={(keys) => {
setCustomKeys(keys)
}}
/>
</View>
)
}
@@ -239,6 +447,9 @@ const styles = StyleSheet.create({
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
groupTopGap: {
marginTop: spacing.xl
},
groupDescription: {
fontSize: typography.bodySize - 1,
color: colors.textSecondary,
@@ -247,7 +458,7 @@ const styles = StyleSheet.create({
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
borderRadius: radii.card,
overflow: 'hidden'
},
sectionTopGap: {
@@ -281,9 +492,38 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
marginTop: 2
},
keycap: {
minWidth: 62,
alignItems: 'center',
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
keycapText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
emptyContainer: {
padding: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
deleteButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(239, 68, 68, 0.1)'
},
deleteButtonPressed: {
backgroundColor: 'rgba(239, 68, 68, 0.2)'
}
})
+1330 -955
View File
File diff suppressed because it is too large Load Diff
+1843 -1350
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -61,6 +61,7 @@ type Props = {
visible: boolean
onClose: () => void
onKeysChanged: (keys: CustomKey[]) => void
onManageShortcuts?: () => void
}
export async function loadCustomKeys(): Promise<CustomKey[]> {
@@ -76,7 +77,7 @@ export async function saveCustomKeys(keys: CustomKey[]): Promise<void> {
await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
}
export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) {
const [step, setStep] = useState<Step>('choose-type')
const [shortcutKey, setShortcutKey] = useState('c')
const [shortcutModifiers, setShortcutModifiers] = useState<TerminalShortcutModifier[]>(['ctrl'])
@@ -216,6 +217,18 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged }: Props) {
<Text style={styles.rowLabel}>Text Macro</Text>
<Text style={styles.rowHint}>Send custom text command</Text>
</Pressable>
{onManageShortcuts ? (
<>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onManageShortcuts}
>
<Text style={styles.rowLabel}>Manage Shortcuts</Text>
<Text style={styles.rowHint}>Show or hide default shortcut keys</Text>
</Pressable>
</>
) : null}
</View>
)}
@@ -4,20 +4,44 @@ import { buildTerminalShortcutKey, TERMINAL_ACCESSORY_KEYS } from './terminal-ac
describe('TERMINAL_ACCESSORY_KEYS', () => {
it('sends reverse-tab with a non-repeatable Shift+Tab key', () => {
const key = TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.label === 'Shift+Tab')
const key = TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.id === 'shiftTab')
expect(key).toEqual({
id: 'shiftTab',
label: 'Shift+Tab',
bytes: '\x1b[Z',
accessibilityLabel: 'Shift Tab'
})
})
it('includes a non-repeatable Enter default key', () => {
expect(TERMINAL_ACCESSORY_KEYS.find((candidate) => candidate.id === 'enter')).toEqual({
id: 'enter',
label: 'Enter',
bytes: '\r',
accessibilityLabel: 'Enter'
})
})
it('has unique non-empty built-in ids', () => {
const ids = TERMINAL_ACCESSORY_KEYS.map((key) => key.id)
expect(ids.every((id) => id.length > 0)).toBe(true)
expect(new Set(ids).size).toBe(ids.length)
})
it('keeps repeat behavior explicit for built-in terminal keys', () => {
const repeatableLabels = new Set(['⌫', 'Del', '↑', '↓', '←', '→'])
const repeatableIds = new Set([
'backspace',
'delete',
'arrowUp',
'arrowDown',
'arrowLeft',
'arrowRight'
])
for (const key of TERMINAL_ACCESSORY_KEYS) {
expect(key.repeatable === true).toBe(repeatableLabels.has(key.label))
expect(key.repeatable === true).toBe(repeatableIds.has(key.id))
}
})
@@ -45,6 +69,11 @@ describe('TERMINAL_ACCESSORY_KEYS', () => {
bytes: '\x1b[Z',
accessibilityLabel: 'Shift Tab'
})
expect(buildTerminalShortcutKey({ key: 'enter', modifiers: [] })).toEqual({
label: 'Enter',
bytes: '\r',
accessibilityLabel: 'Enter'
})
expect(buildTerminalShortcutKey({ key: 'arrowRight', modifiers: ['ctrl', 'shift'] })).toEqual({
label: 'Ctrl+Shift+→',
bytes: '\x1b[1;6C',
+44 -18
View File
@@ -1,4 +1,5 @@
export type TerminalAccessoryKey = {
id: string
label: string
bytes: string
accessibilityLabel?: string
@@ -199,25 +200,50 @@ export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [
}))
export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
{ label: 'Esc', bytes: '\x1b' },
{ label: 'Tab', bytes: '\t' },
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
{ label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
{ label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
{ label: 'Del', bytes: '\x1b[3~', accessibilityLabel: 'Forward delete', repeatable: true },
{ label: '↑', bytes: '\x1b[A', repeatable: true },
{ label: '↓', bytes: '\x1b[B', repeatable: true },
{ label: '←', bytes: '\x1b[D', repeatable: true },
{ label: '→', bytes: '\x1b[C', repeatable: true },
{ label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
{ label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
{ label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
{ label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
{ label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
{ label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
{ label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
{ label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
{ label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
{
id: 'delete',
label: 'Del',
bytes: '\x1b[3~',
accessibilityLabel: 'Forward delete',
repeatable: true
},
{ id: 'arrowUp', label: '', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
{
id: 'arrowDown',
label: '↓',
bytes: '\x1b[B',
accessibilityLabel: 'Arrow Down',
repeatable: true
},
{
id: 'arrowLeft',
label: '←',
bytes: '\x1b[D',
accessibilityLabel: 'Arrow Left',
repeatable: true
},
{
id: 'arrowRight',
label: '→',
bytes: '\x1b[C',
accessibilityLabel: 'Arrow Right',
repeatable: true
},
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
]
export function buildTerminalShortcutKey(
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
createTerminalAccessoryLayoutPreference,
getDefaultTerminalAccessoryBuiltInIds,
getVisibleTerminalAccessoryKeys,
loadTerminalAccessoryLayout,
normalizeTerminalAccessoryLayoutPreference,
resetTerminalAccessoryBuiltInIds,
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible
} from './terminal-accessory-layout'
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn()
}))
vi.mock('@react-native-async-storage/async-storage', () => ({
default: asyncStorageMock
}))
describe('terminal accessory layout', () => {
beforeEach(() => {
asyncStorageMock.getItem.mockReset()
asyncStorageMock.setItem.mockReset()
})
it('defaults include enter', () => {
expect(getDefaultTerminalAccessoryBuiltInIds()).toContain('enter')
expect(getVisibleTerminalAccessoryKeys(getDefaultTerminalAccessoryBuiltInIds())).toContainEqual(
expect.objectContaining({ id: 'enter', bytes: '\r' })
)
})
it('normalizes invalid storage to defaults', () => {
expect(normalizeTerminalAccessoryLayoutPreference(null).visibleBuiltInIds).toEqual(
getDefaultTerminalAccessoryBuiltInIds()
)
expect(
normalizeTerminalAccessoryLayoutPreference({
version: 1,
visibleBuiltInIds: ['escape']
}).visibleBuiltInIds
).toEqual(getDefaultTerminalAccessoryBuiltInIds())
})
it('returns defaults for corrupt or unreadable storage', async () => {
asyncStorageMock.getItem.mockResolvedValueOnce('{')
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds())
)
asyncStorageMock.getItem.mockRejectedValueOnce(new Error('unreadable'))
await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryBuiltInIds())
)
})
it('ignores removed ids and de-dupes visible ids', () => {
expect(
normalizeTerminalAccessoryLayoutPreference({
version: 1,
visibleBuiltInIds: ['escape', 'removed', 'escape', 'tab'],
knownBuiltInIds: getDefaultTerminalAccessoryBuiltInIds()
}).visibleBuiltInIds
).toEqual(['escape', 'tab'])
})
it('appends new defaults only when absent from known ids', () => {
const current = ['escape', 'tab', 'enter']
expect(
normalizeTerminalAccessoryLayoutPreference(
{
version: 1,
visibleBuiltInIds: ['escape'],
knownBuiltInIds: ['escape', 'tab']
},
current
).visibleBuiltInIds
).toEqual(['escape', 'enter'])
expect(
normalizeTerminalAccessoryLayoutPreference(
{
version: 1,
visibleBuiltInIds: ['escape'],
knownBuiltInIds: current
},
current
).visibleBuiltInIds
).toEqual(['escape'])
})
it('keeps hidden known defaults hidden, including an all-hidden layout', () => {
const current = ['escape', 'tab', 'enter']
expect(
normalizeTerminalAccessoryLayoutPreference(
{
version: 1,
visibleBuiltInIds: [],
knownBuiltInIds: current
},
current
).visibleBuiltInIds
).toEqual([])
})
it('toggle and reset helpers preserve built-in order', () => {
expect(setTerminalAccessoryBuiltInVisible(['tab'], 'escape', true, ['escape', 'tab'])).toEqual([
'escape',
'tab'
])
expect(
setTerminalAccessoryBuiltInVisible(['escape', 'tab'], 'escape', false, ['escape', 'tab'])
).toEqual(['tab'])
expect(resetTerminalAccessoryBuiltInIds()).toEqual(getDefaultTerminalAccessoryBuiltInIds())
})
it('saves visible ids with current known built-in ids', async () => {
asyncStorageMock.setItem.mockResolvedValueOnce(undefined)
await saveTerminalAccessoryLayout(['tab', 'tab', 'missing'])
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
JSON.stringify(createTerminalAccessoryLayoutPreference(['tab']))
)
})
it('rejects write failures without mutating helper output', async () => {
asyncStorageMock.setItem.mockRejectedValueOnce(new Error('nope'))
await expect(saveTerminalAccessoryLayout(['escape'])).rejects.toThrow('nope')
expect(createTerminalAccessoryLayoutPreference(['escape']).visibleBuiltInIds).toEqual([
'escape'
])
})
})
@@ -0,0 +1,137 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-accessory-keys'
export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-layout'
export type TerminalAccessoryLayoutPreference = {
version: 1
visibleBuiltInIds: string[]
knownBuiltInIds: string[]
}
function builtInIds(): string[] {
return TERMINAL_ACCESSORY_KEYS.map((key) => key.id)
}
function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreference {
return {
version: 1,
visibleBuiltInIds: [...ids],
knownBuiltInIds: [...ids]
}
}
function stringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null
return value.every((item): item is string => typeof item === 'string') ? value : null
}
function dedupeKnownIds(ids: string[], builtInSet: Set<string>): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const id of ids) {
if (!builtInSet.has(id) || seen.has(id)) continue
seen.add(id)
out.push(id)
}
return out
}
export function getDefaultTerminalAccessoryBuiltInIds(): string[] {
return builtInIds()
}
export function normalizeTerminalAccessoryLayoutPreference(
value: unknown,
currentBuiltInIds = builtInIds()
): TerminalAccessoryLayoutPreference {
const fallback = defaultPreference(currentBuiltInIds)
if (!value || typeof value !== 'object') return fallback
const candidate = value as {
version?: unknown
visibleBuiltInIds?: unknown
knownBuiltInIds?: unknown
}
const visibleInput = stringArray(candidate.visibleBuiltInIds)
const knownInput = stringArray(candidate.knownBuiltInIds)
if (candidate.version !== 1 || !visibleInput || !knownInput) return fallback
const builtInSet = new Set(currentBuiltInIds)
const knownInputSet = new Set(knownInput.filter((id) => builtInSet.has(id)))
const visibleBuiltInIds = dedupeKnownIds(visibleInput, builtInSet)
for (const id of currentBuiltInIds) {
if (!knownInputSet.has(id) && !visibleBuiltInIds.includes(id)) {
visibleBuiltInIds.push(id)
}
}
return {
version: 1,
visibleBuiltInIds,
knownBuiltInIds: [...currentBuiltInIds]
}
}
export function createTerminalAccessoryLayoutPreference(
visibleBuiltInIds: string[],
currentBuiltInIds = builtInIds()
): TerminalAccessoryLayoutPreference {
return {
version: 1,
visibleBuiltInIds: dedupeKnownIds(visibleBuiltInIds, new Set(currentBuiltInIds)),
knownBuiltInIds: [...currentBuiltInIds]
}
}
export function setTerminalAccessoryBuiltInVisible(
visibleBuiltInIds: string[],
id: string,
visible: boolean,
currentBuiltInIds = builtInIds()
): string[] {
const builtInSet = new Set(currentBuiltInIds)
if (!builtInSet.has(id)) {
return createTerminalAccessoryLayoutPreference(visibleBuiltInIds, currentBuiltInIds)
.visibleBuiltInIds
}
const selected = new Set(dedupeKnownIds(visibleBuiltInIds, builtInSet))
if (visible) {
selected.add(id)
} else {
selected.delete(id)
}
return currentBuiltInIds.filter((builtInId) => selected.has(builtInId))
}
export function resetTerminalAccessoryBuiltInIds(): string[] {
return builtInIds()
}
export function getVisibleTerminalAccessoryKeys(
visibleBuiltInIds: string[]
): TerminalAccessoryKey[] {
const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key]))
return dedupeKnownIds(visibleBuiltInIds, new Set(byId.keys())).flatMap((id) => {
const key = byId.get(id)
return key ? [key] : []
})
}
export async function loadTerminalAccessoryLayout(): Promise<TerminalAccessoryLayoutPreference> {
try {
const raw = await AsyncStorage.getItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY)
if (!raw) return defaultPreference()
return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw))
} catch {
return defaultPreference()
}
}
export async function saveTerminalAccessoryLayout(visibleBuiltInIds: string[]): Promise<void> {
const preference = createTerminalAccessoryLayoutPreference(visibleBuiltInIds)
await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference))
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { extractPairingCodeFromUrl } from './pairing'
describe('pairing deep links', () => {
it('extracts the QR pairing code from the hash payload', () => {
expect(extractPairingCodeFromUrl('orca://pair#abc123')).toBe('abc123')
})
it('extracts the pairing code from a query param', () => {
expect(extractPairingCodeFromUrl('orca://pair?code=abc123')).toBe('abc123')
})
it('prefers the query pairing code when both query and hash are present', () => {
expect(extractPairingCodeFromUrl('orca://pair?code=query-code#hash-code')).toBe('query-code')
})
it('ignores empty and unrelated URLs', () => {
expect(extractPairingCodeFromUrl('orca://pair')).toBeNull()
expect(extractPairingCodeFromUrl('https://example.com/pair#abc123')).toBeNull()
})
})
+25 -4
View File
@@ -7,15 +7,36 @@ import { PairingOfferSchema, type PairingOffer } from './types'
export function decodePairingUrl(url: string): PairingOffer | null {
try {
const hashIndex = url.indexOf('#')
if (!url.startsWith('orca://pair') || hashIndex === -1) return null
return decodePairingBase64(url.slice(hashIndex + 1))
const code = extractPairingCodeFromUrl(url)
if (!code) return null
return decodePairingBase64(code)
} catch {
return null
}
}
// Why: accept either an `orca://pair#<base64>` URL or the bare base64
// Why: system camera apps hand us the raw custom-scheme URL. Keeping
// extraction here makes QR scan, paste, and external deep-link flows
// accept the same URL shapes.
export function extractPairingCodeFromUrl(url: string): string | null {
if (!url.startsWith('orca://pair')) return null
const queryIndex = url.indexOf('?')
if (queryIndex !== -1) {
const query = url.slice(queryIndex + 1).split('#')[0] ?? ''
const params = new URLSearchParams(query)
const code = params.get('code')
if (code) {
return code
}
}
const hashIndex = url.indexOf('#')
if (hashIndex !== -1) {
return url.slice(hashIndex + 1) || null
}
return null
}
// Why: accept either an `orca://pair?...` URL or the bare base64
// string so the paste-pair flow can take whichever the user actually
// copied from desktop.
export function parsePairingCode(input: string): PairingOffer | null {
+1 -1
View File
@@ -209,7 +209,7 @@ Wait Options:
Output Options:
--json Emit machine-readable JSON instead of human text
--pairing-code <code> Connect to a remote Orca runtime using an orca://pair#... code
--pairing-code <code> Connect to a remote Orca runtime using an orca://pair?... code
--environment <selector> Connect using a saved environment id or name
--help Show this help message
+1 -1
View File
@@ -209,7 +209,7 @@ function resolveRemotePairing(
if (!pairing) {
throw new RuntimeClientError(
'invalid_argument',
'Invalid remote pairing code. Expected an orca://pair#... URL or bare pairing payload.'
'Invalid remote pairing code. Expected an orca://pair?... URL or bare pairing payload.'
)
}
return pairing
+4 -1
View File
@@ -66,7 +66,10 @@ describe('CLI remote WebSocket transport', () => {
deviceToken: runtime.deviceToken,
publicKeyB64: runtime.publicKeyB64
}
const barePayload = encodePairingOffer(offer).split('#')[1]!
const pairingUrl = encodePairingOffer(offer)
const barePayload = new URLSearchParams(pairingUrl.slice(pairingUrl.indexOf('?') + 1)).get(
'code'
)!
const client = new RuntimeClient('/tmp/unused', 5_000, barePayload)
const status = await client.getCliStatus()
+1 -1
View File
@@ -7,7 +7,7 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [
summary: 'Save a remote Orca runtime environment from a pairing code',
usage: 'orca environment add --name <name> --pairing-code <code> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'name'],
examples: ['orca environment add --name work-laptop --pairing-code orca://pair#...']
examples: ['orca environment add --name work-laptop --pairing-code orca://pair?code=...']
},
{
path: ['environment', 'list'],
@@ -316,7 +316,7 @@ export function RuntimeEnvironmentsPane({
aria-describedby="runtime-server-pairing-code-help"
value={pairingCode}
onChange={(event) => setPairingCode(event.target.value)}
placeholder="orca://pair#..."
placeholder="orca://pair?code=..."
className="h-8 min-w-0 font-mono text-xs"
/>
<p id="runtime-server-pairing-code-help" className="text-xs text-muted-foreground">
+1 -1
View File
@@ -100,7 +100,7 @@ export default function WebConnect({
id="web-runtime-pairing-code"
value={pairingCode}
onChange={(event) => setPairingCode(event.target.value)}
placeholder="orca://pair#..."
placeholder="orca://pair?code=..."
autoComplete="off"
spellCheck={false}
/>
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { parseWebPairingInput, type WebPairingOffer } from './web-pairing'
describe('web pairing input', () => {
const offer: WebPairingOffer = {
v: 2,
endpoint: 'ws://127.0.0.1:6768',
deviceToken: 'token',
publicKeyB64: 'public-key'
}
function encodeOffer() {
return Buffer.from(JSON.stringify(offer), 'utf-8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
it('parses query-form pairing URLs', () => {
expect(parseWebPairingInput(`orca://pair?code=${encodeOffer()}`)).toEqual(offer)
})
it('still parses legacy hash-form pairing URLs', () => {
expect(parseWebPairingInput(`orca://pair#${encodeOffer()}`)).toEqual(offer)
})
})
+8 -1
View File
@@ -15,6 +15,13 @@ export function parseWebPairingInput(input: string): WebPairingOffer | null {
try {
if (trimmed.startsWith('orca://pair')) {
const queryIndex = trimmed.indexOf('?')
if (queryIndex !== -1) {
const query = trimmed.slice(queryIndex + 1).split('#')[0] ?? ''
const params = new URLSearchParams(query)
const code = params.get('code')
return code ? decodePairingPayload(code) : null
}
const hashIndex = trimmed.indexOf('#')
if (hashIndex === -1) {
return null
@@ -88,7 +95,7 @@ function decodePairingPayload(base64url: string): WebPairingOffer | null {
function base64UrlToBytes(value: string): Uint8Array {
const base64 = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=')
const binary = window.atob(padded)
const binary = globalThis.atob(padded)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
+11 -5
View File
@@ -16,7 +16,7 @@ describe('pairing offer', () => {
it('encode then decode round-trips correctly', () => {
const url = encodePairingOffer(offer)
expect(url).toMatch(/^orca:\/\/pair#/)
expect(url).toMatch(/^orca:\/\/pair\?code=/)
const decoded = decodePairingOffer(url)
expect(decoded).toEqual(offer)
@@ -24,18 +24,24 @@ describe('pairing offer', () => {
it('encoded URL uses base64url (no +, /, or = characters)', () => {
const url = encodePairingOffer(offer)
const fragment = url.split('#')[1]!
expect(fragment).not.toMatch(/[+/=]/)
const code = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')!
expect(code).not.toMatch(/[+/=]/)
})
it('rejects URLs with wrong scheme', () => {
expect(() => decodePairingOffer('https://example.com#abc')).toThrow('Invalid pairing URL')
})
it('rejects URLs without fragment', () => {
it('rejects URLs without a pairing code', () => {
expect(() => decodePairingOffer('orca://pair')).toThrow('Invalid pairing URL')
})
it('decodes legacy hash URLs', () => {
const url = encodePairingOffer(offer)
const code = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')!
expect(decodePairingOffer(`orca://pair#${code}`)).toEqual(offer)
})
it('rejects payloads with missing fields', () => {
const partial = { v: 2, endpoint: 'ws://host:1234' }
const base64 = Buffer.from(JSON.stringify(partial)).toString('base64')
@@ -70,7 +76,7 @@ describe('parsePairingCode', () => {
it('parses a bare base64url payload (without scheme prefix)', () => {
const url = encodePairingOffer(offer)
const base64url = url.split('#')[1]!
const base64url = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')!
expect(parsePairingCode(base64url)).toEqual(offer)
})
+28 -6
View File
@@ -20,18 +20,40 @@ export function encodePairingOffer(offer: PairingOffer): string {
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return `orca://pair#${base64url}`
// Why: Android camera intents and Expo Router preserve query params more
// reliably than URL fragments when launching a custom-scheme app.
return `orca://pair?code=${base64url}`
}
export function decodePairingOffer(url: string): PairingOffer {
const hashIndex = url.indexOf('#')
if (!url.startsWith('orca://pair') || hashIndex === -1) {
throw new Error('Invalid pairing URL: must start with orca://pair#')
const code = extractPairingCodeFromUrl(url)
if (!code) {
throw new Error('Invalid pairing URL: must start with orca://pair and include a pairing code')
}
return decodePairingBase64(url.slice(hashIndex + 1))
return decodePairingBase64(code)
}
// Why: accept either an `orca://pair#<base64>` URL or the bare base64
function extractPairingCodeFromUrl(url: string): string | null {
if (!url.startsWith('orca://pair')) {
return null
}
const queryIndex = url.indexOf('?')
if (queryIndex !== -1) {
const query = url.slice(queryIndex + 1).split('#')[0] ?? ''
const params = new URLSearchParams(query)
const code = params.get('code')
if (code) {
return code
}
}
const hashIndex = url.indexOf('#')
if (hashIndex !== -1) {
return url.slice(hashIndex + 1) || null
}
return null
}
// Why: accept either an `orca://pair?...` URL or the bare base64
// string so the mobile paste-pair flow can take whichever the user
// actually copied from desktop.
export function parsePairingCode(input: string): PairingOffer | null {
+1 -1
View File
@@ -42,7 +42,7 @@ export function addEnvironmentFromPairingCode(
if (!offer) {
throw new RuntimeEnvironmentStoreError(
'invalid_argument',
'Invalid pairing code. Expected an orca://pair#... URL or bare pairing payload.'
'Invalid pairing code. Expected an orca://pair?... URL or bare pairing payload.'
)
}
const store = readEnvironmentStore(userDataPath)
+2
View File
@@ -1,3 +1,5 @@
/* eslint-disable max-lines -- Why: telemetry schema tests keep related event
invariants together so cross-event payload rules stay easy to audit. */
// Schema round-trip coverage for the event map. Fail-closed invariants that
// must hold: agent_error is enum-only (error_message / error_stack rejected
// by `.strict()`), unknown enum values fail, and any well-formed payload