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