Skip to content

Commit 66bda4c

Browse files
committed
Merge branch 'feat/desktop-windows-support' of https://github.com/simstudioai/sim into feat/desktop-windows-v2
2 parents a6bdda5 + 357f7cf commit 66bda4c

25 files changed

Lines changed: 384 additions & 137 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,9 +1330,11 @@ jobs:
13301330
# Windows desktop builds are signed with Azure Artifact Signing over GitHub
13311331
# OIDC. Gated like the macOS build: until the Azure identity and signing
13321332
# variables exist, the Windows build skips with a warning, and nothing is
1333-
# ever published unsigned. Windows jobs are separate workflow calls that no
1334-
# other job needs, so a Windows failure never blocks a macOS release; the
1335-
# update feed treats a release without latest.yml as having no Windows build.
1333+
# ever published unsigned. Windows jobs are separate workflow calls, and a
1334+
# Windows failure never blocks a macOS release; the update feed treats a
1335+
# release without latest.yml as having no Windows build. The prerelease path
1336+
# still waits for them: prerelease-publish needs prerelease-build-windows, so
1337+
# it starts only once that job finishes, whatever its result.
13361338
desktop-signing-windows:
13371339
name: desktop-signing-windows
13381340
runs-on: *runner-2vcpu

‎.github/workflows/desktop-release.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -600,8 +600,9 @@ jobs:
600600
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
601601
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
602602

603-
# forceCodeSigning fails the build if any file that should be signed is
604-
# not. publisherName also lands in resources/app-update.yml, where the
603+
# forceCodeSigning fails the build when no signing configuration is
604+
# found (electron-builder 26 checks for one, not each file's signature),
605+
# so a missing Azure setup cannot produce an unsigned installer. publisherName also lands in resources/app-update.yml, where the
605606
# installed app's updater checks each downloaded installer against it.
606607
- name: Package and sign
607608
if: ${{ inputs.sign }}

‎apps/desktop/README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ The native `read_local_file` and `import_local_files` tools also accept absolute
200200

201201
Approved native reads can return bounded text, directory listings, images, or PDFs to the chat. Approved imports transfer file bytes to Workspace Files. Electron revalidates every pending call before using a grant, including remembered grants, checks canonical containment and grant identity throughout the operation, and opens files with no-follow and descriptor identity checks. On macOS and Linux, a native module opens each path component relative to the verified grant descriptor, and directory enumeration uses `fdopendir` on the verified descriptor, so replacing a parent path cannot redirect the open or the listing. Listings scan at most 1,001 entries and return up to 1,000 sorted names with an explicit truncation flag; the cap bounds both memory and filesystem work, rather than promising the globally first 1,000 names in an arbitrarily large directory. Imports reject truncated listings. A model or hosted renderer cannot answer the local consent dialog. These permissions govern the native file tools; the separately enabled terminal still runs with the user's OS privileges.
202202

