Files
orca/mobile/src/components/ProtocolBlockScreen.tsx
T
Jinwoo Hong 2d31941286 fix(mobile): the update-mobile wall opens the exact release (#23789)
* fix(mobile): the update-mobile wall opens the exact release

When the in-app checker knows the newest release, the update-mobile wall
offers "Get Orca <version>" and opens that release's page instead of the
generic releases list or a hand-built App Store link. Mounting the wall
runs the single-flight, bounded checker once. Dismissal is ignored here.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): keep the update checker out of the page closure

The wall now takes the offered release as a prop and opens it through
the external-link seam. A shell-only hook runs the checker once per
update-mobile wall and supplies the release; MobileWebShellScreen, which
no page route reaches, wires it. HostProtocolGate is in every page
closure, so it stays unwired pending a ruling.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): platform-split the wall's release offer and throttle its check

The hook is the one module whose behaviour differs by platform: the native
file runs the checker, the .web.ts sibling offers nothing, so both
HostProtocolGate and MobileWebShellScreen wire it the same way and the
page closure never carries the checker. A remount checks only when no
release is known and the last check is absent or over the retry interval
old, since each check is an unauthenticated GitHub call.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): let the checker decide whether the wall's check is due

The hook's own throttle read lastCheckedAt, which moves only on success,
so after a failed lookup every wall remount looked up again, and before
preferences loaded a cold-start wall looked up despite a fresh stored
result. The checker already owns the cadence: checkIfDue waits for the
stored state and checks only past the retry or daily interval.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): the wall reads the known release and triggers nothing

The started checker's own timer, cold-start and foreground paths already
run every due check, so a check requested by the wall could never be due.
The hook now only returns the known release; the checker is back to main.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* refactor(mobile): the wall reads its release through useWallAppUpdate

The .web.ts sibling alone keeps the page closure clean, so the screen
calls the hook itself and the optional prop, the gate wiring and the
shell wiring go. HostProtocolGate and MobileWebShellScreen are back to
their base content.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-29 19:02:40 -04:00

189 lines
6.9 KiB
TypeScript

import { useWallAppUpdate } from '../app-update/use-wall-app-update'
import { openExternalLink } from '../platform/external-link'
import { useRouteHandoff } from '../navigation/route-handoff'
import { Platform, Pressable, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { CompatVerdict } from '../transport/protocol-compat'
import type { MobileWebBundleCompatVerdict } from '../transport/mobile-web-bundle-compat'
const RELEASES_URL = 'https://github.com/stablyai/orca/releases'
const IOS_APP_STORE_URL = 'itms-apps://apps.apple.com/app/orca-ide/id6766130217'
/** Every wall this screen renders: the protocol one and the bundle one. Both are terminal — there
* is no native workspace to fall back to, so the only way out is updating one of the two apps. */
export type BlockedVerdict =
| Extract<CompatVerdict, { kind: 'blocked' }>
| Extract<MobileWebBundleCompatVerdict, { kind: 'blocked' }>
type Props = {
verdict: BlockedVerdict
}
const DESKTOP_TOO_OLD_BODY =
'This paired desktop app is too old for your current Orca Mobile app. Update Orca on your computer, then try this host again.'
/** What clears the wall. `refresh-bundle` is the one that no store can: the cached workspace is
* older than this host's client floor, so a download fixes it and an app update does not. */
type BlockRemedy = 'update-mobile' | 'update-desktop' | 'refresh-bundle'
function blockRemedy(verdict: BlockedVerdict): BlockRemedy {
switch (verdict.reason) {
case 'mobile-too-old':
case 'bundle-shell-too-old':
return 'update-mobile'
case 'desktop-too-old':
case 'bundle-unavailable':
return 'update-desktop'
case 'bundle-incompatible':
return verdict.side === 'desktop' ? 'update-desktop' : 'refresh-bundle'
}
}
function blockTitle(remedy: BlockRemedy): string {
switch (remedy) {
case 'update-mobile':
return 'Update Orca Mobile'
case 'update-desktop':
return 'Update Orca on your computer'
case 'refresh-bundle':
return 'Refresh the mobile workspace'
}
}
function blockBody(verdict: BlockedVerdict, remedy: BlockRemedy, storeName: string): string {
if (remedy === 'refresh-bundle') {
return 'The workspace cached for this host is older than the desktop expects. Reconnect to this host to download the current one.'
}
if (verdict.reason === 'mobile-too-old') {
return `This desktop needs a newer Orca Mobile app. Update Orca Mobile from ${storeName}, then try this host again.`
}
if (verdict.reason === 'bundle-unavailable') {
return 'This paired desktop app does not include the mobile workspace yet. Update Orca on your computer, then try this host again.'
}
if (remedy === 'update-mobile') {
return `This desktop's mobile workspace needs a newer Orca Mobile app. Update Orca Mobile from ${storeName}, then try this host again.`
}
return DESKTOP_TOO_OLD_BODY
}
export function ProtocolBlockScreen({ verdict }: Props) {
const router = useRouteHandoff()
const mobileUpdate = useWallAppUpdate()
const remedy = blockRemedy(verdict)
// Why: Android APKs ship through GitHub Releases until a Play Store listing exists.
const mobileUpdateTarget =
Platform.OS === 'ios'
? { label: 'Open App Store', url: IOS_APP_STORE_URL, storeName: 'the App Store' }
: { label: 'Open GitHub Releases', url: RELEASES_URL, storeName: 'GitHub Releases' }
const mobileAction = mobileUpdate
? { label: `Get Orca ${mobileUpdate.version}`, url: mobileUpdate.url }
: { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url }
// No download to offer when the fix is a refetch: reconnecting is what this screen leaves you to do.
const primaryAction =
remedy === 'refresh-bundle'
? null
: remedy === 'update-mobile'
? mobileAction
: { label: 'Open GitHub Releases', url: RELEASES_URL }
const title = blockTitle(remedy)
const body = blockBody(verdict, remedy, mobileUpdateTarget.storeName)
const recoveryNote =
remedy === 'refresh-bundle'
? 'If this message stays, remove this host and pair it again.'
: 'Already updated? Go back to Hosts and refresh the connection. If this message stays, remove this host and pair it again.'
return (
<View style={styles.container}>
<View style={styles.card}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.body}>{body}</Text>
{primaryAction ? (
<Pressable
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
onPress={() => {
// The seam: this screen is in the tasks page closure, where react-native's `openURL`
// calls a `window.open` both shells refuse and resolves anyway.
openExternalLink(primaryAction.url)
}}
>
<Text style={styles.primaryButtonText}>{primaryAction.label}</Text>
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressed]}
onPress={() => {
// The handoff, not expo-router's singleton: `/` is the phone's home screen and the
// page does not carry it, so inside the shell a singleton replace renders the root
// route in the WebView rather than leaving it. This posts the target to the shell.
router.replace('/')
}}
>
<Text style={styles.secondaryButtonText}>Back to hosts</Text>
</Pressable>
<Text style={styles.recoveryNote}>{recoveryNote}</Text>
</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
},
recoveryNote: {
fontSize: typography.metaSize,
color: colors.textMuted,
lineHeight: 17,
marginTop: spacing.md
},
pressed: {
opacity: 0.7
}
})