diff --git a/CHANGELOG.md b/CHANGELOG.md index a15bdf3fb4..073dc1b90a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ file is the human-readable summary of what changed and why. ### Desktop / Web UI +- **Sandboxes (preview)** — the desktop app can now create isolated sandbox + servers that run the codev server inside a lightweight VM or container + (Virtualization.framework on macOS, a managed WSL distribution on Windows, + bubblewrap on Linux) with the project folder mounted in. Settings → Servers + gains an **Add sandbox** entry (name + project folder); sandboxes are listed + beside other servers with start, reset, set-default, and delete actions, and + a ready sandbox appears in the server list like any other connection. On + Windows versions below Windows 11 a compatibility warning is shown but + creation is never blocked. The sandbox image is downloaded on first use. + - **WSL setup no longer depends on reaching Microsoft's servers** — on Windows, **Install WSL** now downloads a pinned install bundle from the CoDev storage host (the one host whitelist-only corporate proxies already diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 4198d32727..22b9fda9a6 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -186,9 +186,11 @@ from the squash message first, or use a plain merge. | `packages/app/src/i18n/{en,es}.ts`, `packages/app/src/pages/layout/deep-links.ts` | App-self-reference "OpenCode" → "CoDev Code" across the shared web UI's i18n dicts (28 strings per locale): settings row descriptions, the update toasts, the WSL onboarding step/status strings, the `dialog.server.description`, `error.chain.mcpFailed`, `sidebar.gettingStarted.line1`, the provider-connect "use {{provider}} models in OpenCode" lines, and `app.name.desktop`. The Zen free-models dialog title (`dialog.model.unpaid.freeModels.title`, "Free models provided by OpenCode") was **neutralized** to "Free models included" / "Modelos gratuitos incluidos" rather than rebranded or kept — it read oddly branded either way. i18n **keys** are untouched (they carry `Opencode`/lowercase `opencode`, which never match the exact-case `OpenCode` in values). The deep-link bridge window type `OpenCodeWindow` → `CodevWindow` (its `__CODEV__` global was already renamed in the desktop row). | These render in the desktop app (and the web console), which the fork ships; closes the "in-app i18n strings" gap for this package. | Keep our strings; take upstream's surrounding changes. PRESERVED deliberately: the real external-service names **"OpenCode Zen"** / **"OpenCode Go"** (in the `opencodeZen.*` provider-connect keys and the "OpenCode Go" upsell), plus every `opencode.ai/*` URL — the fork routes to those actual services, so the display text must match what users land on. The built-in **"OpenCode" theme** name (`packages/ui/src/theme/context.tsx`, the shiki theme id, `desktop-theme.schema.json`) is also kept — it names a specific palette, and the fork ships its own `codev` theme alongside it. | | Browser-opened docs-site links: `packages/tui/src/app.tsx`, `packages/app/src/desktop-menu.ts`, `packages/app/src/components/{settings-general,settings-v2/general,dialog-custom-provider}.tsx`, `packages/app/src/pages/{home,layout,error}.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/opencode/src/cli/cmd/{providers,github.handler}.ts` | Links the app opens in the user's browser repointed from `opencode.ai` to the fork's hosted docs. Each one derives its base from `DOCS_URL` (`packages/codev-brand/src/index.ts`, base64-encoded) rather than hardcoding the host: the site is locale-routed and the Code docs live under `/code`, and no URL takes a trailing slash (the site 308-redirects `…/themes/` to `…/themes`). Sites: TUI "Open docs" command and app Documentation menu item → docs root; settings theme "Learn more" (v1 + v2) → `…/code/themes`; custom-provider dialog link → `…/code/providers#custom-provider`; CLI Cloudflare-gateway hint → `…/code/providers#cloudflare-ai-gateway`; GitHub-agent hint → `…/code/github`; home/layout sidebar Help buttons `opencode.ai/desktop-feedback` → docs root. The error page's report button `opencode.ai/desktop-feedback` → `github.com/quickbeard/codev-code/issues` instead (it's a report action, not a docs lookup), icon `discord` → `github`, and the en/es `error.page.report.*` strings → "…CoDev Code team" / "on GitHub". Help submenu (rendered by the shipped web UI's Windows app menu too): "Support Forum" (upstream Discord invite) deleted — the fork has no forum; "Share Feedback"/"Report a Bug" → `github.com/quickbeard/codev-code/issues/new?template=feature-request.yml`/`bug-report.yml` (the fork's template names are hyphenated, unlike upstream's underscored ones). | Fork users should land on the fork's docs and report issues to the fork, not upstream's site/Discord. NOT repointed: Zen/Go product URLs (`opencode.ai/zen`, `/go`, `/auth`), `$schema`/theme-schema ids, API/share/changelog endpoints, MCP OAuth `client_uri`, prompt-file mentions. Also NOT on this base: the ripgrep mirror (`/codev/docs/code/downloads/…`, see its own row) — it is a `public/` static asset, not locale-routed content, and 404s under `/en/docs/code`. | Keep our URLs, icon, and strings; take upstream's surrounding changes. Repoint any new browser-opened `opencode.ai` docs/help link to the fork docs base; leave the NOT-repointed list. | | `packages/desktop/src/main/index.ts`, `packages/desktop/src/main/windows.ts` | APP_NAMES + window title → "CoDev Code" (Dev/Beta variants too) | Desktop app display name. | Keep our names. | -| `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev Code…"; artifactName → `codev-code-desktop-…`; protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which answers a multipart range request with the whole object; electron-updater hardcodes the same `false` for its own `s3` provider citing minio/minio#5285 but defaults it to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `UPDATER_ENABLED` covers that channel and a build with no publish config gets no `app-update.yml` at all, so every check would fail with ENOENT. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId (`ai.opencode.desktop`) and the `opencode://` deep-link scheme stay upstream — identity plumbing wired into main/index.ts and app deep-links; do not rename casually. `rpm.packageName` (`opencode`/`opencode-beta`/`opencode-dev`) also stays — it names the installed Linux package, not a publish target. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. | +| `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev Code…"; artifactName → `codev-code-desktop-…`; protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. Also a second `extraResources` entry shipping `../opencode/dist/node` → `server-bundle` (filter `node.js` + `*.wasm`, dropping the sourcemap, which outweighs the bundle) so the `@codev/sandbox` providers have a real on-disk server directory to mount into guests; `src/main/sandbox/bundle.ts` (fork-only) resolves it under `process.resourcesPath` by that name. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which answers a multipart range request with the whole object; electron-updater hardcodes the same `false` for its own `s3` provider citing minio/minio#5285 but defaults it to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `UPDATER_ENABLED` covers that channel and a build with no publish config gets no `app-update.yml` at all, so every check would fail with ENOENT. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId (`ai.opencode.desktop`) and the `opencode://` deep-link scheme stay upstream — identity plumbing wired into main/index.ts and app deep-links; do not rename casually. `rpm.packageName` (`opencode`/`opencode-beta`/`opencode-dev`) also stays — it names the installed Linux package, not a publish target. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. Keep the `server-bundle` extraResource (covered by `electron-builder.config.test.ts`) — renaming it strands packaged sandbox providers. | | `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. | Keep `false`; take upstream's other updater settings. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). | | 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. | +| 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` workspace devDependency (bundled into the main process by electron-vite, so not a runtime dependency). | 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 }` with key `sandbox:`; `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); `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; 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. | 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. | | `script/publish.ts` | The `if (Script.release)` block calling `packages/desktop/scripts/finalize-latest-{json,yml}.ts` removed, replaced by a comment. | Both finalizers existed to assemble update metadata into a GitHub release, which the fork no longer publishes to. electron-builder already writes a correct per-platform `latest*.yml` next to each installer, and the fork builds one arch per platform (macOS is universal — see the universal-builds row), so there is nothing left to merge. | Keep removed. If upstream adds further desktop release steps here, drop them unless they target the bucket. | | `packages/desktop/scripts/finalize-latest-json.ts`, `packages/desktop/scripts/finalize-latest-yml.ts` | Deleted. | Both `gh release upload` into a GitHub release the fork does not use. `finalize-latest-yml.ts` merged per-arch `latest*.yml` from an upstream build matrix the fork does not run; `finalize-latest-json.ts` additionally built a Tauri-style `latest.json` signed with `@tauri-apps/cli signer` that nothing in the Electron updater path ever reads, and had gone stale besides — it hardcoded `opencode-desktop-mac-{x64,arm64}.app.tar.gz`, an artifact name and format this fork never produces. Leaving them in place is a trap: they look like the release path and upload to the wrong place. | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If a future build matrix produces two `latest.yml` files for one platform (Windows x64 + arm64), write a bucket-targeted merge step rather than restoring these. | | `packages/desktop/src/renderer/index.tsx` | Desktop notification icon `https://opencode.ai/favicon-96x96-v3.png` → the relative `./favicon-96x96-v3.png`. The renderer's `publicDir` is `packages/app/public`, whose `favicon-96x96-v3.png` symlinks to the rebranded `packages/ui/src/assets/favicon/` mark, so the file is emitted next to `index.html` and resolves against the renderer origin (`oc://renderer/` packaged, the vite dev server otherwise) — the same asset and the same relative form `src/renderer/index.html` already uses for ``. | A desktop app should not reach out to a remote host to draw its own notification icon (it fails offline and leaks a request per notification), and the remote file is upstream-branded while the bundled one is the codev mark. | Keep our relative path. If upstream changes the URL, re-point it at the bundled asset rather than taking a new remote host. `packages/app/src/entry.tsx` and `pages/layout/helpers.ts` keep their remote `opencode.ai/favicon*` sources — same bug class, not yet fixed. | diff --git a/packages/app/package.json b/packages/app/package.json index 1674c09bb6..d25fa3101e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -8,6 +8,7 @@ "./desktop-menu": "./src/desktop-menu.ts", "./updater": "./src/updater.ts", "./wsl/types": "./src/wsl/types.ts", + "./sandbox/types": "./src/sandbox/types.ts", "./vite": "./vite.js", "./index.css": "./src/index.css" }, diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 558e7ad0dd..8f52f953f4 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -58,6 +58,7 @@ import { SettingsProvider, useSettings } from "@/context/settings" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider, useSDK } from "@/context/sdk" import { WslServersProvider } from "@/wsl/context" +import { SandboxesProvider } from "@/sandbox/context" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" import LegacyLayout from "@/pages/layout" import NewLayout from "@/pages/layout-new" @@ -379,11 +380,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { > - - - {props.children} - - + + + + {props.children} + + + diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 0876906890..ac16c9ceaf 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -512,6 +512,7 @@ export function useServerManagementController(options: { onSelect?: () => void; async function handleRemove(key: ServerConnection.Key) { try { if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key) + if (key.startsWith("sandbox:")) await platform.sandboxes?.remove(key) tabs.removeServer(key) server.remove(key) if ((await platform.getDefaultServer?.()) === key) { diff --git a/packages/app/src/components/settings-v2/servers.tsx b/packages/app/src/components/settings-v2/servers.tsx index b93f700d24..badf3c3035 100644 --- a/packages/app/src/components/settings-v2/servers.tsx +++ b/packages/app/src/components/settings-v2/servers.tsx @@ -14,6 +14,7 @@ import { useServerManagementController } from "../dialog-select-server" import { DialogServerV2 } from "./dialog-server-v2" import { SettingsListV2 } from "./parts/list" import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings" +import { isSandboxServer, useFilteredSandboxes, SandboxServerSettings } from "@/sandbox/settings" import "./settings-v2.css" export const SettingsServersV2: Component = () => { @@ -22,13 +23,18 @@ export const SettingsServersV2: Component = () => { const controller = useServerManagementController() const [store, setStore] = createStore({ filter: "" }) const wslServers = useFilteredWslServers(() => store.filter) + const sandboxes = useFilteredSandboxes(() => store.filter) const showSearch = createMemo( - () => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1, + () => + controller.sortedItems().filter((item) => !isWslServer(item) && !isSandboxServer(item)).length + + wslServers().length + + sandboxes().length > + 1, ) const filtered = createMemo(() => { - const items = controller.sortedItems().filter((item) => !isWslServer(item)) + const items = controller.sortedItems().filter((item) => !isWslServer(item) && !isSandboxServer(item)) const query = store.filter.trim() if (!query) return items return fuzzysort @@ -86,7 +92,7 @@ export const SettingsServersV2: Component = () => {
0 || wslServers().length > 0} + when={filtered().length > 0 || wslServers().length > 0 || sandboxes().length > 0} fallback={
{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")} @@ -98,6 +104,7 @@ export const SettingsServersV2: Component = () => { > + {(item) => { const key = ServerConnection.key(item) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index cf343bfa7e..cebdbe47c5 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -4,6 +4,7 @@ import type { Accessor } from "solid-js" import type { DesktopMenuAction } from "../desktop-menu" import { ServerConnection } from "./server" import type { WslServersPlatform } from "../wsl/types" +import type { SandboxesPlatform } from "../sandbox/types" import type { UpdaterPlatform } from "../updater" type PickerPaths = string | string[] | null @@ -85,6 +86,9 @@ type PlatformBase = { /** Manage WSL sidecar servers (Electron on Windows only) */ wslServers?: WslServersPlatform + /** Manage sandboxed sidecar servers (Electron only) */ + sandboxes?: SandboxesPlatform + /** Get the preferred display backend (desktop only) */ getDisplayBackend?(): Promise | DisplayBackend | null diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 450129f458..18560ad41b 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -205,6 +205,11 @@ export namespace ServerConnection { variant: "wsl" distro: string } + // Sandboxed server managed by the desktop sandbox controller (fork) + | { + variant: "sandbox" + name: string + } ) & Base @@ -227,6 +232,7 @@ export namespace ServerConnection { return Key.make(conn.http.url) case "sidecar": { if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`) + if (conn.variant === "sandbox") return Key.make(`sandbox:${conn.name}`) return Key.make("sidecar") } case "ssh": diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 99471d7e03..c1b956ab13 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -374,6 +374,24 @@ export const dict = { "wsl.server.menu.label": "WSL server", "wsl.server.retryStart": "Retry start", "wsl.server.updating": "Updating...", + "sandbox.server.add": "Add sandbox", + "sandbox.server.label": "Sandbox", + "sandbox.server.menu.label": "Sandbox", + "sandbox.server.start": "Start", + "sandbox.server.reset": "Reset", + "sandbox.status.starting": "Starting...", + "sandbox.status.ready": "Ready", + "sandbox.status.stopped": "Stopped", + "sandbox.status.failed": "Failed", + "sandbox.job.create": "Creating...", + "sandbox.job.destroy": "Removing...", + "sandbox.job.reset": "Resetting...", + "sandbox.add.namePlaceholder": "Sandbox name", + "sandbox.add.nameInvalid": "Use letters, numbers, dots, dashes, or underscores.", + "sandbox.add.projectDir": "Project folder", + "sandbox.add.choose": "Choose...", + "sandbox.add.create": "Create", + "sandbox.add.downloadHint": "The first sandbox downloads its image; on a slow connection this can take a while.", "wsl.onboarding.step.distro": "Choose distro", "wsl.onboarding.step.opencode": "CoDev Code", "wsl.onboarding.checkingRuntime": "Checking WSL...", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 4170a31ba8..00cd9b8261 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -365,6 +365,24 @@ export const dict = { "wsl.server.menu.label": "Servidor WSL", "wsl.server.retryStart": "Reintentar el inicio", "wsl.server.updating": "Actualizando...", + "sandbox.server.add": "Añadir sandbox", + "sandbox.server.label": "Sandbox", + "sandbox.server.menu.label": "Sandbox", + "sandbox.server.start": "Iniciar", + "sandbox.server.reset": "Restablecer", + "sandbox.status.starting": "Iniciando...", + "sandbox.status.ready": "Listo", + "sandbox.status.stopped": "Detenido", + "sandbox.status.failed": "Falló", + "sandbox.job.create": "Creando...", + "sandbox.job.destroy": "Eliminando...", + "sandbox.job.reset": "Restableciendo...", + "sandbox.add.namePlaceholder": "Nombre del sandbox", + "sandbox.add.nameInvalid": "Usa letras, números, puntos, guiones o guiones bajos.", + "sandbox.add.projectDir": "Carpeta del proyecto", + "sandbox.add.choose": "Elegir...", + "sandbox.add.create": "Crear", + "sandbox.add.downloadHint": "El primer sandbox descarga su imagen; con una conexión lenta puede tardar un poco.", "wsl.onboarding.step.distro": "Elegir distribución", "wsl.onboarding.step.opencode": "CoDev Code", "wsl.onboarding.checkingRuntime": "Comprobando WSL...", diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 7389b22796..485dfd7dc0 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -10,6 +10,7 @@ export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from export { useCommand } from "./context/command" export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" export { useWslServers } from "./wsl/context" +export { useSandboxes } from "./sandbox/context" export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform" export { type UpdaterPlatform, type UpdaterState } from "./updater" export { @@ -26,5 +27,16 @@ export { type WslServersPlatform, type WslServersState, } from "./wsl/types" +export { + type SandboxAvailability, + type SandboxBackend, + type SandboxConfig, + type SandboxItem, + type SandboxJob, + type SandboxRuntime, + type SandboxesEvent, + type SandboxesPlatform, + type SandboxesState, +} from "./sandbox/types" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/sandbox/context.tsx b/packages/app/src/sandbox/context.tsx new file mode 100644 index 0000000000..dd296c3c77 --- /dev/null +++ b/packages/app/src/sandbox/context.tsx @@ -0,0 +1,50 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import { queryOptions, useQuery, useQueryClient } from "@tanstack/solid-query" +import { createEffect, onCleanup } from "solid-js" +import type { SandboxAvailability, SandboxesState } from "./types" +import { usePlatform } from "../context/platform" + +const sandboxesQueryKey = ["platform", "sandboxes"] as const +const sandboxAvailabilityQueryKey = ["platform", "sandboxAvailability"] as const + +export const { use: useSandboxes, provider: SandboxesProvider } = createSimpleContext({ + name: "Sandboxes", + init: () => { + const platform = usePlatform() + const queryClient = useQueryClient() + const state = useQuery(() => { + const api = platform.sandboxes + return queryOptions({ + queryKey: sandboxesQueryKey, + queryFn: () => api!.getState(), + enabled: !!api, + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + }) + }) + const availability = useQuery(() => { + const api = platform.sandboxes + return queryOptions({ + queryKey: sandboxAvailabilityQueryKey, + queryFn: () => api!.availability(), + enabled: !!api, + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + }) + }) + + createEffect(() => { + const api = platform.sandboxes + if (!api) return + const off = api.subscribe((event) => { + queryClient.setQueryData(sandboxesQueryKey, event.state) + }) + onCleanup(off) + }) + + return { + state: state as typeof state & { readonly data: SandboxesState | undefined }, + availability: availability as typeof availability & { readonly data: SandboxAvailability | undefined }, + } + }, +}) diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx new file mode 100644 index 0000000000..a7b639407f --- /dev/null +++ b/packages/app/src/sandbox/dialog-add.tsx @@ -0,0 +1,120 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { showToast } from "@opencode-ai/ui/toast" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2" +import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2" +import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" +import { Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { useSandboxes } from "./context" + +// 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._-]*$/ + +export function DialogAddSandbox() { + const language = useLanguage() + const platform = usePlatform() + const dialog = useDialog() + const sandboxes = useSandboxes() + const api = platform.sandboxes! + const [store, setStore] = createStore({ name: "", projectDir: "", adding: false }) + + const availability = () => sandboxes.availability.data + const unsupportedMessage = () => { + const value = availability() + return value && !value.supported ? value.message : null + } + const warning = () => { + const value = availability() + return value?.supported ? value.warning : null + } + const nameInvalid = () => store.name.length > 0 && !NAME_PATTERN.test(store.name) + const canCreate = () => + availability()?.supported === true && NAME_PATTERN.test(store.name) && store.projectDir.length > 0 + + const pickProjectDir = () => { + if (platform.platform !== "desktop") return + void platform.openDirectoryPickerDialog({ title: language.t("sandbox.add.projectDir") }).then((result) => { + if (typeof result === "string") setStore("projectDir", result) + }) + } + + const create = async () => { + if (!canCreate() || store.adding) return + setStore("adding", true) + try { + await api.add(store.name, store.projectDir) + dialog.close() + } catch (error) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }) + } finally { + setStore("adding", false) + } + } + + return ( + + + {language.t("sandbox.server.add")} + + + + {unsupportedMessage()}

} + > + {/* warn-never-block: the warning is shown, creation stays enabled */} + {(text) =>

{text()}

}
+ setStore("name", event.currentTarget.value)} + /> + +

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

+
+
+ setStore("projectDir", event.currentTarget.value)} + /> + + {language.t("sandbox.add.choose")} + +
+

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

