mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
* feat(mobile): tell users when a newer app binary is installable With OTA page updates, store releases get rare and users stop looking. The shell now asks the channel that installed it. Android sideload reads GitHub's mobile-android-v* tag refs and proves the release has an APK. iOS reads the App Store lookup. A home card above Desktops, dismissible per version, and Settings rows surface the result. The check runs on the desktop updater's cadence: cold start, foreground once 24 h have passed, and a 1 h retry after a failure. The releases atom feed was not used because it lists only the 10 newest releases, which are all desktop builds, so it never carries a mobile tag. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): parse update replies with zod schemas The anti-slop gate refuses Reflect.get on dynamic input. The GitHub refs, the release, the App Store lookup and the stored update record are now parsed into named schemas before they are read. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * docs(mobile): say why the Android update source reads tag refs Record why the Android source reads tag refs. The releases atom feed and /releases?per_page=100 are both newest-first windows that desktop releases fill. Either would silently report "current" once a run of desktop builds pushes the newest mobile release out. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): load update state once and apply review rulings Every check and dismissal now awaits one shared store load. This replaces the merge that guessed whether a check had landed during the load. A manual check that fails while the store loads therefore keeps its 1 h retry instead of re-checking at once. - checking is derived from the in-flight check. - start() uses a per-start flag, so a StrictMode double start applies one load. - A check that finishes after stop() writes nothing. - A corrupt stored update record loses only itself. - Tag refs are parsed with a single schema. - The runtime wiring is folded into one file, and the card moves to home/. - The recorded App Store fixture is oxfmt-formatted, with the same parsed value. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): keep the update timer armed across a stop and restart A check that stayed in flight across stop and restart returned 'failed' without rescheduling. The restart skipped arming because a check was in flight, which left a live checker with no timer until the next foreground. The stop counter is removed. schedule() already arms nothing while no start is active, and saving a real result after a stop is harmless. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * chore: retrigger CI after the RPC recording repin (#23757) landed on main Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): trim the update checker and Settings rows - The load sets prefs and the due time only. start() re-arms the schedule after it. - The Settings result hides through one effect keyed on the result. - onUpdate receives the URL. - The version row is bound once. - The retry and timeout constants are no longer exported. - The unused AppUpdateChecker type is deleted. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): run the update check when its timer fires The armed timer is the due time. Re-checking the wall clock when the timer fired meant a clock stepped back skipped the check and re-armed nothing. The due-time guard now applies only on foreground. The binary version still comes from expoConfig.version. SDK 55 removed Constants.nativeAppVersion, so the no-expo-updates invariant is now named in the comment. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): recover from a future check time and use Apple's page URL If the device clock was ahead when a check ran and was corrected later, the stored check time is in the future. Cold starts then armed a timer for the whole skew, and foreground never came due. The stored state now reads as never checked in that case. The iOS link is the lookup's trackViewUrl instead of a URL built from trackId. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * style(mobile): fit the future-check-time comment in the print width Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
import { startAndroidForegroundPushPresentation } from '../src/notifications/android-foreground-push'
|
|
import { registerPushDismissalTask } from '../src/notifications/push-background-dismissal'
|
|
import { readNativeNotificationData } from '../src/notifications/native-notification-data'
|
|
import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy'
|
|
import { useCallback, useEffect, useRef } from 'react'
|
|
import { View, StyleSheet } from 'react-native'
|
|
import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router'
|
|
import { StatusBar } from 'expo-status-bar'
|
|
import * as SplashScreen from 'expo-splash-screen'
|
|
import * as Notifications from 'expo-notifications'
|
|
import * as Linking from 'expo-linking'
|
|
import { colors } from '../src/theme/mobile-theme'
|
|
import { OrcaLogo } from '../src/components/OrcaLogo'
|
|
import { RpcClientProvider } from '../src/transport/client-context'
|
|
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
|
|
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
|
|
import {
|
|
isRemotePushTrigger,
|
|
pushNotificationRouteData,
|
|
foregroundNotificationBehavior
|
|
} from '../src/notifications/push-receive'
|
|
import { startPushTokenSync } from '../src/notifications/push-registration'
|
|
import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel'
|
|
import { loadHostCatalog } from '../src/transport/host-store'
|
|
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
|
|
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
|
|
import { appUpdateChecker } from '../src/app-update/app-update-runtime'
|
|
|
|
// 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
|
|
// between the native splash and the first React paint.
|
|
SplashScreen.preventAutoHideAsync()
|
|
|
|
// Why at boot and not only on subscribe: the gateway's FCM payload targets the
|
|
// 'orca-desktop' channel, and a background push can land before any socket has
|
|
// connected. Android drops a notification whose channel does not exist yet.
|
|
void ensureDesktopNotificationChannel().catch(() => {})
|
|
void registerPushDismissalTask().catch(() => {})
|
|
|
|
// Register before scheduling so foreground delivery uses the same suppression policy.
|
|
Notifications.setNotificationHandler({
|
|
handleNotification: foregroundNotificationBehavior
|
|
})
|
|
|
|
export default function RootLayout() {
|
|
const router = useRouter()
|
|
const pathname = usePathname()
|
|
const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>()
|
|
useEffect(() => {
|
|
setNotificationViewingWorkspace(
|
|
pathname.includes('/session/') && typeof hostId === 'string' && typeof worktreeId === 'string'
|
|
? { hostId, worktreeId }
|
|
: null
|
|
)
|
|
return () => setNotificationViewingWorkspace(null)
|
|
}, [pathname, hostId, worktreeId])
|
|
const openNotificationRoute = useOpenNotificationRoute()
|
|
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
|
|
|
|
useEffect(() => {
|
|
// Why: pairing publication is journaled across process death; startup must
|
|
// reconcile the server result before another scan can replace that journal.
|
|
void recoverMobileRelayPairing()
|
|
}, [])
|
|
|
|
// Why: a rolled APNs/FCM token stops delivering silently, so every paired host
|
|
// has to be re-registered with the new one as soon as the provider hands it over.
|
|
useEffect(() => startPushTokenSync(), [])
|
|
// Cold start, then foreground and timer checks on the desktop updater's cadence.
|
|
useEffect(() => appUpdateChecker.start(), [])
|
|
useEffect(() => startAndroidForegroundPushPresentation(), [])
|
|
|
|
// 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 = extractPairingCodeFromUrl(url)
|
|
if (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 } })
|
|
}
|
|
}
|
|
|
|
void Linking.getInitialURL().then((url) => {
|
|
if (url) {
|
|
handleUrl(url)
|
|
}
|
|
})
|
|
|
|
const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url))
|
|
return () => sub.remove()
|
|
}, [router])
|
|
|
|
// ─── Notification tap routing ───
|
|
// Why: iOS delivers local notification taps through expo-notifications,
|
|
// not Linking. Route both cold-start and warm-start responses to the host
|
|
// and worktree that scheduled the notification.
|
|
useEffect(() => {
|
|
let disposed = false
|
|
|
|
function clearLastNotificationResponse() {
|
|
try {
|
|
Notifications.clearLastNotificationResponse()
|
|
} catch {
|
|
// Older native shells may not expose the clear API; duplicate guards
|
|
// still protect the current JS runtime.
|
|
}
|
|
}
|
|
|
|
function getInitialNotificationResponse(): Notifications.NotificationResponse | null {
|
|
try {
|
|
return Notifications.getLastNotificationResponse()
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function getNavigationTarget(notification: Notifications.Notification) {
|
|
const hosts = await loadHostCatalog().catch(() => null)
|
|
const data = readNativeNotificationData(notification.request)
|
|
// A gateway push names its host by key fingerprint, not by this device's hostId.
|
|
// With no catalog to resolve against, such a push stays unrouted instead of
|
|
// falling back to whatever hostId its raw data carries.
|
|
const routeData = pushNotificationRouteData(
|
|
data,
|
|
hosts ?? [],
|
|
isRemotePushTrigger(notification.request.trigger)
|
|
)
|
|
return getNotificationNavigationTarget(routeData, {
|
|
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined,
|
|
credentialStatusByHostId: hosts
|
|
? new Map(hosts.map((host) => [host.id, host.credentialStatus]))
|
|
: undefined
|
|
})
|
|
}
|
|
|
|
async function handleNotificationResponse(response: Notifications.NotificationResponse) {
|
|
if (response.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER) {
|
|
clearLastNotificationResponse()
|
|
return
|
|
}
|
|
|
|
const notificationId = response.notification.request.identifier
|
|
if (handledNotificationIdsRef.current.has(notificationId)) {
|
|
return
|
|
}
|
|
handledNotificationIdsRef.current.add(notificationId)
|
|
// Why: RootLayout never unmounts, so cap this tap-dedup set (FIFO) rather
|
|
// than letting it grow one id per notification tapped for the app's life.
|
|
if (handledNotificationIdsRef.current.size > 256) {
|
|
const oldest = handledNotificationIdsRef.current.values().next().value
|
|
if (oldest !== undefined) {
|
|
handledNotificationIdsRef.current.delete(oldest)
|
|
}
|
|
}
|
|
|
|
const target = await getNavigationTarget(response.notification)
|
|
clearLastNotificationResponse()
|
|
if (disposed) {
|
|
return
|
|
}
|
|
if (target) {
|
|
openNotificationRoute(target)
|
|
}
|
|
}
|
|
|
|
const initialResponse = getInitialNotificationResponse()
|
|
if (initialResponse) {
|
|
void handleNotificationResponse(initialResponse)
|
|
}
|
|
|
|
const sub = Notifications.addNotificationResponseReceivedListener((response) => {
|
|
void handleNotificationResponse(response)
|
|
})
|
|
return () => {
|
|
disposed = true
|
|
sub.remove()
|
|
}
|
|
}, [openNotificationRoute])
|
|
// ─── End notification tap routing ───
|
|
|
|
// Why: hide the native splash only once the navigation Stack has been laid
|
|
// out — this is the earliest moment the user will see actual app content.
|
|
// Previously the splash hid when a placeholder View rendered, leaving a
|
|
// grey gap before the real screen appeared.
|
|
const onNavigatorLayout = useCallback(async () => {
|
|
await SplashScreen.hideAsync()
|
|
}, [])
|
|
|
|
return (
|
|
<RpcClientProvider>
|
|
<View style={styles.root} onLayout={onNavigatorLayout}>
|
|
<StatusBar style="light" />
|
|
<Stack
|
|
screenOptions={{
|
|
headerStyle: { backgroundColor: colors.bgPanel },
|
|
headerTintColor: colors.textPrimary,
|
|
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
|
|
contentStyle: { backgroundColor: colors.bgBase },
|
|
headerShadowVisible: false
|
|
// Why: deliberately no `orientation` screenOption. react-native-screens
|
|
// has no value that respects the device rotation lock — even 'default'
|
|
// calls setRequestedOrientation(UNSPECIFIED) at runtime, overriding the
|
|
// manifest. Leaving it unset lets the manifest's "fullUser" (set by the
|
|
// android-respect-rotation-lock config plugin) honor the auto-rotate lock.
|
|
}}
|
|
>
|
|
<Stack.Screen
|
|
name="index"
|
|
options={{
|
|
headerShown: false,
|
|
headerTitle: () => <OrcaLogo size={22} />
|
|
}}
|
|
/>
|
|
<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="mobile-onboarding"
|
|
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
|
|
/>
|
|
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
|
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
|
|
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
|
|
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
|
|
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
|
|
<Stack.Screen name="notifications" options={{ headerShown: false }} />
|
|
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
|
|
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
|
|
<Stack.Screen name="about" options={{ headerShown: false }} />
|
|
<Stack.Screen name="h" options={{ headerShown: false }} />
|
|
</Stack>
|
|
</View>
|
|
</RpcClientProvider>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
root: {
|
|
flex: 1,
|
|
backgroundColor: colors.bgBase
|
|
}
|
|
})
|