Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/desktop/.gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
dist/
release/
build/generated-icon.icon
build/generated-icon.png
playwright-report/
test-results/
16 changes: 15 additions & 1 deletion apps/desktop/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Sim Desktop (macOS)
# Sim Desktop (macOS, Windows)

A thin Electron shell around the hosted Sim web app. The renderer loads the configured origin (default `https://www.sim.ai` — the origin the server actually serves; the apex 301s there) as a normal top-level page in a bundled, pinned Chromium — rendering is identical to Chrome of that version on every machine. No UI is re-implemented and no server stack is bundled.

Expand Down Expand Up @@ -102,6 +102,19 @@ Overall this is **within normal thin-wrapper coupling** — every item is either

Local unsigned build: `bun run package:dir` (app in `release/mac-universal/`). Signed: `bun run package:mac` with `CSC_LINK`/`CSC_KEY_PASSWORD` exported.

### Windows

Build on a Windows host (electron-builder cross-compiles NSIS from macOS/Linux only with Wine, which is not supported here). Prerequisites: Bun ≥ 1.4.2, Node ≥ 20, and `tar` (built into Windows 10+). No C++ toolchain is needed: `scripts/build.ts` skips the AppKit Help-search addon off macOS, and node-pty ships ConPTY prebuilds (`@lydell/node-pty-win32-{x64,arm64}`, fetched by `scripts/ensure-pty-prebuilds.ts` the same way the macOS arches are).

- `bun run package:win:dir` — unpacked app in `release/win-unpacked/` (x64; add `--arm64` for `release/win-arm64-unpacked/`).
- `bun run package:win` — NSIS installer + zip per arch (`Sim-<version>-<arch>.exe`), from the same `electron-builder.yml`.
- Signing: set `CSC_LINK`/`CSC_KEY_PASSWORD` to an Authenticode `.pfx`; unsigned builds trigger SmartScreen on first launch.
- Bun does not run electron's `postinstall` on Windows; if `node_modules/electron/dist/electron.exe` is missing after `bun install`, run `node node_modules/electron/install.js` once.

The agent terminal launches PowerShell (`pwsh`, else Windows PowerShell 5.1) with full shell integration: the hooks are passed as `-EncodedCommand` (a dot-sourced file would be blocked by the default execution policy), `prompt` is wrapped to report the directory, exit code and prompt start, and `PSConsoleHostReadLine` is wrapped to report each command line and its start — the arrangement Windows Terminal and VS Code use. Git for Windows' bash is offered under the app menu's **Terminal Shell** submenu when Git is installed; it uses the existing bash hooks, with `cygpath -w` translating the reported directory to a Windows path. The choice persists as `terminalShell` in `settings.json`. tmux does not exist on Windows; its first probe fails with ENOENT and marks it unavailable for the rest of the session, as on a Mac without tmux.

Windows parity gaps (deliberate, see "Known caveats"): no auto-update (the updater is a no-op off macOS — the installer must be re-downloaded), no Help-menu docs search, no Chrome cookie/password import, and no Terminal.app/iTerm2 theme import.

Local unsigned pre-release share: `SIM_DESKTOP_DEFAULT_ORIGIN=https://www.dev.sim.ai bun run package:share` builds a DMG whose fresh installs default to that origin (baked at build time; official builds leave it unset → prod) and skips per-file signature timestamps. Recipients must clear quarantine once: `xattr -cr /Applications/Sim.app`.

The build also derives the app icon from `SIM_DESKTOP_DEFAULT_ORIGIN`. Every channel uses the exact production icon with its white background and black `sim` mark. Non-production channels add a thin outline using existing platform colors: dev uses orange, staging uses Loop blue, and localhost uses Workflow violet. The macOS menu-bar icon also carries a compact `D`, `S`, or `L` subscript for those environments; production remains unmarked. Native Icon Composer assets live in `build/`; `scripts/build.ts` copies the selected variant to the ignored `build/generated-icon.icon` path consumed by electron-builder. Electron-builder compiles it to `Assets.car` and derives the legacy `.icns` fallback from the same source. Matching 512px PNGs in `static/` provide the Dock icon for unpackaged runs.
Expand Down Expand Up @@ -216,6 +229,7 @@ Raw local file bytes are never exposed through the preload bridge and cannot be
- Third-party web analytics (GTM/GA) are blocked at the network layer by default (`blockThirdPartyAnalytics`); first-party PostHog `/ingest` is untouched.
- `Cmd+F` opens the native find overlay in built-in browser tabs. The hosted Sim workspace continues to use Monaco- and table-specific find surfaces.
- Sign-in uses only the `127.0.0.1` loopback callback, which needs no OS registration — so it completes identically under `bun run dev` (unpackaged) and in a packaged build. There is no custom URL scheme.
- Windows uses the native title bar (the `hiddenInset` traffic-light lane is macOS-only) and the tray's monochrome template icon is drawn as-is, so it is hard to see on a dark taskbar; the tray can be turned off in Desktop settings. Secure storage falls back to DPAPI via `safeStorage`, so remembered grants and credentials still work.

