Mobile: App Store v0.0.4 prep + connection-stability fixes (#1486)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-05 20:46:56 -07:00 committed by GitHub
parent 6e4e668cb3
commit 82b078efa9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 78 additions and 42 deletions

View File

@ -14,9 +14,9 @@
"backgroundColor": "#111111"
},
"ios": {
"supportsTablet": true,
"supportsTablet": false,
"bundleIdentifier": "com.stably.orca.mobile",
"buildNumber": "5",
"buildNumber": "6",
"infoPlist": {
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
"NSAppTransportSecurity": {

View File

@ -13,7 +13,7 @@ import { ensureNotificationPermissions } from '../src/notifications/mobile-notif
export default function NotificationsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [pushEnabled, setPushEnabled] = useState(true)
const [pushEnabled, setPushEnabled] = useState(false)
useFocusEffect(
useCallback(() => {

View File

@ -1,7 +1,15 @@
import { View, Text, StyleSheet, Pressable } from 'react-native'
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Info, Bell, Wrench } from 'lucide-react-native'
import {
ChevronLeft,
ChevronRight,
Info,
Bell,
Wrench,
Shield,
LifeBuoy
} from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
export default function SettingsScreen() {
@ -45,6 +53,24 @@ export default function SettingsScreen() {
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
<View style={[styles.section, styles.sectionSpacer]}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://www.onorca.dev/privacy')}
>
<Shield size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Privacy Policy</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://github.com/stablyai/orca/issues')}
>
<LifeBuoy size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Support</Text>
</Pressable>
</View>
</View>
)
}
@ -78,6 +104,9 @@ const styles = StyleSheet.create({
borderRadius: 12,
overflow: 'hidden'
},
sectionSpacer: {
marginTop: spacing.md
},
row: {
flexDirection: 'row',
alignItems: 'center',

View File

@ -4,13 +4,18 @@ const PINS_PREFIX = 'orca:pins:'
const PREFS_PREFIX = 'orca:prefs:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
// Why: default-off so the iOS notification permission prompt never
// fires until the user explicitly opts in via Settings → Notifications.
// Apple's review guideline 4.5.4 and HIG both prefer user-initiated
// permission prompts; default-on would fire the prompt the moment the
// desktop sent its first notification, which can read as unsolicited.
export async function loadPushNotificationsEnabled(): Promise<boolean> {
try {
const raw = await AsyncStorage.getItem(NOTIF_KEY)
if (raw === null) return true
if (raw === null) return false
return raw === 'true'
} catch {
return true
return false
}
}

View File

@ -23,13 +23,10 @@ import { connect, type RpcClient } from './rpc-client'
import { loadHosts } from './host-store'
import type { ConnectionState, HostProfile } from './types'
const IDLE_CLOSE_MS = 30_000
type StoreEntry = {
client: RpcClient
state: ConnectionState
refCount: number
idleTimer: ReturnType<typeof setTimeout> | null
unsubState: () => void
}
@ -83,7 +80,6 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const closeEntry = useCallback((hostId: string) => {
const entry = storeRef.current.get(hostId)
if (!entry) return
if (entry.idleTimer) clearTimeout(entry.idleTimer)
entry.unsubState()
entry.client.close()
storeRef.current.delete(hostId)
@ -150,7 +146,6 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
client,
state: client.getState(),
refCount: 0,
idleTimer: null,
unsubState
}
storeRef.current.set(hostId, entry)
@ -175,10 +170,6 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const existing = storeRef.current.get(hostId)
if (existing) {
existing.refCount += 1
if (existing.idleTimer) {
clearTimeout(existing.idleTimer)
existing.idleTimer = null
}
return existing.client
}
// Trigger async open. The acquire-side will return null this tick and
@ -197,36 +188,30 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
for (const host of hosts) primedHostsRef.current.set(host.id, host)
}, [])
const release = useCallback(
(hostId: string) => {
const entry = storeRef.current.get(hostId)
if (!entry) return
entry.refCount = Math.max(0, entry.refCount - 1)
if (entry.refCount > 0) return
if (entry.idleTimer) clearTimeout(entry.idleTimer)
entry.idleTimer = setTimeout(() => {
// Why: only close if still idle when the timer fires. A late acquire
// would have cleared the timer.
const cur = storeRef.current.get(hostId)
if (!cur || cur.refCount > 0) return
closeEntry(hostId)
}, IDLE_CLOSE_MS)
},
[closeEntry]
)
// Why: refcount dropping to 0 no longer triggers an idle-close. The
// app deliberately keeps live WebSockets open while the app itself is
// foregrounded — closing on transient navigation gaps was producing
// false 'disconnected' flashes when the user navigated home → host →
// back to home faster than React could re-acquire on the home side.
// Connections still close on: explicit user Disconnect, host removal,
// app backgrounding (OS-level socket suspension), and provider
// unmount (app shutdown).
const release = useCallback((hostId: string) => {
const entry = storeRef.current.get(hostId)
if (!entry) return
entry.refCount = Math.max(0, entry.refCount - 1)
}, [])
const forceReconnect = useCallback(
async (hostId: string) => {
const entry = storeRef.current.get(hostId)
// Why: if the entry was previously closed (e.g. user tapped
// Disconnect), refCount is lost. Fall back to the number of active
// state listeners as a proxy for "screens currently watching this
// host," so the freshly-opened entry doesn't trip the idle-close
// timer immediately.
// Why: preserve refcount across the swap. If the user reaches
// forceReconnect via the Disconnect → Reconnect path, the entry
// was already closed and refCount=0; fall back to active listener
// count as a proxy for "screens still watching this host."
const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0
const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount)
if (entry) {
if (entry.idleTimer) clearTimeout(entry.idleTimer)
entry.unsubState()
entry.client.close()
storeRef.current.delete(hostId)
@ -278,13 +263,23 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}
}, [])
// Close all clients on provider unmount (app shutdown / hot reload).
// Close all clients on provider unmount (app shutdown).
// Why: deps must be empty so this cleanup ONLY runs on real unmount.
// Hot-reload re-evaluates this module, which makes closeEntry a new
// function identity. With [closeEntry] as deps, every Fast Refresh
// would tear down all open WebSockets, leaving screens holding closed
// clients and the user staring at a 'Reconnecting…' card. Reading
// storeRef.current and the locally-scoped closeEntry inside the
// cleanup is safe — the ref is stable across renders, and the
// function captured here will be the one defined in the same
// closure as this effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const store = storeRef.current
return () => {
for (const [hostId] of store) closeEntry(hostId)
}
}, [closeEntry])
}, [])
const value = useMemo<ContextValue>(
() => ({

View File

@ -32,7 +32,14 @@ export type RpcClient = {
close: () => void
}
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]
// Why: capped at 4s so the worst-case "stuck reconnecting" window the
// user perceives is short. Prior 16s ceiling combined with Android's
// suspended-timer behaviour during background → foreground transitions
// often felt like the app would just sit on 'Reconnecting…' forever
// (the timer was queued, the OS had simply not run it yet). Tapping the
// manual Reconnect button bypassed the timer, which is why it felt
// "magic". Shorter backoff makes the auto-recovery path feel as fast.
const RECONNECT_DELAYS = [500, 1000, 2000, 4000]
const REQUEST_TIMEOUT_MS = 30_000
const HANDSHAKE_TIMEOUT_MS = 5_000