cc-haha/desktop/src/lib/desktopRuntime.ts
程序员阿江(Relakkes) 81845fbc49 fix: finalize Electron migration boundaries
Complete the Electron replacement boundary before merging by removing the renderer-side Tauri host fallback, tightening H5/browser access so only desktop navigation is tokenless, and moving desktop release publication to a tag-driven GitHub Actions matrix with a single final publish job.

Constraint: H5/browser capability access must not gain tokenless access through localhost or retired Tauri origins

Constraint: Desktop release artifacts must be built by GitHub Actions from version tags, not treated as local build outputs

Rejected: Keep localhost browser origins trusted for convenience | local browser contexts can access loopback services and must use the H5 token path

Rejected: Publish from each matrix job | partial releases can be created before all platforms finish

Confidence: high

Scope-risk: broad

Directive: Do not reintroduce Tauri origins or localhost browser origins into the trusted desktop origin set without a reviewed security design

Tested: bun test src/server/__tests__/h5-access-policy.test.ts src/server/__tests__/h5-access-auth.test.ts src/server/__tests__/diagnostics-service.test.ts src/server/middleware/cors.test.ts

Tested: bun test scripts/pr/release-workflow.test.ts scripts/release-update-metadata.test.ts

Tested: bun run check:desktop

Tested: bun run check:native

Tested: git diff --check

Not-tested: bun run check:server is blocked by expired quarantine entries server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow
2026-06-02 22:42:53 +08:00

370 lines
9.9 KiB
TypeScript

import {
api,
getBaseUrl,
getDefaultBaseUrl,
hasExplicitDefaultBaseUrl,
setAuthToken,
setBaseUrl,
} from '../api/client'
import { getDesktopHost } from './desktopHost'
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
}
}
function getDetectedDesktopHost() {
return getDesktopHost()
}
export function isDesktopRuntime() {
return getDetectedDesktopHost().isDesktop
}
export function isBrowserH5Runtime() {
return typeof window !== 'undefined' && !isDesktopRuntime()
}
/**
* Synchronously return the running local server's base URL (e.g.
* `http://127.0.0.1:<port>`).
*
* The api client caches the resolved base after startup: `initializeDesktopServerUrl`
* calls `invoke('get_server_url')` (desktop) or resolves a browser/H5 URL, then
* `setBaseUrl(...)`. Until that runs, `getBaseUrl()` returns the default
* (`http://127.0.0.1:3456` or `VITE_DESKTOP_SERVER_URL`).
*/
export function getServerBaseUrl(): string {
return getBaseUrl()
}
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()
const host = getDetectedDesktopHost()
if (!host.isDesktop) {
return initializeBrowserServerUrl(fallbackUrl)
}
try {
const serverUrl = await host.runtime.getServerUrl()
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 query = typeof window !== 'undefined'
? new URLSearchParams(window.location.search)
: null
const queryUrl = query?.get('serverUrl') ?? null
const queryToken = normalizeToken(query?.get('h5Token') ?? query?.get('token'))
const stored = readStoredH5Connection()
const configuredUrl = getConfiguredBrowserServerUrl(fallbackUrl)
const requestedUrl =
normalizeServerUrl(queryUrl) ??
configuredUrl ??
stored.serverUrl ??
fallbackUrl
const token = queryToken ?? 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)
}
if (queryToken && typeof window !== 'undefined') {
try {
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, queryToken)
} catch {
// Ignore storage failures after successful verification.
}
}
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) {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
lastError = new Error(`healthcheck returned non-JSON response from ${serverUrl}/health`)
} else {
const body = await response.json().catch(() => null)
if (body && typeof body === 'object' && 'status' in body && body.status === 'ok') {
return
}
lastError = new Error(`healthcheck returned invalid response from ${serverUrl}/health`)
}
} else {
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()) {
void browserHostname
try {
return !isLoopbackHostname(new URL(serverUrl).hostname)
} catch {
return false
}
}
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)
}