diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index e7ec8f6297f..34e4bd2053d 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -1,5 +1,6 @@ dist/ release/ build/generated-icon.icon +build/generated-icon.png playwright-report/ test-results/ diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 309160f0339..c0dc2f721ab 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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. @@ -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--.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. @@ -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 diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index 25856fb9dc8..2c24a95e6ba 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -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} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a4484720362..9b0da48dc93 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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 ", "homepage": "https://sim.ai", "type": "module", @@ -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", @@ -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:*", @@ -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", diff --git a/apps/desktop/scripts/build.ts b/apps/desktop/scripts/build.ts index b15dc2fb354..e9b5e83d0e2 100644 --- a/apps/desktop/scripts/build.ts +++ b/apps/desktop/scripts/build.ts @@ -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 { diff --git a/apps/desktop/scripts/ensure-pty-prebuilds.ts b/apps/desktop/scripts/ensure-pty-prebuilds.ts index a08856a51f2..52ac745440d 100644 --- a/apps/desktop/scripts/ensure-pty-prebuilds.ts +++ b/apps/desktop/scripts/ensure-pty-prebuilds.ts @@ -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 @@ -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> = { + 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)) @@ -110,17 +121,22 @@ async function fetchPrebuild(arch: string, version: string): Promise { } async function run(): Promise { + 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`) } } } diff --git a/apps/desktop/src/main/browser-agent/file-transfer.test.ts b/apps/desktop/src/main/browser-agent/file-transfer.test.ts index bc4569f37c6..1454fd715a7 100644 --- a/apps/desktop/src/main/browser-agent/file-transfer.test.ts +++ b/apps/desktop/src/main/browser-agent/file-transfer.test.ts @@ -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' @@ -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) @@ -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 () => { diff --git a/apps/desktop/src/main/browser-credentials/vault.test.ts b/apps/desktop/src/main/browser-credentials/vault.test.ts index 264bbe7f799..aa16fa06909 100644 --- a/apps/desktop/src/main/browser-credentials/vault.test.ts +++ b/apps/desktop/src/main/browser-credentials/vault.test.ts @@ -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') diff --git a/apps/desktop/src/main/browser-import/chromium-profiles.test.ts b/apps/desktop/src/main/browser-import/chromium-profiles.test.ts index d39a737ea66..0ceb39fa1b0 100644 --- a/apps/desktop/src/main/browser-import/chromium-profiles.test.ts +++ b/apps/desktop/src/main/browser-import/chromium-profiles.test.ts @@ -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') @@ -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'), '') @@ -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', () => { diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 30c00528b10..cd77f93c5a1 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -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 @@ -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 diff --git a/apps/desktop/src/main/desktop-chat-session-store.test.ts b/apps/desktop/src/main/desktop-chat-session-store.test.ts index cb49ee7f542..775f06d3d80 100644 --- a/apps/desktop/src/main/desktop-chat-session-store.test.ts +++ b/apps/desktop/src/main/desktop-chat-session-store.test.ts @@ -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) @@ -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) }) diff --git a/apps/desktop/src/main/downloads.test.ts b/apps/desktop/src/main/downloads.test.ts index 951bc151f6c..e3a9ec3a7b5 100644 --- a/apps/desktop/src/main/downloads.test.ts +++ b/apps/desktop/src/main/downloads.test.ts @@ -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')) @@ -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')]) ) }) }) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 990fa6724aa..8ba90ca529b 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -98,6 +98,12 @@ import { } from '@/main/session-lifecycle' import { setShellTheme } from '@/main/shell-theme' import { attachTelemetryPolicy } from '@/main/telemetry-policy' +import { + findGitBash, + getPreferredWindowsShell, + isWindowsTerminalShell, + setPreferredWindowsShell, +} from '@/main/terminal/default-shell' import { TerminalRegistry } from '@/main/terminal/registry' import { installTray, type TrayHandle } from '@/main/tray' import { checkForUpdatesInteractive, initUpdater, type UpdaterHandle } from '@/main/updater' @@ -165,6 +171,10 @@ function main(): void { ), }) const scopeEvents = new ScopedEventRouter() + if (process.platform === 'win32') { + const storedShell = config.get('terminalShell') + if (isWindowsTerminalShell(storedShell)) setPreferredWindowsShell(storedShell) + } const terminal = new TerminalRegistry({ load: (scopeId) => desktopChatSessions.getTerminal(processOrigin, scopeId) ?? undefined, save: (scopeId, snapshot) => desktopChatSessions.setTerminal(processOrigin, scopeId, snapshot), @@ -944,25 +954,39 @@ function main(): void { desktopExecutor.start() } await ensureMainWindow() - installApplicationMenu({ - config, - getMainWindow, - isMainWindow: (win) => windows.has(win) && !win.isDestroyed(), - allowHttpLocalhost, - openSettings, - openServerSettings: () => serverWindow.open(), - newWindow: () => void createAndLoadAppWindow(), - newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))), - handleFocusedResourceShortcut: (win, shortcut) => - handleFocusedBrowserShortcut(shortcut, win) || - terminal.handleFocusedShortcut(win, shortcut), - toggleSidebar: () => getMainWindow()?.webContents.send('desktop:command', 'toggle-sidebar'), - openSearch: () => getMainWindow()?.webContents.send('desktop:command', 'open-search'), - signOut: signOutFromMenu, - checkForUpdates: () => - checkForUpdatesInteractive({ getWindow: getMainWindow, events, handle: updater }), - openDiagnostics: () => shell.showItemInFolder(events.filePath), - }) + // Rebuilt after a shell choice so the radio reflects it; Electron menus + // are immutable once built. + const installMenu = () => + installApplicationMenu({ + config, + getMainWindow, + isMainWindow: (win) => windows.has(win) && !win.isDestroyed(), + allowHttpLocalhost, + openSettings, + openServerSettings: () => serverWindow.open(), + newWindow: () => void createAndLoadAppWindow(), + newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))), + handleFocusedResourceShortcut: (win, shortcut) => + handleFocusedBrowserShortcut(shortcut, win) || + terminal.handleFocusedShortcut(win, shortcut), + toggleSidebar: () => getMainWindow()?.webContents.send('desktop:command', 'toggle-sidebar'), + openSearch: () => getMainWindow()?.webContents.send('desktop:command', 'open-search'), + signOut: signOutFromMenu, + checkForUpdates: () => + checkForUpdatesInteractive({ getWindow: getMainWindow, events, handle: updater }), + openDiagnostics: () => shell.showItemInFolder(events.filePath), + terminalShell: { + current: getPreferredWindowsShell, + gitBashAvailable: () => findGitBash() !== null, + select: (choice) => { + setPreferredWindowsShell(choice) + config.set('terminalShell', choice) + config.flush() + installMenu() + }, + }, + }) + installMenu() installDocumentationHelpSearch() setTrayEnabled(config.get('trayEnabled') ?? true) updater = initUpdater({ @@ -1001,7 +1025,17 @@ function main(): void { // The name follows the build's channel ("Sim", "Sim Dev", …) so one developer // can run one install per environment side by side — separate settings, // sessions, locks, and update feeds. -app.setName(APP_NAME_FOR_CHANNEL[channelForOrigin(DEFAULT_ORIGIN)]) +const launchChannel = channelForOrigin(DEFAULT_ORIGIN) +app.setName(APP_NAME_FOR_CHANNEL[launchChannel]) +// Windows groups taskbar buttons and attributes toast notifications by this +// id; without it an unpackaged run shows up as a generic Electron app. Mirrors +// the per-channel appId in scripts/channels.ts so the installed shortcut and +// the running process agree. +if (process.platform === 'win32') { + app.setAppUserModelId( + launchChannel === 'prod' ? 'ai.sim.desktop' : `ai.sim.desktop.${launchChannel}` + ) +} if (process.env.SIM_DESKTOP_USER_DATA) { app.setPath('userData', process.env.SIM_DESKTOP_USER_DATA) } diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 4a596f77a65..aeb1c2e2a63 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -372,8 +372,10 @@ describe('registerIpcHandlers', () => { expect(await handler?.(evilEvent)).toBe(false) expect(await handler?.(appEvent)).toBe(false) - expect(await handler?.(activeAppEvent)).toBe(process.platform === 'darwin') - expect(shell.openExternal).toHaveBeenCalledTimes(process.platform === 'darwin' ? 1 : 0) + // Only macOS and Windows have a settings URL to open. + const hasSettingsPane = process.platform === 'darwin' || process.platform === 'win32' + expect(await handler?.(activeAppEvent)).toBe(hasSettingsPane) + expect(shell.openExternal).toHaveBeenCalledTimes(hasSettingsPane ? 1 : 0) }) it('restricts the OAuth connect handoff to an activated app origin', async () => { diff --git a/apps/desktop/src/main/local-files.test.ts b/apps/desktop/src/main/local-files.test.ts index 1e01abebfd7..f243b7a97b3 100644 --- a/apps/desktop/src/main/local-files.test.ts +++ b/apps/desktop/src/main/local-files.test.ts @@ -12,13 +12,20 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -it('rejects missing paths and directory cycles with explicit errors before uploading', async () => { +// Creating a symlink on Windows needs Developer Mode or elevation, which the +// test runner cannot assume. +const NO_SYMLINKS = process.platform === 'win32' + +it('rejects a missing path with an explicit error before uploading', async () => { expect( await executeLocalFileRequest( { operation: 'read' }, { toolName: 'read_local_file', args: { path: join(root, 'missing') } } ) ).toMatchObject({ ok: false }) +}) + +it.skipIf(NO_SYMLINKS)('rejects a directory cycle with an explicit error', async () => { await symlink(root, join(root, 'cycle')) expect( await executeLocalFileRequest( @@ -40,7 +47,7 @@ it('refuses oversized import files before any workspace mutation or bulk allocat ).toMatchObject({ ok: false, error: expect.stringContaining('64 MB') }) }) -it.each(['file', 'directory'] as const)( +it.skipIf(NO_SYMLINKS).each(['file', 'directory'] as const)( 'rejects %s symlinks outside the import source during manifest and chunk reads', async (kind) => { const source = join(root, 'selected') @@ -79,38 +86,41 @@ it.each(['file', 'directory'] as const)( } ) -it('supports internal symlinks and an explicitly selected symlink root, but rejects a retargeted child', async () => { - const source = join(root, 'selected') - await mkdir(source) - await writeFile(join(source, 'notes.txt'), 'inside') - await symlink(join(source, 'notes.txt'), join(source, 'alias.txt')) - const selectedAlias = join(root, 'selected-alias') - await symlink(source, selectedAlias) - const authorization = { - toolName: 'import_local_files', - args: { path: selectedAlias, targetWorkspaceId: 'target' }, - } - const manifest = await executeLocalFileRequest({ operation: 'manifest' }, authorization) - if (!manifest.ok || manifest.data.kind !== 'manifest') throw new Error('Expected manifest') - const entry = manifest.data.entries.find((item) => item.relativePath === 'alias.txt')! - const request = { - operation: 'chunk', - relativePath: 'alias.txt', - revision: entry.revision, - offset: 0, +it.skipIf(NO_SYMLINKS)( + 'supports internal symlinks and an explicitly selected symlink root, but rejects a retargeted child', + async () => { + const source = join(root, 'selected') + await mkdir(source) + await writeFile(join(source, 'notes.txt'), 'inside') + await symlink(join(source, 'notes.txt'), join(source, 'alias.txt')) + const selectedAlias = join(root, 'selected-alias') + await symlink(source, selectedAlias) + const authorization = { + toolName: 'import_local_files', + args: { path: selectedAlias, targetWorkspaceId: 'target' }, + } + const manifest = await executeLocalFileRequest({ operation: 'manifest' }, authorization) + if (!manifest.ok || manifest.data.kind !== 'manifest') throw new Error('Expected manifest') + const entry = manifest.data.entries.find((item) => item.relativePath === 'alias.txt')! + const request = { + operation: 'chunk', + relativePath: 'alias.txt', + revision: entry.revision, + offset: 0, + } + expect(await executeLocalFileRequest(request, authorization)).toEqual({ + ok: true, + data: { kind: 'chunk', bytes: new Uint8Array(Buffer.from('inside')), eof: true }, + }) + await writeFile(join(root, 'outside.txt'), 'outside') + await rm(join(source, 'alias.txt')) + await symlink(join(root, 'outside.txt'), join(source, 'alias.txt')) + expect(await executeLocalFileRequest(request, authorization)).toMatchObject({ + ok: false, + error: expect.stringContaining('outside this import source'), + }) } - expect(await executeLocalFileRequest(request, authorization)).toEqual({ - ok: true, - data: { kind: 'chunk', bytes: new Uint8Array(Buffer.from('inside')), eof: true }, - }) - await writeFile(join(root, 'outside.txt'), 'outside') - await rm(join(source, 'alias.txt')) - await symlink(join(root, 'outside.txt'), join(source, 'alias.txt')) - expect(await executeLocalFileRequest(request, authorization)).toMatchObject({ - ok: false, - error: expect.stringContaining('outside this import source'), - }) -}) +) it('preserves a UTF-8 BOM and rejects split offsets and limits that cannot fit a character', async () => { const path = join(root, 'unicode.txt') diff --git a/apps/desktop/src/main/local-filesystem.test.ts b/apps/desktop/src/main/local-filesystem.test.ts index 2a98ddf8326..841f713ce89 100644 --- a/apps/desktop/src/main/local-filesystem.test.ts +++ b/apps/desktop/src/main/local-filesystem.test.ts @@ -55,6 +55,10 @@ async function mount(service: LocalFilesystemService): Promise { let root: string let service: LocalFilesystemService @@ -257,17 +261,21 @@ describe('LocalFilesystemService', () => { await expect(reading).resolves.toMatchObject({ ok: true }) }) - it('rejects unknown mounts and symlinks that escape the selected directory', async () => { - const granted = await mount(service) - const outside = await mkdtemp(join(tmpdir(), 'sim-localfs-outside-')) - await writeFile(join(outside, 'secret.txt'), 'secret') - await symlink(join(outside, 'secret.txt'), join(root, 'secret-link.txt')) + it('rejects unknown mounts', async () => { + await mount(service) const missingMount = await service.handle({ operation: 'read', uri: 'localfs://not-granted/file.txt', }) expect(missingMount).toMatchObject({ ok: false, code: 'MOUNT_NOT_FOUND' }) + }) + + it.skipIf(NO_SYMLINKS)('rejects symlinks that escape the selected directory', async () => { + const granted = await mount(service) + const outside = await mkdtemp(join(tmpdir(), 'sim-localfs-outside-')) + await writeFile(join(outside, 'secret.txt'), 'secret') + await symlink(join(outside, 'secret.txt'), join(root, 'secret-link.txt')) const escaped = await service.handle({ operation: 'read', @@ -276,12 +284,9 @@ describe('LocalFilesystemService', () => { expect(escaped).toMatchObject({ ok: false, code: 'ACCESS_DENIED' }) }) - it('resolves a granted file for upload and refuses escapes, directories, and oversize files', async () => { + it('resolves a granted file for upload and refuses directories, oversize files, and unknown mounts', async () => { const granted = await mount(service) const vfsRoot = `user-local/${encodeURIComponent(granted.name)}--${granted.id}` - const outside = await mkdtemp(join(tmpdir(), 'sim-localfs-outside-')) - await writeFile(join(outside, 'secret.txt'), 'secret') - await symlink(join(outside, 'secret.txt'), join(root, 'secret-link.txt')) const file = await service.resolveGrantedFile(`${vfsRoot}/README.md`, 1024) try { @@ -290,9 +295,6 @@ describe('LocalFilesystemService', () => { } finally { await file.handle.close() } - await expect( - service.resolveGrantedFile(`${vfsRoot}/secret-link.txt`, 1024) - ).rejects.toMatchObject({ code: 'ACCESS_DENIED' }) await expect(service.resolveGrantedFile(`${vfsRoot}/src`, 1024)).rejects.toMatchObject({ code: 'NOT_A_FILE', }) @@ -304,6 +306,18 @@ describe('LocalFilesystemService', () => { ).rejects.toMatchObject({ code: 'MOUNT_NOT_FOUND' }) }) + it.skipIf(NO_SYMLINKS)('refuses an upload through a symlink that escapes the grant', async () => { + const granted = await mount(service) + const vfsRoot = `user-local/${encodeURIComponent(granted.name)}--${granted.id}` + const outside = await mkdtemp(join(tmpdir(), 'sim-localfs-outside-')) + await writeFile(join(outside, 'secret.txt'), 'secret') + await symlink(join(outside, 'secret.txt'), join(root, 'secret-link.txt')) + + await expect( + service.resolveGrantedFile(`${vfsRoot}/secret-link.txt`, 1024) + ).rejects.toMatchObject({ code: 'ACCESS_DENIED' }) + }) + it.each(['..', '%2e%2e', 'src%2F..%2FREADME.md', 'README.md%00'])( 'rejects unsafe upload path segment %s', async (segment) => { @@ -318,7 +332,7 @@ describe('LocalFilesystemService', () => { } ) - it.each([false, true])( + it.skipIf(NO_SYMLINKS).each([false, true])( 'closes a file opened through a swapped ancestor (ancestor restored: %s)', async (restoreAncestor) => { const granted = await mount(service) diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index b2234f74d5b..373aa97f80d 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -8,6 +8,7 @@ import type { FocusedResourceShortcut, ResourceTabSelectionShortcut, } from '@/main/resource-shortcuts' +import { WINDOWS_TERMINAL_SHELLS, type WindowsTerminalShell } from '@/main/terminal/default-shell' const ZOOM_STEP = 0.5 @@ -35,6 +36,38 @@ export interface MenuDeps { signOut: () => void checkForUpdates: () => void openDiagnostics: () => void + /** + * Windows only: the shell new terminals launch. macOS has one answer + * (`$SHELL`), so the submenu exists only where there is a choice to make. + */ + terminalShell?: { + current: () => WindowsTerminalShell + gitBashAvailable: () => boolean + select: (shell: WindowsTerminalShell) => void + } +} + +const TERMINAL_SHELL_LABELS: Record = { + powershell: 'PowerShell', + 'git-bash': 'Git Bash', +} + +function terminalShellSubmenu(deps: MenuDeps): MenuItemConstructorOptions[] { + const choice = deps.terminalShell + if (!choice || process.platform !== 'win32') return [] + const current = choice.current() + return [ + { + label: 'Terminal Shell', + submenu: WINDOWS_TERMINAL_SHELLS.map((shell) => ({ + label: TERMINAL_SHELL_LABELS[shell], + type: 'radio' as const, + checked: shell === current, + enabled: shell !== 'git-bash' || choice.gitBashAvailable(), + click: () => choice.select(shell), + })), + }, + ] } /** @@ -199,13 +232,19 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] }, { label: 'Settings…', accelerator: 'CmdOrCtrl+,', click: deps.openSettings }, { label: 'Server…', click: deps.openServerSettings }, + ...terminalShellSubmenu(deps), { label: 'Check for Updates…', click: deps.checkForUpdates }, { label: 'Sign Out', click: deps.signOut }, { type: 'separator' }, - { role: 'hide' }, - { role: 'hideOthers' }, - { role: 'unhide' }, - { type: 'separator' }, + // The hide roles are macOS-only; elsewhere they render as blank rows. + ...(process.platform === 'darwin' + ? ([ + { role: 'hide' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + ] satisfies MenuItemConstructorOptions[]) + : []), { role: 'quit' }, ], }, diff --git a/apps/desktop/src/main/observability.test.ts b/apps/desktop/src/main/observability.test.ts index 82fbfce631b..9fd4e656d63 100644 --- a/apps/desktop/src/main/observability.test.ts +++ b/apps/desktop/src/main/observability.test.ts @@ -25,8 +25,12 @@ describe('scrubUrl', () => { }) }) +// Windows has no POSIX mode bits: there is nothing to assert, and a chmod that +// fails there is reported differently. +const NO_POSIX_MODES = process.platform === 'win32' + describe('createEventLog', () => { - it('creates its directory and log with private permissions', () => { + it.skipIf(NO_POSIX_MODES)('creates its directory and log with private permissions', () => { const root = mkdtempSync(join(tmpdir(), 'sim-desktop-events-')) const dir = join(root, 'logs') const events = createEventLog(dir) @@ -36,21 +40,24 @@ describe('createEventLog', () => { expect(statSync(events.filePath).mode & 0o777).toBe(0o600) }) - it('reports permission failures without exposing local paths or OS errors', () => { - const root = mkdtempSync(join(tmpdir(), 'sim-desktop-events-')) - const overlongDir = join(root, 'x'.repeat(300)) + it.skipIf(NO_POSIX_MODES)( + 'reports permission failures without exposing local paths or OS errors', + () => { + const root = mkdtempSync(join(tmpdir(), 'sim-desktop-events-')) + const overlongDir = join(root, 'x'.repeat(300)) - const events = createEventLog(overlongDir) - events.record('app_launch') + const events = createEventLog(overlongDir) + events.record('app_launch') - expect(mockLogger.warn.mock.calls).toEqual([ - ['Could not apply private desktop event-log permissions', { target: 'directory' }], - ['Could not apply private desktop event-log permissions', { target: 'current-log' }], - ['Could not apply private desktop event-log permissions', { target: 'rotated-log' }], - ]) - expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain(root) - expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain('ENAMETOOLONG') - }) + expect(mockLogger.warn.mock.calls).toEqual([ + ['Could not apply private desktop event-log permissions', { target: 'directory' }], + ['Could not apply private desktop event-log permissions', { target: 'current-log' }], + ['Could not apply private desktop event-log permissions', { target: 'rotated-log' }], + ]) + expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain(root) + expect(JSON.stringify(mockLogger.warn.mock.calls)).not.toContain('ENAMETOOLONG') + } + ) }) describe('installMainProcessFailureObservers', () => { diff --git a/apps/desktop/src/main/terminal/default-shell.ts b/apps/desktop/src/main/terminal/default-shell.ts new file mode 100644 index 00000000000..d4cf857a09e --- /dev/null +++ b/apps/desktop/src/main/terminal/default-shell.ts @@ -0,0 +1,98 @@ +/** + * Picks the shell a new terminal tab launches. + * + * POSIX hosts publish the user's login shell in `$SHELL`. Windows has no such + * convention: `COMSPEC` names cmd.exe, which is the shell of last resort, so + * PowerShell is preferred when it is installed — pwsh (PowerShell 7) first, + * then the Windows PowerShell 5.1 that ships with every supported Windows. + * Git for Windows' bash is offered as an alternative when it is installed, + * because the bash shell-integration hooks work in it unchanged. + */ +import { existsSync } from 'node:fs' +import { delimiter, dirname, join } from 'node:path' + +export type WindowsTerminalShell = 'powershell' | 'git-bash' + +export const WINDOWS_TERMINAL_SHELLS: readonly WindowsTerminalShell[] = ['powershell', 'git-bash'] + +export function isWindowsTerminalShell(value: unknown): value is WindowsTerminalShell { + return value === 'powershell' || value === 'git-bash' +} + +const WINDOWS_POWERSHELL_CANDIDATES = ['pwsh.exe', 'powershell.exe'] as const + +/** + * The shell the next Windows terminal launches. Process-wide rather than + * threaded through every service because the choice is a device preference, + * like the terminal theme, and every chat scope's shells share it. + */ +let preferredWindowsShell: WindowsTerminalShell = 'powershell' + +export function setPreferredWindowsShell(shell: WindowsTerminalShell): void { + preferredWindowsShell = shell +} + +export function getPreferredWindowsShell(): WindowsTerminalShell { + return preferredWindowsShell +} + +function findOnPath(executable: string, env: NodeJS.ProcessEnv): string | null { + for (const directory of (env.PATH ?? env.Path ?? '').split(delimiter)) { + if (!directory) continue + const candidate = join(directory, executable) + if (existsSync(candidate)) return candidate + } + return null +} + +/** + * Git for Windows' bash, or null when Git is not installed. + * + * Only Git's own install locations are checked. A bare `bash.exe` on PATH is + * not trusted: `C:\Windows\System32\bash.exe` is the WSL launcher, which runs + * a Linux VM rather than a shell on this machine. + */ +export function findGitBash(env: NodeJS.ProcessEnv = process.env): string | null { + const roots = [ + env.ProgramFiles, + env['ProgramFiles(x86)'], + env.ProgramW6432, + env.LOCALAPPDATA ? join(env.LOCALAPPDATA, 'Programs') : undefined, + ] + for (const root of roots) { + if (!root) continue + const candidate = join(root, 'Git', 'bin', 'bash.exe') + if (existsSync(candidate)) return candidate + } + // A Git installed elsewhere still puts `cmd\git.exe` on PATH; bash sits + // beside it in the install's `bin`. + const git = findOnPath('git.exe', env) + if (git) { + const candidate = join(dirname(dirname(git)), 'bin', 'bash.exe') + if (existsSync(candidate)) return candidate + } + return null +} + +function findPowerShell(env: NodeJS.ProcessEnv): string | null { + for (const executable of WINDOWS_POWERSHELL_CANDIDATES) { + const found = findOnPath(executable, env) + if (found) return found + } + return null +} + +export function defaultShellPath( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + preference: WindowsTerminalShell = preferredWindowsShell +): string { + if (platform !== 'win32') { + return env.SHELL || '/bin/zsh' + } + if (preference === 'git-bash') { + const gitBash = findGitBash(env) + if (gitBash) return gitBash + } + return findPowerShell(env) ?? env.COMSPEC ?? 'cmd.exe' +} diff --git a/apps/desktop/src/main/terminal/session.ts b/apps/desktop/src/main/terminal/session.ts index 4dd33ad7f43..f9031f9144b 100644 --- a/apps/desktop/src/main/terminal/session.ts +++ b/apps/desktop/src/main/terminal/session.ts @@ -30,6 +30,7 @@ import { type IBufferCell, type IBufferLine, } from '@xterm/headless' +import { defaultShellPath } from '@/main/terminal/default-shell' import { readProcessCwd } from '@/main/terminal/process-cwd' import { buildShellLaunch, @@ -351,7 +352,7 @@ export class TerminalSession { } static create(options: TerminalSessionOptions): TerminalSession { - const shellPath = process.env.SHELL || '/bin/zsh' + const shellPath = defaultShellPath() const shell = detectShell(shellPath) const nonce = createNonce() const integrationDir = mkdtempSync(join(tmpdir(), 'sim-terminal-')) @@ -363,10 +364,11 @@ export class TerminalSession { const { ELECTRON_RUN_AS_NODE: _runAsNode, ...env } = process.env as Record // A shell we cannot instrument still gives the user a working terminal; - // the agent is refused separately via NO_SHELL_INTEGRATION. + // the agent is refused separately via NO_SHELL_INTEGRATION. `-l` is the + // POSIX login flag; Windows shells reject it. const launch = shell ? buildShellLaunch(shell, integrationDir, nonce, env) - : { args: ['-l'], env: {} } + : { args: process.platform === 'win32' ? [] : ['-l'], env: {} } const shellEnv = { ...env, ...launch.env, TERM: 'xterm-256color', TERM_PROGRAM: 'Sim' } const pty = spawn(shellPath, launch.args, { @@ -457,7 +459,7 @@ export class TerminalSession { * at a glance. */ tabState(active: boolean): TerminalTabState { - const directory = this.cwd ? (this.cwd.split('/').filter(Boolean).pop() ?? '/') : null + const directory = this.cwd ? (this.cwd.split(/[\\/]/).filter(Boolean).pop() ?? '/') : null return { terminalId: this.terminalId, // The directory, always: whether to show the running command instead is diff --git a/apps/desktop/src/main/terminal/shell-integration.test.ts b/apps/desktop/src/main/terminal/shell-integration.test.ts index ab47bf5a8ab..b6548771de5 100644 --- a/apps/desktop/src/main/terminal/shell-integration.test.ts +++ b/apps/desktop/src/main/terminal/shell-integration.test.ts @@ -86,6 +86,11 @@ describe('detectShell', () => { it('recognises the shells we can instrument', () => { expect(detectShell('/bin/zsh')).toBe('zsh') expect(detectShell('/usr/local/bin/bash')).toBe('bash') + expect(detectShell('C:\\Program Files\\Git\\bin\\bash.exe')).toBe('bash') + expect(detectShell('C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe')).toBe( + 'powershell' + ) + expect(detectShell('C:\\Program Files\\PowerShell\\7\\pwsh.exe')).toBe('powershell') }) it('returns null for shells without hooks, leaving the terminal uninstrumented', () => { diff --git a/apps/desktop/src/main/terminal/shell-integration.ts b/apps/desktop/src/main/terminal/shell-integration.ts index f69e86e1d2d..75f94d567f8 100644 --- a/apps/desktop/src/main/terminal/shell-integration.ts +++ b/apps/desktop/src/main/terminal/shell-integration.ts @@ -17,15 +17,18 @@ */ import { randomBytes } from 'node:crypto' import { mkdirSync, writeFileSync } from 'node:fs' -import { basename, join } from 'node:path' +import { join } from 'node:path' /** Shells we can install prompt hooks into. */ -export type SupportedShell = 'zsh' | 'bash' +export type SupportedShell = 'zsh' | 'bash' | 'powershell' export function detectShell(shellPath: string): SupportedShell | null { - const name = basename(shellPath) + // Split on both separators rather than basename(): a Windows path must + // resolve the same way on the macOS machines that run the test suite. + const name = (shellPath.split(/[\\/]/).pop() ?? '').toLowerCase().replace(/\.exe$/, '') if (name === 'zsh' || name === '-zsh') return 'zsh' if (name === 'bash' || name === '-bash') return 'bash' + if (name === 'pwsh' || name === 'powershell') return 'powershell' return null } @@ -51,10 +54,14 @@ export interface ParseResult { markers: ShellMarker[] } -/** Undoes the escaping applied by the shell hooks. */ +/** + * Undoes the escaping applied by the shell hooks: `\\` for a backslash and + * `\xHH` for separators. One pass, so an escaped backslash followed by `x3b` + * is not mistaken for an escaped semicolon. + */ function unescapeValue(value: string): string { - return value.replace(/\\x([0-9a-fA-F]{2})/g, (_match, hex: string) => - String.fromCharCode(Number.parseInt(hex, 16)) + return value.replace(/\\\\|\\x([0-9a-fA-F]{2})/g, (_match, hex: string | undefined) => + hex === undefined ? '\\' : String.fromCharCode(Number.parseInt(hex, 16)) ) } @@ -245,11 +252,18 @@ __sim_preexec() { builtin printf '\\e]633;C;%s\\a' "$__sim_nonce" } +# Git for Windows' bash reports /c/Users/... paths; the host, and the rest of +# the app, speak C:\\Users\\..., so the directory is translated when cygpath +# is there to do it. +__sim_cwd() { + if command -v cygpath >/dev/null 2>&1; then cygpath -w "$PWD"; else builtin printf '%s' "$PWD"; fi +} + __sim_precmd() { local st=$? # Cwd is reported before the finish marker so a \`cd\` is already visible by # the time the command's result is resolved. - builtin printf '\\e]633;P;Cwd=%s;%s\\a' "$(__sim_esc "$PWD")" "$__sim_nonce" + builtin printf '\\e]633;P;Cwd=%s;%s\\a' "$(__sim_esc "$(__sim_cwd)")" "$__sim_nonce" if [ -n "$__sim_in_cmd" ]; then builtin printf '\\e]633;D;%s;%s\\a' "$st" "$__sim_nonce" fi @@ -265,6 +279,75 @@ PROMPT_COMMAND="__sim_precmd\${PROMPT_COMMAND:+; $PROMPT_COMMAND}" return rcPath } +/** + * PowerShell has no preexec hook either, and its `prompt` is a plain function + * the host calls. The host also calls `PSConsoleHostReadLine` to read each + * line, so wrapping that yields the exact command text (E) and the moment it + * starts (C), while `prompt` reports the directory (P), the result of the + * previous command (D) and the new prompt (A). This is the arrangement + * Windows Terminal and VS Code use. + * + * The script is handed over as `-EncodedCommand` rather than a file: dot + * sourcing a file is subject to the machine's execution policy, which blocks + * scripts outright on a stock Windows client, and lowering that policy for + * the shell would loosen it for everything the user runs there too. + */ +function powerShellScript(nonce: string): string { + return ` +$global:__simNonce = '${nonce}' +$global:__simInCmd = $false +$global:__simOriginalPrompt = $function:prompt + +function global:__simEscape([string]$Value) { + return $Value.Replace('\\', '\\\\').Replace(';', '\\x3b').Replace([string][char]13, '').Replace([string][char]10, '\\x0a') +} + +function global:__simMarker([string]$Body) { + return [string][char]27 + ']633;' + $Body + ';' + $global:__simNonce + [string][char]7 +} + +function global:prompt { + $succeeded = $? + $code = if ($succeeded) { 0 } elseif ($global:LASTEXITCODE) { [int]$global:LASTEXITCODE } else { 1 } + $location = $ExecutionContext.SessionState.Path.CurrentFileSystemLocation.ProviderPath + $out = __simMarker ('P;Cwd=' + (__simEscape $location)) + if ($global:__simInCmd) { + $out += __simMarker ('D;' + $code) + $global:__simInCmd = $false + } + $original = $null + if ($global:__simOriginalPrompt) { + try { $original = [string](& $global:__simOriginalPrompt) } catch { $original = $null } + } + if (-not $original) { $original = 'PS ' + $location + '> ' } + return $out + (__simMarker 'A') + $original +} + +function global:PSConsoleHostReadLine { + $line = $null + if (Get-Module PSReadLine) { + # The two-argument overload only: in PSReadLine 2.0 (Windows PowerShell + # 5.1) the third parameter is a CancellationToken, and a bool coerces to + # a cancelled one, so that call returns at once and the prompt loops. + $line = [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($Host.Runspace, $ExecutionContext) + } else { + $line = $Host.UI.ReadLine() + } + if ($line -and $line.Trim()) { + $global:__simInCmd = $true + [Console]::Write((__simMarker ('E;' + (__simEscape $line))) + (__simMarker 'C')) + } + return $line +} + +# The agent clears a half-typed line with Ctrl-U before each command, which +# PSReadLine's default Windows key map leaves unbound. +if (Get-Module PSReadLine) { + Set-PSReadLineKeyHandler -Chord Ctrl+u -Function BackwardDeleteLine -ErrorAction SilentlyContinue +} +` +} + export interface ShellLaunch { args: string[] env: Record @@ -282,6 +365,13 @@ export function buildShellLaunch( ): ShellLaunch { mkdirSync(dir, { recursive: true }) + if (shell === 'powershell') { + // Profiles still load (no -NoProfile), so the user's aliases and prompt + // come first and ours wraps theirs. + const encoded = Buffer.from(powerShellScript(nonce), 'utf16le').toString('base64') + return { args: ['-NoLogo', '-NoExit', '-EncodedCommand', encoded], env: {} } + } + if (shell === 'zsh') { writeZshFiles(dir, nonce, env.ZDOTDIR || env.HOME || '') return { diff --git a/bun.lock b/bun.lock index 1b2e0623614..c48c656be77 100644 --- a/bun.lock +++ b/bun.lock @@ -47,8 +47,6 @@ "version": "0.0.0", "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:*", @@ -81,6 +79,12 @@ "typescript": "^7.0.2", "vitest": "^5.0.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", + }, }, "apps/docs": { "name": "docs", @@ -4786,7 +4790,7 @@ "wsl-utils": ["wsl-utils@0.4.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-9YmF+2sFEd+T7TkwlmE337F0IVzfDvDknhtpBQxxXzEOfgPphGlFYpyx0cTuCIFj8/p+sqwBYAeGxOMNSzPPDA=="], - "xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }], + "xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }, "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA=="], "xml": ["xml@1.0.1", "", {}, "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="],