chore(mobile): App Store prep — privacy manifest, debug-log cleanup, and protocol-version compat block (#1440)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
e91377f0b2
commit
63e36d05fd
|
|
@ -138,6 +138,37 @@ cd ..
|
|||
pnpm typecheck:node
|
||||
```
|
||||
|
||||
## Protocol Version Compatibility
|
||||
|
||||
Mobile and desktop talk over a versioned protocol. Because mobile updates lag desktop by 24-48h via the App Store, both sides exchange version numbers on `status.get` so a genuinely incompatible combo can hard-block instead of silently misbehaving.
|
||||
|
||||
Constants live in two files (Metro can't resolve outside `mobile/`):
|
||||
|
||||
- `src/shared/protocol-version.ts` — `DESKTOP_PROTOCOL_VERSION`, `MIN_COMPATIBLE_MOBILE_VERSION`
|
||||
- `mobile/src/transport/protocol-version.ts` — `MOBILE_PROTOCOL_VERSION`, `MIN_COMPATIBLE_DESKTOP_VERSION`
|
||||
|
||||
Today all four are set so `evaluateCompat` always returns `{ kind: 'ok' }` — nothing blocks. The wire format is in place to flip a switch when needed.
|
||||
|
||||
### When to bump
|
||||
|
||||
Bump `DESKTOP_PROTOCOL_VERSION` (and the mobile mirror `MOBILE_PROTOCOL_VERSION` when relevant) for **breaking** changes:
|
||||
|
||||
- Removed RPC method or required parameter that mobile uses
|
||||
- Changed meaning (units, nullability) of an existing field mobile reads
|
||||
- Changed encryption, framing, or auth handshake
|
||||
|
||||
Do **not** bump for additive changes:
|
||||
|
||||
- New RPC methods
|
||||
- New optional fields on existing methods
|
||||
- New event types in `terminal.subscribe`
|
||||
|
||||
Set `MIN_COMPATIBLE_MOBILE_VERSION` (kill-switch) when desktop ships a change that requires a minimum mobile version to function safely. Same for `MIN_COMPATIBLE_DESKTOP_VERSION` from the mobile side.
|
||||
|
||||
When a verdict is `blocked`, `mobile/src/components/ProtocolBlockScreen.tsx` renders a screen pointing the user at either the App Store (mobile too old) or GitHub Releases (desktop too old).
|
||||
|
||||
To exercise the block screen locally: set `MIN_COMPATIBLE_DESKTOP_VERSION = 999` in `mobile/src/transport/protocol-version.ts`, rebuild, pair to any desktop. Revert before merging.
|
||||
|
||||
## Mock Server
|
||||
|
||||
Develop the mobile app without a running Orca desktop instance:
|
||||
|
|
|
|||
|
|
@ -22,6 +22,25 @@
|
|||
"NSAppTransportSecurity": {
|
||||
"NSAllowsLocalNetworking": true
|
||||
}
|
||||
},
|
||||
"privacyManifests": {
|
||||
"NSPrivacyTracking": false,
|
||||
"NSPrivacyTrackingDomains": [],
|
||||
"NSPrivacyCollectedDataTypes": [],
|
||||
"NSPrivacyAccessedAPITypes": [
|
||||
{
|
||||
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
|
||||
"NSPrivacyAccessedAPITypeReasons": ["CA92.1"]
|
||||
},
|
||||
{
|
||||
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
|
||||
"NSPrivacyAccessedAPITypeReasons": ["C617.1"]
|
||||
},
|
||||
{
|
||||
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime",
|
||||
"NSPrivacyAccessedAPITypeReasons": ["35F9.1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
|
|
|
|||
|
|
@ -44,8 +44,10 @@ import { PickerModal, type PickerOption } from '../../../src/components/PickerMo
|
|||
import { ActionSheetContent } from '../../../src/components/ActionSheetModal'
|
||||
import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen'
|
||||
import { getCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import { evaluateCompat, type CompatVerdict } from '../../../src/transport/protocol-compat'
|
||||
import {
|
||||
loadPinnedIds,
|
||||
savePinnedIds,
|
||||
|
|
@ -53,6 +55,15 @@ import {
|
|||
savePreferences
|
||||
} from '../../../src/storage/preferences'
|
||||
|
||||
// Why: locally-typed subset of the desktop's RuntimeStatus we read from
|
||||
// `status.get`. Only the version fields matter to mobile today; everything
|
||||
// else is opaque. Both fields are optional since pre-PR desktops won't
|
||||
// return them — the compat evaluator handles undefined gracefully.
|
||||
type DesktopStatus = {
|
||||
protocolVersion?: number
|
||||
minCompatibleMobileVersion?: number
|
||||
}
|
||||
|
||||
type Worktree = {
|
||||
worktreeId: string
|
||||
repo: string
|
||||
|
|
@ -278,6 +289,7 @@ export default function HostScreen() {
|
|||
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
|
||||
const [hostName, setHostName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [compatVerdict, setCompatVerdict] = useState<CompatVerdict>({ kind: 'ok' })
|
||||
const [lastKnownWorktrees, setLastKnownWorktrees] = useState<Worktree[]>(initialCache ?? [])
|
||||
const [search, setSearch] = useState('')
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
|
|
@ -339,6 +351,10 @@ export default function HostScreen() {
|
|||
useEffect(() => {
|
||||
setHostName('')
|
||||
setError('')
|
||||
setCompatVerdict({ kind: 'ok' })
|
||||
// Why: re-seed from the current host's cache on every hostId change.
|
||||
// The useState initializer only runs on first mount, so if Expo Router
|
||||
// reuses this screen with a different hostId, we must reset here.
|
||||
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
|
||||
if (freshCache) {
|
||||
setWorktrees(freshCache)
|
||||
|
|
@ -420,6 +436,47 @@ export default function HostScreen() {
|
|||
}
|
||||
}, [connState, fetchWorktrees])
|
||||
|
||||
// Why: read desktop's protocol version from status.get on every connect
|
||||
// and re-evaluate compatibility. If the desktop declares this mobile
|
||||
// build too old (or vice versa via the local minimum), the host detail
|
||||
// screen swaps to a hard-block screen instead of the worktree list.
|
||||
// Today's compat constants are wide-open so this never blocks; the wire
|
||||
// format is in place to flip a switch in a future release.
|
||||
useEffect(() => {
|
||||
if (connState !== 'connected' || !client) return
|
||||
let cancelled = false
|
||||
const requestClient = client
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await requestClient.sendRequest('status.get')
|
||||
if (cancelled || clientRef.current !== requestClient) return
|
||||
if (!response.ok) return
|
||||
const status = (response as RpcSuccess).result as DesktopStatus
|
||||
const verdict = evaluateCompat({
|
||||
desktopProtocolVersion: status.protocolVersion,
|
||||
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
|
||||
})
|
||||
setCompatVerdict(verdict)
|
||||
if (verdict.kind === 'blocked') {
|
||||
// Why: deterministic breadcrumb so support can confirm a block
|
||||
// actually fired (vs a render bug). No PII — just version ints.
|
||||
console.warn('[protocol-compat] blocked', {
|
||||
reason: verdict.reason,
|
||||
desktopVersion: verdict.desktopVersion,
|
||||
requiredMobileVersion: verdict.requiredMobileVersion,
|
||||
requiredDesktopVersion: verdict.requiredDesktopVersion
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Why: rare path — sendRequest can throw on transport tear-down.
|
||||
// Treat as transient; verdict stays at previous value.
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [connState, client])
|
||||
|
||||
useEffect(() => {
|
||||
if (connState !== 'connected') return
|
||||
const interval = setInterval(() => {
|
||||
|
|
@ -637,6 +694,10 @@ export default function HostScreen() {
|
|||
)
|
||||
}
|
||||
|
||||
if (compatVerdict.kind === 'blocked') {
|
||||
return <ProtocolBlockScreen verdict={compatVerdict} />
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.topChrome}>
|
||||
|
|
|
|||
|
|
@ -226,10 +226,6 @@ export default function SessionScreen() {
|
|||
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
|
||||
subscribeSeqRef.current.set(handle, seq)
|
||||
|
||||
console.log(
|
||||
`[mobile-fit] subscribeToTerminal handle=${handle} seq=${seq} viewport=${viewportRef.current ? `${viewportRef.current.cols}x${viewportRef.current.rows}` : 'none'} measured=${viewportMeasuredRef.current}`
|
||||
)
|
||||
|
||||
// Why: server handles auto-fit on subscribe — no terminal.focus call needed.
|
||||
// The viewport is embedded in the subscribe params so the server resizes
|
||||
// the PTY before serializing scrollback. This eliminates the focus→safeFit
|
||||
|
|
@ -245,9 +241,6 @@ export default function SessionScreen() {
|
|||
if (subscribeSeqRef.current.get(handle) !== seq) return
|
||||
const data = result as Record<string, unknown>
|
||||
if (data.type === 'scrollback') {
|
||||
console.log(
|
||||
`[mobile-fit] scrollback handle=${handle} cols=${data.cols} rows=${data.rows} displayMode=${data.displayMode} hasSerialized=${!!data.serialized} alreadyInit=${initializedHandlesRef.current.has(handle)}`
|
||||
)
|
||||
if (initializedHandlesRef.current.has(handle)) return
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
|
|
@ -292,9 +285,6 @@ export default function SessionScreen() {
|
|||
} else if (data.type === 'data') {
|
||||
getTerminalRef(handle)?.write(data.chunk as string)
|
||||
} else if (data.type === 'resized') {
|
||||
console.log(
|
||||
`[mobile-fit] resized handle=${handle} cols=${data.cols} rows=${data.rows} displayMode=${data.displayMode} reason=${(data as Record<string, unknown>).reason}`
|
||||
)
|
||||
// Why: inline resize event — the server changed the PTY dimensions
|
||||
// (mode toggle or desktop restore). Reinitialize xterm at the new
|
||||
// dims with fresh scrollback. No resubscribe needed.
|
||||
|
|
@ -368,9 +358,6 @@ export default function SessionScreen() {
|
|||
})
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
|
||||
console.log(
|
||||
`[mobile-fit] fetchTerminals count=${result.terminals.length} allowEmpty=${allowEmptyLoaded} activeHandle=${activeHandleRef.current} lastKnown=${lastKnownTerminalCountRef.current}`
|
||||
)
|
||||
|
||||
if (result.terminals.length === 0 && !allowEmptyLoaded) {
|
||||
return
|
||||
|
|
@ -382,9 +369,6 @@ export default function SessionScreen() {
|
|||
// rapid interactions while still allowing genuine cleanup.
|
||||
if (result.terminals.length === 0 && lastKnownTerminalCountRef.current > 0) {
|
||||
lastKnownTerminalCountRef.current = 0
|
||||
console.log(
|
||||
`[mobile-fit] fetchTerminals SKIP first empty — will clear on next fetch if still empty`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -641,9 +625,6 @@ export default function SessionScreen() {
|
|||
const switchTab = useCallback(
|
||||
(handle: string) => {
|
||||
const prev = activeHandleRef.current
|
||||
console.log(
|
||||
`[mobile-fit] switchTab prev=${prev} next=${handle} hasUnsub=${terminalUnsubsRef.current.has(handle)} hasRef=${!!terminalRefs.current.get(handle)}`
|
||||
)
|
||||
activeHandleRef.current = handle
|
||||
setActiveHandle(handle)
|
||||
if (prev && prev !== handle) {
|
||||
|
|
@ -670,9 +651,6 @@ export default function SessionScreen() {
|
|||
const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => {
|
||||
if (ref) {
|
||||
terminalRefs.current.set(handle, ref)
|
||||
console.log(
|
||||
`[mobile-fit] setTerminalWebViewRef handle=${handle} isActive=${handle === activeHandleRef.current} webReady=${webReadyHandlesRef.current.has(handle)}`
|
||||
)
|
||||
} else {
|
||||
terminalRefs.current.delete(handle)
|
||||
}
|
||||
|
|
@ -682,9 +660,6 @@ export default function SessionScreen() {
|
|||
(handle: string) => {
|
||||
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
|
||||
webReadyHandlesRef.current.add(handle)
|
||||
console.log(
|
||||
`[mobile-fit] handleTerminalWebReady handle=${handle} isActive=${handle === activeHandleRef.current} wasAlreadyReady=${wasAlreadyReady} wasInitialized=${initializedHandlesRef.current.has(handle)}`
|
||||
)
|
||||
if (wasAlreadyReady && initializedHandlesRef.current.has(handle)) {
|
||||
// Why: the native WebView reloaded (Metro hot reload or Android
|
||||
// process churn). The old xterm buffer is gone, so force a fresh
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { router } from 'expo-router'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { CompatVerdict } from '../transport/protocol-compat'
|
||||
import { MOBILE_PROTOCOL_VERSION } from '../transport/protocol-version'
|
||||
|
||||
const RELEASES_URL = 'https://github.com/stablyai/orca/releases'
|
||||
|
||||
type Props = {
|
||||
verdict: Extract<CompatVerdict, { kind: 'blocked' }>
|
||||
}
|
||||
|
||||
export function ProtocolBlockScreen({ verdict }: Props) {
|
||||
const isMobileTooOld = verdict.reason === 'mobile-too-old'
|
||||
|
||||
const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca desktop'
|
||||
const body = isMobileTooOld
|
||||
? `The Orca desktop on this host requires Orca Mobile v${verdict.requiredMobileVersion ?? '?'}+. You have v${MOBILE_PROTOCOL_VERSION}.\n\nUpdate Orca Mobile from the App Store to continue.`
|
||||
: `Orca Mobile requires Orca desktop v${verdict.requiredDesktopVersion ?? '?'}+ to use this host. The desktop is reporting v${verdict.desktopVersion}.`
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Text style={styles.body}>{body}</Text>
|
||||
{/* Why: only desktop side has a deep-link target today —
|
||||
App Store ID isn't published yet, so mobile-too-old is text-only. */}
|
||||
{!isMobileTooOld ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
|
||||
onPress={() => {
|
||||
void Linking.openURL(RELEASES_URL)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.primaryButtonText}>Open GitHub Releases</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressed]}
|
||||
onPress={() => {
|
||||
// Why: route back to the host list so the user can pair a
|
||||
// different host instead of getting trapped on this screen.
|
||||
router.replace('/')
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Pair a different host</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.lg
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.card,
|
||||
padding: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
body: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button,
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
primaryButtonText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.bgBase
|
||||
},
|
||||
secondaryButton: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button,
|
||||
alignItems: 'center'
|
||||
},
|
||||
secondaryButtonText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
// Why: this file mirrors src/shared/protocol-compat.ts (which is
|
||||
// covered by CI vitest). Metro can't resolve out of mobile/, so the
|
||||
// pure function is duplicated here. Keep the two files in sync — when
|
||||
// you change the evaluator's logic, update both. The src/shared/ copy
|
||||
// is the tested canonical version.
|
||||
import { MIN_COMPATIBLE_DESKTOP_VERSION, MOBILE_PROTOCOL_VERSION } from './protocol-version'
|
||||
|
||||
export type CompatVerdict =
|
||||
| { kind: 'ok' }
|
||||
| {
|
||||
kind: 'blocked'
|
||||
reason: 'mobile-too-old' | 'desktop-too-old'
|
||||
desktopVersion: number
|
||||
requiredMobileVersion?: number
|
||||
requiredDesktopVersion?: number
|
||||
}
|
||||
|
||||
export function evaluateCompat(input: {
|
||||
desktopProtocolVersion: number | undefined
|
||||
desktopMinCompatibleMobileVersion: number | undefined
|
||||
}): CompatVerdict {
|
||||
const desktopVersion = input.desktopProtocolVersion ?? 0
|
||||
const requiredMobile = input.desktopMinCompatibleMobileVersion ?? 0
|
||||
|
||||
if (MOBILE_PROTOCOL_VERSION < requiredMobile) {
|
||||
return {
|
||||
kind: 'blocked',
|
||||
reason: 'mobile-too-old',
|
||||
desktopVersion,
|
||||
requiredMobileVersion: requiredMobile
|
||||
}
|
||||
}
|
||||
if (desktopVersion < MIN_COMPATIBLE_DESKTOP_VERSION) {
|
||||
return {
|
||||
kind: 'blocked',
|
||||
reason: 'desktop-too-old',
|
||||
desktopVersion,
|
||||
requiredDesktopVersion: MIN_COMPATIBLE_DESKTOP_VERSION
|
||||
}
|
||||
}
|
||||
return { kind: 'ok' }
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
// Why: declares the mobile's pairing protocol version and the minimum
|
||||
// desktop version it can talk to. Duplicates the desktop's
|
||||
// `src/shared/protocol-version.ts` because Metro/Expo doesn't resolve
|
||||
// outside `mobile/`. Manual sync is acceptable — these constants are
|
||||
// expected to bump less than once a quarter.
|
||||
//
|
||||
// Bump MOBILE_PROTOCOL_VERSION when:
|
||||
// - You change the meaning of an RPC mobile sends.
|
||||
// - You stop relying on a desktop-side feature in a way old desktops
|
||||
// would notice.
|
||||
// Do NOT bump for:
|
||||
// - Adding new optional fields to outbound requests.
|
||||
// - Reading new optional fields on incoming responses.
|
||||
//
|
||||
// Bump MIN_COMPATIBLE_DESKTOP_VERSION when mobile starts relying on a
|
||||
// desktop feature added at a specific desktop protocol version. This
|
||||
// triggers a hard-block screen for users paired to older desktops.
|
||||
|
||||
export const MOBILE_PROTOCOL_VERSION = 1
|
||||
export const MIN_COMPATIBLE_DESKTOP_VERSION = 0
|
||||
|
|
@ -35,13 +35,6 @@ export type RpcClient = {
|
|||
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
const HANDSHAKE_TIMEOUT_MS = 5_000
|
||||
// Why: belt-and-suspenders against React Native WebSocket implementations
|
||||
// that occasionally never fire onerror/onclose for an unreachable host
|
||||
// (observed when waking the device with stale DNS). Without this safety
|
||||
// net the UI sat on 'Connecting…' forever and only a Metro reload
|
||||
// recovered. Five seconds is a generous upper bound — a healthy LAN WS
|
||||
// typically connects in <100ms.
|
||||
const CONNECT_TIMEOUT_MS = 5_000
|
||||
|
||||
export function connect(
|
||||
endpoint: string,
|
||||
|
|
@ -55,7 +48,6 @@ export function connect(
|
|||
let reconnectAttempt = 0
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let intentionallyClosed = false
|
||||
|
||||
// Why: fresh ephemeral keypair per connection provides forward secrecy.
|
||||
|
|
@ -107,24 +99,7 @@ export function connect(
|
|||
|
||||
ws = new WebSocket(endpoint)
|
||||
|
||||
connectTimer = setTimeout(() => {
|
||||
connectTimer = null
|
||||
// Why: WS still stuck before 'open' — force-close so onclose fires
|
||||
// and reconnect logic kicks in (rather than sitting on 'connecting'
|
||||
// until the user reloads Metro). Safe even if the WS is mid-open;
|
||||
// close() will trigger the 'closing' → 'closed' transitions.
|
||||
try {
|
||||
ws?.close()
|
||||
} catch {
|
||||
// ignore — onclose will still wire up reconnect
|
||||
}
|
||||
}, CONNECT_TIMEOUT_MS)
|
||||
|
||||
ws.onopen = () => {
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
reconnectAttempt = 0
|
||||
setState('handshaking')
|
||||
|
||||
|
|
@ -267,10 +242,6 @@ export function connect(
|
|||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
}
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
if (intentionallyClosed) {
|
||||
setState('disconnected')
|
||||
rejectAllPending('Connection closed')
|
||||
|
|
@ -411,10 +382,6 @@ export function connect(
|
|||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
}
|
||||
if (connectTimer) {
|
||||
clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
|
|
|
|||
|
|
@ -6,5 +6,6 @@
|
|||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"]
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["**/*.test.ts", "**/*.test.tsx"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,16 @@ describe('OrcaRuntimeService', () => {
|
|||
expect(runtime.getRuntimeId()).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports protocol version and minimum compatible mobile version on status', () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
const status = runtime.getStatus()
|
||||
expect(typeof status.protocolVersion).toBe('number')
|
||||
expect(typeof status.minCompatibleMobileVersion).toBe('number')
|
||||
expect(status.protocolVersion).toBeGreaterThanOrEqual(1)
|
||||
expect(status.minCompatibleMobileVersion).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('claims the first window as authoritative and ignores later windows', () => {
|
||||
const runtime = createRuntime()
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import type {
|
|||
WorktreeStartupLaunch
|
||||
} from '../../shared/types'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import {
|
||||
DESKTOP_PROTOCOL_VERSION,
|
||||
MIN_COMPATIBLE_MOBILE_VERSION
|
||||
} from '../../shared/protocol-version'
|
||||
import type {
|
||||
RuntimeGraphStatus,
|
||||
RuntimeRepoSearchRefs,
|
||||
|
|
@ -594,7 +598,9 @@ export class OrcaRuntimeService {
|
|||
graphStatus: this.graphStatus,
|
||||
authoritativeWindowId: this.authoritativeWindowId,
|
||||
liveTabCount: this.tabs.size,
|
||||
liveLeafCount: this.leaves.size
|
||||
liveLeafCount: this.leaves.size,
|
||||
protocolVersion: DESKTOP_PROTOCOL_VERSION,
|
||||
minCompatibleMobileVersion: MIN_COMPATIBLE_MOBILE_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1350,9 +1356,6 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
this.resizeHeadlessTerminal(ptyId, clampedCols, clampedRows)
|
||||
|
||||
console.log(
|
||||
`[mobile-fit] handleMobileSubscribe notifier=${!!this.notifier} ptyId=${ptyId} mode=mobile-fit cols=${clampedCols} rows=${clampedRows}`
|
||||
)
|
||||
this.notifier?.terminalFitOverrideChanged(ptyId, 'mobile-fit', clampedCols, clampedRows)
|
||||
|
||||
// Why: mobile-fit via resizeForClient is a deliberate mobile action;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { evaluateCompat } from './protocol-compat'
|
||||
|
||||
const MOBILE_V = 1
|
||||
|
||||
describe('evaluateCompat', () => {
|
||||
it('returns ok when both desktop fields are undefined and constants are wide-open', () => {
|
||||
const verdict = evaluateCompat({
|
||||
mobileProtocolVersion: MOBILE_V,
|
||||
minCompatibleDesktopVersion: 0,
|
||||
desktopProtocolVersion: undefined,
|
||||
desktopMinCompatibleMobileVersion: undefined
|
||||
})
|
||||
expect(verdict).toEqual({ kind: 'ok' })
|
||||
})
|
||||
|
||||
it('returns ok when desktop reports version equal to mobile', () => {
|
||||
const verdict = evaluateCompat({
|
||||
mobileProtocolVersion: MOBILE_V,
|
||||
minCompatibleDesktopVersion: 0,
|
||||
desktopProtocolVersion: MOBILE_V,
|
||||
desktopMinCompatibleMobileVersion: 0
|
||||
})
|
||||
expect(verdict).toEqual({ kind: 'ok' })
|
||||
})
|
||||
|
||||
it('returns ok when desktop reports a newer version (additive changes assumed safe)', () => {
|
||||
const verdict = evaluateCompat({
|
||||
mobileProtocolVersion: MOBILE_V,
|
||||
minCompatibleDesktopVersion: 0,
|
||||
desktopProtocolVersion: MOBILE_V + 5,
|
||||
desktopMinCompatibleMobileVersion: 0
|
||||
})
|
||||
expect(verdict).toEqual({ kind: 'ok' })
|
||||
})
|
||||
|
||||
it('blocks with mobile-too-old when desktop requires a newer mobile', () => {
|
||||
const verdict = evaluateCompat({
|
||||
mobileProtocolVersion: MOBILE_V,
|
||||
minCompatibleDesktopVersion: 0,
|
||||
desktopProtocolVersion: 5,
|
||||
desktopMinCompatibleMobileVersion: MOBILE_V + 1
|
||||
})
|
||||
expect(verdict).toEqual({
|
||||
kind: 'blocked',
|
||||
reason: 'mobile-too-old',
|
||||
desktopVersion: 5,
|
||||
requiredMobileVersion: MOBILE_V + 1
|
||||
})
|
||||
})
|
||||
|
||||
it('coerces undefined desktopVersion to 0 in the verdict payload', () => {
|
||||
const verdict = evaluateCompat({
|
||||
mobileProtocolVersion: MOBILE_V,
|
||||
minCompatibleDesktopVersion: 0,
|
||||
desktopProtocolVersion: undefined,
|
||||
desktopMinCompatibleMobileVersion: MOBILE_V + 1
|
||||
})
|
||||
expect(verdict).toMatchObject({
|
||||
kind: 'blocked',
|
||||
reason: 'mobile-too-old',
|
||||
desktopVersion: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks with desktop-too-old when desktop reports below the local minimum', () => {
|
||||
const verdict = evaluateCompat({
|
||||
mobileProtocolVersion: MOBILE_V,
|
||||
minCompatibleDesktopVersion: 5,
|
||||
desktopProtocolVersion: 3,
|
||||
desktopMinCompatibleMobileVersion: 0
|
||||
})
|
||||
expect(verdict).toEqual({
|
||||
kind: 'blocked',
|
||||
reason: 'desktop-too-old',
|
||||
desktopVersion: 3,
|
||||
requiredDesktopVersion: 5
|
||||
})
|
||||
})
|
||||
|
||||
it('mobile-too-old wins precedence when both constraints would fire', () => {
|
||||
// Why: documents the intended kill-switch precedence — desktop's
|
||||
// refusal of a too-old mobile takes priority over mobile's local
|
||||
// refusal of a too-old desktop.
|
||||
const verdict = evaluateCompat({
|
||||
mobileProtocolVersion: MOBILE_V,
|
||||
minCompatibleDesktopVersion: 99,
|
||||
desktopProtocolVersion: -1,
|
||||
desktopMinCompatibleMobileVersion: MOBILE_V + 1
|
||||
})
|
||||
expect(verdict.kind).toBe('blocked')
|
||||
expect((verdict as { reason: string }).reason).toBe('mobile-too-old')
|
||||
})
|
||||
|
||||
it('with minCompatibleDesktopVersion = 0 every reported desktop passes', () => {
|
||||
for (const v of [0, 1, 2, 99]) {
|
||||
expect(
|
||||
evaluateCompat({
|
||||
mobileProtocolVersion: MOBILE_V,
|
||||
minCompatibleDesktopVersion: 0,
|
||||
desktopProtocolVersion: v,
|
||||
desktopMinCompatibleMobileVersion: 0
|
||||
})
|
||||
).toEqual({ kind: 'ok' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
// Why: pure compat evaluator shared between desktop tests and mobile
|
||||
// runtime. Mobile imports a thin wrapper (`mobile/src/transport/protocol-compat.ts`)
|
||||
// that injects the mobile-side constants; desktop tests import this
|
||||
// directly so the function is covered by the root vitest suite.
|
||||
// All four numbers are passed in to keep the function dependency-free.
|
||||
|
||||
export type CompatVerdict =
|
||||
| { kind: 'ok' }
|
||||
| {
|
||||
kind: 'blocked'
|
||||
reason: 'mobile-too-old' | 'desktop-too-old'
|
||||
desktopVersion: number
|
||||
requiredMobileVersion?: number
|
||||
requiredDesktopVersion?: number
|
||||
}
|
||||
|
||||
export function evaluateCompat(input: {
|
||||
mobileProtocolVersion: number
|
||||
minCompatibleDesktopVersion: number
|
||||
desktopProtocolVersion: number | undefined
|
||||
desktopMinCompatibleMobileVersion: number | undefined
|
||||
}): CompatVerdict {
|
||||
// Why: absent fields → 0 lets mobile keep talking to pre-PR desktops.
|
||||
// Bumping minCompatibleDesktopVersion above 0 will fence those older
|
||||
// desktops alongside any explicitly-version-0 desktop, which is the
|
||||
// intended kill-switch behavior.
|
||||
const desktopVersion = input.desktopProtocolVersion ?? 0
|
||||
const requiredMobile = input.desktopMinCompatibleMobileVersion ?? 0
|
||||
|
||||
// Why: mobile-too-old precedence — if desktop says "I refuse this
|
||||
// mobile build" (kill switch), that wins over any local mobile
|
||||
// judgment about desktop's age.
|
||||
if (input.mobileProtocolVersion < requiredMobile) {
|
||||
return {
|
||||
kind: 'blocked',
|
||||
reason: 'mobile-too-old',
|
||||
desktopVersion,
|
||||
requiredMobileVersion: requiredMobile
|
||||
}
|
||||
}
|
||||
if (desktopVersion < input.minCompatibleDesktopVersion) {
|
||||
return {
|
||||
kind: 'blocked',
|
||||
reason: 'desktop-too-old',
|
||||
desktopVersion,
|
||||
requiredDesktopVersion: input.minCompatibleDesktopVersion
|
||||
}
|
||||
}
|
||||
return { kind: 'ok' }
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// Why: declares the desktop's mobile-pairing protocol version so mobile
|
||||
// builds can detect declared-incompatible combos and hard-block at pair
|
||||
// time. Today's values are wide-open (mobile=any, desktop=any), so
|
||||
// nothing actually blocks; the wire format is ready for the day we
|
||||
// ship a genuinely-breaking change.
|
||||
//
|
||||
// Bump DESKTOP_PROTOCOL_VERSION when:
|
||||
// - You remove an RPC method or required parameter that mobile uses.
|
||||
// - You change the meaning (units, nullability) of an existing field
|
||||
// mobile reads.
|
||||
// - You change encryption, framing, or the auth handshake.
|
||||
// Do NOT bump for:
|
||||
// - Adding new RPC methods.
|
||||
// - Adding new optional fields on existing methods.
|
||||
// - Adding new event types in `terminal.subscribe`.
|
||||
//
|
||||
// Bump MIN_COMPATIBLE_MOBILE_VERSION when desktop ships a change that
|
||||
// requires a minimum mobile version to function safely. This is the
|
||||
// "kill switch": desktop can refuse old mobile builds without needing
|
||||
// a desktop release of mobile.
|
||||
|
||||
export const DESKTOP_PROTOCOL_VERSION = 1
|
||||
export const MIN_COMPATIBLE_MOBILE_VERSION = 0
|
||||
|
|
@ -11,6 +11,11 @@ export type RuntimeStatus = {
|
|||
authoritativeWindowId: number | null
|
||||
liveTabCount: number
|
||||
liveLeafCount: number
|
||||
// Why: optional so mobile builds can read both new and pre-PR desktops.
|
||||
// Absence is treated as 0 by mobile's compat evaluator. See
|
||||
// src/shared/protocol-version.ts for bump discipline.
|
||||
protocolVersion?: number
|
||||
minCompatibleMobileVersion?: number
|
||||
}
|
||||
|
||||
export type CliRuntimeState =
|
||||
|
|
|
|||
Loading…
Reference in New Issue