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) +}