mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 09:27:29 +00:00
* build(mobile): upgrade to Expo SDK 58 and React Native 0.88 Expo 58 drops Zod from @expo/cli, which clears the last Zod 3 advisory (GHSA-mrhx-c7xc-m3w5) in mobile along with its local patch and override. - Expo 55 -> 58, React Native 0.83 -> 0.88, React 19.3, gesture-handler 3, webview 14, reanimated 4.7, worklets 0.13 (SDK 58 bundled versions, pinned below today's batch to respect minimumReleaseAge). - Port the react-native and react-native-webview patches unchanged; drop expo-constants, expo-notifications, @expo/cli, expo-router, zod and query-string patches plus the zod/query-string/xcode>uuid overrides, which upstream fixed or no longer need. - iOS minimum rises to 16.4 (Expo 58 / RN 0.88 requirement). - Migrate to RN 0.88 strict types: instance ref types, NamedStyles, AppState.currentState, list header/footer nulls. - Replace removed runtime APIs: InteractionManager -> idle callback with a macrotask fallback; StyleSheet.absoluteFillObject -> absoluteFill; FileHandle.readBytes -> readBytesSync (it became async). - Resolve react-native/asset-registry for shared assets in Metro and alias the old registry path for react-native-svg in the desktop mobile-web bundle. - Reduce the iOS scene plugin to the reconnect override; Expo 58's template now ships the scene delegate itself. * fix(mobile): bridge react-native-svg web imports and libsodium runtime for RN 0.88 - Share react-native-svg web shims between the mobile-web build and its render checks: map the old asset-registry path to react-native-web's registry and stub the Android-only touch-sound TurboModule. - Patch react-native-libsodium 1.7.0 to read the JSI runtime from the bridge proxy; RN 0.88 removed RCTCxxBridge.runtime. * fix(mobile): use Expo 58's legacy promise resolver for audio permissions * fix(mobile): skip null handles in the RPC recording timer shims for RN 0.88 types * style(mobile): format MobilePRSidebar after rebase * fix(mobile): name RN 0.88 text input refs for declarations; allow queued back in stack render check - Type text input refs with an exported interface so declaration emit can name hook results (RN 0.88 aliases TextInputInstance to an unexported class). - Expo Router 58 drains back() after a commit; check the interrupted push's exit from the frame it starts, bounded to three frames of lag. * chore(mobile): justify the text input handle interface for React Doctor * chore(mobile): use a nameable intersection alias for text input refs * fix(mobile): use sync expo-file-system writes, copies and moves on Expo 58 Expo 58 made File.write/copy/move and FileHandle.writeBytes return promises. Our callers never awaited them, so failures escaped their try/catch and the store's tmp-then-move and PDF writes could race their close or rename. * build(mobile): raise the page bundle budget to 10 MiB for Expo 58 Expo 58 adds about 310 KB to the page: expo-router (+258 KB, now carrying React Navigation, net about +110 KB) and Reanimated 4.7 (+152 KB). Main had 85 KB of headroom left. 10 MiB still stays within the 10 MiB per-asset contract. * fix(mobile): drop the window-metrics seed RN 0.88 removed from Android RN 0.88 no longer holds window metrics: DeviceInfoModule reads the activity's WindowMetrics on demand and setWindowDisplayMetrics is gone, so MainActivity no longer compiled. Layout scale still comes from the screen metrics we reseed. --------- Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
429 lines
15 KiB
TypeScript
429 lines
15 KiB
TypeScript
import * as reticle from '../src/pairing/pairing-scan-reticle-dimensions'
|
|
import { useState, useRef, useCallback, useEffect } from 'react'
|
|
import {
|
|
AppState,
|
|
View,
|
|
Text,
|
|
Pressable,
|
|
ActivityIndicator,
|
|
Linking,
|
|
type LayoutChangeEvent
|
|
} from 'react-native'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import { CameraView, useCameraPermissions } from 'expo-camera'
|
|
import { useRouter } from 'expo-router'
|
|
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
|
|
import { decodePairingUrl, parsePairingCode } from '../src/pairing/pairing'
|
|
import { getPairingScanErrorMessage } from '../src/pairing/pairing-scan-error-message'
|
|
import {
|
|
startPreProfilePairing,
|
|
type PreProfilePairingAttempt
|
|
} from '../src/pairing/pre-profile-pairing-coordinator'
|
|
import type { ConnectionLogEntry } from '../src/diagnostics/connection/connection-log-types'
|
|
import type { PairingOffer } from '../../src/shared/remote-runtime/pairing/mobile-relay-pairing-offer'
|
|
import { useRefreshHostClient } from '../src/transport/connection/host-client-hooks'
|
|
import { colors, spacing, pressedStyle } from '../src/theme/mobile-theme'
|
|
import { useMobileColorScheme } from '../src/theme/use-mobile-color-scheme'
|
|
import { useMobileTranslation } from '../src/i18n/use-mobile-translation'
|
|
import { TextInputModal } from '../src/components/TextInputModal'
|
|
import { ConnectionLog } from '../src/components/ConnectionLog'
|
|
import {
|
|
loadMobileOnboardingSteps,
|
|
mobileOnboardingDestination
|
|
} from '../src/onboarding/mobile-onboarding-plan'
|
|
import { pairScanStyles as styles } from '../src/pair-scan-styles'
|
|
import {
|
|
PAIRING_SUCCESS_HOLD_MS,
|
|
PairingProgress,
|
|
SCAN_DECODE_BEAT_MS,
|
|
ScanReticle,
|
|
Step,
|
|
waitForBeat
|
|
} from '../src/pairing/pairing-visuals'
|
|
import { triggerSuccess } from '../src/platform/haptics'
|
|
import { getPairingFailureMessage } from '../src/pairing/pairing-failure-message'
|
|
|
|
// Bound initial pairing at ~25s (one connect timeout plus retry) so broken routes show an actionable error/log; live RPC retries indefinitely.
|
|
const PAIRING_OVERALL_TIMEOUT_MS = 25_000
|
|
|
|
export default function PairScanScreen() {
|
|
useMobileColorScheme()
|
|
const t = useMobileTranslation()
|
|
const router = useRouter()
|
|
const refreshHostClient = useRefreshHostClient()
|
|
const insets = useSafeAreaInsets()
|
|
const [permission, requestPermission, getPermission] = useCameraPermissions()
|
|
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
|
|
const [decoded, setDecoded] = useState(false)
|
|
const [paired, setPaired] = useState(false)
|
|
const [errorMessage, setErrorMessage] = useState('')
|
|
const [pasteVisible, setPasteVisible] = useState(false)
|
|
const [cameraBounds, setCameraBounds] = useState({ width: 0, height: 0 })
|
|
const [logs, setLogs] = useState<ConnectionLogEntry[]>([])
|
|
const logsRef = useRef<ConnectionLogEntry[]>([])
|
|
const processingRef = useRef(false)
|
|
const mountedRef = useRef(true)
|
|
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
|
|
|
|
useEffect(() => {
|
|
const subscription = AppState.addEventListener('change', (state) => {
|
|
if (state === 'active') {
|
|
void getPermission().catch(() => {})
|
|
}
|
|
})
|
|
return () => subscription.remove()
|
|
}, [getPermission])
|
|
|
|
const setPairScanRootRef = useCallback((node: unknown): void => {
|
|
if (node !== null) {
|
|
mountedRef.current = true
|
|
return
|
|
}
|
|
// Dispose pairing on route detach: attempts can outlive the visible screen, before passive cleanup.
|
|
mountedRef.current = false
|
|
activePairingAttemptRef.current?.dispose()
|
|
activePairingAttemptRef.current = null
|
|
}, [])
|
|
|
|
const handleBarCodeScanned = useCallback(
|
|
({ data }: { data: string }) => {
|
|
if (processingRef.current) {
|
|
return
|
|
}
|
|
processingRef.current = true
|
|
|
|
const offer = decodePairingUrl(data)
|
|
if (!offer) {
|
|
setStatus('error')
|
|
setErrorMessage(getPairingScanErrorMessage(data, t))
|
|
processingRef.current = false
|
|
return
|
|
}
|
|
|
|
setDecoded(true)
|
|
void waitForBeat(SCAN_DECODE_BEAT_MS).then(() => testAndSave(offer))
|
|
},
|
|
[router, t]
|
|
)
|
|
|
|
const handlePasteSubmit = useCallback(
|
|
(input: string) => {
|
|
setPasteVisible(false)
|
|
if (processingRef.current) {
|
|
return
|
|
}
|
|
processingRef.current = true
|
|
|
|
const offer = parsePairingCode(input)
|
|
if (!offer) {
|
|
setStatus('error')
|
|
setErrorMessage(
|
|
t(
|
|
'app.pairScan.invalidPairingCode',
|
|
'Not a valid pairing code — copy it from your computer and paste again'
|
|
)
|
|
)
|
|
processingRef.current = false
|
|
return
|
|
}
|
|
|
|
void testAndSave(offer)
|
|
},
|
|
[t]
|
|
)
|
|
|
|
const handleCameraLayout = useCallback((event: LayoutChangeEvent) => {
|
|
const { width, height } = event.nativeEvent.layout
|
|
const nextBounds = {
|
|
width: Math.round(width),
|
|
height: Math.round(height)
|
|
}
|
|
setCameraBounds((currentBounds) =>
|
|
currentBounds.width === nextBounds.width && currentBounds.height === nextBounds.height
|
|
? currentBounds
|
|
: nextBounds
|
|
)
|
|
}, [])
|
|
|
|
async function testAndSave(offer: PairingOffer) {
|
|
// A decoded QR waits out the reticle beat; the screen may have left meanwhile.
|
|
if (!mountedRef.current) {
|
|
return
|
|
}
|
|
setStatus('connecting')
|
|
logsRef.current = []
|
|
setLogs([])
|
|
activePairingAttemptRef.current?.dispose()
|
|
|
|
const attempt = startPreProfilePairing({
|
|
offer,
|
|
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
|
|
connectOptions: {
|
|
onLog: (entry) => {
|
|
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
|
|
return
|
|
}
|
|
logsRef.current = [...logsRef.current, entry]
|
|
setLogs(logsRef.current)
|
|
}
|
|
}
|
|
})
|
|
activePairingAttemptRef.current = attempt
|
|
try {
|
|
const { hostId } = await attempt.result
|
|
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
|
attempt.dispose()
|
|
if (activePairingAttemptRef.current === attempt) {
|
|
activePairingAttemptRef.current = null
|
|
}
|
|
if (!mountedRef.current || !attemptIsCurrent) {
|
|
return
|
|
}
|
|
// Re-pair reuses host id; close cached client so it reloads the persisted endpoint/relay (STA-1840).
|
|
refreshHostClient(hostId)
|
|
setPaired(true)
|
|
triggerSuccess()
|
|
const [onboardingSteps] = await Promise.all([
|
|
loadMobileOnboardingSteps(),
|
|
waitForBeat(PAIRING_SUCCESS_HOLD_MS)
|
|
])
|
|
if (!mountedRef.current) {
|
|
return
|
|
}
|
|
router.replace(mobileOnboardingDestination(onboardingSteps, hostId))
|
|
} catch (err) {
|
|
const timedOut = attempt.timedOut
|
|
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
|
attempt.dispose()
|
|
if (activePairingAttemptRef.current === attempt) {
|
|
activePairingAttemptRef.current = null
|
|
}
|
|
if (!mountedRef.current || !attemptIsCurrent) {
|
|
return
|
|
}
|
|
console.warn('[pair] connect failed', err)
|
|
setStatus('error')
|
|
setErrorMessage(
|
|
getPairingFailureMessage({
|
|
screen: 'scan',
|
|
error: err,
|
|
timedOut,
|
|
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
|
|
t
|
|
})
|
|
)
|
|
processingRef.current = false
|
|
}
|
|
}
|
|
|
|
function retry() {
|
|
setStatus('scanning')
|
|
setDecoded(false)
|
|
setErrorMessage('')
|
|
logsRef.current = []
|
|
setLogs([])
|
|
processingRef.current = false
|
|
}
|
|
|
|
// Include Android three-button/iOS home-indicator inset so paste button stays clear.
|
|
const containerPadding = {
|
|
paddingTop: insets.top + spacing.sm,
|
|
paddingBottom: insets.bottom + spacing.sm
|
|
}
|
|
// Keep QR guide square even on rectangular iPad previews.
|
|
const reticleSize = Math.min(
|
|
Math.round(Math.min(cameraBounds.width, cameraBounds.height) * reticle.SCALE),
|
|
reticle.MAX_SIZE
|
|
)
|
|
|
|
if (!permission) {
|
|
return (
|
|
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
|
|
<ActivityIndicator color={colors.textSecondary} />
|
|
</View>
|
|
)
|
|
}
|
|
|
|
if (!permission.granted && status === 'scanning') {
|
|
const canAskAgain = permission.canAskAgain !== false
|
|
return (
|
|
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
|
|
<Pressable
|
|
style={({ pressed }) => [styles.backButton, pressed && pressedStyle]}
|
|
onPress={() => router.back()}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('common.back', 'Back')}
|
|
>
|
|
<ChevronLeft size={22} color={colors.textSecondary} />
|
|
</Pressable>
|
|
<View style={styles.centered}>
|
|
<Text style={styles.title}>
|
|
{canAskAgain
|
|
? t('app.pairScan.title', 'Pair with desktop')
|
|
: t('app.pairScan.cameraDisabledTitle', 'Camera Access Disabled')}
|
|
</Text>
|
|
<Text style={styles.subtitle}>
|
|
{canAskAgain
|
|
? t(
|
|
'app.pairScan.cameraPermissionDescription',
|
|
'Scan the QR code from Orca on your desktop, or paste the pairing code instead.'
|
|
)
|
|
: t(
|
|
'app.pairScan.cameraDisabledDescription',
|
|
'Enable camera access in Settings, or paste the pairing code instead.'
|
|
)}
|
|
</Text>
|
|
<Pressable
|
|
style={({ pressed }) => [styles.primaryButton, pressed && pressedStyle]}
|
|
onPress={canAskAgain ? requestPermission : () => void Linking.openSettings()}
|
|
>
|
|
{canAskAgain && <QrCode size={16} color={colors.bgBase} />}
|
|
<Text style={styles.primaryButtonText}>
|
|
{canAskAgain
|
|
? t('common.continue', 'Continue')
|
|
: t('app.pairScan.openSettings', 'Open Settings')}
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={({ pressed }) => [styles.pasteButton, pressed && styles.pasteButtonPressed]}
|
|
onPress={() => setPasteVisible(true)}
|
|
>
|
|
<ClipboardIcon size={16} color={colors.textSecondary} />
|
|
<Text style={styles.pasteButtonText}>
|
|
{t('app.pairScan.pasteInstead', 'Paste code instead')}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
<TextInputModal
|
|
visible={pasteVisible}
|
|
title={t('app.pairScan.pasteCodeTitle', 'Paste pairing code')}
|
|
message={t(
|
|
'app.pairScan.pasteCodeMessage',
|
|
'Copy the code shown under the QR on your computer.'
|
|
)}
|
|
placeholder={t(
|
|
'app.pairScan.pasteCodePlaceholder',
|
|
'orca://pair?code=... or paste the code'
|
|
)}
|
|
onSubmit={handlePasteSubmit}
|
|
onCancel={() => setPasteVisible(false)}
|
|
/>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
|
|
<Pressable
|
|
style={({ pressed }) => [styles.backButton, pressed && pressedStyle]}
|
|
onPress={() => router.back()}
|
|
hitSlop={8}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('common.back', 'Back')}
|
|
>
|
|
<ChevronLeft size={22} color={colors.textSecondary} />
|
|
</Pressable>
|
|
|
|
<View style={styles.steps}>
|
|
<Step number={1} text={t('app.pairScan.openDesktopStep', 'Open Orca on your computer')} />
|
|
<Step
|
|
number={2}
|
|
text={t('app.pairScan.openMobileSettingsStep', 'Go to Settings → Mobile')}
|
|
/>
|
|
<Step number={3} text={t('app.pairScan.scanCodeStep', 'Scan the QR code')} />
|
|
</View>
|
|
|
|
{status === 'scanning' && (
|
|
<>
|
|
{/* Pause camera behind paste sheet to save power and prevent unnoticed scans. */}
|
|
{!pasteVisible && (
|
|
<View style={styles.cameraWrap} onLayout={handleCameraLayout}>
|
|
<CameraView
|
|
style={styles.camera}
|
|
facing="back"
|
|
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
|
onBarcodeScanned={handleBarCodeScanned}
|
|
/>
|
|
<ScanReticle size={reticleSize} decoded={decoded} />
|
|
</View>
|
|
)}
|
|
{pasteVisible && <View style={styles.cameraPlaceholder} />}
|
|
<Pressable
|
|
style={({ pressed }) => [styles.pasteButton, pressed && styles.pasteButtonPressed]}
|
|
onPress={() => setPasteVisible(true)}
|
|
>
|
|
<ClipboardIcon size={16} color={colors.textSecondary} />
|
|
<Text style={styles.pasteButtonText}>
|
|
{t('app.pairScan.pastePairingCode', 'Or paste pairing code')}
|
|
</Text>
|
|
</Pressable>
|
|
</>
|
|
)}
|
|
|
|
{status === 'connecting' && (
|
|
<View style={styles.centered}>
|
|
<PairingProgress
|
|
succeeded={paired}
|
|
connectingLabel={t('app.pairScan.connecting', 'Connecting…')}
|
|
labelStyle={styles.connectingText}
|
|
/>
|
|
<View style={styles.logSlot}>
|
|
<ConnectionLog entries={logs} title={t('app.pairScan.pairingLog', 'Pairing log')} />
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{status === 'error' && (
|
|
<View style={styles.centered}>
|
|
<Text style={styles.errorText}>{errorMessage}</Text>
|
|
{logs.length > 0 && (
|
|
<View style={styles.logSlot}>
|
|
<ConnectionLog entries={logs} title={t('app.pairScan.pairingLog', 'Pairing log')} />
|
|
</View>
|
|
)}
|
|
<View style={styles.errorActions}>
|
|
<Pressable
|
|
style={({ pressed }) => [styles.primaryButton, pressed && pressedStyle]}
|
|
onPress={retry}
|
|
>
|
|
<Text style={styles.primaryButtonText}>
|
|
{t('app.pairScan.tryAgain', 'Try Again')}
|
|
</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={({ pressed }) => [
|
|
styles.secondaryButton,
|
|
pressed && styles.pasteButtonPressed
|
|
]}
|
|
onPress={() => {
|
|
retry()
|
|
setPasteVisible(true)
|
|
}}
|
|
>
|
|
<Text style={styles.secondaryButtonText}>
|
|
{t('app.pairScan.pasteInstead', 'Paste code instead')}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
<TextInputModal
|
|
visible={pasteVisible}
|
|
title={t('app.pairScan.pasteCodeTitle', 'Paste pairing code')}
|
|
message={t(
|
|
'app.pairScan.pasteCodeMessage',
|
|
'Copy the code shown under the QR on your computer.'
|
|
)}
|
|
placeholder={t(
|
|
'app.pairScan.pasteCodePlaceholder',
|
|
'orca://pair?code=... or paste the code'
|
|
)}
|
|
onSubmit={handlePasteSubmit}
|
|
onCancel={() => setPasteVisible(false)}
|
|
/>
|
|
</View>
|
|
)
|
|
}
|