Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .claude/rules/global.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/docs/electron-upgrade-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
78 changes: 77 additions & 1 deletion apps/desktop/e2e/packaged-smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 })
}
})
27 changes: 26 additions & 1 deletion apps/desktop/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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)
})
})
20 changes: 14 additions & 6 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -524,7 +529,6 @@ function main(): void {
const serverWindow = createServerWindow({
config,
defaultOrigin: DEFAULT_ORIGIN,
pagePath: SERVER_PAGE,
preloadPath,
isPackaged: app.isPackaged,
getParentWindow: getMainWindow,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
23 changes: 12 additions & 11 deletions apps/desktop/src/main/ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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 = {
Expand All @@ -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' }
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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' }])
Expand Down
Loading
Loading