Visitar URL original
feat(desktop): Windows support by waleedlatif1 · Pull Request #9499 · simstudioai/sim · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
a4819eb
feat(desktop): package an NSIS x64 installer for Windows
waleedlatif1 Oct 31, 2026
1babb21
feat(desktop): self-update Windows shells through electron-updater's …
waleedlatif1 Oct 31, 2026
3783207
feat(desktop): advertise the shell platform and gate surfaces per pla…
waleedlatif1 Oct 31, 2026
b8dfb41
feat(desktop): build a Windows File/Edit/View/Help menu
waleedlatif1 Oct 31, 2026
9fd011a
feat(desktop): full-colour Windows tray icon that opens Sim on click
waleedlatif1 Oct 31, 2026
9a4b88a
feat(desktop): Windows single-instance focus, AUMID, and native windo…
waleedlatif1 Oct 31, 2026
d0a4ec4
feat(desktop): report the app theme preference and strip the picker m…
waleedlatif1 Oct 31, 2026
41077cf
feat(desktop): serve Windows update feeds alongside macOS
waleedlatif1 Oct 31, 2026
95010d3
feat(desktop): point sim-setup and the docs at the Windows build
waleedlatif1 Oct 31, 2026
50a1ce7
feat(desktop): run the packaged smoke suite on Windows
waleedlatif1 Oct 31, 2026
87cd48d
fix(desktop): validate Windows paths lexically before touching the fi…
waleedlatif1 Oct 31, 2026
57fb0f4
ci(desktop): build, sign, and publish the Windows installer
waleedlatif1 Oct 31, 2026
261bf2c
feat(desktop): document Windows packaging, signing, and updates
waleedlatif1 Oct 31, 2026
822f048
fix(desktop): keep the committed bridge contract snapshot
waleedlatif1 Oct 31, 2026
bafbdae
fix(desktop): close Windows clipboard and file-name gaps in the agent…
waleedlatif1 Oct 31, 2026
92cf233
merge part A
waleedlatif1 Oct 31, 2026
e4c8d87
merge part B
waleedlatif1 Oct 31, 2026
9bb3c50
merge part C
waleedlatif1 Oct 31, 2026
10146e4
fix(desktop): reconcile the Windows parts after merge
waleedlatif1 Oct 31, 2026
6d1af69
test(desktop): run the desktop unit suite on Windows
waleedlatif1 Oct 31, 2026
046a793
fix(desktop): declare per-platform node-pty prebuilds as optional
waleedlatif1 Oct 31, 2026
7563f8b
ci(desktop): let the macOS callers grant the Windows job's OIDC scope
waleedlatif1 Oct 31, 2026
89ad2ea
test(desktop): give the preferences fixture a terminal-capable shell
waleedlatif1 Oct 31, 2026
bb5a1bb
docs(desktop): keep the install step's existing anchor
waleedlatif1 Oct 31, 2026
9f5991e
fix(desktop): address review on Windows gating, prereleases, and renames
waleedlatif1 Oct 31, 2026
b5a2e83
feat(desktop): ship the native local-file tools on Windows
waleedlatif1 Oct 31, 2026
420e9fc
merge Windows native file tools
waleedlatif1 Oct 31, 2026
5a87b83
feat(terminal-protocol): name Windows programs and carry each termina…
waleedlatif1 Oct 31, 2026
2e39dfe
feat(desktop): run and instrument Windows shells in the agent terminal
waleedlatif1 Oct 31, 2026
e5e376a
feat(desktop): stop a Windows terminal command by its process tree
waleedlatif1 Oct 31, 2026
386a0cd
feat(desktop): keep tmux off on Windows
waleedlatif1 Oct 31, 2026
4b6b17b
feat(desktop): ship the agent terminal on Windows
waleedlatif1 Oct 31, 2026
99d67bb
feat(desktop): send Mothership the desktop's platform and each termin…
waleedlatif1 Oct 31, 2026
ba0fbe6
docs(desktop): document the agent terminal on Windows
waleedlatif1 Oct 31, 2026
55c723b
merge Windows agent terminal
waleedlatif1 Oct 31, 2026
6fe2b11
docs(desktop): list local file tools as available on Windows
waleedlatif1 Oct 31, 2026
1f21b02
chore(mothership): sync the desktop platform and terminal shell proto…
waleedlatif1 Oct 31, 2026
954229b
refactor(desktop): drop the terminal and local-file platform gates
waleedlatif1 Oct 31, 2026
c39ccf1
fix(desktop): keep a stopped or silent terminal command from reading …
waleedlatif1 Oct 31, 2026
7c2a4a5
fix(desktop): give Git Bash its MSYS environment and Windows cwd
waleedlatif1 Oct 31, 2026
ee2aca9
feat(desktop): name a Windows program in the close-running-terminal d…
waleedlatif1 Oct 31, 2026
9c8306a
fix(desktop): run Windows terminals on the bundled ConPTY, which keep…
waleedlatif1 Oct 31, 2026
40841c9
test(desktop): keep skipping the unwritable-directory test on Windows
waleedlatif1 Oct 31, 2026
3bb88d5
fix(desktop): answer the bundled ConPTY's startup query so Windows sh…
waleedlatif1 Oct 31, 2026
9e66d97
perf(desktop): interrupt a stopped command before reading its processes
waleedlatif1 Oct 31, 2026
9dec590
perf(desktop): run Git Bash's cygpath only when the directory changes
waleedlatif1 Oct 31, 2026
6f95c59
refactor(desktop): keep node-pty's platform differences in terminal/pty
waleedlatif1 Oct 31, 2026
7fc52bf
refactor(desktop): name shell programs with terminal-protocol's progr…
waleedlatif1 Oct 31, 2026
c7a5094
refactor(desktop): read tmux support from the platform feature table
waleedlatif1 Oct 31, 2026
ff5f251
refactor(desktop): declare each IPC channel's platform feature on its…
waleedlatif1 Oct 31, 2026
2d22635
refactor(desktop): drop copyCredential's repeat of the passwordCopy gate
waleedlatif1 Oct 31, 2026
4a97d8d
refactor(desktop): restore a minimized window inside bringWindowToFront
waleedlatif1 Oct 31, 2026
2d95608
refactor(desktop): drop stagedFileName's unused platform parameter
waleedlatif1 Oct 31, 2026
ae793eb
refactor(desktop): derive win32-path's global name pattern from the s…
waleedlatif1 Oct 31, 2026
4a04693
refactor(desktop): share file identity and entry kinds across the loc…
waleedlatif1 Oct 31, 2026
19081e6
perf(desktop): classify a Windows listing's reparse points sixteen at…
waleedlatif1 Oct 31, 2026
39d8677
refactor(desktop): name the desktop platforms once in desktop-bridge
waleedlatif1 Oct 31, 2026
7d3c850
refactor(desktop): name each platform's update artifacts once in desk…
waleedlatif1 Oct 31, 2026
7efa552
refactor(desktop): read every channel's identity from one shared table
waleedlatif1 Oct 31, 2026
0a02a0e
refactor(desktop): split the application menu into shared items and p…
waleedlatif1 Oct 31, 2026
77b04be
refactor(desktop): share the update routes' error responses and drop …
waleedlatif1 Oct 31, 2026
0e142ce
perf(desktop): hand the Windows tray icon's scales over as PNG buffers
waleedlatif1 Oct 31, 2026
db4837a
refactor(desktop): upload the Windows release artifacts in one ordere…
waleedlatif1 Oct 31, 2026
40c054e
refactor(desktop): keep pty options and file type info private to the…
waleedlatif1 Oct 31, 2026
4352ba7
test(desktop): satisfy the test-pattern and unused-export audits
waleedlatif1 Oct 31, 2026
ad4ec17
test(desktop): leave the Chromium profile readers out of the Windows …
waleedlatif1 Oct 31, 2026
43c5e5c
fix(desktop): stop only the stopped command's processes on Windows
waleedlatif1 Oct 31, 2026
fb3414f
fix(desktop): answer ConPTY's startup query when it arrives split acr…
waleedlatif1 Oct 31, 2026
be091d0
fix(terminal-protocol): read each Windows shell's own escape and quoting
waleedlatif1 Oct 31, 2026
4e03097
docs(desktop): name the platform feature table's actual readers
waleedlatif1 Oct 31, 2026
3680790
fix(desktop): find a Windows command behind a shell launcher
waleedlatif1 Oct 31, 2026
9fa4316
fix(desktop): type the agent's next command only once the prompt is d…
waleedlatif1 Oct 31, 2026
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
Prev Previous commit
Next Next commit
fix(desktop): close Windows clipboard and file-name gaps in the agent…
… browser

