use-app-bootstrap.ts
1 import { useEffect, useState, useCallback } from 'react' 2 import { useAppStore } from '@/stores/use-app-store' 3 import { getStoredAccessKey, clearStoredAccessKey, api } from '@/lib/app/api-client' 4 import { safeStorageGet, safeStorageSet } from '@/lib/app/safe-storage' 5 import { connectWs, disconnectWs } from '@/lib/ws-client' 6 import { fetchWithTimeout, isAbortError, isTimeoutError } from '@/lib/fetch-timeout' 7 import { isDevelopmentLikeRuntime } from '@/lib/runtime/runtime-env' 8 import { useWs } from '@/hooks/use-ws' 9 import { useMountedRef } from '@/hooks/use-mounted-ref' 10 import { resolveSetupDone } from '@/hooks/setup-done-detection' 11 import { setIfChanged } from '@/stores/set-if-changed' 12 import type { Agent } from '@/types' 13 14 const AUTH_CHECK_TIMEOUT_MS = isDevelopmentLikeRuntime() ? 20_000 : 8_000 15 const POST_AUTH_BOOTSTRAP_TIMEOUT_MS = isDevelopmentLikeRuntime() ? 20_000 : 8_000 16 17 function isExpectedAuthProbeError(err: unknown): boolean { 18 return isAbortError(err) || isTimeoutError(err) 19 } 20 21 export function useAppBootstrap() { 22 const currentUser = useAppStore((s) => s.currentUser) 23 const setUser = useAppStore((s) => s.setUser) 24 const hydrated = useAppStore((s) => s._hydrated) 25 const hydrate = useAppStore((s) => s.hydrate) 26 const loadNetworkInfo = useAppStore((s) => s.loadNetworkInfo) 27 const loadSessions = useAppStore((s) => s.loadSessions) 28 const loadSettings = useAppStore((s) => s.loadSettings) 29 30 const [authChecked, setAuthChecked] = useState(false) 31 const [authenticated, setAuthenticated] = useState(false) 32 const [setupDone, setSetupDone] = useState<boolean | null>(null) 33 const [userReady, setUserReady] = useState(false) 34 const [agentReady, setAgentReady] = useState(false) 35 const mountedRef = useMountedRef() 36 37 const checkAuth = useCallback(async () => { 38 try { 39 const res = await fetchWithTimeout('/api/auth', {}, AUTH_CHECK_TIMEOUT_MS) 40 const data = await res.json().catch((err) => { 41 console.warn('Failed to parse /api/auth JSON:', err) 42 return {} 43 }) 44 if (data?.authenticated === true) { 45 if (!mountedRef.current) return 46 setAuthenticated(true) 47 setAuthChecked(true) 48 return 49 } 50 } catch (err) { 51 if (!isExpectedAuthProbeError(err)) { 52 console.warn('Auth check probe failed, falling back to stored key:', err) 53 } 54 } 55 56 const key = getStoredAccessKey() 57 if (!key) { 58 if (!mountedRef.current) return 59 setAuthenticated(false) 60 setAuthChecked(true) 61 return 62 } 63 64 try { 65 const res = await fetchWithTimeout('/api/auth', { 66 method: 'POST', 67 headers: { 'Content-Type': 'application/json' }, 68 body: JSON.stringify({ key }), 69 }, AUTH_CHECK_TIMEOUT_MS) 70 if (res.ok) { 71 if (!mountedRef.current) return 72 setAuthenticated(true) 73 } else { 74 clearStoredAccessKey() 75 if (!mountedRef.current) return 76 setAuthenticated(false) 77 } 78 } catch (err) { 79 if (!isExpectedAuthProbeError(err)) { 80 console.warn('Stored key auth check failed:', err) 81 } 82 clearStoredAccessKey() 83 if (!mountedRef.current) return 84 setAuthenticated(false) 85 } finally { 86 if (!mountedRef.current) return 87 setAuthChecked(true) 88 } 89 }, [mountedRef]) 90 91 useEffect(() => { 92 hydrate() 93 }, [hydrate]) 94 95 useEffect(() => { 96 if (!hydrated) return 97 if (safeStorageGet('sc_setup_done') === '1') { 98 setSetupDone(true) 99 } 100 const handler = () => setSetupDone(true) 101 window.addEventListener('sc:setup-complete', handler) 102 return () => window.removeEventListener('sc:setup-complete', handler) 103 }, [hydrated]) 104 105 useEffect(() => { 106 if (hydrated) checkAuth() 107 }, [hydrated, checkAuth]) 108 109 useEffect(() => { 110 if (!authenticated) { 111 setUserReady(false) 112 setAgentReady(false) 113 return 114 } 115 let cancelled = false 116 ;(async () => { 117 if (currentUser) { 118 if (!cancelled && mountedRef.current) setUserReady(true) 119 return 120 } 121 try { 122 const settings = await api<{ userName?: string }>('GET', '/settings', undefined, { 123 timeoutMs: POST_AUTH_BOOTSTRAP_TIMEOUT_MS, 124 retries: 0, 125 }) 126 if (settings.userName) { 127 setUser(settings.userName) 128 } 129 } catch (err) { 130 console.warn('Failed to sync user from server:', err) 131 } finally { 132 if (!cancelled && mountedRef.current) setUserReady(true) 133 } 134 })() 135 return () => { cancelled = true } 136 }, [authenticated, currentUser, mountedRef, setUser]) 137 138 useEffect(() => { 139 if (!authenticated) return 140 connectWs() 141 loadNetworkInfo() 142 loadSettings() 143 loadSessions() 144 return () => { disconnectWs() } 145 }, [authenticated, loadNetworkInfo, loadSessions, loadSettings]) 146 147 useWs('sessions', loadSessions, 15000) 148 149 useEffect(() => { 150 if (!authenticated || !userReady || !currentUser) return 151 let cancelled = false 152 ;(async () => { 153 try { 154 const agents = await api<Record<string, Agent>>('GET', '/agents', undefined, { 155 timeoutMs: POST_AUTH_BOOTSTRAP_TIMEOUT_MS, 156 retries: 0, 157 }) 158 if (cancelled) return 159 setIfChanged(useAppStore.setState, 'agents', agents) 160 161 const { currentAgentId, appSettings } = useAppStore.getState() 162 const targetId = (currentAgentId && agents[currentAgentId]) 163 ? currentAgentId 164 : (appSettings.defaultAgentId && agents[appSettings.defaultAgentId]) 165 ? appSettings.defaultAgentId 166 : Object.values(agents)[0]?.id || null 167 168 if (targetId) { 169 await useAppStore.getState().setCurrentAgent(targetId) 170 } 171 } catch (err) { 172 console.warn('Failed to initialize agents:', err) 173 } 174 if (!cancelled && mountedRef.current) setAgentReady(true) 175 })() 176 return () => { cancelled = true } 177 }, [authenticated, currentUser, mountedRef, userReady]) 178 179 useEffect(() => { 180 if (!authenticated || !userReady) return 181 let cancelled = false 182 ;(async () => { 183 try { 184 const [settingsResult, credsResult] = await Promise.allSettled([ 185 api<{ setupCompleted?: boolean }>('GET', '/settings', undefined, { 186 timeoutMs: POST_AUTH_BOOTSTRAP_TIMEOUT_MS, 187 retries: 0, 188 }), 189 api<Record<string, unknown>>('GET', '/credentials', undefined, { 190 timeoutMs: POST_AUTH_BOOTSTRAP_TIMEOUT_MS, 191 retries: 0, 192 }), 193 ]) 194 if (cancelled) return 195 const settings = settingsResult.status === 'fulfilled' ? settingsResult.value : {} 196 const creds = credsResult.status === 'fulfilled' ? credsResult.value : {} 197 const bothFailed = settingsResult.status === 'rejected' && credsResult.status === 'rejected' 198 const done = resolveSetupDone(settings, creds, bothFailed) 199 if (done) safeStorageSet('sc_setup_done', '1') 200 if (!mountedRef.current) return 201 setSetupDone(done) 202 } catch (err) { 203 console.warn('Failed to check setup state:', err) 204 if (!cancelled && mountedRef.current) setSetupDone(true) 205 } 206 })() 207 return () => { cancelled = true } 208 }, [authenticated, mountedRef, userReady]) 209 210 return { 211 hydrated, 212 authChecked, 213 authenticated, 214 setAuthenticated, 215 currentUser, 216 userReady, 217 setupDone, 218 setSetupDone, 219 agentReady 220 } 221 }