## Electron upgrades

Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,26 @@ mac:
entitlementsInherit: build/entitlements.mac.plist
notarize: true

win:
target:
- target: nsis
arch: [x64, arm64]
- target: zip
arch: [x64, arm64]
# electron-builder derives the multi-size .ico from this PNG; scripts/build.ts
# copies the selected channel's 1024px logo here next to the .icon bundle.
icon: build/generated-icon.png
# Unsigned: SmartScreen warns on first launch until a code-signing
# certificate is configured (CSC_LINK / CSC_KEY_PASSWORD, as on macOS).
signAndEditExecutable: true

nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
deleteAppDataOnUninstall: false
shortcutName: ${productName}

dmg:
sign: false
title: ${productName}
Expand Down
12 changes: 9 additions & 3 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"license": "Apache-2.0",
"description": "Sim desktop app for macOS \u2014 Electron shell around the hosted web app",
"description": "Sim desktop app for macOS and Windows \u2014 Electron shell around the hosted web app",
"author": "Sim <support@sim.ai>",
"homepage": "https://sim.ai",
"type": "module",
Expand All @@ -18,6 +18,8 @@
"start": "electron .",
"package:dir": "bun run build && electron-builder --mac dir --publish never",
"package:mac": "bun run build && electron-builder --mac --publish never",
"package:win:dir": "bun run build && electron-builder --win dir --publish never",
"package:win": "bun run build && electron-builder --win --publish never",
"package:share": "bun run scripts/package-share.ts",
"install:local": "bun run scripts/install-local.ts",
"type-check": "tsc --noEmit",
Expand All @@ -31,8 +33,6 @@
},
"dependencies": {
"@lydell/node-pty": "1.2.0-beta.12",
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@sim/browser-protocol": "workspace:*",
"@sim/desktop-bridge": "workspace:*",
"@sim/logger": "workspace:*",
Expand All @@ -45,6 +45,12 @@
"safe-regex2": "5.1.0",
"pdf-lib": "1.17.1"
},
"optionalDependencies": {
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
"@lydell/node-pty-win32-x64": "1.2.0-beta.12"
},
"devDependencies": {
"@electron/fuses": "1.8.0",
"@playwright/test": "1.61.1",
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ const appIcon = identityForOrigin(bakedDefaultOrigin).icon
const generatedIcon = 'build/generated-icon.icon'
rmSync(generatedIcon, { force: true, recursive: true })
cpSync(appIcon, generatedIcon, { recursive: true })
// Windows has no Icon Composer path; electron-builder builds the .ico from the
// same 1024px logo the macOS bundle is composed from.
const generatedWindowsIcon = 'build/generated-icon.png'
rmSync(generatedWindowsIcon, { force: true })
cpSync(join(appIcon, 'Assets', 'logo.png'), generatedWindowsIcon)
console.log(`• Selecting desktop icon: ${appIcon}`)

function compileNativeHelpSearch(): void {
Expand Down
40 changes: 28 additions & 12 deletions apps/desktop/scripts/ensure-pty-prebuilds.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
/**
* Fetches the node-pty prebuilt binaries for every architecture the macOS
* universal build ships.
* Fetches the node-pty prebuilt binaries for every architecture the host
* platform's build ships: both halves of the macOS universal app, or x64 and
* arm64 on Windows.
*
* `@lydell/node-pty` selects its native binary at runtime from a per-arch
* package (`@lydell/node-pty-darwin-arm64`, `-darwin-x64`), each declaring a
* matching `cpu` field. Package managers honour that field, so installing on
* an arm64 Mac leaves the x64 binary absent and the x64 half of the universal
* app ships without a working PTY. Fetching the tarball directly is the only
* way to get both without lying about the host architecture.
* package (`@lydell/node-pty-darwin-arm64`, `-win32-x64`, …), each declaring
* matching `os` and `cpu` fields. Package managers honour those fields, so
* installing on an arm64 Mac leaves the x64 binary absent and the x64 half of
* the universal app ships without a working PTY. Fetching the tarball directly
* is the only way to get both without lying about the host architecture. The
* per-arch packages are `optionalDependencies` so electron-builder's
* dependency walk skips the other platform's rather than failing the build.
*
* Both binaries live at distinct paths, so `@electron/universal` never has to
* lipo them together — it sees byte-identical trees in both halves and keeps
Expand All @@ -32,7 +35,15 @@ import { getErrorMessage } from '@sim/utils/errors'

const logger = createLogger('DesktopPtyPrebuilds')

const REQUIRED_ARCHES = ['darwin-arm64', 'darwin-x64'] as const
const REQUIRED_ARCHES_FOR_PLATFORM: Partial<Record<NodeJS.Platform, readonly string[]>> = {
darwin: ['darwin-arm64', 'darwin-x64'],
win32: ['win32-x64', 'win32-arm64'],
}

/** The addon each prebuild ships: a Unix pty, or the ConPTY bridge on Windows. */
function prebuildBinary(arch: string): string {
return arch.startsWith('win32-') ? 'conpty.node' : 'pty.node'
}

const desktopDir = dirname(dirname(fileURLToPath(import.meta.url)))
const workspaceRoot = dirname(dirname(desktopDir))
Expand Down Expand Up @@ -110,17 +121,22 @@ async function fetchPrebuild(arch: string, version: string): Promise<void> {
}

async function run(): Promise<void> {
const requiredArches = REQUIRED_ARCHES_FOR_PLATFORM[process.platform]
if (!requiredArches) {
throw new Error(`No desktop packaging target for platform "${process.platform}"`)
}
const version = await pinnedVersion()
for (const arch of REQUIRED_ARCHES) {
for (const arch of requiredArches) {
const dir = packageDir(arch)
if (existsSync(join(dir, 'prebuilds', arch, 'pty.node'))) {
const binary = prebuildBinary(arch)
if (existsSync(join(dir, 'prebuilds', arch, binary))) {
logger.info('node-pty prebuild present', { arch })
continue
}
logger.info('Fetching node-pty prebuild', { arch, version })
await fetchPrebuild(arch, version)
if (!existsSync(join(dir, 'prebuilds', arch, 'pty.node'))) {
throw new Error(`Downloaded @lydell/node-pty-${arch} but pty.node is missing`)
if (!existsSync(join(dir, 'prebuilds', arch, binary))) {
throw new Error(`Downloaded @lydell/node-pty-${arch} but ${binary} is missing`)
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/main/browser-agent/file-transfer.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { open, rename, rm, truncate } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { basename, join } from 'node:path'
import { BROWSER_FILE_TRANSFER_MAX_BYTES } from '@sim/browser-protocol'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

Expand Down Expand Up @@ -59,7 +59,7 @@ describe('stageUploadFiles', () => {
body: JSON.stringify({ toolCallId: 'call-1', index: 0 }),
signal,
})
expect(staged.map((path) => path.split('/').pop())).toEqual(['Q3 plan.pdf', 'granted.txt'])
expect(staged.map((path) => basename(path))).toEqual(['Q3 plan.pdf', 'granted.txt'])
expect(readFileSync(staged[0], 'utf8')).toBe('workspace bytes')
expect(readFileSync(staged[1], 'utf8')).toBe('local bytes')
expect(staged.every((path) => path.startsWith(join(temp, 'sim-browser-uploads')))).toBe(true)
Expand All @@ -83,7 +83,7 @@ describe('stageUploadFiles', () => {
})

expect(staged.startsWith(join(temp, 'sim-browser-uploads'))).toBe(true)
expect(staged.endsWith('/evil')).toBe(true)
expect(basename(staged)).toBe('evil')
})

it('refuses a workspace file over the transfer ceiling', async () => {
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/main/browser-credentials/vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ describe('CredentialVault', () => {
expect(JSON.parse(onDisk)).toMatchObject({ version: 1, ciphertext: expect.any(String) })
})

it('writes the vault file owner-only', async () => {
// Windows has no POSIX mode bits to assert on.
it.skipIf(process.platform === 'win32')('writes the vault file owner-only', async () => {
const vault = new CredentialVault(vaultPath, encryption())
await vault.importCredentials(CANDIDATES, 'keep-existing')

Expand Down
35 changes: 21 additions & 14 deletions apps/desktop/src/main/browser-import/chromium-profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ async function writeLocalState(
)
}

// Creating a symlink on Windows needs Developer Mode or elevation, which the
// test runner cannot assume.
const NO_SYMLINKS = process.platform === 'win32'

describe('listBrowserProfiles', () => {
it('returns display names, default profile first, with namespaced ids', async () => {
await addProfile(CHROME, 'Profile 2')
Expand Down Expand Up @@ -81,7 +85,7 @@ describe('listBrowserProfiles', () => {
expect(profiles.map(({ id }) => id)).toEqual(['chrome:Default'])
})

it('refuses a profile directory redirected through a symlink', async () => {
it.skipIf(NO_SYMLINKS)('refuses a profile directory redirected through a symlink', async () => {
const outsideProfile = join(home, 'outside-profile')
await mkdir(outsideProfile, { recursive: true })
await writeFile(join(outsideProfile, 'Login Data'), '')
Expand All @@ -93,20 +97,23 @@ describe('listBrowserProfiles', () => {
await expect(listBrowserProfiles(CHROME, home)).resolves.toEqual([])
})

it('refuses symlinked, hard-linked, and non-file password databases', async () => {
const outsideDatabase = join(home, 'outside-login-data')
await writeFile(outsideDatabase, '')

const userDataDir = userDataDirFor(CHROME, home)
for (const directory of ['Default', 'Profile 2', 'Profile 3']) {
await mkdir(join(userDataDir, directory), { recursive: true })
it.skipIf(NO_SYMLINKS)(
'refuses symlinked, hard-linked, and non-file password databases',
async () => {
const outsideDatabase = join(home, 'outside-login-data')
await writeFile(outsideDatabase, '')

const userDataDir = userDataDirFor(CHROME, home)
for (const directory of ['Default', 'Profile 2', 'Profile 3']) {
await mkdir(join(userDataDir, directory), { recursive: true })
}
await symlink(outsideDatabase, join(userDataDir, 'Default', 'Login Data For Account'))
await link(outsideDatabase, join(userDataDir, 'Profile 2', 'Login Data For Account'))
await mkdir(join(userDataDir, 'Profile 3', 'Login Data For Account'))

await expect(listBrowserProfiles(CHROME, home)).resolves.toEqual([])
}
await symlink(outsideDatabase, join(userDataDir, 'Default', 'Login Data For Account'))
await link(outsideDatabase, join(userDataDir, 'Profile 2', 'Login Data For Account'))
await mkdir(join(userDataDir, 'Profile 3', 'Login Data For Account'))

await expect(listBrowserProfiles(CHROME, home)).resolves.toEqual([])
})
)
})

describe('listAllBrowserProfiles', () => {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { DesktopZoomPercent, TerminalAppearanceTheme } from '@sim/desktop-b
import { createLogger } from '@sim/logger'
import { isLoopbackHostname } from '@sim/security/ssrf'
import { writeJsonFileAtomicallySync } from '@/main/atomic-json-file'
import type { WindowsTerminalShell } from '@/main/terminal/default-shell'

/** settings.json is meant to be readable when a user opens it. */
const SETTINGS_INDENT = 2
Expand Down Expand Up @@ -114,6 +115,8 @@ export interface DesktopSettings {
terminalTheme?: TerminalAppearanceTheme
/** Device-wide default zoom for built-in terminal canvases. */
terminalDefaultZoom?: DesktopZoomPercent
/** Windows only: which installed shell new terminals launch. */
terminalShell?: WindowsTerminalShell
/**
* Top-level sites visited in the dedicated agent-browser profile. This is
* local inference metadata only; no cookies, credentials, or account data
Expand Down
10 changes: 9 additions & 1 deletion apps/desktop/src/main/desktop-chat-session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe('DesktopChatSessionStore', () => {
expect(restarted.getTerminal(ORIGIN, 'chat-a')).toEqual(TERMINAL)
})

it('encrypts the complete descriptor payload and writes it owner-only', () => {
it('encrypts the complete descriptor payload', () => {
const provider = encryption()
const store = open(provider)
store.setBrowser(ORIGIN, 'chat-secret', BROWSER)
Expand All @@ -115,6 +115,14 @@ describe('DesktopChatSessionStore', () => {
expect(onDisk).not.toContain('report.csv')
expect(JSON.parse(onDisk)).toEqual({ v: 1, ciphertext: expect.any(String) })
expect(provider.encryptString).toHaveBeenCalledOnce()
})

// Windows has no POSIX mode bits to assert on.
it.skipIf(process.platform === 'win32')('writes the store file owner-only', () => {
const store = open(encryption())
store.setBrowser(ORIGIN, 'chat-secret', BROWSER)

expect(store.flush()).toBe(true)
expect(statSync(filePath).mode & 0o077).toBe(0)
})

Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/main/downloads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ describe('sanitizeFilename', () => {
})

describe('uniqueDownloadPath', () => {
it('treats a dangling symlink as occupied', async () => {
// Creating a symlink on Windows needs Developer Mode or elevation.
it.skipIf(process.platform === 'win32')('treats a dangling symlink as occupied', async () => {
const directory = mkdtempSync(join(tmpdir(), 'sim-download-path-'))
symlinkSync(join(directory, 'missing-target'), join(directory, 'report.csv'))

Expand Down Expand Up @@ -45,7 +46,7 @@ describe('uniqueDownloadPath', () => {
])

expect(new Set([first, second])).toEqual(
new Set(['/Downloads/report.csv', '/Downloads/report (copy-1).csv'])
new Set([join('/Downloads', 'report.csv'), join('/Downloads', 'report (copy-1).csv')])
)
})
})
Loading