cc-haha/desktop/src/stores/hahaOAuthStore.ts
程序员阿江(Relakkes) d83efc1b04 Prevent desktop OAuth sessions from drifting out of sync
Desktop auth was reporting stale local token files as logged in, could inherit a parent OAuth token after logout, and kept polling even when browser launch failed. This change validates stored tokens through the refresh path, clears inherited OAuth env before spawning the CLI, fetches subscription metadata on initial login, and moves polling start until after the browser opens. Regression tests cover the server and desktop store paths.

Constraint: Desktop CLI must bypass macOS Keychain by injecting env OAuth when available
Rejected: Keep status endpoint file-based only | reports expired sessions as logged in
Confidence: high
Scope-risk: moderate
Directive: Official desktop auth state must be derived from ensureFreshTokens rather than oauth.json presence alone
Tested: bun test src/server/__tests__/conversation-service.test.ts src/server/__tests__/haha-oauth-service.test.ts src/server/__tests__/haha-oauth-api.test.ts
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run test
Not-tested: Manual end-to-end desktop OAuth flow against the live provider
2026-04-18 01:45:18 +08:00

98 lines
2.3 KiB
TypeScript

// desktop/src/stores/hahaOAuthStore.ts
import { create } from 'zustand'
import { hahaOAuthApi, type HahaOAuthStatus } from '../api/hahaOAuth'
const POLL_INTERVAL_MS = 2_000
type HahaOAuthState = {
status: HahaOAuthStatus | null
isPolling: boolean
isLoading: boolean
error: string | null
fetchStatus: () => Promise<void>
login: () => Promise<{ authorizeUrl: string }>
logout: () => Promise<void>
startPolling: () => void
stopPolling: () => void
}
export const useHahaOAuthStore = create<HahaOAuthState>((set, get) => {
let pollTimer: ReturnType<typeof setTimeout> | null = null
return {
status: null,
isPolling: false,
isLoading: false,
error: null,
fetchStatus: async () => {
try {
const status = await hahaOAuthApi.status()
set({ status, error: null })
} catch (err) {
set({ error: err instanceof Error ? err.message : String(err) })
}
},
login: async () => {
set({ isLoading: true, error: null })
try {
const res = await hahaOAuthApi.start()
set({ isLoading: false })
return { authorizeUrl: res.authorizeUrl }
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : String(err),
})
throw err
}
},
logout: async () => {
get().stopPolling()
set({ isLoading: true })
try {
await hahaOAuthApi.logout()
set({ status: { loggedIn: false }, isLoading: false })
} catch (err) {
set({
isLoading: false,
error: err instanceof Error ? err.message : String(err),
})
throw err
}
},
startPolling: () => {
if (pollTimer) return
set({ isPolling: true })
const scheduleNext = () => {
pollTimer = setTimeout(async () => {
await get().fetchStatus()
const cur = get().status
if (cur && cur.loggedIn) {
get().stopPolling()
return
}
if (get().isPolling) {
scheduleNext()
}
}, POLL_INTERVAL_MS)
}
scheduleNext()
},
stopPolling: () => {
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
set({ isPolling: false })
},
}
})