+
+
+ + dialog.close()}> + {language.t("common.cancel")} + + + void create()} + > + + + + + + +
+ ) +} diff --git a/packages/app/src/sandbox/settings.tsx b/packages/app/src/sandbox/settings.tsx new file mode 100644 index 0000000000..3bab5d12a7 --- /dev/null +++ b/packages/app/src/sandbox/settings.tsx @@ -0,0 +1,134 @@ +import { Tag } from "@opencode-ai/ui/v2/badge-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { useMutation } from "@tanstack/solid-query" +import fuzzysort from "fuzzysort" +import { type Accessor, For, Show, createMemo } from "solid-js" +import type { useServerManagementController } from "@/components/dialog-select-server" +import { ServerHealthIndicator } from "@/components/server/server-row" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { ServerConnection } from "@/context/server" +import { showToast } from "@/utils/toast" +import { useSandboxes } from "./context" +import type { SandboxRuntime } from "./types" + +type Controller = ReturnType + +export function isSandboxServer(server: ServerConnection.Any) { + return server.type === "sidecar" && server.variant === "sandbox" +} + +export function sandboxRuntimeRetryable(runtime: SandboxRuntime) { + return runtime.kind === "failed" || runtime.kind === "stopped" +} + +export function useFilteredSandboxes(filter: Accessor) { + const sandboxes = useSandboxes() + return createMemo(() => { + const items = sandboxes.state.data?.sandboxes ?? [] + const query = filter().trim() + if (!query) return items + return fuzzysort + .go(query, items, { keys: [(item) => item.config.name, (item) => item.config.id] }) + .map((x) => x.obj) + }) +} + +export function SandboxServerSettings(props: { + controller: Controller + sandboxes: ReturnType +}) { + const platform = usePlatform() + const language = useLanguage() + const sandboxes = useSandboxes() + const api = platform.sandboxes + + const request = useMutation(() => ({ + mutationFn: (action: () => Promise) => action(), + onError: (error) => + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }), + })) + + return ( + + + {(item) => { + const key = ServerConnection.Key.make(item.config.id) + // Create/reset/destroy jobs are single-flight in the controller, so a + // job on this sandbox means its lifecycle actions must wait. + const busy = () => sandboxes.state.data?.job?.id === item.config.id + const meta = () => { + if (busy()) return language.t(`sandbox.job.${sandboxes.state.data!.job!.kind}`) + if (item.runtime.kind === "failed") return item.runtime.message + return `${item.config.backend} · ${language.t(`sandbox.status.${item.runtime.kind}`)}` + } + return ( +
+
+ +
+ + {item.config.name} + + {language.t("sandbox.server.label")} + + + {meta()} +
+
+
+ + {language.t("dialog.server.status.default")} + + + } + aria-label={language.t("common.moreOptions")} + /> + + + + {language.t("sandbox.server.menu.label")} + + api && request.mutate(() => api.start(key))}> + {language.t("sandbox.server.start")} + + + api && request.mutate(() => api.reset(key))}> + {language.t("sandbox.server.reset")} + + + props.controller.setDefault(key)}> + {language.t("dialog.server.menu.default")} + + + + props.controller.setDefault(null)}> + {language.t("dialog.server.menu.defaultRemove")} + + + + request.mutate(() => props.controller.handleRemove(key))}> + {language.t("dialog.server.menu.delete")} + + + + + +
+
+ ) + }} +
+
+ ) +} diff --git a/packages/app/src/sandbox/types.ts b/packages/app/src/sandbox/types.ts new file mode 100644 index 0000000000..60f29ff789 --- /dev/null +++ b/packages/app/src/sandbox/types.ts @@ -0,0 +1,51 @@ +// Wire contract between the desktop main process's sandbox controller and the +// renderer, mirroring the WSL pattern (src/wsl/types.ts): the app owns the +// types, preload aliases them, and the main process keeps its own copies in +// @codev/sandbox — the app must never import that node-flavored package. + +export type SandboxBackend = "vz" | "wsl" | "bwrap" + +export type SandboxConfig = { + id: string + name: string + backend: SandboxBackend + projectDir: string +} + +export type SandboxRuntime = + | { kind: "starting" } + | { kind: "ready"; url: string; username: string | null; password: string | null } + | { kind: "failed"; message: string } + | { kind: "stopped" } + +export type SandboxItem = { + config: SandboxConfig + runtime: SandboxRuntime +} + +export type SandboxJob = { + kind: "create" | "destroy" | "reset" + id: string + startedAt: number +} + +export type SandboxesState = { + sandboxes: SandboxItem[] + job: SandboxJob | null +} + +export type SandboxesEvent = { type: "state"; state: SandboxesState } + +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 + add(name: string, projectDir: string): Promise + remove(id: string): Promise + reset(id: string): Promise + start(id: string): Promise +} diff --git a/packages/app/src/wsl/settings.tsx b/packages/app/src/wsl/settings.tsx index ce68e47eeb..803dc5f34b 100644 --- a/packages/app/src/wsl/settings.tsx +++ b/packages/app/src/wsl/settings.tsx @@ -13,6 +13,7 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server" import { showToast } from "@/utils/toast" +import { DialogAddSandbox } from "@/sandbox/dialog-add" import { DialogAddWslServer } from "./dialog-add-server" import { useWslServers } from "./context" import { wslCodevAction, wslRuntimeRetryable } from "./settings-model" @@ -30,9 +31,12 @@ export function AddServerMenu(props: { onAddServer: () => void }) { const openAddWsl = () => { dialog.push(() => ) } + const openAddSandbox = () => { + dialog.push(() => ) + } return ( {language.t("dialog.server.add.button")} @@ -46,7 +50,12 @@ export function AddServerMenu(props: { onAddServer: () => void }) { {language.t("dialog.server.add.button")} - {language.t("wsl.server.add")} + + {language.t("wsl.server.add")} + + + {language.t("sandbox.server.add")} + diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index c6031a08ff..1bb24c7558 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -100,6 +100,29 @@ test("leaves dev builds without an update feed", async () => { expect(config.publish).toBeUndefined() }) +// The sandbox providers mount this directory into guests; serverBundleDir() +// (src/main/sandbox/bundle.ts) resolves it under process.resourcesPath in +// packaged builds, so the resource must exist and keep its name. The filter is +// load-bearing too: node.js.map is larger than the bundle itself. +test("ships the server bundle as a mountable resource", async () => { + const previous = process.env.CODEV_CHANNEL + process.env.CODEV_CHANNEL = "prod" + + const module = await import("./electron-builder.config.ts?sandbox=prod") + const config = module.default as Configuration + + if (previous === undefined) delete process.env.CODEV_CHANNEL + else process.env.CODEV_CHANNEL = previous + + const resources = config.extraResources + expect(Array.isArray(resources)).toBe(true) + const bundle = (resources as Exclude) + .filter((entry) => typeof entry !== "string") + .find((entry) => entry.to === "server-bundle") + expect(bundle?.from).toBe("../opencode/dist/node") + expect(bundle?.filter).toEqual(["node.js", "*.wasm"]) +}) + test("keeps a hidden prod launcher for old Linux pins", async () => { const previous = process.env.CODEV_CHANNEL process.env.CODEV_CHANNEL = "prod" diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 3d621264ba..199d4a5d65 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -82,6 +82,15 @@ const getBase = (appId: string): Configuration => ({ to: "native/", filter: ["index.js", "index.d.ts", "build/Release/mac_window.node", "swift-build/**"], }, + // The sandbox providers (@codev/sandbox) mount a real dist/node directory + // into the guest, which the server copy inlined into the main bundle via + // virtual:codev-server cannot provide. The sourcemap is excluded: it is + // larger than the bundle itself and the guest never reads it. + { + from: "../opencode/dist/node", + to: "server-bundle", + filter: ["node.js", "*.wasm"], + }, ], mac: { category: "public.app-category.developer-tools", diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index ad88352e1c..96142d1503 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -46,6 +46,8 @@ import { import { createWslServersController } from "./wsl/servers" import { registerWslIpcHandlers } from "./wsl/ipc" import { spawnWslSidecar } from "./wsl/sidecar" +import { createDesktopSandboxController } from "./sandbox/controller" +import { registerSandboxIpcHandlers } from "./sandbox/ipc" import { migrate } from "./migrate" import { cleanupStoreFiles } from "./store-cleanup" @@ -162,9 +164,14 @@ const main = Effect.gen(function* () { }, }, ) + const sandboxes = createDesktopSandboxController({ + log: (message, meta) => logger.log(message, meta), + error: (message, meta) => logger.error(message, meta), + }) const stopSidecars = async () => { await killSidecar() wslServers.stopAll() + sandboxes.stopAll() } const relaunch = () => { setAppQuitting() @@ -300,6 +307,7 @@ const main = Effect.gen(function* () { recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"), }) registerWslIpcHandlers(wslServers) + registerSandboxIpcHandlers(sandboxes) void updater.start() const updateTimer = setInterval(() => void updater.check(), 10 * 60 * 1000) updateTimer.unref() @@ -364,6 +372,7 @@ const main = Effect.gen(function* () { if (process.platform === "win32") { void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) } + void sandboxes.initialize().catch((error) => logger.error("sandbox initialization failed", error)) yield* Effect.promise(() => health.wait).pipe( Effect.timeout("30 seconds"), diff --git a/packages/desktop/src/main/sandbox/bundle.ts b/packages/desktop/src/main/sandbox/bundle.ts new file mode 100644 index 0000000000..56fa780ee7 --- /dev/null +++ b/packages/desktop/src/main/sandbox/bundle.ts @@ -0,0 +1,15 @@ +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { app } from "electron" + +const root = dirname(fileURLToPath(import.meta.url)) + +// The sandbox providers bind-mount a real dist/node directory into the guest, +// so the server copy inlined into the main bundle via virtual:codev-server +// cannot serve them. Packaged builds ship the directory as the server-bundle +// extraResource (electron-builder.config.ts); dev runs use the workspace build +// the desktop bundle itself was compiled from, so both stay version-locked to +// the running app. +export function serverBundleDir() { + return app.isPackaged ? join(process.resourcesPath, "server-bundle") : join(root, "../../../opencode/dist/node") +} diff --git a/packages/desktop/src/main/sandbox/controller.ts b/packages/desktop/src/main/sandbox/controller.ts new file mode 100644 index 0000000000..b7e10bcd27 --- /dev/null +++ b/packages/desktop/src/main/sandbox/controller.ts @@ -0,0 +1,103 @@ +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { app } from "electron" +import { + SANDBOX_MANIFEST_URL, + createBwrapProvider, + createSandboxController, + createVzProvider, + createWslProvider, + fetchSandboxManifest, + requireImageAsset, +} from "@codev/sandbox" +import type { SandboxArch, SandboxAvailability, SandboxBackend, SandboxConfig, SandboxProvider } from "@codev/sandbox" +import { getStore } from "../store" +import { SANDBOXES_KEY } from "../store-keys" +import { serverBundleDir } from "./bundle" + +const root = dirname(fileURLToPath(import.meta.url)) + +type ControllerLogger = { + log: (message: string, meta?: unknown) => void + error: (message: string, meta?: unknown) => void +} + +export type DesktopSandboxController = ReturnType + +export function createDesktopSandboxController(logger: ControllerLogger) { + const provider = platformProvider() + const controller = createSandboxController({ + providers: provider ? { [provider.backend]: provider } : {}, + readSandboxes: readPersistedSandboxes, + writeSandboxes: writePersistedSandboxes, + logger, + }) + return { + ...controller, + // 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, + availability: (): Promise => { + if (!provider) + return Promise.resolve({ supported: false, message: `sandboxes are not supported on ${process.platform}` }) + return provider.availability() + }, + } +} + +function platformProvider(): SandboxProvider | undefined { + const arch = sandboxArch() + if (!arch) return undefined + const options = (imageBackend: SandboxBackend) => ({ + dataDir: join(app.getPath("userData"), "sandboxes"), + cacheDir: join(app.getPath("userData"), "sandbox-images"), + bundleDir: serverBundleDir(), + resolveImage: async (opts?: { signal?: AbortSignal }) => { + const manifest = await fetchSandboxManifest(SANDBOX_MANIFEST_URL, { signal: opts?.signal }) + return requireImageAsset(manifest, imageBackend, arch) + }, + }) + // The manifest publishes bwrap and vz images only: WSL boots the same rootfs + // tarball as bubblewrap (wsl --import brings its own kernel), so both + // resolve the bwrap asset and only vz needs its own kernel+disk image. + if (process.platform === "darwin") return createVzProvider({ ...options("vz"), helperPath: vmmHelperPath() }) + if (process.platform === "win32") return createWslProvider(options("bwrap")) + if (process.platform === "linux") return createBwrapProvider(options("bwrap")) + return undefined +} + +function sandboxArch(): SandboxArch | undefined { + if (process.arch === "x64" || process.arch === "arm64") return process.arch + return undefined +} + +// Packaged macOS builds ship the signed codev-vmm helper as an extraResource; +// dev runs use the swift build output in the sandbox package +// (bun run build:vzhelper from packages/codev-sandbox). +function vmmHelperPath() { + return app.isPackaged + ? join(process.resourcesPath, "codev-vmm") + : join(root, "../../../codev-sandbox/native/vzhelper/dist/codev-vmm") +} + +function readPersistedSandboxes(): SandboxConfig[] { + const existing = getStore().get(SANDBOXES_KEY) + if (!existing || typeof existing !== "object") return [] + const record = existing as { sandboxes?: unknown } + const list = Array.isArray(record.sandboxes) ? record.sandboxes : [] + return list.flatMap(normalizePersistedSandbox) +} + +function writePersistedSandboxes(sandboxes: SandboxConfig[]) { + getStore().set(SANDBOXES_KEY, { sandboxes }) +} + +function normalizePersistedSandbox(value: unknown): SandboxConfig[] { + if (!value || typeof value !== "object") return [] + const record = value as Record + if (typeof record.id !== "string" || !record.id.length) return [] + if (typeof record.name !== "string" || !record.name.length) return [] + if (record.backend !== "vz" && record.backend !== "wsl" && record.backend !== "bwrap") return [] + if (typeof record.projectDir !== "string" || !record.projectDir.length) return [] + return [{ id: record.id, name: record.name, backend: record.backend, projectDir: record.projectDir }] +} diff --git a/packages/desktop/src/main/sandbox/ipc.ts b/packages/desktop/src/main/sandbox/ipc.ts new file mode 100644 index 0000000000..168fd3cecf --- /dev/null +++ b/packages/desktop/src/main/sandbox/ipc.ts @@ -0,0 +1,66 @@ +import { app, ipcMain } from "electron" +import type { IpcMainInvokeEvent } from "electron" +import { sandboxServerId } from "@codev/sandbox" +import type { DesktopSandboxController } from "./controller" + +// Unlike the WSL channels there is no unavailable-stub variant: the controller +// exists on every platform, and availability() carries the unsupported message +// (or the warn-never-block Windows warnings) to the renderer. +export function registerSandboxIpcHandlers(controller: DesktopSandboxController) { + const subscriptions = new Map void>() + const unsubscribe = (id: number) => { + const off = subscriptions.get(id) + if (!off) return + off() + subscriptions.delete(id) + } + + app.once("will-quit", () => { + subscriptions.forEach((off) => off()) + subscriptions.clear() + }) + + ipcMain.handle("sandbox-subscribe", (event) => { + const id = event.sender.id + if (subscriptions.has(id)) return + subscriptions.set( + id, + controller.subscribe((payload) => { + if (event.sender.isDestroyed()) { + unsubscribe(id) + return + } + event.sender.send("sandbox-event", payload) + }), + ) + event.sender.once("destroyed", () => unsubscribe(id)) + }) + ipcMain.handle("sandbox-unsubscribe", (event) => unsubscribe(event.sender.id)) + ipcMain.handle("sandbox-get-state", () => controller.getState()) + ipcMain.handle("sandbox-availability", () => controller.availability()) + ipcMain.handle("sandbox-add", (_event: IpcMainInvokeEvent, name: unknown, projectDir: unknown) => { + const backend = controller.backend + if (!backend) throw new Error(`sandboxes are not supported on ${process.platform}`) + const sandboxName = requireSandboxIpcString("sandbox name", name) + return controller.addSandbox({ + id: sandboxServerId(sandboxName), + name: sandboxName, + backend, + projectDir: requireSandboxIpcString("project directory", projectDir), + }) + }) + ipcMain.handle("sandbox-remove", (_event: IpcMainInvokeEvent, id: unknown) => + controller.removeSandbox(requireSandboxIpcString("sandbox id", id)), + ) + ipcMain.handle("sandbox-reset", (_event: IpcMainInvokeEvent, id: unknown) => + controller.resetSandbox(requireSandboxIpcString("sandbox id", id)), + ) + ipcMain.handle("sandbox-start", (_event: IpcMainInvokeEvent, id: unknown) => + controller.startSandbox(requireSandboxIpcString("sandbox id", id)), + ) +} + +function requireSandboxIpcString(name: string, value: unknown) { + if (typeof value === "string" && value.length > 0) return value + throw new Error(`Invalid ${name}`) +} diff --git a/packages/desktop/src/main/store-keys.ts b/packages/desktop/src/main/store-keys.ts index f6f4030dba..0a59f2d53d 100644 --- a/packages/desktop/src/main/store-keys.ts +++ b/packages/desktop/src/main/store-keys.ts @@ -3,5 +3,6 @@ export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl" export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete" export const OLD_LAYOUT_ELIGIBLE_KEY = "oldLayoutEligible" export const WSL_SERVERS_KEY = "wslServers" +export const SANDBOXES_KEY = "sandboxes" export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" export const WINDOW_IDS_KEY = "windowIds" diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 510cf93080..f3d7c93f60 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer, webUtils } from "electron" -import type { ElectronAPI, WslServersEvent } from "./types" +import type { ElectronAPI, SandboxesEvent, WslServersEvent } from "./types" import type { UpdaterState } from "@opencode-ai/app/updater" const updaterCallbacks = new Set<(state: UpdaterState) => void>() @@ -36,6 +36,23 @@ const api: ElectronAPI = { removeServer: (id) => ipcRenderer.invoke("wsl-servers-remove", id), startServer: (id) => ipcRenderer.invoke("wsl-servers-start", id), }, + sandboxes: { + getState: () => ipcRenderer.invoke("sandbox-get-state"), + subscribe: (cb) => { + const handler = (_: unknown, event: SandboxesEvent) => cb(event) + ipcRenderer.on("sandbox-event", handler) + void ipcRenderer.invoke("sandbox-subscribe") + return () => { + ipcRenderer.removeListener("sandbox-event", handler) + void ipcRenderer.invoke("sandbox-unsubscribe") + } + }, + availability: () => ipcRenderer.invoke("sandbox-availability"), + add: (name, projectDir) => ipcRenderer.invoke("sandbox-add", name, projectDir), + remove: (id) => ipcRenderer.invoke("sandbox-remove", id), + reset: (id) => ipcRenderer.invoke("sandbox-reset", id), + start: (id) => ipcRenderer.invoke("sandbox-start", id), + }, updater: { subscribe: async (cb) => { updaterCallbacks.add(cb) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index 4534e529b9..02749b9ebb 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -1,5 +1,6 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import type { WslServersPlatform } from "@opencode-ai/app/wsl/types" +import type { SandboxesPlatform } from "@opencode-ai/app/sandbox/types" import type { UpdaterState } from "@opencode-ai/app/updater" export type { WslDistroProbe, @@ -14,6 +15,15 @@ export type { WslServersEvent, WslServersState, } from "@opencode-ai/app/wsl/types" +export type { + SandboxAvailability, + SandboxConfig, + SandboxItem, + SandboxJob, + SandboxRuntime, + SandboxesEvent, + SandboxesState, +} from "@opencode-ai/app/sandbox/types" export type ServerReadyData = { url: string @@ -22,6 +32,7 @@ export type ServerReadyData = { } export type WslServersAPI = WslServersPlatform +export type SandboxesAPI = SandboxesPlatform export type UpdaterAPI = { subscribe: (cb: (state: UpdaterState) => void) => Promise<() => void> check: () => Promise @@ -46,6 +57,7 @@ export type ElectronAPI = { installCli: () => Promise awaitInitialization: () => Promise wslServers: WslServersAPI + sandboxes: SandboxesAPI updater: UpdaterAPI consumeInitialDeepLinks: () => Promise getDefaultServerUrl: () => Promise diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index c0694d80c3..8d65b82acf 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -12,6 +12,7 @@ import { PlatformProvider, ServerConnection, useCommand, + useSandboxes, useWslServers, } from "@opencode-ai/app" import type { UpdaterState } from "@opencode-ai/app/updater" @@ -25,7 +26,8 @@ import { initI18n, t } from "./i18n" import { initializationData, initializationReady } from "./initialization" import { DesktopFirstLaunchOnboarding } from "./onboarding" import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" -import { availableStartupServer, readyWslConnections } from "./wsl/connections" +import { readyWslConnections } from "./wsl/connections" +import { readySandboxConnections, startupServer } from "./sandbox/connections" import "./styles.css" import { Splash } from "@opencode-ai/ui/logo" import { useTheme } from "@opencode-ai/ui/theme/context" @@ -285,6 +287,8 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { wslServers: wslServersApi, + sandboxes: window.api.sandboxes, + getDisplayBackend: async () => { return window.api.getDisplayBackend().catch(() => null) }, @@ -386,9 +390,15 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { function App() { const wslServers = useWslServers() + const sandboxes = useSandboxes() const ready = createMemo( () => - !defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading && !wslServers.isLoading, + !defaultServer.loading && + !sidecar.loading && + !windowCount.loading && + !locale.loading && + !wslServers.isLoading && + !sandboxes.state.isLoading, ) const servers = createMemo(() => { const data = initializationData(sidecar) @@ -406,10 +416,11 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { }) } list.push(...readyWslConnections(wslServers.data)) + list.push(...readySandboxConnections(sandboxes.state.data)) return list }) const effectiveDefaultServer = createMemo(() => - ServerConnection.Key.make(availableStartupServer(defaultServer.latest, wslServers.data)), + ServerConnection.Key.make(startupServer(defaultServer.latest, wslServers.data, sandboxes.state.data)), ) return ( }> diff --git a/packages/desktop/src/renderer/sandbox/connections.test.ts b/packages/desktop/src/renderer/sandbox/connections.test.ts new file mode 100644 index 0000000000..1b43de8f41 --- /dev/null +++ b/packages/desktop/src/renderer/sandbox/connections.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import type { SandboxesState } from "@opencode-ai/app/sandbox/types" +import { readySandboxConnections, startupServer } from "./connections" + +const state = (kind: "starting" | "ready" | "failed" | "stopped"): SandboxesState => ({ + job: null, + sandboxes: [ + { + config: { id: "sandbox:skills", name: "skills", backend: "vz", projectDir: "/tmp/project" }, + runtime: runtime(kind), + }, + ], +}) + +function runtime(kind: "starting" | "ready" | "failed" | "stopped") { + if (kind === "ready") return { kind, url: "http://127.0.0.1:4096", username: "opencode", password: "secret" } + if (kind === "failed") return { kind, message: "boom" } + return { kind } +} + +describe("sandbox desktop connections", () => { + test("publishes a sandbox only after it reports ready", () => { + expect(readySandboxConnections(state("starting"))).toEqual([]) + expect(readySandboxConnections(state("failed"))).toEqual([]) + expect(readySandboxConnections(state("stopped"))).toEqual([]) + expect(readySandboxConnections(state("ready"))).toEqual([ + expect.objectContaining({ displayName: "skills", label: "Sandbox", variant: "sandbox" }), + ]) + }) + + test("does not block desktop startup on a configured sandbox default", () => { + const key = "sandbox:skills" + expect(startupServer(key, undefined, undefined)).toBe("sidecar") + expect(startupServer(key, undefined, state("starting"))).toBe("sidecar") + expect(startupServer(key, undefined, state("ready"))).toBe(key) + }) + + test("delegates non-sandbox keys to the WSL rules", () => { + expect(startupServer(null, undefined, undefined)).toBe("sidecar") + expect(startupServer("wsl:Debian", undefined, state("ready"))).toBe("sidecar") + expect(startupServer("http://example.test", undefined, undefined)).toBe("http://example.test") + }) +}) diff --git a/packages/desktop/src/renderer/sandbox/connections.ts b/packages/desktop/src/renderer/sandbox/connections.ts new file mode 100644 index 0000000000..df1f91b2db --- /dev/null +++ b/packages/desktop/src/renderer/sandbox/connections.ts @@ -0,0 +1,37 @@ +import type { SandboxesState } from "@opencode-ai/app/sandbox/types" +import type { WslServersState } from "@opencode-ai/app/wsl/types" +import { availableStartupServer } from "../wsl/connections" + +export function readySandboxConnections(state?: SandboxesState) { + return (state?.sandboxes ?? []).flatMap((item) => { + if (item.runtime.kind !== "ready") return [] + return [ + { + displayName: item.config.name, + label: "Sandbox", + type: "sidecar" as const, + variant: "sandbox" as const, + name: item.config.name, + http: { + url: item.runtime.url, + username: item.runtime.username ?? undefined, + password: item.runtime.password ?? undefined, + }, + }, + ] + }) +} + +// The upstream availableStartupServer only validates wsl: keys; a persisted +// sandbox: default must equally fall back to the local sidecar until its +// sandbox is ready, or startup would hang on a dead connection. +export function startupServer( + defaultServer: string | null | undefined, + wsl: WslServersState | undefined, + sandboxes: SandboxesState | undefined, +) { + const key = defaultServer ?? "sidecar" + if (!key.startsWith("sandbox:")) return availableStartupServer(defaultServer, wsl) + if (sandboxes?.sandboxes.some((item) => item.config.id === key && item.runtime.kind === "ready")) return key + return "sidecar" +}