From 63e36d05fd2e461be4fcc52d150d983f91101de2 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 5 May 2026 19:23:56 -0700 Subject: [PATCH] =?UTF-8?q?chore(mobile):=20App=20Store=20prep=20=E2=80=94?= =?UTF-8?q?=20privacy=20manifest,=20debug-log=20cleanup,=20and=20protocol-?= =?UTF-8?q?version=20compat=20block=20(#1440)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Orca --- mobile/README.md | 31 +++++ mobile/app.json | 19 ++++ mobile/app/h/[hostId]/index.tsx | 61 ++++++++++ .../app/h/[hostId]/session/[worktreeId].tsx | 25 ---- mobile/src/components/ProtocolBlockScreen.tsx | 105 +++++++++++++++++ mobile/src/transport/protocol-compat.ts | 42 +++++++ mobile/src/transport/protocol-version.ts | 20 ++++ mobile/src/transport/rpc-client.ts | 33 ------ mobile/tsconfig.json | 3 +- src/main/runtime/orca-runtime.test.ts | 10 ++ src/main/runtime/orca-runtime.ts | 11 +- src/shared/protocol-compat.test.ts | 107 ++++++++++++++++++ src/shared/protocol-compat.ts | 50 ++++++++ src/shared/protocol-version.ts | 23 ++++ src/shared/runtime-types.ts | 5 + 15 files changed, 482 insertions(+), 63 deletions(-) create mode 100644 mobile/src/components/ProtocolBlockScreen.tsx create mode 100644 mobile/src/transport/protocol-compat.ts create mode 100644 mobile/src/transport/protocol-version.ts create mode 100644 src/shared/protocol-compat.test.ts create mode 100644 src/shared/protocol-compat.ts create mode 100644 src/shared/protocol-version.ts diff --git a/mobile/README.md b/mobile/README.md index 4d9181b16..ff03e6b91 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -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: diff --git a/mobile/app.json b/mobile/app.json index c04f02882..7656263a4 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -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": { diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 7fd24db83..342077407 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -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({ kind: 'ok' }) const [lastKnownWorktrees, setLastKnownWorktrees] = useState(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 + } + return ( diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 8c7b8f82a..196a2f9fa 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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 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).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 diff --git a/mobile/src/components/ProtocolBlockScreen.tsx b/mobile/src/components/ProtocolBlockScreen.tsx new file mode 100644 index 000000000..368976676 --- /dev/null +++ b/mobile/src/components/ProtocolBlockScreen.tsx @@ -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 +} + +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 ( + + + {title} + {body} + {/* 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 ? ( + [styles.primaryButton, pressed && styles.pressed]} + onPress={() => { + void Linking.openURL(RELEASES_URL) + }} + > + Open GitHub Releases + + ) : null} + [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('/') + }} + > + Pair a different host + + + + ) +} + +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 + } +}) diff --git a/mobile/src/transport/protocol-compat.ts b/mobile/src/transport/protocol-compat.ts new file mode 100644 index 000000000..12dc0de40 --- /dev/null +++ b/mobile/src/transport/protocol-compat.ts @@ -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' } +} diff --git a/mobile/src/transport/protocol-version.ts b/mobile/src/transport/protocol-version.ts new file mode 100644 index 000000000..9445c1f28 --- /dev/null +++ b/mobile/src/transport/protocol-version.ts @@ -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 diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 9bbeb5ee3..ed47bbdb2 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -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 | null = null let handshakeTimer: ReturnType | null = null - let connectTimer: ReturnType | 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 diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json index c07b7787f..6aea44464 100644 --- a/mobile/tsconfig.json +++ b/mobile/tsconfig.json @@ -6,5 +6,6 @@ "@/*": ["./src/*"] } }, - "include": ["**/*.ts", "**/*.tsx"] + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["**/*.test.ts", "**/*.test.tsx"] } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 20d66814f..53b26ca19 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9e5075917..a61d0498e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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; diff --git a/src/shared/protocol-compat.test.ts b/src/shared/protocol-compat.test.ts new file mode 100644 index 000000000..1798e97fc --- /dev/null +++ b/src/shared/protocol-compat.test.ts @@ -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' }) + } + }) +}) diff --git a/src/shared/protocol-compat.ts b/src/shared/protocol-compat.ts new file mode 100644 index 000000000..72477cfb4 --- /dev/null +++ b/src/shared/protocol-compat.ts @@ -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' } +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts new file mode 100644 index 000000000..e82329913 --- /dev/null +++ b/src/shared/protocol-version.ts @@ -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 diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index be8adc809..5b3a6fed2 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -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 =