diff --git a/.claude/rules/global.md b/.claude/rules/global.md index afd2290e37d..8ae6e0e874c 100644 --- a/.claude/rules/global.md +++ b/.claude/rules/global.md @@ -68,6 +68,9 @@ const clone = structuredClone(obj) const filtered = filterUndefined(obj) ``` +## Deployment flags in the browser +Client code inside a workspace reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never the `isHosted`/`isBillingEnabled` constants from `env-flags`. Those constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit, so a tab recovered from one would render Sim Cloud as self-hosted. The reader is seeded from the server-resolved workspace host context. Server code keeps reading `env-flags`. + ## Package Manager Use `bun` and `bunx`, not `npm` and `npx`. diff --git a/CLAUDE.md b/CLAUDE.md index 773e3bccf51..cc25ad704c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ You are a professional software engineer. All code must follow best practices: a - `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — object trimming; never `Object.fromEntries(Object.entries(...).filter(...))` - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis - `backoffWithJitter(attempt, retryAfterMs, options?)` / `parseRetryAfter(header)` from `@sim/utils/retry` — shared retry pacing; never reimplement exponential backoff inline +- **Deployment flags in the browser**: client code inside a workspace reads `hosted`, `billingEnabled`, `chatEnabled`, and the enterprise feature set through `useDeploymentShape()` (components) or `getDeploymentShape()` (block conditions, stores, helpers) from `@/lib/core/config/deployment-shape`, never `isHosted`/`isBillingEnabled`/... from `env-flags`. The constants freeze at module init from the root layout's `NEXT_PUBLIC_*` transport, which Next's bare 404 shell and `global-error` never emit; the reader is seeded from the server-resolved workspace host context instead. Server code keeps reading `env-flags` - **Package Manager**: Use `bun` and `bunx`, not `npm` and `npx` - **Type-checking**: Run `bun run type-check` (per workspace) or `bunx turbo run type-check` (all of them). Do not remove the `@typescript/native` alias from the root `devDependencies` — nothing imports it, but it is what makes a bare `tsc` resolve to the native TypeScript 7 compiler instead of the ~10x slower JavaScript TypeScript 6 one that `@typescript/typescript6` pulls in transitively. `bun run check:native-typecheck` enforces this diff --git a/apps/desktop/README.md b/apps/desktop/README.md index bd3e9dffc5d..823bf0d4ced 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -19,6 +19,7 @@ src/main/ # main process (bundled to dist/main.cjs) handoff.ts # 127.0.0.1 loopback login handoff + token redeem session-lifecycle.ts # sign-out teardown, 401 watcher, connect intercept load-health.ts # offline/error page, auto-retry, watchdog + local-pages.ts # sim-shell: scheme for the bundled pages (file: cannot read app.asar with its privileges fused off) local-filesystem.ts # session-scoped read-only directory grants + localfs:// broker local-filesystem-grant-store.ts # those grants, encrypted at rest desktop-settings.ts # renderer-facing settings surface @@ -38,7 +39,7 @@ src/preload/ # isolated renderer bridges index.ts # hosted-app contextBridge IPC bridge (dist/preload.cjs) browser/ # minimal agent-browser credential helper (dist/browser-preload.cjs) native/ # Node-API/AppKit bridge for native macOS Help docs search -static/ # bundled local pages (offline.html) +static/ # bundled local pages (offline.html, server.html), served over sim-shell: e2e/ # Playwright _electron smoke suite ``` @@ -130,7 +131,7 @@ Yes — the architecture has a single, clean seam for native features, and nothi 1. **One bridge.** The preload (`src/preload/index.ts`) exposes `window.simDesktop` via `contextBridge` on the main window. This is the *only* channel between web content and native capability. It exposes narrow, typed methods — never raw `ipcRenderer` (Electron security checklist item 20). 2. **Feature-detect, never assume.** The same web app is served to browsers and to the desktop from one origin, so a desktop feature is progressive enhancement: `if (window.simDesktop) { … }`. In a browser `window.simDesktop` is `undefined` and the feature is simply absent. (`isHosted` already tags these sessions for analytics.) -3. **Gate in main.** Every channel is validated in `src/main/ipc.ts` by sender frame — app-origin for capability calls, bundled `file:` pages for shell-control calls (checklist item 17). A new native feature adds one gated channel there. +3. **Gate in main.** Every channel is validated in `src/main/ipc.ts` by sender frame — app-origin for capability calls, the bundled `sim-shell://pages/…` documents for shell-control calls (checklist item 17). A new native feature adds one gated channel there. 4. **Single-source the contract.** `apps/sim` cannot import from `apps/desktop` (monorepo rule: `apps/* → packages/*` only). The bridge interface lives in the shared types-only `packages/desktop-bridge` package, which both the preload and web app consume. Concrete example — a "Reveal in Finder" button: diff --git a/apps/desktop/docs/electron-upgrade-checklist.md b/apps/desktop/docs/electron-upgrade-checklist.md index 04aa583558a..dbdd667bddf 100644 --- a/apps/desktop/docs/electron-upgrade-checklist.md +++ b/apps/desktop/docs/electron-upgrade-checklist.md @@ -4,7 +4,7 @@ The rendering-parity guarantee (identical to Chrome of the pinned version) is on 1. **Read the release notes.** Electron breaking-changes page for the target major, plus its Chromium/Node versions. Note anything touching: session/cookies, permissions, `setWindowOpenHandler`, `will-navigate`/`will-redirect`, preload/sandbox, `net`/loopback, fuses. 2. **Bump the pin** in `apps/desktop/package.json` (exact version), `bun install`, `bun run type-check && bun run test`. -3. **Fuses:** the packaged smoke test asserts the complete fuse wire. Decide the policy for every new fuse, configure it in `electron-builder.yml` when supported, and update the expected wire only after verifying the packaged binary. +3. **Fuses:** the packaged smoke test asserts the complete fuse wire. Decide the policy for every new fuse, configure it in `electron-builder.yml` when supported, and update the expected wire only after verifying the packaged binary. `grantFileProtocolExtraPrivileges` stays off, which is why the bundled pages are served over `sim-shell:` (`src/main/local-pages.ts`) rather than `file:` — with it off, `file:` cannot read inside `app.asar`. The packaged smoke test loads the offline page over remote debugging to prove the pages still render after an upgrade. 4. **Cookie-encryption go/no-go:** packaged build → sign in → quit → relaunch → still signed in. If the session is lost, flip `enableCookieEncryption: false`, file it in the README, and retest. 5. **Manual spot-checks (packaged build):** - Google sign-in via the system-browser handoff (127.0.0.1 loopback callback → token redeem). diff --git a/apps/desktop/e2e/packaged-smoke.spec.ts b/apps/desktop/e2e/packaged-smoke.spec.ts index e7613321e87..f4010e8abfa 100644 --- a/apps/desktop/e2e/packaged-smoke.spec.ts +++ b/apps/desktop/e2e/packaged-smoke.spec.ts @@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { FuseV1Options, FuseVersion, getCurrentFuseWire } from '@electron/fuses' -import { expect, test } from '@playwright/test' +import { type Browser, chromium, expect, test } from '@playwright/test' const FUSE_DISABLED = '0'.charCodeAt(0) const FUSE_ENABLED = '1'.charCodeAt(0) @@ -82,3 +82,79 @@ test('packaged main process starts and records launch telemetry', async () => { rmSync(userDataPath, { recursive: true, force: true }) } }) + +// The unpackaged suite cannot see this: the bundled pages live inside app.asar +// only once packaged, and the file-protocol fuse is only off once packaged. +// v0.8.13 through v0.8.19 shipped both pages blank because nothing loaded them +// in that configuration. Chromium's remote-debugging switch is honoured by the +// fused binary, which is what lets the test read the rendered page. +test('packaged shell renders the bundled offline page', async () => { + const executablePath = process.env.SIM_DESKTOP_EXECUTABLE + if (!executablePath) throw new Error('SIM_DESKTOP_EXECUTABLE is required') + const userDataPath = mkdtempSync(join(tmpdir(), 'sim-desktop-packaged-e2e-')) + // Cookie encryption and safeStorage key their secret off the app's identity + // in the login keychain. A build under test (unsigned locally, or the first + // run on a machine that already has the real app's item) would block on a + // Keychain prompt on its main thread, and the debugging endpoint with it. + const child = spawn(executablePath, ['--remote-debugging-port=0', '--use-mock-keychain'], { + env: { + ...process.env, + SIM_DESKTOP_ORIGIN: 'http://127.0.0.1:1', + SIM_DESKTOP_USER_DATA: userDataPath, + }, + stdio: 'ignore', + }) + const portFile = join(userDataPath, 'DevToolsActivePort') + let browser: Browser | undefined + + try { + await expect + .poll( + () => { + if (child.exitCode !== null || child.signalCode !== null) { + throw new Error( + `Packaged app exited with ${child.exitCode ?? child.signalCode ?? 'unknown status'}` + ) + } + return existsSync(portFile) && readFileSync(portFile, 'utf8').trim().length > 0 + }, + { timeout: 15_000 } + ) + .toBe(true) + const port = Number(readFileSync(portFile, 'utf8').split('\n')[0]) + browser = await chromium.connectOverCDP(`http://127.0.0.1:${port}`) + const findPage = (urlPrefix: string) => + browser + ?.contexts() + .flatMap((context) => context.pages()) + .find((page) => page.url().startsWith(urlPrefix)) + await expect + .poll(() => Boolean(findPage('sim-shell://pages/offline.html?')), { timeout: 15_000 }) + .toBe(true) + const offline = findPage('sim-shell://pages/offline.html?') + if (!offline) throw new Error('offline page disappeared') + await expect(offline.locator('#title')).toHaveText('Can’t connect to Sim') + await expect(offline.locator('#server')).toBeVisible() + + // The picker is the recovery path from here. Opening it and reading the + // pre-filled value crosses the local-page IPC gate twice, which packaged + // builds also used to refuse: the allowlist was resolved against a working + // directory that is `/` when Finder launches the app. + await offline.locator('#server').click() + await expect + .poll(() => Boolean(findPage('sim-shell://pages/server.html')), { timeout: 15_000 }) + .toBe(true) + const picker = findPage('sim-shell://pages/server.html') + if (!picker) throw new Error('server picker disappeared') + await expect(picker.locator('h1')).toHaveText('Sim server') + await expect(picker.locator('#origin')).toHaveValue('http://127.0.0.1:1') + } finally { + await browser?.close().catch(() => {}) + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit') + child.kill('SIGKILL') + await exited + } + rmSync(userDataPath, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index b96f923908b..4b6c03016ca 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -157,7 +157,7 @@ test.describe('desktop shell smoke', () => { app = await launchApp('http://127.0.0.1:1') const window = await app.firstWindow() await window.waitForSelector('#retry', { timeout: 30_000 }) - expect(window.url().startsWith('file:')).toBe(true) + expect(window.url()).toMatch(/^sim-shell:\/\/pages\/offline\.html\?/) await expect(window.locator('.wordmark')).toBeVisible() await expect(window.locator('.wordmark')).toHaveAttribute('aria-label', 'Sim') await expect(window.locator('#title')).toHaveText('Can’t connect to Sim') @@ -183,4 +183,29 @@ test.describe('desktop shell smoke', () => { await expect(window.locator('#retry')).toHaveCSS('outline-style', 'solid') await expect(window.locator('#detail')).toHaveAttribute('role', 'status') }) + + // The picker is the only way to repoint a shell whose server is unreachable. + // Its page, the pre-filled value (which crosses the local-page IPC gate) and + // Escape are asserted together because the packaged build once opened it as + // a blank sheet with no way out. + test('the offline page opens the server picker, pre-filled, and Escape closes it', async () => { + app = await launchApp('http://127.0.0.1:1') + const window = await app.firstWindow() + await window.waitForSelector('#server', { timeout: 30_000 }) + + const pickerPromise = app.waitForEvent('window') + await window.locator('#server').click() + const picker = await pickerPromise + + expect(picker.url()).toBe('sim-shell://pages/server.html') + await expect(picker.locator('h1')).toHaveText('Sim server') + await expect(picker.locator('#origin')).toHaveValue('http://127.0.0.1:1') + + const closed = picker.waitForEvent('close') + // The main process destroys the window on the key-down, so the key-up half + // of `press` has no target to reach; the close event is the assertion. + await picker.keyboard.press('Escape').catch(() => {}) + await closed + expect(app.windows()).toHaveLength(1) + }) }) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 9cfcc37b401..4bb615b229b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,4 +1,4 @@ -import { join, resolve } from 'node:path' +import { join } from 'node:path' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { OpenDialogOptions, Session, WebContents } from 'electron' @@ -57,6 +57,12 @@ import { registerIpcHandlers } from '@/main/ipc' import { attachLoadHealth, type LoadHealthHandle } from '@/main/load-health' import { LocalFilesystemService } from '@/main/local-filesystem' import { createEncryptedLocalFilesystemGrantStore } from '@/main/local-filesystem-grant-store' +import { + attachLocalPageProtocol, + isLocalPageUrl, + localPageUrl, + registerLocalPageScheme, +} from '@/main/local-pages' import { installApplicationMenu } from '@/main/menu' import { openExternalSafe } from '@/main/navigation' import { createEventLog, installMainProcessFailureObservers } from '@/main/observability' @@ -91,8 +97,6 @@ function reportHandoffFailure(error: unknown): void { logger.error('Sign-in handoff failed', { error: getErrorMessage(error) }) } -const OFFLINE_PAGE = 'static/offline.html' -const SERVER_PAGE = 'static/server.html' const DOCK_ICON_FOR_CHANNEL = { prod: 'dock-icon.png', staging: 'dock-icon-staging.png', @@ -259,6 +263,7 @@ function main(): void { } configuredPartitions.add(partition) setupPermissionHandlers(ses, appOrigin) + attachLocalPageProtocol(ses) attachCspFallback(ses, appOrigin) attachDownloadHandling(ses, events) attachTelemetryPolicy(ses, config.get('blockThirdPartyAnalytics') ?? true) @@ -425,7 +430,7 @@ function main(): void { allowHttpLocalhost: allowHttpLocalhost(), }) const loadHealth = attachLoadHealth(win, { - offlinePagePath: OFFLINE_PAGE, + offlinePageUrl: (query) => localPageUrl('offline.html', query), getStartUrl: () => `${appOrigin()}${route}`, isOnline: () => net.isOnline(), events, @@ -524,7 +529,6 @@ function main(): void { const serverWindow = createServerWindow({ config, defaultOrigin: DEFAULT_ORIGIN, - pagePath: SERVER_PAGE, preloadPath, isPackaged: app.isPackaged, getParentWindow: getMainWindow, @@ -754,7 +758,7 @@ function main(): void { appOrigin, allowHttpLocalhost, accountDataAvailable, - localPagePaths: [resolve(OFFLINE_PAGE), resolve(SERVER_PAGE)], + isLocalPageUrl, scopeEvents, retryLoad: (sender) => { const win = windowForContents(sender) @@ -894,6 +898,10 @@ if (process.env.SIM_DESKTOP_USER_DATA) { app.setPath('userData', process.env.SIM_DESKTOP_USER_DATA) } +// The scheme the offline page and server picker load from must be declared +// before the app is ready; the per-session handlers attach later. +registerLocalPageScheme() + // Capture native minidumps for main/renderer/GPU crashes. Local-only: there is // no crash-ingest backend, so nothing is uploaded — the dumps land under // userData/Crashpad and the event log records where. Must start before the app diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index b75537b14c6..519c9a9cf46 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -133,6 +133,7 @@ import { getSearchSuggestions } from '@/main/browser-search/suggestions' import { trackInputActivity } from '@/main/input-activity' import { type IpcDeps, openMicrophoneSettings, registerIpcHandlers } from '@/main/ipc' import { LocalFilesystemService } from '@/main/local-filesystem' +import { isLocalPageUrl } from '@/main/local-pages' import { TerminalRegistry } from '@/main/terminal/registry' import { findCachedTerminalThemeProfile, listTerminalThemeProfiles } from '@/main/terminal-themes' @@ -224,14 +225,14 @@ function trackedSender() { } const rejectedSender = () => trackedSender().sender -const fileSender = rejectedSender() +const localPageSender = rejectedSender() const appSender = rejectedSender() const evilSender = rejectedSender() const activeSender = trackedSender() const activeChooserSender = trackedSender() -const fileEvent = { - senderFrame: { url: 'file:///app/static/offline.html' }, - sender: fileSender, +const localPageEvent = { + senderFrame: { url: 'sim-shell://pages/offline.html?kind=dns&detail=probe' }, + sender: localPageSender, } const appEvent = { senderFrame: { url: `${APP}/workspace/ws1` }, sender: appSender } const activeAppEvent = { @@ -246,7 +247,7 @@ const inactiveAppEvent = { const evilEvent = { senderFrame: { url: 'https://evil.example/page' }, sender: evilSender } const arbitraryFileEvent = { senderFrame: { url: 'file:///Users/example/private.html' }, - sender: fileSender, + sender: localPageSender, } /** The chooser anchors a native menu, so it needs a sender with a window. */ const FAKE_WINDOW = { id: 'main-window' } @@ -288,7 +289,7 @@ describe('registerIpcHandlers', () => { appOrigin: () => APP, allowHttpLocalhost: () => false, accountDataAvailable: () => true, - localPagePaths: ['/app/static/offline.html', '/app/static/server.html'], + isLocalPageUrl, retryLoad: vi.fn(), beginOAuthConnect: vi.fn(async () => true), localFilesystem: new LocalFilesystemService({ @@ -402,7 +403,7 @@ describe('registerIpcHandlers', () => { const { invoke } = collectHandlers() const handler = invoke.get('desktop:oauth-connect') expect(await handler?.(evilEvent, 'slack')).toBe(false) - expect(await handler?.(fileEvent, 'slack')).toBe(false) + expect(await handler?.(localPageEvent, 'slack')).toBe(false) expect(await handler?.(appEvent, 'slack')).toBe(false) expect(deps.beginOAuthConnect).not.toHaveBeenCalled() expect(await handler?.(activeAppEvent, 42)).toBe(false) @@ -681,8 +682,8 @@ describe('registerIpcHandlers', () => { expect(deps.retryLoad).not.toHaveBeenCalled() on.get('offline:retry')?.(arbitraryFileEvent) expect(deps.retryLoad).not.toHaveBeenCalled() - on.get('offline:retry')?.(fileEvent) - expect(deps.retryLoad).toHaveBeenCalledWith(fileSender) + on.get('offline:retry')?.(localPageEvent) + expect(deps.retryLoad).toHaveBeenCalledWith(localPageSender) }) it('registers every channel the preload bridge invokes or sends', () => { @@ -738,7 +739,7 @@ describe('registerIpcHandlers', () => { ok: false, error: expect.stringContaining('not allowed'), }) - expect(await handler?.(fileEvent, 'tool-1', 'browser_navigate', {})).toMatchObject({ + expect(await handler?.(localPageEvent, 'tool-1', 'browser_navigate', {})).toMatchObject({ ok: false, }) expect(await handler?.(appEvent, 'tool-1', 'browser_snapshot', {}, 'chat-1')).toMatchObject({ @@ -1648,7 +1649,7 @@ describe('registerIpcHandlers', () => { const handler = invoke.get('browser-import:list-profiles') expect(await handler?.(evilEvent)).toEqual([]) - expect(await handler?.(fileEvent)).toEqual([]) + expect(await handler?.(localPageEvent)).toEqual([]) expect(listChromeImportProfiles).not.toHaveBeenCalled() expect(await handler?.(appEvent)).toEqual([{ id: 'Default', label: 'Person 1' }]) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 3befddec165..c4f9fd51662 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -1,5 +1,3 @@ -import { normalize } from 'node:path' -import { fileURLToPath } from 'node:url' import { BROWSER_TOOL_AUTHORIZATION_TIMEOUT_MS, type BrowserPanelAction, @@ -332,8 +330,8 @@ export interface IpcDeps { allowHttpLocalhost: () => boolean /** False while local account-data persistence is unavailable or teardown must be retried. */ accountDataAvailable: () => boolean - /** Absolute paths of the bundled recovery pages allowed to control the shell. */ - localPagePaths: readonly string[] + /** Whether a frame URL is one of the bundled pages allowed to control the shell. */ + isLocalPageUrl: (url: string) => boolean retryLoad: (sender: WebContents) => void localFilesystem: LocalFilesystemService terminal: TerminalRegistry @@ -383,7 +381,8 @@ export interface IpcDeps { /** * Who may call a channel: * - `app-origin`: only the remote app origin (main window pages). - * - `local-page`: only bundled `file:` pages (offline) — shell control. + * - `local-page`: only the bundled pages served from the shell's own scheme + * (offline, server) — shell control. * - `browser-page`: only the built-in browser's own tabs, identified by * WebContents rather than by URL. These carry reports from the browser * preload about untrusted pages, so they are the one inbound surface whose @@ -435,16 +434,9 @@ type ChannelSpec = function isLocalPageSender( event: IpcMainEvent | IpcMainInvokeEvent, - localPagePaths: readonly string[] + isLocalPageUrl: (url: string) => boolean ): boolean { - try { - const url = new URL(event.senderFrame?.url ?? '') - if (url.protocol !== 'file:') return false - const senderPath = normalize(fileURLToPath(url)) - return localPagePaths.some((allowedPath) => senderPath === normalize(allowedPath)) - } catch { - return false - } + return isLocalPageUrl(event.senderFrame?.url ?? '') } /** @@ -1929,7 +1921,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { if (gate === 'any') return true if (gate === 'app-origin') return isAppOriginSender(event, deps.appOrigin()) if (gate === 'browser-page') return isAgentWebContents(event.sender) - return isLocalPageSender(event, deps.localPagePaths) + return isLocalPageSender(event, deps.isLocalPageUrl) } const featureAllowed = (feature: ChannelFeature | undefined): boolean => { diff --git a/apps/desktop/src/main/load-health.test.ts b/apps/desktop/src/main/load-health.test.ts index e313d0a6533..d306809932e 100644 --- a/apps/desktop/src/main/load-health.test.ts +++ b/apps/desktop/src/main/load-health.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' -import { classifyLoadError } from '@/main/load-health' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { attachLoadHealth, classifyLoadError } from '@/main/load-health' +import { BrowserWindow as MockBrowserWindow } from '@/test/electron-mock' describe('classifyLoadError', () => { it('ignores aborted navigations (OAuth redirects abort constantly)', () => { @@ -32,3 +33,77 @@ describe('classifyLoadError', () => { expect(classifyLoadError(-324)).toBe('unreachable') }) }) + +vi.mock('electron', () => import('@/test/electron-mock')) + +describe('attachLoadHealth', () => { + function setup() { + vi.useFakeTimers() + const win = new MockBrowserWindow() + const events = { record: vi.fn() } + attachLoadHealth(win as never, { + offlinePageUrl: ({ kind, detail }) => + `sim-shell://pages/offline.html?kind=${kind}&detail=${encodeURIComponent(detail)}`, + getStartUrl: () => 'https://sim.example.com/workspace', + isOnline: () => true, + events: events as never, + }) + const failLoad = (errorCode: number, description: string, url: string) => { + const handler = win.webContents.on.mock.calls.find(([name]) => name === 'did-fail-load')?.[1] + if (!handler) throw new Error('no did-fail-load handler') + ;(handler as (...args: unknown[]) => void)({}, errorCode, description, url, true) + } + return { win, events, failLoad } + } + + afterEach(() => { + vi.useRealTimers() + }) + + it('swaps a failed origin load for the bundled offline page', () => { + const { win, events, failLoad } = setup() + + failLoad(-105, 'ERR_NAME_NOT_RESOLVED', 'https://sim.example.com/workspace') + + expect(win.loadURL).toHaveBeenCalledWith( + 'sim-shell://pages/offline.html?kind=dns&detail=ERR_NAME_NOT_RESOLVED%20(-105)' + ) + expect(events.record).toHaveBeenCalledWith('load_failure', { + kind: 'dns', + detail: 'ERR_NAME_NOT_RESOLVED (-105)', + }) + }) + + // A packaged build once failed to load the offline page itself and re-showed + // it on every failure; that must stop at the first one. + it('does not loop when the offline page itself fails to load', () => { + const { win, events, failLoad } = setup() + + failLoad(-105, 'ERR_NAME_NOT_RESOLVED', 'https://sim.example.com/workspace') + failLoad(-6, 'ERR_FILE_NOT_FOUND', 'sim-shell://pages/offline.html?kind=dns') + + expect(win.loadURL).toHaveBeenCalledTimes(1) + expect(events.record).toHaveBeenCalledTimes(1) + }) + + // Stopping the retry instead would strand the window blank until a relaunch. + // The origin keeps being retried on the usual cadence; only the broken + // bundled page is never navigated to again. + it('keeps retrying the origin after the offline page broke, without reloading it', () => { + const { win, events, failLoad } = setup() + + failLoad(-105, 'ERR_NAME_NOT_RESOLVED', 'https://sim.example.com/workspace') + failLoad(-6, 'ERR_FILE_NOT_FOUND', 'sim-shell://pages/offline.html?kind=dns') + vi.advanceTimersByTime(5000) + + expect(win.loadURL).toHaveBeenCalledTimes(2) + expect(win.loadURL).toHaveBeenLastCalledWith('https://sim.example.com/workspace') + + failLoad(-105, 'ERR_NAME_NOT_RESOLVED', 'https://sim.example.com/workspace') + vi.advanceTimersByTime(5000) + + expect(events.record).toHaveBeenCalledTimes(2) + expect(win.loadURL).toHaveBeenCalledTimes(3) + expect(win.loadURL).toHaveBeenLastCalledWith('https://sim.example.com/workspace') + }) +}) diff --git a/apps/desktop/src/main/load-health.ts b/apps/desktop/src/main/load-health.ts index ab8b9d4c141..de71f85ecb9 100644 --- a/apps/desktop/src/main/load-health.ts +++ b/apps/desktop/src/main/load-health.ts @@ -35,7 +35,8 @@ export function classifyLoadError(errorCode: number): LoadErrorKind { } export interface LoadHealthDeps { - offlinePagePath: string + /** URL of the bundled offline page, carrying the failure it should explain. */ + offlinePageUrl: (query: { kind: LoadErrorKind; detail: string }) => string getStartUrl: () => string isOnline: () => boolean events: EventRecorder @@ -48,13 +49,14 @@ export interface LoadHealthHandle { /** * Branded recovery for a fully remote renderer: on main-frame load failures - * the window swaps to the bundled offline page (a local file, never wrapping - * the origin), auto-retries when the network returns, and a first-paint + * the window swaps to the bundled offline page (served from the shell's own + * scheme, never wrapping the origin), auto-retries when the network returns, and a first-paint * watchdog catches servers that accept connections but never respond. */ export function attachLoadHealth(win: BrowserWindow, deps: LoadHealthDeps): LoadHealthHandle { let intendedUrl: string | null = null let showingOffline = false + let offlinePageBroken = false let retryTimer: NodeJS.Timeout | undefined let watchdogTimer: NodeJS.Timeout | undefined @@ -107,7 +109,12 @@ export function attachLoadHealth(win: BrowserWindow, deps: LoadHealthDeps): Load } showingOffline = true deps.events.record('load_failure', { kind, detail }) - void win.loadFile(deps.offlinePagePath, { query: { kind, detail } }) + // A bundled page that failed once fails for a packaging reason, not a + // transient one, so it is never navigated to again. The origin retry stays + // armed regardless: it is the only way the window recovers on its own. + if (!offlinePageBroken) { + void win.loadURL(deps.offlinePageUrl({ kind, detail })) + } startAutoRetry() } @@ -122,6 +129,18 @@ export function attachLoadHealth(win: BrowserWindow, deps: LoadHealthDeps): Load if (kind === 'ignored') { return } + // Only the origin is retried through the offline page. If the bundled + // page itself failed there is nothing left to swap to, and showing it + // again would loop. + if (showingOffline && !validatedURL?.startsWith('http')) { + offlinePageBroken = true + logger.error('Bundled offline page failed to load', { + errorCode, + errorDescription, + url: scrubUrl(validatedURL ?? ''), + }) + return + } if (validatedURL?.startsWith('http')) { intendedUrl = validatedURL } diff --git a/apps/desktop/src/main/local-pages.test.ts b/apps/desktop/src/main/local-pages.test.ts new file mode 100644 index 00000000000..a425b2f6235 --- /dev/null +++ b/apps/desktop/src/main/local-pages.test.ts @@ -0,0 +1,151 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Session } from 'electron' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import { + attachLocalPageProtocol, + createLocalPageHandler, + isLocalPageUrl, + LOCAL_PAGE_ORIGIN, + localPageUrl, +} from '@/main/local-pages' + +describe('localPageUrl', () => { + it('addresses the bundled pages on the shell scheme', () => { + expect(localPageUrl('server.html')).toBe('sim-shell://pages/server.html') + expect(localPageUrl('offline.html')).toBe('sim-shell://pages/offline.html') + }) + + it('encodes the query it is given', () => { + const url = new URL( + localPageUrl('offline.html', { kind: 'dns', detail: 'ERR_NAME_NOT_RESOLVED (-105)' }) + ) + // Node's URL parser has no notion of Chromium's `standard` privilege, so + // `origin` serialises as "null" here; scheme, host and path are what count. + expect(url.protocol).toBe('sim-shell:') + expect(url.host).toBe('pages') + expect(url.pathname).toBe('/offline.html') + expect(url.searchParams.get('kind')).toBe('dns') + expect(url.searchParams.get('detail')).toBe('ERR_NAME_NOT_RESOLVED (-105)') + }) +}) + +describe('isLocalPageUrl', () => { + it('accepts the bundled pages with or without a query', () => { + expect(isLocalPageUrl('sim-shell://pages/offline.html')).toBe(true) + expect(isLocalPageUrl('sim-shell://pages/offline.html?kind=dns&detail=x')).toBe(true) + expect(isLocalPageUrl('sim-shell://pages/server.html')).toBe(true) + }) + + // The IPC gate for shell control runs on this: a page the server serves, a + // stray file, or a bundled asset that is not a page must all be refused. + it('rejects every other scheme, host, and path', () => { + for (const url of [ + 'file:///app/static/offline.html', + 'https://www.sim.ai/offline.html', + 'sim-shell://evil/offline.html', + 'sim-shell://pages/SeasonSansUprightsVF.woff2', + 'sim-shell://pages/static/offline.html', + 'sim-shell://pages/', + 'not a url', + '', + ]) { + expect(isLocalPageUrl(url), url).toBe(false) + } + }) +}) + +describe('createLocalPageHandler', () => { + let root: string + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'sim-local-pages-')) + writeFileSync(join(root, 'offline.html'), '