The agent's clipboard guard also refuses Blink's Shift+Insert, Ctrl+Insert,
and Shift+Delete bindings off macOS, and a Cmd editing shortcut becomes
Ctrl there instead of the Windows key. Staged uploads and downloads get
NTFS-safe names on Windows. Password copy refuses in main as well as at
the bridge, and the credential storage comments describe DPAPI and the
confirmation fallback alongside Keychain and Touch ID.
  • Loading branch information
waleedlatif1 committed Oct 31, 2026
commit bafbdaeba9cf44d039f5ebe8660ffa4e53e3569c
9 changes: 6 additions & 3 deletions apps/desktop/src/main/browser-agent/file-transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { app } from 'electron'
import { ToolError } from '@/main/browser-agent/errors'
import type { BrowserAppSession } from '@/main/browser-agent/session'
import { toWin32SafeFileName } from '@/main/win32-path'

/** Granted local folders; the caller owns and must close each returned file handle. */
export interface LocalFileSource {
Expand All @@ -39,12 +40,14 @@ function directoryName(value: string): string {
return encodeURIComponent(value).replaceAll('.', '%2E')
}

/** A file name that cannot leave the staging directory. */
function stagedFileName(name: string): string {
/** A file name that cannot leave the staging directory, and that Windows stores as spelled. */
function stagedFileName(name: string, platform: NodeJS.Platform = process.platform): string {
const base = basename(name)
.replace(/[\x00-\x1f\x7f]/g, '')
.trim()
return base && base !== '.' && base !== '..' ? base.slice(0, 200) : 'upload'
.slice(0, 200)
const safe = platform === 'win32' ? toWin32SafeFileName(base) : base
return safe && safe !== '.' && safe !== '..' ? safe : 'upload'
}

function filenameFromDisposition(header: string | null): string | null {
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/main/browser-agent/keyboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ vi.mock('electron', () => import('@/test/electron-mock'))
import { WebContentsView } from 'electron'
import {
buildKeyDispatchPlan,
comboTouchesClipboard,
dispatchKeyCombo,
KeyDispatchError,
modifierKeyEvents,
Expand Down Expand Up @@ -101,6 +102,12 @@ describe('buildKeyDispatchPlan', () => {
expect(linuxDown.commands).toBeUndefined()
})

it('sends a Cmd editing shortcut as Ctrl on Windows instead of the Windows key', () => {
const [down] = buildKeyDispatchPlan(parseKeyCombo('Cmd+Z', 'win32'), 'win32')
expect(down.modifiers).toBe(2)
expect(down.commands).toBeUndefined()
})

it('encodes the CDP modifier bitmask (Alt=1 Ctrl=2 Meta=4 Shift=8)', () => {
const [down] = buildKeyDispatchPlan(parseKeyCombo('Control+Shift+K'), 'linux')
expect(down.modifiers).toBe(2 | 8)
Expand All @@ -110,6 +117,19 @@ describe('buildKeyDispatchPlan', () => {
})
})

describe('comboTouchesClipboard', () => {
it.each(['Shift+Insert', 'Control+Insert', 'Shift+Delete'])(
'refuses %s on Windows, where Blink binds it to paste, copy, or cut',
(key) => {
expect(comboTouchesClipboard(parseKeyCombo(key, 'win32'), 'win32')).toBe(true)
}
)

it.each(['Insert', 'Delete', 'Control+Delete'])('leaves %s alone on Windows', (key) => {
expect(comboTouchesClipboard(parseKeyCombo(key, 'win32'), 'win32')).toBe(false)
})
})

describe('modifierKeyEvents', () => {
it('presses each chord modifier in order and releases them in reverse', () => {
const { downs, ups } = modifierKeyEvents(parseKeyCombo('Control+Shift+Y', 'linux'), 'linux')
Expand Down
23 changes: 16 additions & 7 deletions apps/desktop/src/main/browser-agent/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,9 +251,18 @@ function editingCommandFor(combo: ParsedCombo): string | null {
/**
* Whether a combo would drive the system clipboard. Checked before dispatch
* rather than by withholding the CDP `commands` array: off macOS these are
* Blink-native, so a key event alone still performs them.
* Blink-native, so a key event alone still performs them. That includes
* Blink's legacy bindings there: Shift+Insert pastes, Ctrl+Insert copies,
* and Shift+Delete cuts.
*/
export function comboTouchesClipboard(combo: ParsedCombo): boolean {
export function comboTouchesClipboard(
combo: ParsedCombo,
platform: NodeJS.Platform = process.platform
): boolean {
if (platform !== 'darwin') {
if (combo.key === 'Insert' && (combo.shift || combo.ctrl)) return true
if (combo.key === 'Delete' && combo.shift) return true
}
if (!combo.ctrl && !combo.meta) return false
const key = combo.key.toLowerCase()
return key === 'v' || key === 'c' || key === 'x'
Expand All @@ -265,13 +274,13 @@ export function comboTouchesClipboard(combo: ParsedCombo): boolean {
* explicitly (same technique as Puppeteer/Playwright). The model doesn't know
* the host OS and often says "Control+A", so on macOS Ctrl is treated as Cmd
* for these shortcuts: both must select all, not silently no-op. On other
* platforms Ctrl+key is handled inside Blink and needs no help.
* platforms Ctrl+key is handled inside Blink and needs no help, so a Cmd+A
* the model meant for a Mac becomes Ctrl+A there instead of the Windows key.
*/
function normalizeComboForPlatform(combo: ParsedCombo, platform: NodeJS.Platform): ParsedCombo {
if (platform !== 'darwin' || !combo.ctrl || combo.meta || editingCommandFor(combo) === null) {
return combo
}
return { ...combo, ctrl: false, meta: true }
if (editingCommandFor(combo) === null || combo.ctrl === combo.meta) return combo
if (platform === 'darwin') return combo.ctrl ? { ...combo, ctrl: false, meta: true } : combo
return combo.meta ? { ...combo, ctrl: true, meta: false } : combo
}

function macEditingCommands(combo: ParsedCombo, platform: NodeJS.Platform): string[] {
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/browser-credentials/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { FillCoordinator, type FillCoordinatorDeps } from '@/main/browser-creden
import { authorizeForSecret, revokeSecretAuthorization } from '@/main/browser-credentials/os-auth'
import { CredentialVault } from '@/main/browser-credentials/vault'
import { clearSites } from '@/main/browser-sites'
import { desktopFeatures } from '@/shared/platform-features'

/** How long a copied password may sit on the clipboard before Sim clears it. */
const CLIPBOARD_CLEAR_MS = 30_000
Expand Down Expand Up @@ -104,8 +105,10 @@ export async function revealCredential(id: string): Promise<string | null> {
*
* The clipboard is only cleared if it still holds this password — overwriting
* whatever the user copied in the meantime would be its own small betrayal.
* Refused where clearing cannot reach every copy (`DesktopFeatures.passwordCopy`).
*/
export async function copyCredential(id: string): Promise<boolean> {
if (!desktopFeatures(process.platform).passwordCopy) return false
const authorized = await authorizeForSecret({
credentialId: id,
operation: 'copy',
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop/src/main/browser-credentials/os-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ const AUTH_GRACE_MS = 30_000
*
* Neither operation dominates the other, so a grant satisfies only its own.
* `reveal` hands the plaintext to the Sim renderer; `copy` publishes it to the
* macOS pasteboard, which every process on the machine can read, which
* system clipboard, which every process on the machine can read, which
* clipboard managers persist to disk beyond the 30s `clipboard.clear()`, and
* which Universal Clipboard syncs to the user's other devices. Ordering them
* which Universal Clipboard syncs to the user's other devices. (Windows keeps
* the same text in Clipboard History and the cloud clipboard, which is why
* copy is not offered there at all.) Ordering them
* either way lets one consent authorize an exposure the prompt never described.
*/
export type SecretOperation = 'reveal' | 'copy'
Expand Down Expand Up @@ -96,8 +98,8 @@ export function revokeSecretAuthorization(credentialId?: string): void {
* renderer cannot drive or fake.
*
* Touch ID is used when the machine has it. Where it does not (a Mac mini
* without a Touch ID keyboard, or a user who has not enrolled), the fallback
* is a native modal that the main process owns. That is genuinely weaker than
* without a Touch ID keyboard, a user who has not enrolled, or any Windows
* machine), the fallback is a native modal that the main process owns. That is genuinely weaker than
* biometric auth — it proves presence, not identity — so it is a deliberate,
* documented trade rather than a silent one, and it still cannot be triggered
* without a real click in the page.
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/main/browser-credentials/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/o
* The encrypted credential store.
*
* The whole record set is encrypted as one blob with Electron `safeStorage`
* (Keychain on macOS), written atomically with restrictive permissions. There
* is no plaintext fallback: when OS-backed encryption is unavailable the vault
* (Keychain on macOS, DPAPI on Windows), written atomically with restrictive
* permissions. There is no plaintext fallback: when OS-backed encryption is unavailable the vault
* reports itself unavailable and refuses to store anything, because a password
* file Sim cannot encrypt is worse than a feature the user does not get.
*
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/main/browser-sites/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import {
* anyway. It is a name and an icon per host — never a visit log, never a URL
* beyond the host itself.
*
* Encrypted with the same OS-backed key as the credential vault. The hostnames
* in here include ones only a saved password knows about, and those live in an
* encrypted vault today; writing them to a plaintext file beside it would
* quietly undo that.
* Encrypted with the same OS-backed `safeStorage` key as the credential vault
* (Keychain on macOS, DPAPI on Windows). The hostnames in here include ones
* only a saved password knows about, and those live in an encrypted vault
* today; writing them to a plaintext file beside it would quietly undo that.
*/

/**
Expand Down
12 changes: 9 additions & 3 deletions apps/desktop/src/main/downloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { Session } from 'electron'
import { app } from 'electron'
import { isAgentWebContents } from '@/main/browser-agent/registry'
import type { EventRecorder } from '@/main/observability'
import { toWin32SafeFileName } from '@/main/win32-path'

const logger = createLogger('DesktopDownloads')

Expand All @@ -27,15 +28,20 @@ const MIME_EXTENSIONS: Record<string, string> = {

/**
* Strips path separators and control characters from a server- or
* blob-suggested filename so it can never escape the chosen directory.
* blob-suggested filename so it can never escape the chosen directory. On
* Windows it also becomes a name NTFS stores exactly as spelled.
*/
export function sanitizeFilename(name: string): string {
export function sanitizeFilename(
name: string,
platform: NodeJS.Platform = process.platform
): string {
const cleaned = name
.replace(/[/\\]/g, '_')
.replace(/[\u0000-\u001f]/g, '')
.replace(/^\.+/, '')
.trim()
return cleaned.slice(0, MAX_FILENAME_LENGTH)
.slice(0, MAX_FILENAME_LENGTH)
return platform === 'win32' ? toWin32SafeFileName(cleaned) : cleaned
}

/**
Expand Down
54 changes: 54 additions & 0 deletions apps/desktop/src/main/win32-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { isWin32SafeSegment, toWin32SafeFileName } from '@/main/win32-path'

describe('isWin32SafeSegment', () => {
it.each(['CON', 'con', 'Nul.txt', 'COM1', 'lpt9.log', 'CON .txt', 'COM¹'])(
'refuses the reserved device name %s',
(segment) => {
expect(isWin32SafeSegment(segment)).toBe(false)
}
)

it.each(['file.txt:stream', 'a<b', 'a>b', 'a"b', 'a|b', 'a?b', 'a*b', 'a\\b', 'a/b', 'a\tb'])(
'refuses %s, which holds a character NTFS rejects or reads as a stream',
(segment) => {
expect(isWin32SafeSegment(segment)).toBe(false)
}
)

it.each(['file.', 'file ', '...'])('refuses %s, which Windows would silently trim', (segment) => {
expect(isWin32SafeSegment(segment)).toBe(false)
})

it.each(['notes.txt', 'CONFIG', 'console.log', 'com10', '.env'])('accepts %s', (segment) => {
expect(isWin32SafeSegment(segment)).toBe(true)
})
})

describe('toWin32SafeFileName', () => {
it('replaces characters NTFS rejects', () => {
expect(toWin32SafeFileName('Q3: plan <draft>|"final"?*.pdf')).toBe(
'Q3_ plan _draft___final___.pdf'
)
})

it('drops the trailing dots and spaces Windows would strip', () => {
expect(toWin32SafeFileName('report. . ')).toBe('report')
})

it('keeps a reserved device stem from opening the device', () => {
expect(toWin32SafeFileName('nul.txt')).toBe('_nul.txt')
expect(toWin32SafeFileName('AUX')).toBe('_AUX')
})

it('leaves an ordinary name alone', () => {
expect(toWin32SafeFileName('Q3 plan.pdf')).toBe('Q3 plan.pdf')
})

it('produces a name that passes the segment check', () => {
for (const name of ['CON:x.', 'a*b ', 'lpt1', 'x|y.']) {
const safe = toWin32SafeFileName(name)
expect(isWin32SafeSegment(safe)).toBe(true)
}
})
})