203-
On Windows, Node cannot open a path relative to a directory handle, so the same tools use checked Node fs opens instead (`src/main/win32-native-files.ts`). Paths are validated lexically first: only drive paths and `~\` paths are accepted, and reserved device names, alternate data streams, and trailing dots or spaces are refused. Then Electron checks that the granted folder still has its recorded identity, resolves the path and refuses it unless it is already canonical beneath the granted folder (so any symlink or junction in it, even one pointing back inside, is refused), and opens it only when the opened handle has the identity the path had just before the open; after the open it checks the path and the granted folder again. A directory listing must leave the path naming the opened handle. Listings report symlinks and junctions as links and never follow them, under the same caps as macOS. These checks narrow, but cannot eliminate, a race by a process already running as the user that swaps a path component between a check and the open. The model itself cannot create links or junctions: the tools are read-only. OneDrive Files-On-Demand placeholders are downloaded when they are read or imported. UNC and other network paths, including a drive letter mapped to a share, are refused in this release.
203+
On Windows, Node cannot open a path relative to a directory handle, so the same tools use checked Node fs opens instead (`src/main/win32-native-files.ts`). Paths are validated lexically first: only drive paths and `~\` paths are accepted, and reserved device names, alternate data streams, and trailing dots or spaces are refused. Then Electron checks that the granted folder still has its recorded identity, resolves the path and refuses it unless it is already canonical beneath the granted folder (so any symlink or junction in it, even one pointing back inside, is refused), and opens it only when the opened handle has the identity the path had just before the open; after the open it checks the path and the granted folder again. A directory listing must leave the path naming the opened handle. Listings report symlinks and junctions as links and never follow them, under the same caps as macOS. These checks narrow, but cannot eliminate, a race by a process already running as the user that swaps a path component between a check and the open: unlike macOS's `openat(O_NOFOLLOW)` walk, the open follows a swapped-in junction or symlink (the identity check then refuses any handle that is not the checked file, but a symlink to a share has already been contacted). The model itself cannot create links or junctions: the tools are read-only. OneDrive Files-On-Demand placeholders are downloaded when they are read or imported. UNC and other network paths, including a drive letter mapped to a share, are refused in this release, at authorization before any consent dialog; a symlink to a share inside a requested path is found by walking the path with `lstat` and refused before it is followed (`src/main/local-realpath.ts`).
204204

205205
## Windows
206206

@@ -209,9 +209,9 @@ The Windows build is a one-click, per-user NSIS installer (`Sim-<version>-x64.ex
209209
- Local: `bun run package:win` (installer) or `bun run package:win:dir` (unpacked app in `release/win-unpacked/`), on Windows. `scripts/build-native.ts` skips the macOS/Linux native modules there, and `scripts/ensure-pty-prebuilds.ts` fetches the `win32-x64` ConPTY prebuild. `install:local` and `package:share` are macOS-only.
210210
- Channel isolation: one-click installs derive their install directory and updater cache from the package **name**, not `productName`, so CI passes `-c.extraMetadata.name=<slug>` (`sim`, `sim-staging`, `sim-dev`, `sim-local`) beside `-c.productName`/`-c.appId`. `-c.appId` must match the AppUserModelID the app sets at runtime, or toasts and taskbar pins split. Local builds without the slug install as the package name.
211211
- Icons: `build/icon*.ico` (16–256 px) are committed, generated from the per-channel `static/dock-icon*.png` by `bun run scripts/generate-windows-icons.ts` (needs the workspace's hoisted `sharp`); `scripts/build.ts` copies the channel's one to the ignored `build/generated-icon.ico`.
212-
- Signing: Azure Artifact Signing through `win.azureSignOptions`, authenticated by GitHub OIDC (`azure/login`); nothing in `electron-builder.yml` or the workflow names the account. `signExts: [".node"]` also signs the node-pty addons, and `-c.forceCodeSigning=true` fails the build on anything unsigned. `azureSignOptions.publisherName` is written into `resources/app-update.yml`; without it electron-updater **skips** signature verification of downloaded installers, so CI asserts it is present, and it must equal the certificate's subject CN exactly. Windows ASAR integrity is embedded in the exe by electron-builder, so the fuse policy carries over unchanged.
212+
- Signing: Azure Artifact Signing through `win.azureSignOptions`, authenticated by GitHub OIDC (`azure/login`); nothing in `electron-builder.yml` or the workflow names the account. `signExts: [".node"]` also signs the node-pty addons, and `-c.forceCodeSigning=true` fails the build when no signing configuration is found (electron-builder 26 checks for the configuration, not each file's signature). `azureSignOptions.publisherName` is written into `resources/app-update.yml`; without it electron-updater **skips** signature verification of downloaded installers, so CI asserts it is present, and it must equal the certificate's subject CN exactly. Windows ASAR integrity is embedded in the exe by electron-builder, so the fuse policy carries over unchanged.
213213
- Updates: the NSIS updater requests `latest.yml` from the same `/api/desktop/update` feed. `ready` is reached when electron-updater's own `update-downloaded` fires (after the publisher check); there is no native staging step. Restart runs the installer silently and relaunches; as on macOS it asks first only when it would interrupt running work (see Terminal below for what counts on Windows), and a required update never asks. Builds without a packaged `app-update.yml` fall back to the manual-download pipeline. Deltas use `.exe.blockmap`.
214-
- Publication: Windows publishes independently of macOS. `desktop-signing-windows` probes the Azure configuration and skips with a warning until it exists; unsigned Windows builds are never published. No job waits on the Windows calls, so a Windows failure never blocks the macOS release, and the feed treats a release without `latest.yml` as having no Windows build (Windows shells keep the newest release that has one). `latest.yml` is uploaded last.
214+
- Publication: Windows publishes independently of macOS. `desktop-signing-windows` probes the Azure configuration and skips with a warning until it exists; unsigned Windows builds are never published. A Windows failure never blocks the macOS release, though the prerelease path waits for it to finish (`prerelease-publish` needs `prerelease-build-windows`), and the feed treats a release without `latest.yml` as having no Windows build (Windows shells keep the newest release that has one). `latest.yml` is uploaded last.
215215
- SmartScreen: a new signing identity starts without download reputation, so early installers can show "Windows protected your PC" until reputation builds. Keep the same certificate profile across releases.
216216
- Terminal: runs PowerShell 7 (`pwsh.exe`) when it is on `Path`, else Windows PowerShell 5.1, else cmd, which has no shell integration, so the agent cannot run commands in it. `SHELL` set to a Windows path (`C:\Program Files\Git\bin\bash.exe`) selects Git Bash; its init file reads `/etc/profile` as a login shell would (for the MSYS `PATH`) and reports the cwd through `cygpath -w`. PowerShell starts with `-NoProfile -NoExit -EncodedCommand`: the script emits the startup marker, loads the user's profiles in PowerShell's order under their own execution policy, then wraps `prompt` and PSReadLine's `PSConsoleHostReadLine` to emit the OSC 633 markers (no PSReadLine, no integration). Windows has no process groups, so Stop sends Ctrl-C, then `taskkill /T /F` to each process the shell started since the command was typed, found among its descendants because a launcher can sit between the pty and the real shell; a process started earlier, such as a background server, is left running. An update restart asks first while an integrated shell is running a command, or, in cmd, while any process the shell started is still running. tmux and terminal themes are macOS and Linux only. `src/main/terminal/windows-shells.test.ts` drives PowerShell 7, Windows PowerShell 5.1 and Git Bash through ConPTY on the Windows CI leg.
217217
- Native local-file tools: ship with the checks described in [Local filesystem access](#local-filesystem-access).

‎apps/desktop/electron-builder.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,9 @@ win:
109109
- target: nsis
110110
arch: [x64]
111111
icon: build/generated-icon.ico
112-
# .exe files are always signed; also sign the native addons (node-pty) so
113-
# every binary Sim loads carries the publisher's Authenticode signature.
112+
# .exe files are always signed; this also signs the native addons (node-pty).
113+
# DLLs are not in signExts, so conpty.dll and Electron's own DLLs ship as
114+
# their upstream builds left them, not with Sim's signature.
114115
signExts: [".node"]
115116
files:
116117
- "!**/node_modules/@lydell/node-pty-darwin-*/**"

‎apps/desktop/src/main/browser-credentials/vault.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import { normalizeOrigin, normalizeUsername } from '@/main/browser-credentials/o
1414
* The encrypted credential store.
1515
*
1616
* The whole record set is encrypted as one blob with Electron `safeStorage`
17-
* (Keychain on macOS, DPAPI on Windows), written atomically with restrictive
18-
* permissions. There is no plaintext fallback: when OS-backed encryption is unavailable the vault
17+
* (Keychain on macOS, DPAPI on Windows) and written atomically. On macOS the file is owner-only
18+
* (0600); Windows ignores POSIX modes, so there the protection is DPAPI's per-user key plus the
19+
* ACL on the user's profile folder. There is no plaintext fallback: when OS-backed encryption is unavailable the vault
1920
* reports itself unavailable and refuses to store anything, because a password
2021
* file Sim cannot encrypt is worse than a feature the user does not get.
2122
*

‎apps/desktop/src/main/downloads.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ describe('sanitizeFilename', () => {
4242
expect(sanitized).not.toContain('/')
4343
expect(sanitized.startsWith('.')).toBe(false)
4444
})
45+
46+
it('keeps a long reserved Windows device name within the length cap once prefixed', () => {
47+
const sanitized = sanitizeFilename(`con.${'a'.repeat(300)}`, 'win32')
48+
49+
expect(sanitized.startsWith('_con.')).toBe(true)
50+
expect(sanitized.length).toBeLessThanOrEqual(200)
51+
})
4552
})
4653

4754
describe('uniqueDownloadPath', () => {

‎apps/desktop/src/main/downloads.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,11 @@ export function sanitizeFilename(
4141
.replace(/^\.+/, '')
4242
.trim()
4343
.slice(0, MAX_FILENAME_LENGTH)
44-
return platform === 'win32' ? toWin32SafeFileName(cleaned) : cleaned
44+
if (platform !== 'win32') return cleaned
45+
const safe = toWin32SafeFileName(cleaned)
46+
return safe.length > MAX_FILENAME_LENGTH
47+
? toWin32SafeFileName(cleaned.slice(0, MAX_FILENAME_LENGTH - 1))
48+
: safe
4549
}
4650

4751
/**

‎apps/desktop/src/main/local-file-permissions.ts‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { lstat, realpath, stat } from 'node:fs/promises'
1+
import { lstat, stat } from 'node:fs/promises'
22
import { homedir } from 'node:os'
33
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
44
import { isDesktopScopeId } from '@sim/desktop-bridge'
55
import type { BrowserWindow } from 'electron'
66
import { showShellDialog } from '@/main/dialogs'
77
import type { LocalFileAuthorization } from '@/main/local-files'
88
import type { LocalFileAccess, LocalFilesystemService } from '@/main/local-filesystem'
9+
import { localRealpath } from '@/main/local-realpath'
910
import { openNativeFile } from '@/main/native-directory'
1011
import { resolveWin32NativePath } from '@/main/win32-path'
1112

@@ -27,8 +28,9 @@ interface PendingFolderDecision {
2728
}
2829

2930
/**
30-
* Lexically resolves a model-supplied path. Runs before any filesystem call,
31-
* which on Windows is what keeps a UNC path from reaching the network.
31+
* Lexically resolves a model-supplied path without touching the filesystem. On
32+
* Windows it refuses a path that is itself a share; a link inside the path is
33+
* left to {@link localRealpath}, which callers resolve it with.
3234
*/
3335
export function nativePath(
3436
value: unknown,
@@ -39,7 +41,7 @@ export function nativePath(
3941
throw new Error('A native absolute path or ~/ path is required.')
4042
if (platform === 'win32') return resolveWin32NativePath(value, home())
4143
const path =
42-
value === '~' ? homedir() : value.startsWith('~/') ? join(homedir(), value.slice(2)) : value
44+
value === '~' ? home() : value.startsWith('~/') ? join(home(), value.slice(2)) : value
4345
if (!isAbsolute(path)) throw new Error('Use an absolute path or ~/ path.')
4446
return resolve(path)
4547
}
@@ -80,7 +82,7 @@ export class LocalFilePermissions {
8082
!isDesktopScopeId(authorization.args.folderId)))
8183
)
8284
throw new Error('A valid destination workspace and folder are required for imports.')
83-
const path = await realpath(nativePath(authorization.args.path))
85+
const path = await localRealpath(nativePath(authorization.args.path))
8486
if (this.fullFileAccess()) {
8587
return this.unrestrictedAccess(path, context)
8688
}
@@ -130,7 +132,7 @@ export class LocalFilePermissions {
130132
return this.authorizedAccess(
131133
{
132134
path,
133-
resolve: realpath,
135+
resolve: (requested) => localRealpath(requested),
134136
open: (requested, directory = false) =>
135137
openNativeFile(folder, relative(folder, requested), identity, directory),
136138
},

‎apps/desktop/src/main/local-files.ts‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { BigIntStats } from 'node:fs'
12
import { lstat, stat } from 'node:fs/promises'
23
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
34
import type {
@@ -23,8 +24,9 @@ export interface LocalFileAuthorization {
2324
args: Record<string, unknown>
2425
}
2526

26-
function revision(info: Awaited<ReturnType<typeof stat>>): string {
27-
return `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}`
27+
/** Bigint fields, since NTFS file IDs exceed 2^53 and would collide as numbers. */
28+
function revision(info: BigIntStats): string {
29+
return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}`
2830
}
2931

3032
function boundedInteger(value: unknown, fallback: number, max: number): number {
@@ -183,18 +185,19 @@ async function manifest(
183185
)
184186
const canonical = await access.resolve(current)
185187
assertImportPath(root, canonical)
186-
const info = await stat(canonical)
188+
const info = await stat(canonical, { bigint: true })
189+
const size = Number(info.size)
187190
if (!info.isDirectory() && !info.isFile())
188191
throw new Error(`Cannot import special filesystem entry: ${current}`)
189192
if (ancestors.has(canonical)) throw new Error(`Directory link cycle: ${current}`)
190-
if (info.isFile() && info.size > MAX_DESKTOP_IMPORT_FILE_BYTES)
193+
if (info.isFile() && size > MAX_DESKTOP_IMPORT_FILE_BYTES)
191194
throw new Error(
192195
'Desktop imports support files up to 64 MB. Use the file uploader for larger files.'
193196
)
194197
entries.push({
195198
relativePath: relative(path, current).split(sep).join('/'),
196199
kind: info.isDirectory() ? 'directory' : 'file',
197-
size: info.size,
200+
size,
198201
revision: revision(info),
199202
})
200203
if (info.isDirectory()) {
@@ -252,23 +255,24 @@ export async function executeLocalFileRequest(
252255
const offset = boundedInteger(request.offset, 0, Number.MAX_SAFE_INTEGER)
253256
const file = await access.open(canonical)
254257
try {
255-
const info = await file.stat()
258+
const info = await file.statBigint()
259+
const size = Number(info.size)
256260
if (!info.isFile() || revision(info) !== request.revision)
257261
throw new Error(
258262
'The source file changed during import. Inspect the partial result before retrying.'
259263
)
260-
if (offset > info.size) throw new Error('The requested offset is outside the file.')
261-
const buffer = Buffer.alloc(Math.min(CHUNK_BYTES, info.size - offset))
264+
if (offset > size) throw new Error('The requested offset is outside the file.')
265+
const buffer = Buffer.alloc(Math.min(CHUNK_BYTES, size - offset))
262266
const { bytesRead } = await file.read(buffer, 0, buffer.length, offset)
263-
if (revision(await file.stat()) !== request.revision)
267+
if (revision(await file.statBigint()) !== request.revision)
264268
throw new Error('The source file changed during import.')
265269
await access.resolve(child)
266270
return {
267271
ok: true,
268272
data: {
269273
kind: 'chunk',
270274
bytes: new Uint8Array(buffer.subarray(0, bytesRead)),
271-
eof: offset + bytesRead >= info.size,
275+
eof: offset + bytesRead >= size,
272276
},
273277
}
274278
} finally {

0 commit comments

Comments
 (0)