/ src / hooks / use-app-bootstrap.ts
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  }