cc-haha/desktop/src/lib/desktopRuntime.ts
程序员阿江(Relakkes) d1211e4dca Keep desktop smoke on the configured backend
Browser-mode startup now respects an explicit Vite desktop server URL before falling back to same-origin H5 serving. This keeps local desktop smoke tests pointed at the real API server while preserving same-origin behavior for packaged H5 access.

Constraint: Vite dev smoke serves the web app and API on different loopback ports
Rejected: Treat same-origin as always preferred | Vite would answer /api requests with HTML and the chat surface never becomes ready
Confidence: high
Scope-risk: narrow
Tested: cd desktop && bun run test -- src/lib/desktopRuntime.test.ts src/api/client.test.ts --run
Tested: bun run check:desktop
Tested: bun run quality:gate --mode baseline --allow-live --only 'provider-smoke:*' --only 'desktop-smoke:*' --provider-model codingplan:main:codingplan-main
Not-tested: Full bun run quality:pr after this commit; it will run again during push
2026-05-10 23:01:43 +08:00

366 lines
9.5 KiB
TypeScript

import {
api,
getDefaultBaseUrl,
hasExplicitDefaultBaseUrl,
setAuthToken,
setBaseUrl,
} from '../api/client'
export const H5_SERVER_URL_STORAGE_KEY = 'cc-haha-h5-server-url'
export const H5_TOKEN_STORAGE_KEY = 'cc-haha-h5-token'
type H5ConnectionFailureReason =
| 'missing-token'
| 'invalid-token'
| 'verify-failed'
| 'unreachable'
export type StoredH5Connection = {
serverUrl: string | null
token: string | null
}
export class H5ConnectionRequiredError extends Error {
readonly serverUrl: string
readonly reason: H5ConnectionFailureReason
constructor(message: string, serverUrl: string, reason: H5ConnectionFailureReason) {
super(message)
this.name = 'H5ConnectionRequiredError'
this.serverUrl = serverUrl
this.reason = reason
}
}
export function isTauriRuntime() {
if (typeof window === 'undefined') return false
return '__TAURI_INTERNALS__' in window || '__TAURI__' in window
}
export function isBrowserH5Runtime() {
return typeof window !== 'undefined' && !isTauriRuntime()
}
export function readStoredH5Connection(): StoredH5Connection {
if (typeof window === 'undefined') {
return { serverUrl: null, token: null }
}
try {
return {
serverUrl: normalizeServerUrl(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)),
token: normalizeToken(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)),
}
} catch {
return { serverUrl: null, token: null }
}
}
export function clearStoredH5Connection() {
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(H5_SERVER_URL_STORAGE_KEY)
window.localStorage.removeItem(H5_TOKEN_STORAGE_KEY)
} catch {
// Ignore storage failures
}
}
setAuthToken(null)
}
export async function saveAndVerifyH5Connection(serverUrl: string, token: string) {
const normalizedServerUrl = normalizeServerUrl(serverUrl)
const normalizedToken = normalizeToken(token)
if (!normalizedServerUrl) {
throw new Error('Enter a valid server URL.')
}
if (!normalizedToken) {
throw new Error('Enter your H5 access token.')
}
setBaseUrl(normalizedServerUrl)
setAuthToken(normalizedToken)
rememberStoredH5ServerUrl(normalizedServerUrl)
try {
await waitForHealth(normalizedServerUrl)
await verifyH5Access()
} catch (error) {
clearStoredH5Token()
throw normalizeBrowserH5Error(error, normalizedServerUrl)
}
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, normalizedToken)
} catch {
// Ignore storage failures after a successful verification.
}
}
return normalizedServerUrl
}
export function isH5ConnectionRequiredError(error: unknown): error is H5ConnectionRequiredError {
return error instanceof H5ConnectionRequiredError
}
export async function initializeDesktopServerUrl() {
const fallbackUrl = getDefaultBaseUrl()
if (!isTauriRuntime()) {
return initializeBrowserServerUrl(fallbackUrl)
}
try {
const { invoke } = await import('@tauri-apps/api/core')
const serverUrl = await invoke<string>('get_server_url')
setBaseUrl(serverUrl)
setAuthToken(null)
await waitForHealth(serverUrl)
return serverUrl
} catch (error) {
const message =
error instanceof Error ? error.message : `desktop server startup failed: ${String(error)}`
console.error('[desktop] Failed to initialize desktop server URL', error)
throw new Error(message || `desktop server startup failed (fallback would be ${fallbackUrl})`)
}
}
async function initializeBrowserServerUrl(fallbackUrl: string) {
const queryUrl =
typeof window !== 'undefined'
? new URLSearchParams(window.location.search).get('serverUrl')
: null
const stored = readStoredH5Connection()
const requestedUrl =
normalizeServerUrl(queryUrl) ??
stored.serverUrl ??
getConfiguredBrowserServerUrl(fallbackUrl) ??
fallbackUrl
const token = stored.token
const browserH5Runtime = requiresH5AuthForServerUrl(requestedUrl)
setBaseUrl(requestedUrl)
setAuthToken(browserH5Runtime ? token : null)
if (browserH5Runtime) {
rememberStoredH5ServerUrl(requestedUrl)
}
try {
await waitForHealth(requestedUrl)
} catch (error) {
if (browserH5Runtime) {
clearStoredH5Token()
throw normalizeBrowserH5Error(error, requestedUrl)
}
throw error
}
if (!browserH5Runtime) {
await ensureBrowserApiAccessibleWithoutH5(requestedUrl)
return requestedUrl
}
if (!token) {
clearStoredH5Token()
throw new H5ConnectionRequiredError(
'Enter your H5 token to continue.',
requestedUrl,
'missing-token',
)
}
try {
await verifyH5Access()
} catch (error) {
clearStoredH5Token()
throw normalizeBrowserH5Error(error, requestedUrl)
}
return requestedUrl
}
async function waitForHealth(serverUrl: string) {
let lastError: unknown
for (let attempt = 0; attempt < 30; attempt++) {
try {
const response = await fetch(`${serverUrl}/health`, {
cache: 'no-store',
})
if (response.ok) {
return
}
lastError = new Error(`healthcheck returned ${response.status}`)
} catch (error) {
lastError = error
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
throw new Error(
lastError instanceof Error
? `Server healthcheck failed: ${lastError.message}`
: 'Server healthcheck failed',
)
}
async function verifyH5Access() {
await api.post<{ ok: true }>('/api/h5-access/verify')
}
async function ensureBrowserApiAccessibleWithoutH5(serverUrl: string) {
const response = await fetch(`${serverUrl}/api/status`, {
cache: 'no-store',
})
if (response.status === 401) {
throw new H5ConnectionRequiredError(
'Enter your H5 token to continue.',
serverUrl,
'missing-token',
)
}
}
function normalizeServerUrl(value: string | null | undefined) {
const trimmed = value?.trim()
if (!trimmed) return null
try {
return new URL(trimmed).toString().replace(/\/$/, '')
} catch {
return null
}
}
function normalizeToken(value: string | null | undefined) {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
function getSameOriginServerUrl() {
if (typeof window === 'undefined') {
return null
}
if (window.location.protocol !== 'http:' && window.location.protocol !== 'https:') {
return null
}
return normalizeServerUrl(window.location.origin)
}
function getConfiguredBrowserServerUrl(fallbackUrl: string) {
if (hasExplicitDefaultBaseUrl()) {
return normalizeServerUrl(fallbackUrl)
}
return getSameOriginServerUrl()
}
export function isLoopbackHostname(hostname: string) {
const normalized = hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase()
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'
}
export function requiresH5AuthForServerUrl(serverUrl: string, browserHostname = getBrowserHostname()) {
try {
const serverHostname = new URL(serverUrl).hostname
if (isLoopbackHostname(serverHostname)) {
return false
}
if (browserHostname && isLoopbackHostname(browserHostname) && isPrivateNetworkHostname(serverHostname)) {
return false
}
return true
} catch {
return false
}
}
function isPrivateNetworkHostname(hostname: string) {
const normalized = hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase()
if (normalized === '0.0.0.0') {
return true
}
const ipv4Parts = normalized.split('.')
if (ipv4Parts.length === 4 && ipv4Parts.every((part) => /^\d+$/.test(part))) {
const octets = ipv4Parts.map((part) => Number(part))
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
return false
}
const a = octets[0] ?? -1
const b = octets[1] ?? -1
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
)
}
return normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
normalized.startsWith('fe80:')
}
function getBrowserHostname() {
if (typeof window === 'undefined') return null
return window.location.hostname
}
function normalizeBrowserH5Error(error: unknown, serverUrl: string) {
if (error instanceof H5ConnectionRequiredError) {
return error
}
if (error instanceof Error && error.message.startsWith('Server healthcheck failed')) {
return new H5ConnectionRequiredError(
`Unable to reach ${serverUrl}. Check the server URL or network access.`,
serverUrl,
'unreachable',
)
}
const message =
error instanceof Error ? error.message : 'Unable to verify the H5 access token.'
const status = typeof error === 'object' && error !== null && 'status' in error
? (error as { status?: unknown }).status
: undefined
const unauthorized = status === 401 || message.includes('401') || message.toLowerCase().includes('unauthorized')
return new H5ConnectionRequiredError(
unauthorized ? 'The saved H5 token is no longer valid.' : 'Unable to verify the H5 access token.',
serverUrl,
unauthorized ? 'invalid-token' : 'verify-failed',
)
}
function rememberStoredH5ServerUrl(serverUrl: string) {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, serverUrl)
} catch {
// Ignore storage failures.
}
}
function clearStoredH5Token() {
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(H5_TOKEN_STORAGE_KEY)
} catch {
// Ignore storage failures.
}
}
setAuthToken(null)
}