Mobile: App Store v0.0.4 prep + connection-stability fixes (#1486)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
6e4e668cb3
commit
82b078efa9
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
() => ({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue