From 5c1583f852acae00b97daac99ee7fe777db08548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 10 May 2026 00:35:42 +0800 Subject: [PATCH] Preserve recoverable browser H5 startup paths behind proxies Remote browser bootstrap now keeps reverse-proxy path prefixes, recognizes IPv6 loopback as local, and converts remote health or verify failures into recoverable H5 connection prompts instead of falling back to the generic startup error surface. Token invalidation clears only the stored token so users can retry without re-entering the server address. Constraint: Review fixes must stay inside the existing Task 4 browser-runtime scope without changing the Tauri localhost startup path Rejected: Clear both stored server URL and token on remote bootstrap failures | it forces unnecessary re-entry after stale credentials Directive: Any future browser-mode startup error for non-loopback backends should remain recoverable through H5ConnectionView unless the desktop/Tauri contract changes Confidence: high Scope-risk: narrow Tested: cd desktop && bunx vitest run src/api/client.test.ts src/api/websocket.test.ts src/lib/desktopRuntime.test.ts src/components/layout/AppShell.test.tsx Tested: cd desktop && bun run lint Tested: git diff --check -- desktop/src/api/client.ts desktop/src/api/websocket.ts desktop/src/lib/desktopRuntime.ts desktop/src/components/layout/H5ConnectionView.tsx desktop/src/components/layout/AppShell.tsx desktop/src/api/client.test.ts desktop/src/api/websocket.test.ts desktop/src/lib/desktopRuntime.test.ts desktop/src/components/layout/AppShell.test.tsx Not-tested: Live reverse-proxy browser session against a real remote H5 server --- desktop/src/api/websocket.test.ts | 8 ++ desktop/src/api/websocket.ts | 3 +- .../src/components/layout/AppShell.test.tsx | 15 +++ desktop/src/lib/desktopRuntime.test.ts | 111 ++++++++++++++++++ desktop/src/lib/desktopRuntime.ts | 75 +++++++++--- 5 files changed, 197 insertions(+), 15 deletions(-) create mode 100644 desktop/src/lib/desktopRuntime.test.ts diff --git a/desktop/src/api/websocket.test.ts b/desktop/src/api/websocket.test.ts index 8cbead42..19402dd5 100644 --- a/desktop/src/api/websocket.test.ts +++ b/desktop/src/api/websocket.test.ts @@ -114,4 +114,12 @@ describe('wsManager reconnect buffering', () => { 'wss://remote.example.com/ws/secure-session', ) }) + + it('preserves reverse-proxy subpaths when building websocket URLs', () => { + clientMocks.baseUrl = 'https://public.example.com/app' + + expect(buildSessionWebSocketUrl('s1')).toBe( + 'wss://public.example.com/app/ws/s1', + ) + }) }) diff --git a/desktop/src/api/websocket.ts b/desktop/src/api/websocket.ts index aff11dbc..06f98b67 100644 --- a/desktop/src/api/websocket.ts +++ b/desktop/src/api/websocket.ts @@ -180,7 +180,8 @@ class WebSocketManager { export function buildSessionWebSocketUrl(sessionId: string) { const url = new URL(getBaseUrl()) url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - url.pathname = `/ws/${encodeURIComponent(sessionId)}` + const basePath = url.pathname === '/' ? '' : url.pathname.replace(/\/$/, '') + url.pathname = `${basePath}/ws/${encodeURIComponent(sessionId)}` const token = getAuthToken() if (token) { diff --git a/desktop/src/components/layout/AppShell.test.tsx b/desktop/src/components/layout/AppShell.test.tsx index ded1fee4..4dd87bab 100644 --- a/desktop/src/components/layout/AppShell.test.tsx +++ b/desktop/src/components/layout/AppShell.test.tsx @@ -169,6 +169,21 @@ describe('AppShell boot flow', () => { expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument() }) + it('shows the H5 connection view for unreachable remote browser startup failures', async () => { + mocks.initializeDesktopServerUrl.mockRejectedValueOnce( + Object.assign(new Error('Unable to reach https://remote.example.com. Check the server URL or network access.'), { + name: 'H5ConnectionRequiredError', + serverUrl: 'https://remote.example.com', + }), + ) + + render() + + expect(await screen.findByText('h5 connection view')).toBeInTheDocument() + expect(screen.getByText('Unable to reach https://remote.example.com. Check the server URL or network access.')).toBeInTheDocument() + expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument() + }) + it('retries bootstrap after a successful H5 connection', async () => { mocks.initializeDesktopServerUrl .mockRejectedValueOnce( diff --git a/desktop/src/lib/desktopRuntime.test.ts b/desktop/src/lib/desktopRuntime.test.ts new file mode 100644 index 00000000..8bae06d6 --- /dev/null +++ b/desktop/src/lib/desktopRuntime.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const clientMocks = vi.hoisted(() => ({ + defaultBaseUrl: 'http://127.0.0.1:3456', + setBaseUrl: vi.fn(), + setAuthToken: vi.fn(), + postVerify: vi.fn(), +})) + +vi.mock('../api/client', () => ({ + api: { + post: clientMocks.postVerify, + }, + getDefaultBaseUrl: () => clientMocks.defaultBaseUrl, + setAuthToken: clientMocks.setAuthToken, + setBaseUrl: clientMocks.setBaseUrl, +})) + +import { + H5ConnectionRequiredError, + H5_SERVER_URL_STORAGE_KEY, + H5_TOKEN_STORAGE_KEY, + initializeDesktopServerUrl, + isLoopbackHostname, + requiresH5AuthForServerUrl, +} from './desktopRuntime' + +describe('desktopRuntime browser H5 bootstrap', () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + window.localStorage.clear() + window.history.pushState({}, '', '/') + globalThis.fetch = originalFetch + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.fetch = originalFetch + }) + + it('treats IPv6 loopback as local', () => { + expect(isLoopbackHostname('[::1]')).toBe(true) + expect(isLoopbackHostname('::1')).toBe(true) + expect(requiresH5AuthForServerUrl('http://[::1]:3456')).toBe(false) + expect(requiresH5AuthForServerUrl('http://127.0.0.1:3456')).toBe(false) + expect(requiresH5AuthForServerUrl('http://localhost:3456')).toBe(false) + expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(true) + }) + + it('clears an invalid token but preserves the remembered remote server URL', async () => { + window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp') + window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'stale-token') + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(null, { status: 200 }), + ) as typeof fetch + clientMocks.postVerify.mockRejectedValueOnce(new Error('Unauthorized')) + + await expect(initializeDesktopServerUrl()).rejects.toMatchObject({ + name: 'H5ConnectionRequiredError', + serverUrl: 'https://public.example.com/app', + message: 'The saved H5 token is no longer valid.', + } satisfies Partial) + + expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe( + 'https://public.example.com/app', + ) + expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull() + }) + + it('normalizes unreachable remote browser startup into a recoverable H5 error', async () => { + vi.useFakeTimers() + window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Funreachable.example.com') + globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) as typeof fetch + + const startup = expect(initializeDesktopServerUrl()).rejects.toMatchObject({ + name: 'H5ConnectionRequiredError', + serverUrl: 'https://unreachable.example.com', + message: 'Unable to reach https://unreachable.example.com. Check the server URL or network access.', + } satisfies Partial) + await vi.runAllTimersAsync() + + await startup + + expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe( + 'https://unreachable.example.com', + ) + }) + + it('normalizes remote verify failures like disabled H5 or CORS into recoverable H5 errors', async () => { + window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com') + window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'h5_token') + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(null, { status: 200 }), + ) as typeof fetch + clientMocks.postVerify.mockRejectedValueOnce(new TypeError('Failed to fetch')) + + await expect(initializeDesktopServerUrl()).rejects.toMatchObject({ + name: 'H5ConnectionRequiredError', + serverUrl: 'https://public.example.com', + message: 'Unable to verify the H5 access token.', + } satisfies Partial) + + expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe( + 'https://public.example.com', + ) + expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull() + }) +}) diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts index 10c5c48a..77682689 100644 --- a/desktop/src/lib/desktopRuntime.ts +++ b/desktop/src/lib/desktopRuntime.ts @@ -3,7 +3,11 @@ import { api, getDefaultBaseUrl, 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' +type H5ConnectionFailureReason = + | 'missing-token' + | 'invalid-token' + | 'verify-failed' + | 'unreachable' export type StoredH5Connection = { serverUrl: string | null @@ -73,18 +77,18 @@ export async function saveAndVerifyH5Connection(serverUrl: string, token: string setBaseUrl(normalizedServerUrl) setAuthToken(normalizedToken) + rememberStoredH5ServerUrl(normalizedServerUrl) try { await waitForHealth(normalizedServerUrl) await verifyH5Access() } catch (error) { - setAuthToken(null) - throw normalizeH5VerificationError(error, normalizedServerUrl) + clearStoredH5Token() + throw normalizeBrowserH5Error(error, normalizedServerUrl) } if (typeof window !== 'undefined') { try { - window.localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, normalizedServerUrl) window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, normalizedToken) } catch { // Ignore storage failures after a successful verification. @@ -128,17 +132,30 @@ async function initializeBrowserServerUrl(fallbackUrl: string) { const stored = readStoredH5Connection() const requestedUrl = normalizeServerUrl(queryUrl) ?? stored.serverUrl ?? fallbackUrl const token = stored.token + const browserH5Runtime = requiresH5AuthForServerUrl(requestedUrl) setBaseUrl(requestedUrl) setAuthToken(token) - await waitForHealth(requestedUrl) + if (browserH5Runtime) { + rememberStoredH5ServerUrl(requestedUrl) + } - if (!requiresH5Auth(requestedUrl)) { + try { + await waitForHealth(requestedUrl) + } catch (error) { + if (browserH5Runtime) { + clearStoredH5Token() + throw normalizeBrowserH5Error(error, requestedUrl) + } + throw error + } + + if (!browserH5Runtime) { return requestedUrl } if (!token) { - clearStoredH5Connection() + clearStoredH5Token() throw new H5ConnectionRequiredError( 'Enter your H5 token to continue.', requestedUrl, @@ -149,8 +166,8 @@ async function initializeBrowserServerUrl(fallbackUrl: string) { try { await verifyH5Access() } catch (error) { - clearStoredH5Connection() - throw normalizeH5VerificationError(error, requestedUrl) + clearStoredH5Token() + throw normalizeBrowserH5Error(error, requestedUrl) } return requestedUrl @@ -202,22 +219,30 @@ function normalizeToken(value: string | null | undefined) { return trimmed ? trimmed : null } -function requiresH5Auth(serverUrl: string) { +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) { try { - const hostname = new URL(serverUrl).hostname - return hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1' + return !isLoopbackHostname(new URL(serverUrl).hostname) } catch { return false } } -function normalizeH5VerificationError(error: unknown, serverUrl: string) { +function normalizeBrowserH5Error(error: unknown, serverUrl: string) { if (error instanceof H5ConnectionRequiredError) { return error } if (error instanceof Error && error.message.startsWith('Server healthcheck failed')) { - return new Error(`Unable to reach ${serverUrl}. Check the server URL or network access.`) + return new H5ConnectionRequiredError( + `Unable to reach ${serverUrl}. Check the server URL or network access.`, + serverUrl, + 'unreachable', + ) } const message = @@ -229,3 +254,25 @@ function normalizeH5VerificationError(error: unknown, serverUrl: string) { 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) +}