diff --git a/CHANGELOG.md b/CHANGELOG.md index 0300f29034..8fd134e978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **A sandbox can be created from an image file you already have** — creating + a sandbox always downloaded its environment, which is well over a gigabyte + and impossible on a machine with no route to the internet. **Add sandbox** + now has an optional **Sandbox image** field: leave it empty and the right + image is downloaded as before, or choose a file you already have — one built + in-house, or one a colleague copied to you. The file is checked as soon as + you choose it, and the dialog says plainly what is wrong when it cannot be + used: it is not a sandbox image at all, it is an image for a different + operating system (a macOS image on Windows, say), or it is built for a + different kind of processor (an Intel image on an Apple-silicon Mac). The + file is read where it sits rather than copied, and it is remembered, so + resetting the sandbox rebuilds it from the same file. +- **The sandbox name is checked as you type** — a name that was already taken, + or too long for the environments it names, was only rejected once creation + had started, which on a fresh machine meant waiting out a large download + first. Duplicate names, over-long names, and characters that are unsafe in a + Windows distribution or a folder name are now caught in the dialog, with a + message saying what to change. - **The office skills now work inside a sandbox** — the document skills (Word, Excel, PowerPoint, PDF) were installed into the sandbox image, but several pieces they depend on were not, so they failed once you actually diff --git a/DIVERGENCES.md b/DIVERGENCES.md index a065f83149..9f7bfd9e3a 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -197,7 +197,7 @@ from the squash message first, or use a plain merge. | Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; both return the same `WslCommandResult` shape, so the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads the pinned bundle (`wsl.zip`: runtime MSI + Ubuntu-24.04 image) from the codev storage host, enables VirtualMachinePlatform via offline dism + installs the MSI behind one UAC prompt, and falls back to upstream's `wsl --install` / `--web-download` paths only when the bundle cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. | | Async settings stores: `packages/desktop/src/main/{store,index}.ts`, `packages/desktop/package.json` | `store.ts` no longer uses electron-store: `getStore` now returns the fork-only `store-file.ts` store — in-memory state, flushed write-behind through `store-writer.ts`'s serialized latest-wins async atomic writer (temp → fsync → rename via `fs.promises`, so the fsync runs on the libuv threadpool). Adds `flushStoreWrites(timeoutMs)`; `removeStoreFile`/`removeStoreFileIfEmpty` discard/flush the writer around the delete so a late flush cannot resurrect the file. `index.ts`: `stopSidecars` awaits `flushStoreWrites(1500)`. `package.json`: the `electron-store` dependency is removed. On-disk format is unchanged (flat keys, tab-indented JSON, no extension) — existing files load as-is; a corrupt file now starts empty with a logged error instead of throwing from every accessor. | conf v15's `store.set` is a synchronous fsync on the Electron main thread, and one of those parked the whole app in uninterruptible disk sleep (kernel journal stall + lost ext4 fast-commit wakeup, observed live during a sandbox mount-apply restart): event loop dead, IPC dead, quit impossible. conf also rereads the file on every access, which makes write-behind unsound with the stock class (a set on one key reads a stale file and drops a still-queued update), so the storage layer had to be replaced rather than patched. | Keep the fork store; take upstream's surrounding changes. If upstream adds electron-store options (schema, migrations, dot-notation) or new store call sites, extend `store-file.ts` to match — its API is the superset the desktop actually uses (`get/set/has/delete/clear/store/path`). Do not reintroduce a synchronous write path on the main thread. | | Desktop sandbox wiring: `packages/desktop/src/main/{index,store-keys}.ts`, `packages/desktop/package.json` | Hooks the fork-only `src/main/sandbox/{bundle,controller,ipc}.ts` modules into startup: `index.ts` constructs the sandbox controller beside the WSL servers controller, adds `sandboxes.stopAll()` to `stopSidecars`, registers the `sandbox-*` IPC channels next to the WSL ones, and calls `sandboxes.initialize()` (all platforms) once the local server is ready; `store-keys.ts` gains `SANDBOXES_KEY = "sandboxes"`; `package.json` gains the `@codev/sandbox` and `@codev/gateway` workspace devDependencies (bundled into the main process by electron-vite, so not runtime dependencies; gateway supplies the sandbox egress broker's credentials and config fragment). | The desktop app is where sandboxes are created and run; `createSandboxController` generalizes the proven WSL-servers controller pattern to the per-platform `@codev/sandbox` providers (macOS vz / Windows wsl / Linux bwrap). | Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from `initialize()` needing the local server ready first. Do not rename the `sandbox-*` channels or the `sandboxes` store key — preload/renderer and persisted sandbox configs depend on them. | -| Desktop sandbox renderer slice: `packages/app/package.json`, `packages/app/src/{index.ts,app.tsx}`, `packages/app/src/context/{platform,server}.tsx`, `packages/app/src/components/{settings-v2/servers.tsx,dialog-select-server.tsx}`, `packages/app/src/wsl/settings.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Surfaces the fork-only sandbox modules (`packages/app/src/sandbox/*`, `packages/desktop/src/renderer/sandbox/*`) in the UI: app `package.json` exports `./sandbox/types` (mirror of `./wsl/types`); `index.ts` re-exports `useSandboxes` + the sandbox types; `app.tsx` mounts `SandboxesProvider` inside `WslServersProvider`; `platform.tsx` gains optional `sandboxes?: SandboxesPlatform`; `server.tsx` adds the third `ServerConnection.Sidecar` variant `{ variant: "sandbox"; name; projectDir?; mounts? }` with key `sandbox:` (`projectDir` is the host folder mounted into the guest and `mounts` the extra host folders with their resolved guest paths — the guest only ever sees its own mount points, so this is the only way the app can name a sandbox's projects after the folders it works on); `settings-v2/servers.tsx` filters sandbox rows out of the generic list and renders `SandboxServerSettings` beside the WSL section; `dialog-select-server.tsx` `handleRemove` routes `sandbox:` keys to `platform.sandboxes.remove` (like `wsl:` keys) and moves the `tabs.removeServer(key)` + `server.remove(key)` pair *above* both awaited platform removals; `wsl/settings.tsx` `AddServerMenu` shows the menu when either wslServers or sandboxes exist and gains an "Add sandbox" item; i18n gains the `sandbox.*` key family (including the optional per-sandbox CPU/memory fields, macOS-only since only the vz backend applies them; `sandbox.add.projectDirHint`, the standing explanation above the folder field; the `sandbox.mounts.*` family for the manage-folders dialog that adds/removes extra mounts on an existing sandbox; `sandbox.job.queued`, shown while a sandbox's create/reset/destroy waits its turn behind another sandbox's job; and `sandbox.server.stop` for the menu entry that shuts a sandbox down until it is explicitly started again); preload exposes `api.sandboxes` over the `sandbox-*` channels and `renderer/index.tsx` wires the platform api, loading gate, ready-connection list, and the `startupServer` fallback (fork-only wrapper that validates `sandbox:` default keys the way `availableStartupServer` validates `wsl:` ones). | Ready sandboxes must appear as first-class servers and be manageable from settings; the slice mirrors the WSL vertical exactly so upstream conflicts resolve the same way in both. Removing a server destroys a disk and takes seconds, and the connection leaves the list the moment it finishes, so the tab cleanup has to happen before the await rather than after it. | Keep every listed edit; take upstream's surrounding changes. If upstream adds a `Sidecar` variant or reworks `AddServerMenu`, re-apply the sandbox variant/menu item on top. The `sandbox:` key format is persisted as the default-server key and as `config.id` in the main-process store — never change one side alone. Keep the tab cleanup ahead of the platform removals in `handleRemove`; if upstream reorders it back, re-apply. | +| Desktop sandbox renderer slice: `packages/app/package.json`, `packages/app/src/{index.ts,app.tsx}`, `packages/app/src/context/{platform,server}.tsx`, `packages/app/src/components/{settings-v2/servers.tsx,dialog-select-server.tsx}`, `packages/app/src/wsl/settings.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Surfaces the fork-only sandbox modules (`packages/app/src/sandbox/*`, `packages/desktop/src/renderer/sandbox/*`) in the UI: app `package.json` exports `./sandbox/types` (mirror of `./wsl/types`); `index.ts` re-exports `useSandboxes` + the sandbox types; `app.tsx` mounts `SandboxesProvider` inside `WslServersProvider`; `platform.tsx` gains optional `sandboxes?: SandboxesPlatform` and, on the desktop variant, `openFilePathDialog` (a path-only file picker — the existing attachment picker reads the file's bytes, which is unusable for a multi-gigabyte guest image), implemented in `renderer/index.tsx` over the existing `open-file-picker` channel and releasing the read token immediately; `server.tsx` adds the third `ServerConnection.Sidecar` variant `{ variant: "sandbox"; name; projectDir?; mounts? }` with key `sandbox:` (`projectDir` is the host folder mounted into the guest and `mounts` the extra host folders with their resolved guest paths — the guest only ever sees its own mount points, so this is the only way the app can name a sandbox's projects after the folders it works on); `settings-v2/servers.tsx` filters sandbox rows out of the generic list and renders `SandboxServerSettings` beside the WSL section; `dialog-select-server.tsx` `handleRemove` routes `sandbox:` keys to `platform.sandboxes.remove` (like `wsl:` keys) and moves the `tabs.removeServer(key)` + `server.remove(key)` pair *above* both awaited platform removals; `wsl/settings.tsx` `AddServerMenu` shows the menu when either wslServers or sandboxes exist and gains an "Add sandbox" item; i18n gains the `sandbox.*` key family (including the optional per-sandbox CPU/memory fields, macOS-only since only the vz backend applies them; `sandbox.add.projectDirHint`, the standing explanation above the folder field; the `sandbox.mounts.*` family for the manage-folders dialog that adds/removes extra mounts on an existing sandbox; `sandbox.job.queued`, shown while a sandbox's create/reset/destroy waits its turn behind another sandbox's job; `sandbox.server.stop` for the menu entry that shuts a sandbox down until it is explicitly started again; and the `sandbox.add.image*` / `sandbox.add.name*` / `sandbox.arch.*` families for the optional custom-image field and the sandbox-name checks); preload exposes `api.sandboxes` over the `sandbox-*` channels and `renderer/index.tsx` wires the platform api, loading gate, ready-connection list, and the `startupServer` fallback (fork-only wrapper that validates `sandbox:` default keys the way `availableStartupServer` validates `wsl:` ones). | Ready sandboxes must appear as first-class servers and be manageable from settings; the slice mirrors the WSL vertical exactly so upstream conflicts resolve the same way in both. Removing a server destroys a disk and takes seconds, and the connection leaves the list the moment it finishes, so the tab cleanup has to happen before the await rather than after it. | Keep every listed edit; take upstream's surrounding changes. If upstream adds a `Sidecar` variant or reworks `AddServerMenu`, re-apply the sandbox variant/menu item on top. The `sandbox:` key format is persisted as the default-server key and as `config.id` in the main-process store — never change one side alone. Keep the tab cleanup ahead of the platform removals in `handleRemove`; if upstream reorders it back, re-apply. | | Desktop identity: `packages/desktop/src/main/{index,store}.ts`, `packages/desktop/electron-builder.config{,.test}.ts`, `packages/desktop/scripts/copy-metainfo.ts`, `packages/desktop/resources/linux/opencode-desktop.desktop` (deleted), `packages/app/src/pages/layout/{deep-links.ts,helpers.test.ts}`, `packages/codev-brand/test/brand.test.ts` | appId `ai.opencode.desktop{,.beta,.dev}` → `ai.codev.desktop{,.beta,.dev}` (both copies: `APP_IDS` in main and in the builder config, plus the unpackaged fallback); deep-link scheme `opencode://` → `codev://` in all three places that must agree — `protocols.schemes`, `app.setAsDefaultProtocolClient`, and the renderer's `parseUrl` — plus the `argv.startsWith` filter the second-instance handler uses; `rpm.packageName` `opencode`/`opencode-beta`/`opencode-dev` → `codev`/`codev-beta`/`codev-dev`; the hidden `opencode-desktop.desktop` legacy launcher and its `deb`/`rpm` fpm wiring deleted. `src/main/migrate.ts` keeps the old ids — they name the *Tauri* app's directories, not ours. | The appId is the whole desktop identity: `app.setPath("userData", join(appData, appId))`, so it also names the data directory, and it drives the macOS bundle id, the Windows AppUserModelId and NSIS keys, the Linux `.desktop` filename, `executableName` and `StartupWMClass`, and the AppStream metainfo id. Leaving it as opencode meant the fork's app identified itself as upstream to the OS. Renamed as a clean cut with no data migration and no back-compat shim, on the explicit basis that there is no installed base: existing profiles under `appData/ai.opencode.desktop*` are simply orphaned (settings, session history, sandboxes and the downloaded guest image), and a packaged update across the rename would install beside the old app rather than replace it. The legacy launcher went with it — it existed to rescue GNOME/KDE pins from a superseded packaging id, and its `Exec=/opt/OpenCode/...` no longer matched any install layout. | Keep our ids and scheme; take upstream's other config. Never rename the `TAURI_APP_IDS` in `migrate.ts` to match — a blind find-and-replace there points the Tauri migration at a directory that never existed. All three deep-link sites have to move together or links open nothing; `electron-builder.config.test.ts` covers the ids, the package names and the scheme per channel. | diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index cebdbe47c5..23844fe982 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -133,6 +133,9 @@ export type Platform = PlatformBase & platform: "desktop" os?: DesktopOS openDirectoryPickerDialog(opts?: OpenDirectoryPickerOptions): Promise + /** Pick one file and get its path back. Unlike the attachment picker + * this never reads the file, which matters for multi-gigabyte ones. */ + openFilePathDialog(opts?: { title?: string; extensions?: string[] }): Promise } ) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 13cf45d6f8..f3546cb3d9 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -395,7 +395,24 @@ export const dict = { "sandbox.job.reset": "Resetting...", "sandbox.job.queued": "Waiting for another sandbox...", "sandbox.add.namePlaceholder": "Sandbox name", - "sandbox.add.nameInvalid": "Use letters, numbers, dots, dashes, or underscores.", + "sandbox.add.nameInvalid": "Start with a letter or number, then use letters, numbers, dots, dashes, or underscores.", + "sandbox.add.nameTooLong": "Keep the name to {{max}} characters or fewer.", + "sandbox.add.nameDuplicate": "You already have a sandbox with this name.", + "sandbox.arch.x64": "Intel/AMD (x86_64)", + "sandbox.arch.arm64": "ARM64 (Apple silicon)", + "sandbox.add.image": "Sandbox image", + "sandbox.add.imagePlaceholder": "Sandbox image file (optional)", + "sandbox.add.imageHint": + "Leave this empty and the right image is downloaded for you. Choose a file only if you have one already \u2014 an image built for this computer, or one someone shared with you.", + "sandbox.add.imageClear": "Clear", + "sandbox.add.imageChecking": "Checking the image\u2026", + "sandbox.add.imageOk": "This image can be used on this computer.", + "sandbox.add.imageUnreadable": "That file could not be read. Check it still exists and you have permission to open it.", + "sandbox.add.imageNotArchive": "That is not a sandbox image. A sandbox image is a .tar.gz file.", + "sandbox.add.imageNotSandbox": "That .tar.gz is not a sandbox image \u2014 it does not contain a sandbox environment.", + "sandbox.add.imageWrongOsMac": "That image is for macOS sandboxes. This computer needs the Windows/Linux image.", + "sandbox.add.imageWrongOsOther": "That image is for Windows/Linux sandboxes. This computer needs the macOS image.", + "sandbox.add.imageWrongArch": "That image is built for {{found}} machines, which this computer is not.", "sandbox.add.projectDir": "Project folder", "sandbox.add.projectDirHint": "Choose the folder you want to work on. The sandbox can only reach folders you mount from this computer — you can mount more later — and the agent edits files in them without asking.", @@ -406,6 +423,7 @@ export const dict = { "sandbox.add.resourcesInvalid": "CPUs must be 1-32 and memory 1-64 GB.", "sandbox.add.downloadHint": "The first sandbox downloads its image; on a slow connection this can take a while.", "sandbox.add.downloadProgress": "Downloading image… {{done}}/{{total}} MB", + "sandbox.add.creating": "Creating the sandbox\u2026 this takes a few minutes.", "sandbox.mounts.title": "Sandbox folders", "sandbox.mounts.hint": "Folders mounted here are the only ones on this computer the sandbox can reach, and the agent edits files in them without asking.", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 4850bcaa41..1afb02c99b 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -386,7 +386,24 @@ export const dict = { "sandbox.job.reset": "Restableciendo...", "sandbox.job.queued": "Esperando a otro sandbox...", "sandbox.add.namePlaceholder": "Nombre del sandbox", - "sandbox.add.nameInvalid": "Usa letras, números, puntos, guiones o guiones bajos.", + "sandbox.add.nameInvalid": "Empieza con una letra o un n\u00famero y usa despu\u00e9s letras, n\u00fameros, puntos, guiones o guiones bajos.", + "sandbox.add.nameTooLong": "Usa como m\u00e1ximo {{max}} caracteres.", + "sandbox.add.nameDuplicate": "Ya tienes un sandbox con este nombre.", + "sandbox.arch.x64": "Intel/AMD (x86_64)", + "sandbox.arch.arm64": "ARM64 (Apple silicon)", + "sandbox.add.image": "Imagen del sandbox", + "sandbox.add.imagePlaceholder": "Archivo de imagen del sandbox (opcional)", + "sandbox.add.imageHint": + "D\u00e9jalo vac\u00edo y se descargar\u00e1 la imagen adecuada. Elige un archivo solo si ya tienes uno: una imagen creada para este equipo o una que te hayan compartido.", + "sandbox.add.imageClear": "Quitar", + "sandbox.add.imageChecking": "Comprobando la imagen\u2026", + "sandbox.add.imageOk": "Esta imagen se puede usar en este equipo.", + "sandbox.add.imageUnreadable": "No se pudo leer ese archivo. Comprueba que sigue existiendo y que tienes permiso para abrirlo.", + "sandbox.add.imageNotArchive": "Eso no es una imagen de sandbox. Una imagen de sandbox es un archivo .tar.gz.", + "sandbox.add.imageNotSandbox": "Ese .tar.gz no es una imagen de sandbox: no contiene un entorno de sandbox.", + "sandbox.add.imageWrongOsMac": "Esa imagen es para sandboxes de macOS. Este equipo necesita la imagen de Windows/Linux.", + "sandbox.add.imageWrongOsOther": "Esa imagen es para sandboxes de Windows/Linux. Este equipo necesita la imagen de macOS.", + "sandbox.add.imageWrongArch": "Esa imagen est\u00e1 hecha para equipos {{found}}, y este no lo es.", "sandbox.add.projectDir": "Carpeta del proyecto", "sandbox.add.projectDirHint": "Elige la carpeta en la que quieres trabajar. El sandbox solo puede acceder a las carpetas de este equipo que montes — puedes montar más después — y el agente edita archivos en ellas sin preguntar.", @@ -397,6 +414,7 @@ export const dict = { "sandbox.add.resourcesInvalid": "Las CPU deben ser 1-32 y la memoria 1-64 GB.", "sandbox.add.downloadHint": "El primer sandbox descarga su imagen; con una conexión lenta puede tardar un poco.", "sandbox.add.downloadProgress": "Descargando imagen… {{done}}/{{total}} MB", + "sandbox.add.creating": "Creando el sandbox\u2026 esto tarda unos minutos.", "sandbox.mounts.title": "Carpetas del sandbox", "sandbox.mounts.hint": "Las carpetas montadas aquí son las únicas de este equipo a las que el sandbox puede acceder, y el agente edita archivos en ellas sin preguntar.", diff --git a/packages/app/src/sandbox/create-phase.test.ts b/packages/app/src/sandbox/create-phase.test.ts new file mode 100644 index 0000000000..50964c23ff --- /dev/null +++ b/packages/app/src/sandbox/create-phase.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test" +import { sandboxCreatePhase } from "./create-phase" +import type { SandboxJob } from "./types" + +const job = (over: Partial): SandboxJob => ({ + kind: "create", + id: "sandbox:work", + startedAt: 0, + status: "running", + ...over, +}) + +test("is idle before the create starts", () => { + expect(sandboxCreatePhase({ adding: false })).toBe("idle") + // A job belonging to some other sandbox must not make this dialog busy. + expect(sandboxCreatePhase({ adding: false, job: job({}) })).toBe("idle") +}) + +test("waits while the job is queued behind another sandbox", () => { + expect(sandboxCreatePhase({ adding: true, job: job({ status: "queued" }) })).toBe("queued") +}) + +test("downloads only while bytes are still arriving", () => { + expect(sandboxCreatePhase({ adding: true, job: job({ progress: { bytes: 5, totalBytes: 100 } }) })).toBe( + "downloading", + ) +}) + +// The bug this exists for: progress stops updating at 100% and stays there for +// the whole unpack, so the dialog used to claim it was still downloading. +test("switches to creating once the download completes", () => { + expect(sandboxCreatePhase({ adding: true, job: job({ progress: { bytes: 100, totalBytes: 100 } }) })).toBe("creating") +}) + +// A cached image reports nothing at all, and a custom image reports its full +// size in one go — neither is a download. +test("is creating when no bytes are ever fetched", () => { + expect(sandboxCreatePhase({ adding: true, job: job({}) })).toBe("creating") + expect(sandboxCreatePhase({ adding: true })).toBe("creating") +}) diff --git a/packages/app/src/sandbox/create-phase.ts b/packages/app/src/sandbox/create-phase.ts new file mode 100644 index 0000000000..0e8310b80b --- /dev/null +++ b/packages/app/src/sandbox/create-phase.ts @@ -0,0 +1,18 @@ +import type { SandboxJob } from "./types" + +// What an in-flight create is doing, for the Add sandbox dialog's status line. +// +// The job carries progress for fetching the image and nothing else, so the +// phase has to be read from what that progress is *not* saying: a report that +// has reached its total, or no report at all, means the fetch is done (or was +// never needed) and the slow part — unpacking and building the sandbox — is +// under way. Getting this wrong leaves the dialog claiming to download a file +// it already has, or one the user supplied from their own disk. +export type SandboxCreatePhase = "idle" | "queued" | "downloading" | "creating" + +export function sandboxCreatePhase(input: { adding: boolean; job?: SandboxJob }): SandboxCreatePhase { + if (!input.adding) return "idle" + if (input.job?.status === "queued") return "queued" + const reported = input.job?.progress + return reported && reported.bytes < reported.totalBytes ? "downloading" : "creating" +} diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx index 2f2f503f35..d1f9e31013 100644 --- a/packages/app/src/sandbox/dialog-add.tsx +++ b/packages/app/src/sandbox/dialog-add.tsx @@ -9,14 +9,12 @@ import { Show } from "solid-js" import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" -import type { SandboxConfig } from "./types" +import type { SandboxConfig, SandboxImageProblem } from "./types" import { useSandboxes } from "./context" +import { sandboxCreatePhase } from "./create-phase" +import { sandboxNameUsable, validateSandboxName } from "./name-model" import { sandboxResourcesValid } from "./resources" -// Distribution and directory names are derived from the sandbox name across -// every backend, so keep it to characters that are safe in all of them. -const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ - // onCreated fires with the new sandbox before the dialog closes. Callers that // need to react to a creation must use it rather than watching the sandbox // list: that list arrives over a separate IPC event, so it is still empty for @@ -27,7 +25,20 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) => const dialog = useDialog() const sandboxes = useSandboxes() const api = platform.sandboxes! - const [store, setStore] = createStore({ name: "", projectDir: "", cpus: "", memoryGiB: "", adding: false }) + const [store, setStore] = createStore({ + name: "", + projectDir: "", + cpus: "", + memoryGiB: "", + adding: false, + imagePath: "", + imageChecking: false, + imageProblem: null as SandboxImageProblem | null, + // The path the verdict in imageProblem belongs to. A field edited since + // then is unchecked, not approved — without this a typed path would show + // as fine until the create failed on it. + imageCheckedPath: null as string | null, + }) // Only the macOS VM backend applies per-sandbox limits; WSL's are VM-global // and bubblewrap has none, so the fields appear only where they take effect. @@ -51,12 +62,73 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) => const value = availability() return value?.supported ? value.warning : null } - const nameInvalid = () => store.name.length > 0 && !NAME_PATTERN.test(store.name) + // Checked against the sandboxes that already exist, so a name collision is + // reported while typing rather than by a create that fails after minutes of + // downloading. + const takenNames = () => (sandboxes.state.data?.sandboxes ?? []).map((item) => item.config.name) + const nameError = () => validateSandboxName(store.name, takenNames()) + const nameMessage = () => { + const error = nameError() + if (!error) return null + if (error.code === "too-long") return language.t("sandbox.add.nameTooLong", { max: error.max }) + if (error.code === "duplicate") return language.t("sandbox.add.nameDuplicate") + return language.t("sandbox.add.nameInvalid") + } + const imageMessage = () => { + const problem = store.imageProblem + if (!problem) return null + if (problem.code === "wrong-arch") + return language.t("sandbox.add.imageWrongArch", { + found: language.t(problem.found === "x64" ? "sandbox.arch.x64" : "sandbox.arch.arm64"), + }) + if (problem.code === "wrong-os") + return language.t(problem.found === "vz" ? "sandbox.add.imageWrongOsMac" : "sandbox.add.imageWrongOsOther") + if (problem.code === "not-gzip") return language.t("sandbox.add.imageNotArchive") + if (problem.code === "unreadable") return language.t("sandbox.add.imageUnreadable") + return language.t("sandbox.add.imageNotSandbox") + } const canCreate = () => availability()?.supported === true && - NAME_PATTERN.test(store.name) && + sandboxNameUsable(store.name, takenNames()) && store.projectDir.length > 0 && - resourcesValid() + resourcesValid() && + // An unchecked image is not an approved one. + (!store.imagePath || (store.imageCheckedPath === store.imagePath && !store.imageProblem)) + + // The main process is the authority on what counts as usable here; the + // dialog only renders its verdict. Runs when a file is picked and when a + // hand-typed path is committed, never per keystroke. + const checkImage = async (path: string) => { + if (!path || store.imageCheckedPath === path) return + setStore({ imageChecking: true, imageProblem: null }) + // A failed round-trip is reported as unreadable rather than as "fine": + // null is the answer for a usable image, so it must not double as an error. + const problem = await api.inspectImage(path).catch((): SandboxImageProblem => ({ code: "unreadable" })) + // The field can move on during the round-trip; a stale verdict must not + // be applied to a path the user has since changed. + if (store.imagePath !== path) return + setStore({ imageChecking: false, imageProblem: problem, imageCheckedPath: path }) + } + + const pickImage = () => { + if (platform.platform !== "desktop") return + void platform + .openFilePathDialog({ title: language.t("sandbox.add.image"), extensions: ["gz", "tgz"] }) + .then((path) => { + if (!path) return + setStore({ imagePath: path, imageProblem: null, imageCheckedPath: null }) + return checkImage(path) + }) + // A picker that fails has to say so: swallowing it leaves the field + // empty after the user chose a file, which reads as the app ignoring them. + .catch((error) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + ) + } const pickProjectDir = () => { if (platform.platform !== "desktop") return @@ -65,15 +137,18 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) => }) } - // This create's image-download progress, pushed by the main process while - // the add is in flight. Jobs are queued, so the create may still be waiting - // behind another sandbox's job: no progress until it starts running. - const progress = () => { - if (!store.adding) return undefined - return sandboxes.state.data?.jobs.find((job) => job.kind === "create" && job.status === "running")?.progress + const createJob = () => sandboxes.state.data?.jobs.find((entry) => entry.kind === "create") + const createPhase = () => sandboxCreatePhase({ adding: store.adding, job: createJob() }) + const downloadProgress = () => (createPhase() === "downloading" ? createJob()?.progress : undefined) + // Null while the bar is up: the byte counter under it already says what is + // happening, and a custom image needs no download warning at all. + const statusMessage = () => { + const phase = createPhase() + if (phase === "queued") return language.t("sandbox.job.queued") + if (phase === "creating") return language.t("sandbox.add.creating") + if (phase === "downloading") return null + return store.imagePath ? null : language.t("sandbox.add.downloadHint") } - const queued = () => - store.adding && sandboxes.state.data?.jobs.some((job) => job.kind === "create" && job.status === "queued") const create = async () => { if (!canCreate() || store.adding) return @@ -85,6 +160,7 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) => store.name, store.projectDir, resourcesApply() ? { cpus: cpus(), memoryMiB: memoryMiB() } : undefined, + store.imagePath || undefined, ) if (created) { props.onCreated?.(created) @@ -129,9 +205,7 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) => disabled={store.adding} onInput={(event) => setStore("name", event.currentTarget.value)} /> - -

{language.t("sandbox.add.nameInvalid")}

-
+ {(text) =>

{text()}

}
{/* The folder is the sandbox's only reach into this machine and is fixed for its lifetime, so say so before the picker opens rather than leaving it to be discovered. */} @@ -149,6 +223,43 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) => {language.t("sandbox.add.choose")} + {/* Optional: most people want the published image downloaded for + them, so the hint leads with that and explains what the field is + for before offering the picker. */} +

{language.t("sandbox.add.imageHint")}

+
+ + setStore({ imagePath: event.currentTarget.value, imageProblem: null, imageChecking: false }) + } + onChange={(event) => void checkImage(event.currentTarget.value)} + /> + + {language.t("sandbox.add.choose")} + + + + setStore({ imagePath: "", imageProblem: null, imageChecking: false, imageCheckedPath: null }) + } + > + {language.t("sandbox.add.imageClear")} + + +
+ +

{language.t("sandbox.add.imageChecking")}

+
+ {(text) =>

{text()}

}
+ +

{language.t("sandbox.add.imageOk")}

+

{language.t("sandbox.add.resourcesInvalid")}

- - {queued() ? language.t("sandbox.job.queued") : language.t("sandbox.add.downloadHint")} -

- } - > + {(current) => (
@@ -197,6 +301,7 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) =>
)} + {(text) =>

{text()}

}
diff --git a/packages/app/src/sandbox/name-model.test.ts b/packages/app/src/sandbox/name-model.test.ts new file mode 100644 index 0000000000..e6101520b0 --- /dev/null +++ b/packages/app/src/sandbox/name-model.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import { SANDBOX_NAME_MAX, sandboxNameUsable, validateSandboxName } from "./name-model" + +describe("validateSandboxName", () => { + test("accepts ordinary names", () => { + expect(validateSandboxName("work", [])).toBeNull() + expect(validateSandboxName("codev-hub", [])).toBeNull() + expect(validateSandboxName("Repo_2.0", [])).toBeNull() + }) + + test("says nothing about an empty field", () => { + // Nothing has been typed yet — that is not an error to show anyone. + expect(validateSandboxName("", [])).toBeNull() + expect(sandboxNameUsable("", [])).toBe(false) + }) + + test("rejects characters that are unsafe as a distro or directory name", () => { + expect(validateSandboxName("my sandbox", [])).toEqual({ code: "charset" }) + expect(validateSandboxName("work/tmp", [])).toEqual({ code: "charset" }) + expect(validateSandboxName("hé", [])).toEqual({ code: "charset" }) + }) + + test("rejects a leading dot or dash", () => { + // A leading dot hides the data directory; a leading dash reads as a flag + // to the wsl CLI. + expect(validateSandboxName(".hidden", [])).toEqual({ code: "charset" }) + expect(validateSandboxName("-flag", [])).toEqual({ code: "charset" }) + }) + + test("rejects a name past the length limit", () => { + expect(validateSandboxName("a".repeat(SANDBOX_NAME_MAX), [])).toBeNull() + expect(validateSandboxName("a".repeat(SANDBOX_NAME_MAX + 1), [])).toEqual({ + code: "too-long", + max: SANDBOX_NAME_MAX, + }) + }) + + test("rejects a name already in use, ignoring case", () => { + expect(validateSandboxName("work", ["work"])).toEqual({ code: "duplicate" }) + // macOS and Windows would give both sandboxes the same data directory. + expect(validateSandboxName("Work", ["work"])).toEqual({ code: "duplicate" }) + expect(validateSandboxName("work2", ["work"])).toBeNull() + }) + + test("reports the charset problem before the duplicate one", () => { + // Both are wrong; the message that tells the user what to type wins. + expect(validateSandboxName("bad name", ["bad name"])).toEqual({ code: "charset" }) + }) +}) diff --git a/packages/app/src/sandbox/name-model.ts b/packages/app/src/sandbox/name-model.ts new file mode 100644 index 0000000000..d6c9350043 --- /dev/null +++ b/packages/app/src/sandbox/name-model.ts @@ -0,0 +1,31 @@ +// Pure validation for the sandbox name field. The name is not just a label: it +// becomes the sandbox's id (`sandbox:`), its data directory, and on +// Windows the WSL distribution name — so it has to be checked before a create +// starts, not after one fails minutes into an image download. + +export type SandboxNameError = { code: "charset" } | { code: "too-long"; max: number } | { code: "duplicate" } + +// Distribution and directory names derive from this, and a create that fails +// on a too-long path does so only after the download. 32 is comfortably under +// every limit involved while leaving room for readable names. +export const SANDBOX_NAME_MAX = 32 + +// Must start alphanumeric (a leading dot hides the data directory, a leading +// dash reads as a flag to the wsl CLI) and stay in characters that are safe as +// both a WSL distribution name and a directory name on all three platforms. +const SANDBOX_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/ + +export function validateSandboxName(name: string, existing: string[]): SandboxNameError | null { + if (!name.length) return null + if (!SANDBOX_NAME_PATTERN.test(name)) return { code: "charset" } + if (name.length > SANDBOX_NAME_MAX) return { code: "too-long", max: SANDBOX_NAME_MAX } + // Compared case-insensitively: macOS and Windows filesystems treat "Work" + // and "work" as one directory, so two such sandboxes would fight over the + // same data directory even though their ids differ. + if (existing.some((taken) => taken.toLowerCase() === name.toLowerCase())) return { code: "duplicate" } + return null +} + +export function sandboxNameUsable(name: string, existing: string[]) { + return name.length > 0 && validateSandboxName(name, existing) === null +} diff --git a/packages/app/src/sandbox/types.ts b/packages/app/src/sandbox/types.ts index 0ed6370be9..b6b3b9110b 100644 --- a/packages/app/src/sandbox/types.ts +++ b/packages/app/src/sandbox/types.ts @@ -5,6 +5,8 @@ export type SandboxBackend = "vz" | "wsl" | "bwrap" +export type SandboxArch = "x64" | "arm64" + export type SandboxConfig = { id: string name: string @@ -13,6 +15,9 @@ export type SandboxConfig = { // Extra host folders shared into the guest alongside projectDir; absent // means none. Objects so a readOnly flag can be added without migration. mounts?: SandboxMount[] + // A guest image file the user supplied instead of the published one; reset + // re-provisions from it, so it is kept for the sandbox's life. + imagePath?: string // Set when the user stopped this sandbox, so it stays down across relaunches // instead of booting with the rest. stopped?: boolean @@ -68,16 +73,34 @@ export type SandboxesState = { export type SandboxesEvent = { type: "state"; state: SandboxesState } -export type SandboxAvailability = - | { supported: true; warning: string | null } - | { supported: false; message: string } +// What a guest image turned out to be, mirroring @codev/sandbox's inspector. +// "vz" is the macOS kernel+disk pair; "rootfs" is the tarball Windows and +// Linux share, so those two cannot be told apart — by design. +export type SandboxImageFormat = "vz" | "rootfs" + +export type SandboxImageProblem = + | { code: "unreadable" } + | { code: "not-gzip" } + | { code: "not-a-sandbox-image" } + | { code: "wrong-os"; found: SandboxImageFormat } + | { code: "wrong-arch"; found: SandboxArch } + +export type SandboxAvailability = { supported: true; warning: string | null } | { supported: false; message: string } export type SandboxesPlatform = { getState(): Promise subscribe(cb: (event: SandboxesEvent) => void): () => void availability(): Promise // Resolves undefined when the create was cancelled mid-download. - add(name: string, projectDir: string, resources?: SandboxResources): Promise + add( + name: string, + projectDir: string, + resources?: SandboxResources, + imagePath?: string, + ): Promise + // Checks a candidate custom image against this machine before it is used. + // Null means it is usable here. + inspectImage(path: string): Promise // Aborts the in-flight create/reset job for this sandbox name, if any. cancel(name: string): Promise remove(id: string): Promise diff --git a/packages/codev-sandbox/src/bwrap/provider.ts b/packages/codev-sandbox/src/bwrap/provider.ts index 19037fea79..676e15b31d 100644 --- a/packages/codev-sandbox/src/bwrap/provider.ts +++ b/packages/codev-sandbox/src/bwrap/provider.ts @@ -9,7 +9,7 @@ import { sandboxBootEnv, } from "../boot" import { createBrokerServer, type SandboxEgress } from "../broker" -import { readImageMarker, stageAsset, writeImageMarker } from "../image" +import { localImageAsset, readImageMarker, stageAsset, writeImageMarker } from "../image" import type { SandboxAsset } from "../manifest" import { resolveGuestMounts } from "../mounts" import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider" @@ -67,7 +67,9 @@ export function createBwrapProvider(options: BwrapProviderOptions): SandboxProvi const startTimeoutMs = options.startTimeoutMs ?? 30_000 const prepare = async (config: SandboxConfig, opts?: SandboxJobOptions) => { - const asset = await options.resolveImage({ signal: opts?.signal }) + const asset = config.imagePath + ? await localImageAsset(config.imagePath) + : await options.resolveImage({ signal: opts?.signal }) const dirs = sandboxDirs(options.dataDir, config.name) // reset wipes the rootfs (it is the guest's mutable state), so a matching // marker alone is not proof — the rootfs must actually be there. diff --git a/packages/codev-sandbox/src/image.test.ts b/packages/codev-sandbox/src/image.test.ts index 4ef8e97251..2fe89880a0 100644 --- a/packages/codev-sandbox/src/image.test.ts +++ b/packages/codev-sandbox/src/image.test.ts @@ -3,9 +3,11 @@ import { createHash } from "node:crypto" import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" +import { pathToFileURL } from "node:url" import { imageMarkerPath, isAssetStaged, + localImageAsset, partialAssetPath, readImageMarker, stageAsset, @@ -254,3 +256,43 @@ test("image markers round-trip and read as null when unreadable", async () => { await rm(dir, { recursive: true, force: true }) }) + +// A user-supplied image is described the same way a published one is, so the +// providers' marker-and-stage path needs no special case beyond staging. +test("describes a local image file as an asset", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-local-image-")) + const path = join(dir, "vz-arm64.tar.gz") + await writeFile(path, payload) + const asset = await localImageAsset(path) + expect(asset).toEqual({ url: pathToFileURL(path).href, sha256: payloadSha256, bytes: payload.byteLength }) + await rm(dir, { recursive: true, force: true }) +}) + +test("rejects a local image that is not there", async () => { + expect(localImageAsset(join(tmpdir(), "codev-missing-image.tar.gz"))).rejects.toThrow("does not exist") +}) + +test("stages a local image where it lies instead of copying it into the cache", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-local-image-")) + const cacheDir = join(dir, "cache") + const path = join(dir, "vz-arm64.tar.gz") + await writeFile(path, payload) + const asset = await localImageAsset(path) + const reported: number[] = [] + expect(await stageAsset(cacheDir, asset, { onProgress: (progress) => reported.push(progress.bytes) })).toBe(path) + // Nothing was downloaded or duplicated, and the dialog still sees a + // completed progress report rather than a stalled bar. + expect(await readdir(cacheDir).catch(() => null)).toBeNull() + expect(reported).toEqual([payload.byteLength]) + await rm(dir, { recursive: true, force: true }) +}) + +test("refuses a local image that has since been deleted", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-local-image-")) + const path = join(dir, "vz-arm64.tar.gz") + await writeFile(path, payload) + const asset = await localImageAsset(path) + await rm(path) + expect(stageAsset(join(dir, "cache"), asset)).rejects.toThrow("no longer available") + await rm(dir, { recursive: true, force: true }) +}) diff --git a/packages/codev-sandbox/src/image.ts b/packages/codev-sandbox/src/image.ts index 98effbc729..1922121258 100644 --- a/packages/codev-sandbox/src/image.ts +++ b/packages/codev-sandbox/src/image.ts @@ -3,6 +3,7 @@ import { createReadStream, createWriteStream } from "node:fs" import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises" import { finished } from "node:stream/promises" import { basename, dirname, join } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" import type { SandboxAsset } from "./manifest" import type { SandboxProgress } from "./provider" @@ -32,7 +33,29 @@ export async function isAssetStaged(cacheDir: string, asset: SandboxAsset) { return !!file && file.isFile() && file.size === asset.bytes } +// Describes a guest image the user supplied from disk instead of the manifest. +// Same shape as a published asset so the providers' marker-and-stage path is +// unchanged, and the digest means a reset still re-provisions when the file +// behind it has changed. +export async function localImageAsset(path: string): Promise { + const file = await stat(path).catch(() => null) + if (!file?.isFile()) throw new Error(`sandbox image file does not exist: ${path}`) + const hash = createHash("sha256") + for await (const chunk of createReadStream(path)) hash.update(chunk as Buffer) + return { url: pathToFileURL(path).href, sha256: hash.digest("hex"), bytes: file.size } +} + export async function stageAsset(cacheDir: string, asset: SandboxAsset, opts?: StageAssetOptions): Promise { + // A custom image is used where it lies: there is nothing to download, and + // copying gigabytes into the cache would only duplicate what the user + // already has on disk. + if (asset.url.startsWith("file:")) { + const path = fileURLToPath(asset.url) + const file = await stat(path).catch(() => null) + if (!file?.isFile()) throw new Error(`custom sandbox image is no longer available: ${path}`) + opts?.onProgress?.({ bytes: asset.bytes, totalBytes: asset.bytes }) + return path + } if (await isAssetStaged(cacheDir, asset)) return stagedAssetPath(cacheDir, asset) await mkdir(cacheDir, { recursive: true }) await removeStalePartials(cacheDir, asset) @@ -165,9 +188,7 @@ async function verifyPartial(partial: string, asset: SandboxAsset) { const digest = hash.digest("hex") if (digest === asset.sha256) return await rm(partial, { force: true }) - throw new Error( - `sandbox asset failed verification for ${asset.url}: expected sha256 ${asset.sha256}, got ${digest}`, - ) + throw new Error(`sandbox asset failed verification for ${asset.url}: expected sha256 ${asset.sha256}, got ${digest}`) } async function partialSize(partial: string) { diff --git a/packages/codev-sandbox/src/index.ts b/packages/codev-sandbox/src/index.ts index fae08b2fc4..7c10a724db 100644 --- a/packages/codev-sandbox/src/index.ts +++ b/packages/codev-sandbox/src/index.ts @@ -15,3 +15,4 @@ export * from "./vz/protocol" export * from "./vz/provider" export * from "./wsl/cli" export * from "./wsl/provider" +export * from "./inspect" diff --git a/packages/codev-sandbox/src/inspect.test.ts b/packages/codev-sandbox/src/inspect.test.ts new file mode 100644 index 0000000000..3f765a8e06 --- /dev/null +++ b/packages/codev-sandbox/src/inspect.test.ts @@ -0,0 +1,159 @@ +import { expect, test } from "bun:test" +import { mkdtemp, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { gzipSync } from "node:zlib" +import { inspectSandboxImage, validateSandboxImage } from "./inspect" + +// Minimal ustar writer: the inspector reads names, sizes and the first bytes +// of each member, so the fixtures only need to be structurally real. +function tarHeader(name: string, size: number) { + const header = Buffer.alloc(512) + header.write(name, 0, 100, "utf8") + header.write("000644 \0", 100, 8) + header.write("000000 \0", 108, 8) + header.write("000000 \0", 116, 8) + header.write(`${size.toString(8).padStart(11, "0")}\0`, 124, 12) + header.write("00000000000 ", 136, 12) + header.write(" ", 148, 8) + header.write("0", 156, 1) + header.write("ustar\x0000", 257, 8) + let sum = 0 + for (const byte of header) sum += byte + header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8) + return header +} + +function tar(entries: { name: string; data?: Buffer }[]) { + const blocks: Buffer[] = [] + for (const entry of entries) { + const data = entry.data ?? Buffer.alloc(0) + blocks.push(tarHeader(entry.name, data.length), data) + const padding = (512 - (data.length % 512)) % 512 + if (padding) blocks.push(Buffer.alloc(padding)) + } + blocks.push(Buffer.alloc(1024)) + return Buffer.concat(blocks) +} + +async function fixture(name: string, contents: Buffer) { + const dir = await mkdtemp(join(tmpdir(), "codev-inspect-")) + const path = join(dir, name) + await writeFile(path, contents) + return path +} + +// Both kernels are EFI-stub PE images opening with "MZ"; the arch magic sits +// further in, which is exactly what kernelArch keys on. +function arm64Kernel() { + const kernel = Buffer.alloc(2048) + kernel.write("MZ", 0, "latin1") + kernel.write("ARM\x64", 56, "latin1") + return kernel +} + +function x64Kernel() { + const kernel = Buffer.alloc(2048) + kernel.write("MZ", 0, "latin1") + kernel.write("HdrS", 514, "latin1") + return kernel +} + +function elf(machine: number) { + const binary = Buffer.alloc(64) + binary.writeUInt32BE(0x7f454c46, 0) + binary.writeUInt16LE(machine, 18) + return binary +} + +test("recognizes a vz image and its architecture", async () => { + const arm = await fixture("vz.tar.gz", gzipSync(tar([{ name: "vmlinux", data: arm64Kernel() }, { name: "rootfs.img" }]))) + expect(await inspectSandboxImage(arm)).toEqual({ kind: "image", format: "vz", arch: "arm64" }) + const intel = await fixture("vz.tar.gz", gzipSync(tar([{ name: "vmlinux", data: x64Kernel() }, { name: "rootfs.img" }]))) + expect(await inspectSandboxImage(intel)).toEqual({ kind: "image", format: "vz", arch: "x64" }) +}) + +test("reads a rootfs image's architecture from the build's version marker", async () => { + const path = await fixture( + "bwrap.tar.gz", + gzipSync( + tar([ + { name: "etc/" }, + { name: "etc/hostname", data: Buffer.from("guest\n") }, + { name: "etc/codev-office-versions", data: Buffer.from("dotnet=8.0.423\npandoc=3.10.1\narch=amd64\n") }, + ]), + ), + ) + expect(await inspectSandboxImage(path)).toEqual({ kind: "image", format: "rootfs", arch: "x64" }) +}) + +// An image built without the office layer has no marker file, so the arch has +// to come from the first ELF in the tree. +test("falls back to an ELF header when the marker file is absent", async () => { + const path = await fixture( + "bwrap.tar.gz", + gzipSync(tar([{ name: "etc/" }, { name: "usr/bin/node", data: elf(0xb7) }])), + ) + expect(await inspectSandboxImage(path)).toEqual({ kind: "image", format: "rootfs", arch: "arm64" }) +}) + +test("reports an archive that is not a sandbox image", async () => { + const path = await fixture( + "holiday.tar.gz", + gzipSync(tar([{ name: "photos/" }, { name: "photos/beach.jpg", data: Buffer.from("\xff\xd8\xff\xe0", "latin1") }])), + ) + expect(await inspectSandboxImage(path)).toEqual({ kind: "not-a-sandbox-image" }) +}) + +test("reports a file that is not a gzip archive at all", async () => { + const path = await fixture("screenshot.png", Buffer.from("\x89PNG\r\n\x1a\n", "latin1")) + expect(await inspectSandboxImage(path)).toEqual({ kind: "not-gzip" }) +}) + +test("reports a missing or empty file", async () => { + expect(await inspectSandboxImage(join(tmpdir(), "codev-does-not-exist.tar.gz"))).toEqual({ kind: "unreadable" }) + expect(await inspectSandboxImage(await fixture("empty.tar.gz", Buffer.alloc(0)))).toEqual({ kind: "unreadable" }) +}) + +test("accepts an image that matches the machine", () => { + const inspection = { kind: "image", format: "vz", arch: "arm64" } as const + expect(validateSandboxImage(inspection, { backend: "vz", arch: "arm64" })).toBeNull() +}) + +test("rejects an image built for the other architecture", () => { + const inspection = { kind: "image", format: "vz", arch: "x64" } as const + expect(validateSandboxImage(inspection, { backend: "vz", arch: "arm64" })).toEqual({ + code: "wrong-arch", + found: "x64", + }) +}) + +test("rejects an image built for a different operating system", () => { + // A Windows/Linux rootfs tarball offered to the macOS backend... + expect(validateSandboxImage({ kind: "image", format: "rootfs", arch: "arm64" }, { backend: "vz", arch: "arm64" })).toEqual( + { code: "wrong-os", found: "rootfs" }, + ) + // ...and a macOS image offered to Windows. + expect(validateSandboxImage({ kind: "image", format: "vz", arch: "x64" }, { backend: "wsl", arch: "x64" })).toEqual({ + code: "wrong-os", + found: "vz", + }) +}) + +// Windows and Linux consume the same rootfs tarball, so neither can be told +// from the other — accepting it for both is correct, not a gap. +test("accepts one rootfs image for both Windows and Linux", () => { + const inspection = { kind: "image", format: "rootfs", arch: "x64" } as const + expect(validateSandboxImage(inspection, { backend: "wsl", arch: "x64" })).toBeNull() + expect(validateSandboxImage(inspection, { backend: "bwrap", arch: "x64" })).toBeNull() +}) + +test("allows an image whose architecture could not be determined", () => { + const inspection = { kind: "image", format: "rootfs", arch: null } as const + expect(validateSandboxImage(inspection, { backend: "bwrap", arch: "arm64" })).toBeNull() +}) + +test("passes the unusable-file reasons straight through", () => { + expect(validateSandboxImage({ kind: "not-gzip" }, { backend: "vz", arch: "arm64" })).toEqual({ code: "not-gzip" }) + expect(validateSandboxImage({ kind: "unreadable" }, { backend: "vz", arch: "arm64" })).toEqual({ code: "unreadable" }) +}) diff --git a/packages/codev-sandbox/src/inspect.ts b/packages/codev-sandbox/src/inspect.ts new file mode 100644 index 0000000000..17c3b715df --- /dev/null +++ b/packages/codev-sandbox/src/inspect.ts @@ -0,0 +1,183 @@ +import { createReadStream } from "node:fs" +import { open, stat } from "node:fs/promises" +import { createGunzip } from "node:zlib" +import type { SandboxArch, SandboxBackend } from "./provider" + +// A guest image is one of two shapes. "vz" is the macOS pair (a kernel plus an +// ext4 disk); "rootfs" is the Debian root filesystem tarball that bubblewrap +// and `wsl --import` both consume — the same file serves Windows and Linux, so +// those two are indistinguishable by design. +export type SandboxImageFormat = "vz" | "rootfs" + +export type SandboxImageInspection = + | { kind: "image"; format: SandboxImageFormat; arch: SandboxArch | null } + // Deliberately separate reasons: "this is not a sandbox image" and "this is + // not even an archive" call for different advice to the user. + | { kind: "unreadable" } + | { kind: "not-gzip" } + | { kind: "not-a-sandbox-image" } + +export function imageFormatForBackend(backend: SandboxBackend): SandboxImageFormat { + return backend === "vz" ? "vz" : "rootfs" +} + +// Only the first bytes of any member are read, so this stays cheap on a +// multi-gigabyte tarball: the vz kernel is the first member, and a rootfs +// carries its arch marker among the early etc/ entries. The budget bounds the +// pathological case (an archive with neither) rather than the normal one. +const SCAN_BUDGET_BYTES = 256 * 1024 * 1024 +const TAR_BLOCK = 512 +// Enough to cover the x86 kernel's "HdrS" at offset 514. +const PEEK_BYTES = 1024 + +export async function inspectSandboxImage(path: string): Promise { + const file = await stat(path).catch(() => null) + if (!file?.isFile() || file.size === 0) return { kind: "unreadable" } + const handle = await open(path, "r").catch(() => null) + if (!handle) return { kind: "unreadable" } + const magic = Buffer.alloc(2) + await handle.read(magic, 0, 2, 0) + await handle.close() + // gzip: 1f 8b. Checked up front so a .png or a plain tar fails fast with a + // reason we can explain, instead of surfacing as a zlib stream error. + if (magic[0] !== 0x1f || magic[1] !== 0x8b) return { kind: "not-gzip" } + + const source = createReadStream(path).pipe(createGunzip()) + let format: SandboxImageFormat | null = null + let arch: SandboxArch | null = null + try { + for await (const entry of tarEntries(source, SCAN_BUDGET_BYTES)) { + if (entry.name === "vmlinux" || entry.name === "rootfs.img" || entry.name === "initrd") { + format = "vz" + if (entry.name === "vmlinux") arch = kernelArch(entry.peek) ?? arch + } else if (looksLikeRootfs(entry.name)) { + format ??= "rootfs" + } + // Written by the image build; the cheapest and most direct answer. + if (entry.name === "etc/codev-office-versions") arch = versionsArch(entry.peek) ?? arch + // Any ELF in the tree answers it too, for an image built without the + // office layer (and so without the marker file above). + if (!arch) arch = elfArch(entry.peek) ?? arch + if (format && arch) break + } + } catch { + // A truncated or corrupt stream mid-scan: whatever was learned before the + // failure is not trustworthy enough to accept. + return { kind: "not-a-sandbox-image" } + } finally { + source.destroy() + } + if (!format) return { kind: "not-a-sandbox-image" } + return { kind: "image", format, arch } +} + +export type SandboxImageProblem = + | { code: "unreadable" } + | { code: "not-gzip" } + | { code: "not-a-sandbox-image" } + | { code: "wrong-os"; found: SandboxImageFormat } + | { code: "wrong-arch"; found: SandboxArch } + +// Null means usable. An image whose architecture could not be determined is +// allowed through with no problem reported: refusing a valid image because the +// probe could not prove its arch would be worse than the rare mismatch that +// slips past, and the guest fails loudly on first boot either way. +export function validateSandboxImage( + inspection: SandboxImageInspection, + expected: { backend: SandboxBackend; arch: SandboxArch }, +): SandboxImageProblem | null { + if (inspection.kind !== "image") return { code: inspection.kind } + if (inspection.format !== imageFormatForBackend(expected.backend)) + return { code: "wrong-os", found: inspection.format } + if (inspection.arch && inspection.arch !== expected.arch) return { code: "wrong-arch", found: inspection.arch } + return null +} + +type TarEntry = { name: string; peek: Buffer } + +async function* tarEntries(source: AsyncIterable, budget: number): AsyncGenerator { + const iterator = source[Symbol.asyncIterator]() + let buffer: Buffer = Buffer.alloc(0) + let read = 0 + const pull = async () => { + const next = await iterator.next() + if (next.done) return false + buffer = buffer.length ? Buffer.concat([buffer, next.value]) : next.value + read += next.value.length + return true + } + const ensure = async (bytes: number) => { + while (buffer.length < bytes) if (!(await pull())) return false + return true + } + const take = (bytes: number) => { + const out = buffer.subarray(0, bytes) + buffer = buffer.subarray(bytes) + return out + } + while (read < budget) { + if (!(await ensure(TAR_BLOCK))) return + const header = take(TAR_BLOCK) + // Two zero blocks terminate the archive; one is enough to stop scanning. + if (header[0] === 0) return + const name = cstring(header.subarray(0, 100)) + const size = Number.parseInt(cstring(header.subarray(124, 136)).trim() || "0", 8) + if (!Number.isFinite(size) || size < 0) return + const padded = Math.ceil(size / TAR_BLOCK) * TAR_BLOCK + const peekLength = Math.min(size, PEEK_BYTES) + let peek = Buffer.alloc(0) + if (peekLength > 0) { + if (!(await ensure(peekLength))) return + peek = Buffer.from(take(peekLength)) + } + let skip = padded - peekLength + while (skip > 0) { + if (buffer.length === 0 && !(await pull())) return + skip -= take(Math.min(skip, buffer.length)).length + } + yield { name, peek } + } +} + +function cstring(buffer: Buffer) { + const end = buffer.indexOf(0) + return buffer.subarray(0, end === -1 ? buffer.length : end).toString("utf8") +} + +// The top-level directories a Linux root filesystem always has. Matched on the +// tar's own entry names, which carry no leading slash. +const ROOTFS_ENTRIES = new Set(["etc", "usr", "bin", "sbin", "var", "opt", "lib"]) + +function looksLikeRootfs(name: string) { + const top = name.replace(/\/+$/, "").split("/")[0] ?? "" + return ROOTFS_ENTRIES.has(top) +} + +// Both architectures' kernels are EFI-stub PE images starting "MZ", so the +// magic further in is what actually distinguishes them: arm64 stamps "ARM\x64" +// at offset 56, x86 stamps "HdrS" at 514 (verified against both built kernels). +function kernelArch(peek: Buffer): SandboxArch | null { + if (peek.length >= 60 && peek.subarray(56, 60).toString("latin1") === "ARM\x64") return "arm64" + if (peek.length >= 518 && peek.subarray(514, 518).toString("latin1") === "HdrS") return "x64" + return null +} + +function versionsArch(peek: Buffer): SandboxArch | null { + const match = /^arch=(\S+)$/m.exec(peek.toString("utf8")) + if (!match) return null + // The file records the Debian architecture the image was built for. + if (match[1] === "amd64") return "x64" + if (match[1] === "arm64") return "arm64" + return null +} + +const ELF_MACHINE_X86_64 = 0x3e +const ELF_MACHINE_AARCH64 = 0xb7 + +function elfArch(peek: Buffer): SandboxArch | null { + if (peek.length < 20 || peek.readUInt32BE(0) !== 0x7f454c46) return null + const machine = peek.readUInt16LE(18) + if (machine === ELF_MACHINE_X86_64) return "x64" + if (machine === ELF_MACHINE_AARCH64) return "arm64" + return null +} diff --git a/packages/codev-sandbox/src/provider.ts b/packages/codev-sandbox/src/provider.ts index ac13379dc8..993567eb2a 100644 --- a/packages/codev-sandbox/src/provider.ts +++ b/packages/codev-sandbox/src/provider.ts @@ -16,6 +16,10 @@ export type SandboxConfig = { // instead of booting with the rest. Absent means start it, which is what // every sandbox persisted before this existed wants. stopped?: boolean + // A guest image file the user supplied instead of the published one. Kept + // for the sandbox's life because reset re-provisions from it; absent means + // download the manifest's image, which is the normal path. + imagePath?: string // Per-sandbox limits, applied by the vz backend at boot. Omitted means the // provider default. WSL and bubblewrap ignore them: WSL's limits live in // the user's VM-global .wslconfig, and bubblewrap has no cgroup plumbing diff --git a/packages/codev-sandbox/src/vz/provider.ts b/packages/codev-sandbox/src/vz/provider.ts index f6c18be052..33f34c8a4f 100644 --- a/packages/codev-sandbox/src/vz/provider.ts +++ b/packages/codev-sandbox/src/vz/provider.ts @@ -5,7 +5,7 @@ import { checkSandboxHealth, generateSandboxCredentials, pollSandboxHealth, sand import { createBrokerServer, type SandboxEgress } from "../broker" import { forwardTcpToUnix } from "../bwrap/bridge" import { runCommand, unpackRootfs } from "../bwrap/rootfs" -import { readImageMarker, stageAsset, writeImageMarker } from "../image" +import { localImageAsset, readImageMarker, stageAsset, writeImageMarker } from "../image" import type { SandboxAsset } from "../manifest" import { resolveGuestMounts } from "../mounts" import { macosSandboxAvailability } from "../policy" @@ -95,8 +95,13 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { await copyFile(join(dirs.image, VZ_DISK_FILE), dirs.disk, constants.COPYFILE_FICLONE) } + // A custom image is used from where it lies; otherwise the published one + // is resolved from the manifest and downloaded. + const resolveAsset = (config: SandboxConfig, opts?: SandboxJobOptions) => + config.imagePath ? localImageAsset(config.imagePath) : options.resolveImage({ signal: opts?.signal }) + const prepare = async (config: SandboxConfig, opts?: SandboxJobOptions) => { - await provision(config, await options.resolveImage({ signal: opts?.signal }), opts) + await provision(config, await resolveAsset(config, opts), opts) } return { @@ -298,7 +303,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { // pristine disk; when it has moved on, reset doubles as the upgrade path // and re-provisions from the current image. await rm(dirs.disk, { force: true }) - await provision(config, await options.resolveImage(opts), opts) + await provision(config, await resolveAsset(config, opts), opts) }, } } diff --git a/packages/codev-sandbox/src/wsl/provider.ts b/packages/codev-sandbox/src/wsl/provider.ts index 226c8bcd0a..6bedbcb191 100644 --- a/packages/codev-sandbox/src/wsl/provider.ts +++ b/packages/codev-sandbox/src/wsl/provider.ts @@ -10,7 +10,7 @@ import { sandboxBootEnv, } from "../boot" import { createBrokerServer, type SandboxEgress } from "../broker" -import { readImageMarker, stageAsset, writeImageMarker } from "../image" +import { localImageAsset, readImageMarker, stageAsset, writeImageMarker } from "../image" import { wslFirewallScript } from "./firewall" import type { SandboxAsset } from "../manifest" import { windowsBuildNumber, windowsSandboxAvailability } from "../policy" @@ -109,7 +109,9 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider const prepare = async (config: SandboxConfig, opts?: SandboxJobOptions) => { const distro = wslDistroName(config.name) - const asset = await options.resolveImage({ signal: opts?.signal }) + const asset = config.imagePath + ? await localImageAsset(config.imagePath) + : await options.resolveImage({ signal: opts?.signal }) const dirs = wslSandboxDirs(options.dataDir, config.name) if (await registered(distro)) { // Registered on the current image: nothing to do. A stale (or unknown) diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 4563b688ac..349493f0fa 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -157,6 +157,25 @@ export function registerIpcHandlers(deps: Deps) { }, ) + // Picking a path is not attaching a file: nothing is read, so the 20 MB + // attachment budget must not apply (a sandbox guest image is gigabytes) and + // no read authorization is minted. The filters keep an "All files" group so + // an image with an unusual name is still selectable. + ipcMain.handle( + "open-file-path-picker", + async (_event: IpcMainInvokeEvent, opts?: { title?: string; extensions?: string[] }) => { + const result = await dialog.showOpenDialog({ + properties: ["openFile"], + title: opts?.title ?? "Choose a file", + filters: opts?.extensions?.length + ? [{ name: "Files", extensions: opts.extensions }, { name: "All files", extensions: ["*"] }] + : undefined, + }) + if (result.canceled) return null + return result.filePaths[0] ?? null + }, + ) + ipcMain.handle("read-picked-file", async (event: IpcMainInvokeEvent, token: string, filePath: string) => { return pickedFiles.read(event.sender.id, token, filePath) }) diff --git a/packages/desktop/src/main/sandbox/controller.ts b/packages/desktop/src/main/sandbox/controller.ts index 3f2ba5c19d..db7991d874 100644 --- a/packages/desktop/src/main/sandbox/controller.ts +++ b/packages/desktop/src/main/sandbox/controller.ts @@ -39,6 +39,8 @@ export function createDesktopSandboxController(logger: ControllerLogger) { // The backend every new sandbox on this machine gets; null means the // platform has no provider and add must be rejected up front. backend: provider?.backend ?? null, + // What a custom image has to match to be usable here. + arch: sandboxArch() ?? null, availability: (): Promise => { if (!provider) return Promise.resolve({ supported: false, message: `sandboxes are not supported on ${process.platform}` }) @@ -125,6 +127,8 @@ function normalizePersistedSandbox(value: unknown): SandboxConfig[] { ...(mounts.length ? { mounts } : {}), // Only an explicit true keeps a sandbox down; anything else starts it. ...(record.stopped === true ? { stopped: true } : {}), + // Kept for the sandbox's life: reset re-provisions from the same file. + ...(typeof record.imagePath === "string" && record.imagePath.length ? { imagePath: record.imagePath } : {}), ...(sandboxResourcesValid(resources) ? resources : {}), }, ] diff --git a/packages/desktop/src/main/sandbox/ipc.ts b/packages/desktop/src/main/sandbox/ipc.ts index 59418eba18..11e24c06c9 100644 --- a/packages/desktop/src/main/sandbox/ipc.ts +++ b/packages/desktop/src/main/sandbox/ipc.ts @@ -1,8 +1,14 @@ import { stat } from "node:fs/promises" import { app, ipcMain } from "electron" import type { IpcMainInvokeEvent } from "electron" -import { sandboxResourcesValid, sandboxServerId, validateMountPaths } from "@codev/sandbox" -import type { MountValidationError } from "@codev/sandbox" +import { + inspectSandboxImage, + sandboxResourcesValid, + sandboxServerId, + validateMountPaths, + validateSandboxImage, +} from "@codev/sandbox" +import type { MountValidationError, SandboxImageInspection } from "@codev/sandbox" import type { DesktopSandboxController } from "./controller" // Unlike the WSL channels there is no unavailable-stub variant: the controller @@ -40,17 +46,32 @@ export function registerSandboxIpcHandlers(controller: DesktopSandboxController) ipcMain.handle("sandbox-unsubscribe", (event) => unsubscribe(event.sender.id)) ipcMain.handle("sandbox-get-state", () => controller.getState()) ipcMain.handle("sandbox-availability", () => controller.availability()) + // Inspects a candidate image so the dialog can report the problem while the + // user is still filling the form. Returns the same problem shape the add + // path enforces, so the two can never disagree about what is acceptable. + ipcMain.handle("sandbox-inspect-image", async (_event: IpcMainInvokeEvent, path: unknown) => + sandboxImageProblem(controller, await inspectSandboxImage(requireSandboxIpcString("image path", path))), + ) ipcMain.handle( "sandbox-add", - (_event: IpcMainInvokeEvent, name: unknown, projectDir: unknown, resources: unknown) => { + async (_event: IpcMainInvokeEvent, name: unknown, projectDir: unknown, resources: unknown, image: unknown) => { const backend = controller.backend if (!backend) throw new Error(`sandboxes are not supported on ${process.platform}`) const sandboxName = requireSandboxIpcString("sandbox name", name) + // The dialog checks the same things for inline feedback; this is the + // authoritative gate, so a stale or hand-crafted call cannot get an + // unusable image past it. + const imagePath = image === undefined || image === null ? undefined : requireSandboxIpcString("image path", image) + if (imagePath) { + const problem = sandboxImageProblem(controller, await inspectSandboxImage(imagePath)) + if (problem) throw new Error(imageProblemMessage(problem)) + } return controller.addSandbox({ id: sandboxServerId(sandboxName), name: sandboxName, backend, projectDir: requireSandboxIpcString("project directory", projectDir), + ...(imagePath ? { imagePath } : {}), ...requireSandboxResources(resources), }) }, @@ -89,6 +110,27 @@ export function registerSandboxIpcHandlers(controller: DesktopSandboxController) }) } +// Neither can be missing in practice — a platform with no provider rejects add +// before this runs — but the validator needs both, so an absent one is treated +// as "cannot vouch for this image" rather than silently passing it. +function sandboxImageProblem(controller: DesktopSandboxController, inspection: SandboxImageInspection) { + if (!controller.backend || !controller.arch) return { code: "not-a-sandbox-image" } as const + return validateSandboxImage(inspection, { backend: controller.backend, arch: controller.arch }) +} + +// The renderer renders these from its own i18n copy; this text is the fallback +// for a direct IPC call and for the error a failed add surfaces. +function imageProblemMessage(problem: NonNullable>) { + if (problem.code === "unreadable") return "the image file could not be read" + if (problem.code === "not-gzip") return "that file is not a sandbox image (expected a .tar.gz archive)" + if (problem.code === "not-a-sandbox-image") return "that archive is not a sandbox image" + if (problem.code === "wrong-os") + return problem.found === "vz" + ? "that is a macOS sandbox image; this computer needs the Windows/Linux image" + : "that is a Windows/Linux sandbox image; this computer needs the macOS image" + return `that image is built for ${problem.found === "x64" ? "Intel/x86_64" : "ARM64"} machines` +} + const SANDBOX_MOUNTS_MAX = 16 function requireSandboxMounts(value: unknown) { diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index b66a48344b..5fc4a9a077 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -48,7 +48,9 @@ const api: ElectronAPI = { } }, availability: () => ipcRenderer.invoke("sandbox-availability"), - add: (name, projectDir, resources) => ipcRenderer.invoke("sandbox-add", name, projectDir, resources), + add: (name, projectDir, resources, imagePath) => + ipcRenderer.invoke("sandbox-add", name, projectDir, resources, imagePath), + inspectImage: (path) => ipcRenderer.invoke("sandbox-inspect-image", path), cancel: (name) => ipcRenderer.invoke("sandbox-cancel", name), remove: (id) => ipcRenderer.invoke("sandbox-remove", id), reset: (id) => ipcRenderer.invoke("sandbox-reset", id), @@ -110,6 +112,7 @@ const api: ElectronAPI = { openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts), openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts), + openFilePathPicker: (opts) => ipcRenderer.invoke("open-file-path-picker", opts), readPickedFile: (token, path) => ipcRenderer.invoke("read-picked-file", token, path), releasePickedFiles: (token) => ipcRenderer.invoke("release-picked-files", token), getPathForFile: (file) => webUtils.getPathForFile(file), diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 02749b9ebb..284e92052d 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -93,6 +93,8 @@ export type ElectronAPI = { defaultPath?: string extensions?: string[] }) => Promise<{ token: string; files: { path: string; name: string; size: number }[] } | null> + // Path only: never reads the file, so no attachment size budget applies. + openFilePathPicker: (opts?: { title?: string; extensions?: string[] }) => Promise readPickedFile: (token: string, path: string) => Promise releasePickedFiles: (token: string) => Promise getPathForFile: (file: File) => string diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index fde029197f..1716de3e2e 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -183,6 +183,13 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { }) }, + async openFilePathDialog(opts) { + return window.api.openFilePathPicker({ + title: opts?.title ?? t("desktop.dialog.chooseFile"), + extensions: opts?.extensions, + }) + }, + async openAttachmentPickerDialog(opts, onFile) { const result = await window.api.openFilePicker({ multiple: opts?.multiple ?? false,