mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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
366 lines
9.5 KiB
TypeScript
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)
|
|
}
|