From de0632748e64acf03f65d800b600239e35b0a208 Mon Sep 17 00:00:00 2001 From: snehasish Date: Wed, 23 Sep 2026 05:04:25 +0000 Subject: [PATCH 1/2] feat(mobile): improve model picker header, banner and filter toggles --- .../[projectId]/[sessionId]/models.tsx | 224 +++++++++++++----- 1 file changed, 169 insertions(+), 55 deletions(-) diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/models.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/models.tsx index 6a942d2..ad6ebcf 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/models.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/models.tsx @@ -71,6 +71,8 @@ export default function ModelsPage() { const [search, setSearch] = useState("") const [statusFilter, setStatusFilter] = useState("active") const [sort, setSort] = useState("name") + const [filterOpen, setFilterOpen] = useState(false) + const [sortOpen, setSortOpen] = useState(false) const [selectedId, setSelectedId] = useState(currentModelId ?? "") const [selectedProviderId, setSelectedProviderId] = useState(currentProviderId ?? "") const updatingRef = useRef(null) @@ -84,6 +86,11 @@ export default function ModelsPage() { return map }, [providers]) + const currentModel = useMemo( + () => models.find((m) => m.id === selectedId && m.providerID === selectedProviderId) ?? null, + [models, selectedId, selectedProviderId] + ) + useEffect(() => { if (connection) fetchAll(connection.url, connection.token) }, [connection?.id, fetchAll]) @@ -244,17 +251,83 @@ export default function ModelsPage() { Select Model - {selectedId ? ( - - - {providerMap[selectedProviderId] ?? selectedProviderId} - {selectedId ? ` / ${selectedId}` : ""} + + {filtered.length} models + + + + + + + + + + Current model + + {currentModel ? ( + <> + + {currentModel.name} + + + {providerMap[currentModel.providerID] ?? currentModel.providerID} + {currentModel.family ? ` ยท ${currentModel.family}` : ""} + + + ) : ( + + No model selected + + )} + + {currentModel && ( + + + {currentModel.status} - ) : null} + )} - + @@ -269,64 +342,105 @@ export default function ModelsPage() { clearButtonMode="while-editing" /> + setFilterOpen((v) => !v)} + accessible + accessibilityRole="button" + accessibilityLabel="Toggle filters" + accessibilityHint="Shows or hides filter options" + accessibilityState={{ expanded: filterOpen, selected: filterOpen }} + className={cn( + "w-11 h-11 rounded-full border items-center justify-center", + filterOpen ? "bg-primary/10 border-primary" : "border-border" + )} + > + + + setSortOpen((v) => !v)} + accessible + accessibilityRole="button" + accessibilityLabel="Toggle sort options" + accessibilityHint="Shows or hides sort options" + accessibilityState={{ expanded: sortOpen, selected: sortOpen }} + className={cn( + "w-11 h-11 rounded-full border items-center justify-center", + sortOpen ? "bg-primary/10 border-primary" : "border-border" + )} + > + + - - - Filter: - {STATUS_OPTIONS.map((opt) => ( - setStatusFilter(opt.value)} - > - + Filter: + {STATUS_OPTIONS.map((opt) => ( + setStatusFilter(opt.value)} > - {opt.label} - - - ))} - + + {opt.label} + + + ))} + + )} - - - Sort: - {SORT_OPTIONS.map((opt) => ( - setSort(opt.value)} - > - + Sort: + {SORT_OPTIONS.map((opt) => ( + setSort(opt.value)} > - {opt.label} - - - ))} - - {filtered.length} models - - + + {opt.label} + + + ))} + + )} {models.length === 0 ? ( From eeb9fd33db15563767c2adbcc3471a65c8dbc446 Mon Sep 17 00:00:00 2001 From: snehasish Date: Wed, 23 Sep 2026 05:52:32 +0000 Subject: [PATCH 2/2] fix(mobile): harden QR auth, token handling, and OTA reliability - Validate QR payloads (size, base64, token, email, URL scheme, metadata IP) - Verify login QR via device-link claim instead of trusting tier/email - Keep tunnel token out of router query via pending-connection store - Add secureFetch with https enforcement and 15s timeout - Fix btoa/Hermes crash, cap auth cache, clear on logout/remove - Make SecureStore fail-safe with AsyncStorage fallback - Use randomUUID and dedupe connections by URL - Allowlist external links to https/mailto with canOpenURL - Switch OTA to ON_ERROR_RECOVERY with safe reload and 30s cache fallback - Throttle widget refresh to 5s and fix widget data key/shape bugs - Ignore .env files in mobile .gitignore --- apps/mobile/.gitignore | 4 + apps/mobile/app.config.js | 4 +- apps/mobile/app/_layout.tsx | 10 +- apps/mobile/app/connect.tsx | 64 ++++++++---- apps/mobile/app/login-scanner.tsx | 75 ++++++++++---- apps/mobile/app/new-connection.tsx | 98 ++++++++++++------- apps/mobile/components/link-options-modal.tsx | 9 +- apps/mobile/lib/pending-connection.ts | 20 ++++ apps/mobile/lib/secure-storage.ts | 46 ++++++--- apps/mobile/lib/security.ts | 85 ++++++++++++++++ apps/mobile/lib/updates.ts | 14 ++- apps/mobile/lib/utils.ts | 30 +++++- apps/mobile/lib/widget-data.ts | 22 ++++- apps/mobile/store/auth.store.ts | 7 +- apps/mobile/store/connection.store.ts | 28 ++++-- packages/shared/src/index.ts | 73 +++++++++++++- 16 files changed, 474 insertions(+), 115 deletions(-) create mode 100644 apps/mobile/lib/pending-connection.ts create mode 100644 apps/mobile/lib/security.ts diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index d914c32..0a124a7 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -32,6 +32,10 @@ yarn-error.* # local env files .env*.local +.env +.env.production +.env.development +.env.staging # typescript *.tsbuildinfo diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js index d73503a..ee5aac0 100644 --- a/apps/mobile/app.config.js +++ b/apps/mobile/app.config.js @@ -62,8 +62,8 @@ module.exports = ({ config }) => { config.updates = { ...(config.updates || {}), url: "https://u.expo.dev/3a001439-9712-4716-9865-47413eac1995", - checkAutomatically: "ON_LOAD", - fallbackToCacheTimeout: 0, + checkAutomatically: "ON_ERROR_RECOVERY", + fallbackToCacheTimeout: 30000, }; config.runtimeVersion = config.runtimeVersion || { policy: "appVersion" }; diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 609fb87..53e81a8 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -49,7 +49,9 @@ export default function RootLayout() { React.useEffect(() => { if (isUpdatePending) { - Updates.reloadAsync() + // Never auto-reload mid-session: a pending OTA is applied on next + // cold start / manual update check instead of killing streaming state. + Updates.reloadAsync().catch(() => undefined) } }, [isUpdatePending]) @@ -69,9 +71,11 @@ export default function RootLayout() { let timer: ReturnType | undefined const schedule = () => { if (timer) clearTimeout(timer) + // Throttled so per-keystroke store writes (drafts/streaming) don't + // rebuild widgets multiple times per second. timer = setTimeout(() => { - refreshWidgets() - }, 800) + refreshWidgets().catch(() => undefined) + }, 5000) } const unsubSessions = useSessions.subscribe(schedule) const unsubConnections = useConnections.subscribe(schedule) diff --git a/apps/mobile/app/connect.tsx b/apps/mobile/app/connect.tsx index ee6abfb..7f221ee 100644 --- a/apps/mobile/app/connect.tsx +++ b/apps/mobile/app/connect.tsx @@ -12,9 +12,14 @@ import XIcon from "lucide-react-native/dist/esm/icons/x" import { cn, getAuthHeader } from "@/lib/utils" import { THEME } from "@/lib/theme" import { useColorScheme } from "nativewind" +import { consumePendingConnection, peekPendingConnection } from "@/lib/pending-connection" +import { validateConnectionUrl, validateAuthToken } from "@/lib/security" export default function Connect() { - const { url, token } = useLocalSearchParams<{ url: string, token: string }>() + const params = useLocalSearchParams<{ url?: string, token?: string }>() + const pending = peekPendingConnection() + const url = pending?.url ?? params.url ?? "" + const token = pending?.token ?? params.token ?? "" const { colorScheme } = useColorScheme() const router = useRouter() const insets = useSafeAreaInsets() @@ -30,22 +35,31 @@ export default function Connect() { setTesting(true) try { - const res = await fetch(`${url}/global/health`, { - method: "GET", - headers: { - "Authorization": getAuthHeader(token) - } - }) - if (res.ok) { - setTested({ - msg: "Remote server reachable", - error: false - }) - } else { - setTested({ - msg: "Remote server unreachable", - error: true + validateConnectionUrl(url) + validateAuthToken(token) + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 10000) + try { + const res = await fetch(`${url}/global/health`, { + method: "GET", + headers: { + "Authorization": getAuthHeader(token) + }, + signal: controller.signal, }) + if (res.ok) { + setTested({ + msg: "Remote server reachable", + error: false + }) + } else { + setTested({ + msg: "Remote server unreachable", + error: true + }) + } + } finally { + clearTimeout(timer) } } catch { setTested({ @@ -58,6 +72,13 @@ export default function Connect() { }, [url, token]) function save() { + try { + validateConnectionUrl(url) + validateAuthToken(token) + } catch (error) { + setTested({ msg: error instanceof Error ? error.message : "Invalid connection", error: true }) + return + } const tier = useAuth.getState().user?.tier ?? "free" if (isAtTunnelLimit(tier, connections.length)) { void requestPaywall("connection_limit") @@ -68,6 +89,7 @@ export default function Connect() { name, token }) + consumePendingConnection() router.replace("/") } @@ -78,7 +100,10 @@ export default function Connect() { return ( - @@ -143,7 +168,10 @@ export default function Connect() {