From 7e9f749ea975533e53eed8ca0eb2aa9a54b6e528 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 12 Aug 2026 18:26:59 +0700 Subject: [PATCH 1/2] feat(sandbox): pin the WSL installer bundle URL The storage bucket now hosts the hand-uploaded wsl.zip (verified live, 643MB): the pinned WSL runtime MSI plus the Ubuntu 24.04 distro image for the WSL servers feature. One zip means a corporate whitelist rule covers a single URL; the member names are constants because the install flow extracts them by exact name. Co-Authored-By: Claude Fable 5 --- packages/codev-sandbox/src/const.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/codev-sandbox/src/const.ts b/packages/codev-sandbox/src/const.ts index d2e1c724ac..d206734dc6 100644 --- a/packages/codev-sandbox/src/const.ts +++ b/packages/codev-sandbox/src/const.ts @@ -10,3 +10,13 @@ export const SANDBOX_MANIFEST_URL = `${SANDBOX_IMAGES_BASE_URL}/manifest.json` // WSL MSI installers mirrored for corporate networks where only the storage // host is whitelisted; the Microsoft `wsl --install` path stays as fallback. export const INSTALLERS_BASE_URL = `${STORAGE_BASE}/installers` + +// The WSL install bundle is one hand-uploaded zip so a corporate whitelist +// rule covers a single URL. The member names are part of the contract: the +// install flow extracts them from the zip by exact name. The MSI carries the +// pinned WSL runtime version in its filename; the .wsl file is the Ubuntu +// distro image for the WSL servers feature (`wsl --install --from-file`), +// not the sandbox rootfs, which ships via the image manifest instead. +export const WSL_INSTALLER_ZIP_URL = `${INSTALLERS_BASE_URL}/wsl.zip` +export const WSL_INSTALLER_MSI_NAME = "wsl.2.7.11.0.x64.msi" +export const WSL_DISTRO_IMAGE_NAME = "ubuntu-24.04.4-wsl-amd64.wsl" From 09f14874076c930d1368866134d654d1ef074bd6 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Wed, 12 Aug 2026 18:47:57 +0700 Subject: [PATCH 2/2] feat(desktop): install WSL from the storage bundle with fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install WSL and Install Ubuntu-24.04 now prefer the pinned wsl.zip from the codev storage host: download, extract members by exact name (zip.js, streamed, testable off Windows), enable VirtualMachinePlatform via offline dism and run the MSI quietly behind a single UAC prompt, or import the bundled distro image with --from-file. The original Microsoft-facing wsl --install paths remain as fallback, taken only when the bundle cannot be obtained — a completed-but-failed elevated install is never retried behind a second UAC prompt. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 15 ++ DIVERGENCES.md | 1 + bun.lock | 1 + packages/desktop/package.json | 1 + .../src/main/wsl/install-bundle.test.ts | 65 +++++++ .../desktop/src/main/wsl/install-bundle.ts | 174 ++++++++++++++++++ packages/desktop/src/main/wsl/runtime.ts | 11 +- packages/desktop/src/main/wsl/servers.ts | 7 +- 8 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 packages/desktop/src/main/wsl/install-bundle.test.ts create mode 100644 packages/desktop/src/main/wsl/install-bundle.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 080b92faaa..a15bdf3fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ file is the human-readable summary of what changed and why. ## Unreleased +### Desktop / Web UI + +- **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 + allow), enables the Virtual Machine Platform feature offline, and installs + the WSL runtime MSI quietly behind a single elevation prompt; the previous + `wsl --install` path against Microsoft's servers remains as an automatic + fallback when the bundle cannot be downloaded. Installing the + **Ubuntu-24.04** distro likewise imports the bundled image + (`wsl --install --from-file`) and only falls back to `--web-download` for + other distros or when the bundle is unavailable. Genuine install failures + still surface as errors rather than being retried behind a second + elevation prompt. + ## 1.18.4-8 ### Desktop / Web UI diff --git a/DIVERGENCES.md b/DIVERGENCES.md index bc577a15d3..4198d32727 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -188,6 +188,7 @@ from the squash message first, or use a plain merge. | `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/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. | | `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/bun.lock b/bun.lock index 9a05c3df09..41a809b14d 100644 --- a/bun.lock +++ b/bun.lock @@ -417,6 +417,7 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", + "@codev/sandbox": "workspace:*", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", "@sentry/solid": "catalog:", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 34a1e5324c..87ed66e6ce 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -36,6 +36,7 @@ }, "devDependencies": { "@actions/artifact": "4.0.0", + "@codev/sandbox": "workspace:*", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", "@sentry/solid": "catalog:", diff --git a/packages/desktop/src/main/wsl/install-bundle.test.ts b/packages/desktop/src/main/wsl/install-bundle.test.ts new file mode 100644 index 0000000000..4e41d55e32 --- /dev/null +++ b/packages/desktop/src/main/wsl/install-bundle.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test" +import { mkdtemp, readFile, readdir } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { WSL_DISTRO_IMAGE_NAME, WSL_INSTALLER_MSI_NAME } from "@codev/sandbox" +import { bundleProvidesDistro, ensureWslInstallBundle, wslRuntimeInstallScript } from "./install-bundle" + +// Built with zip.js rather than checked in as a fixture: the test only cares +// that members are found and extracted by exact name, not about zip bytes. +async function fixtureZip() { + const { BlobWriter, TextReader, ZipWriter, configure } = await import("@zip.js/zip.js") + configure({ useWebWorkers: false }) + const writer = new ZipWriter(new BlobWriter("application/zip")) + await writer.add(WSL_INSTALLER_MSI_NAME, new TextReader("msi-bytes")) + await writer.add(WSL_DISTRO_IMAGE_NAME, new TextReader("distro-bytes")) + return writer.close() +} + +test("downloads, extracts by member name, and never fetches twice", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-bundle-")) + const zip = await fixtureZip() + let fetches = 0 + const fetchImpl = (async () => { + fetches += 1 + return new Response(zip) + }) as typeof fetch + + const first = await ensureWslInstallBundle({ dir, fetchImpl }) + expect(await readFile(first.msiPath, "utf8")).toBe("msi-bytes") + expect(await readFile(first.distroImagePath, "utf8")).toBe("distro-bytes") + // The zip is deleted after extraction and nothing half-written survives. + expect((await readdir(dir)).toSorted()).toEqual([WSL_DISTRO_IMAGE_NAME, WSL_INSTALLER_MSI_NAME].toSorted()) + + const second = await ensureWslInstallBundle({ dir, fetchImpl }) + expect(second).toEqual(first) + expect(fetches).toBe(1) +}) + +test("fails loudly when the zip is missing a member", async () => { + const dir = await mkdtemp(join(tmpdir(), "wsl-bundle-")) + const { BlobWriter, TextReader, ZipWriter, configure } = await import("@zip.js/zip.js") + configure({ useWebWorkers: false }) + const writer = new ZipWriter(new BlobWriter("application/zip")) + await writer.add(WSL_INSTALLER_MSI_NAME, new TextReader("msi-bytes")) + const zip = await writer.close() + const fetchImpl = (async () => new Response(zip)) as typeof fetch + + expect(ensureWslInstallBundle({ dir, fetchImpl })).rejects.toThrow(WSL_DISTRO_IMAGE_NAME) +}) + +test("elevated script tolerates reboot-required exits and quotes the MSI path", () => { + const script = wslRuntimeInstallScript(`C:\\Users\\O'Brien\\wsl.msi`) + expect(script).toContain("/featurename:VirtualMachinePlatform") + expect(script).toContain("'C:\\Users\\O''Brien\\wsl.msi'") + // 3010 = ERROR_SUCCESS_REBOOT_REQUIRED, expected from both dism and msiexec. + expect(script).toContain("($dism -ne 0) -and ($dism -ne 3010)") + expect(script).toContain("($msi.ExitCode -ne 0) -and ($msi.ExitCode -ne 3010)") +}) + +test("only Ubuntu-24.04 is served from the bundle", () => { + expect(bundleProvidesDistro("Ubuntu-24.04")).toBe(true) + expect(bundleProvidesDistro("ubuntu-24.04")).toBe(true) + expect(bundleProvidesDistro("Ubuntu-22.04")).toBe(false) + expect(bundleProvidesDistro("Debian")).toBe(false) +}) diff --git a/packages/desktop/src/main/wsl/install-bundle.ts b/packages/desktop/src/main/wsl/install-bundle.ts new file mode 100644 index 0000000000..6475035822 --- /dev/null +++ b/packages/desktop/src/main/wsl/install-bundle.ts @@ -0,0 +1,174 @@ +import { createWriteStream, existsSync } from "node:fs" +import { mkdir, rename, rm, writeFile } from "node:fs/promises" +import { dirname, join } from "node:path" +import { Writable } from "node:stream" +import electron from "electron" +import { WSL_DISTRO_IMAGE_NAME, WSL_INSTALLER_MSI_NAME, WSL_INSTALLER_ZIP_URL } from "@codev/sandbox" +import type { RunWslOptions, WslCommandResult } from "./runtime" +import { + installWslDistro, + installWslRuntimeElevated, + resolveSystem32Command, + runInteractiveCommand, + runPowerShell, +} from "./runtime" + +// The customer fleet sits behind whitelist-only proxies that cannot reach +// Microsoft's servers, so WSL setup prefers the hand-uploaded bundle on the +// codev storage host and only falls back to the original `wsl --install` +// paths when the bundle cannot be obtained. Genuine install failures are NOT +// retried on the fallback: both paths need the same elevation, and a second +// UAC prompt right after a failed one helps nobody. + +// What the distro image registers itself as (its embedded +// /etc/wsl-distribution.conf shortname), and also the name Microsoft's online +// catalog lists — which is what makes the by-name fallback equivalent. Must +// move together with WSL_DISTRO_IMAGE_NAME. +export const BUNDLED_DISTRO_NAME = "Ubuntu-24.04" + +const INSTALL_TIMEOUT_MS = 15 * 60_000 +// 643MB over a slow corporate link; observed as low as ~35 KB/s. +const DOWNLOAD_TIMEOUT_MS = 6 * 60 * 60_000 + +type InstallLogger = { + log: (message: string, meta?: unknown) => void + error: (message: string, meta?: unknown) => void +} + +export type InstallBundleOptions = RunWslOptions & { + dir?: string + url?: string + fetchImpl?: typeof fetch +} + +export async function installWslRuntimeDualPath( + opts?: InstallBundleOptions, + logger?: InstallLogger, +): Promise { + const primary = bundleSupportsThisMachine() + ? await installWslRuntimeFromBundle(opts).catch(intoError) + : new Error("the WSL install bundle is x64-only") + if (!(primary instanceof Error)) return primary + if (opts?.signal?.aborted) throw primary + logger?.error("bundled WSL install unavailable; falling back to wsl --install", { message: primary.message }) + return installWslRuntimeElevated(opts) +} + +export async function installWslDistroDualPath( + name: string, + opts?: InstallBundleOptions, + logger?: InstallLogger, +): Promise { + const bundle = + bundleProvidesDistro(name) && bundleSupportsThisMachine() + ? await ensureWslInstallBundle(opts).catch(intoError) + : new Error(`the WSL install bundle does not provide ${name}`) + if (!(bundle instanceof Error)) { + return runInteractiveCommand( + resolveSystem32Command("wsl.exe"), + ["--install", "--from-file", bundle.distroImagePath, "--no-launch"], + { ...opts, timeoutMs: opts?.timeoutMs ?? INSTALL_TIMEOUT_MS }, + INSTALL_TIMEOUT_MS, + ) + } + if (opts?.signal?.aborted) throw bundle + logger?.error("bundled distro install unavailable; falling back to --web-download", { message: bundle.message }) + return installWslDistro(name, opts) +} + +export function bundleProvidesDistro(name: string) { + return name.toLowerCase() === BUNDLED_DISTRO_NAME.toLowerCase() +} + +// Acquisition problems (download, extraction) throw and let the caller fall +// back; the elevated install itself ran once we return a result, even a +// failing one. +export async function installWslRuntimeFromBundle(opts?: InstallBundleOptions): Promise { + const bundle = await ensureWslInstallBundle(opts) + const scriptPath = join(dirname(bundle.msiPath), "install-wsl-runtime.ps1") + await writeFile(scriptPath, wslRuntimeInstallScript(bundle.msiPath)) + const command = [ + "$ErrorActionPreference = 'Stop'", + `$process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-File',('"{0}"' -f ${psQuote(scriptPath)})) -Wait -PassThru`, + "if ($null -ne $process.ExitCode) { exit $process.ExitCode }", + ].join("; ") + return runPowerShell(command, { ...opts, timeoutMs: opts?.timeoutMs ?? INSTALL_TIMEOUT_MS }) +} + +// One elevated script covers both steps so the user sees a single UAC prompt. +// 3010 is ERROR_SUCCESS_REBOOT_REQUIRED from both dism and msiexec — the +// expected outcome on a machine that never had VirtualMachinePlatform — and +// the pending-restart handling downstream already covers it. +export function wslRuntimeInstallScript(msiPath: string) { + return [ + "$ErrorActionPreference = 'Continue'", + "& dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart", + "$dism = $LASTEXITCODE", + "if (($dism -ne 0) -and ($dism -ne 3010)) { exit $dism }", + `$msi = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', ('"{0}"' -f ${psQuote(msiPath)}), '/qn', '/norestart') -Wait -PassThru`, + "if (($msi.ExitCode -ne 0) -and ($msi.ExitCode -ne 3010)) { exit $msi.ExitCode }", + "exit 0", + ].join("\r\n") +} + +export async function ensureWslInstallBundle(opts?: InstallBundleOptions) { + const dir = opts?.dir ?? join(electron.app.getPath("userData"), "wsl-installer") + const msiPath = join(dir, WSL_INSTALLER_MSI_NAME) + const distroImagePath = join(dir, WSL_DISTRO_IMAGE_NAME) + if (existsSync(msiPath) && existsSync(distroImagePath)) return { msiPath, distroImagePath } + + await mkdir(dir, { recursive: true }) + const zipPath = join(dir, "wsl.zip") + if (!existsSync(zipPath)) await downloadTo(zipPath, opts) + await extractZipMembers(zipPath, dir, [WSL_INSTALLER_MSI_NAME, WSL_DISTRO_IMAGE_NAME]) + if (!existsSync(msiPath) || !existsSync(distroImagePath)) + throw new Error(`wsl.zip did not contain ${WSL_INSTALLER_MSI_NAME} and ${WSL_DISTRO_IMAGE_NAME}`) + // Members extracted, so the zip is 643MB of dead weight from here on. + await rm(zipPath, { force: true }) + return { msiPath, distroImagePath } +} + +async function downloadTo(zipPath: string, opts?: InstallBundleOptions) { + const fetchImpl = opts?.fetchImpl ?? fetch + const url = opts?.url ?? WSL_INSTALLER_ZIP_URL + const signal = AbortSignal.any( + [opts?.signal, AbortSignal.timeout(opts?.timeoutMs ?? DOWNLOAD_TIMEOUT_MS)].filter( + (value): value is AbortSignal => value !== undefined, + ), + ) + const response = await fetchImpl(url, { signal }) + if (!response.ok || !response.body) throw new Error(`downloading ${url} failed: HTTP ${response.status}`) + const partial = `${zipPath}.partial` + await response.body.pipeTo(Writable.toWeb(createWriteStream(partial)), { signal }) + await rename(partial, zipPath) +} + +// zip.js instead of PowerShell Expand-Archive: streams each member to disk +// without buffering it, and keeps this file testable off Windows. +async function extractZipMembers(zipPath: string, dir: string, names: string[]) { + const { openAsBlob } = await import("node:fs") + const { BlobReader, ZipReader, configure } = await import("@zip.js/zip.js") + configure({ useWebWorkers: false }) + const reader = new ZipReader(new BlobReader(await openAsBlob(zipPath))) + const entries = await reader.getEntries() + for (const name of names) { + const entry = entries.find((item) => item.filename === name || item.filename.endsWith(`/${name}`)) + if (!entry?.getData) throw new Error(`wsl.zip has no ${name}`) + const partial = join(dir, `${name}.partial`) + await entry.getData(Writable.toWeb(createWriteStream(partial))) + await rename(partial, join(dir, name)) + } + await reader.close() +} + +function bundleSupportsThisMachine() { + return process.arch === "x64" +} + +function psQuote(value: string) { + return `'${value.replace(/'/g, "''")}'` +} + +function intoError(error: unknown) { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/packages/desktop/src/main/wsl/runtime.ts b/packages/desktop/src/main/wsl/runtime.ts index 0f6dc00f38..d64744e37d 100644 --- a/packages/desktop/src/main/wsl/runtime.ts +++ b/packages/desktop/src/main/wsl/runtime.ts @@ -41,7 +41,7 @@ export function runWsl(args: string[], opts: RunWslOptions = {}) { return runCommand("wsl", args, opts) } -function runPowerShell(command: string, opts: RunWslOptions = {}) { +export function runPowerShell(command: string, opts: RunWslOptions = {}) { return runCommand( "powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", command], @@ -110,7 +110,12 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) { }) } -function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) { +export function runInteractiveCommand( + command: string, + args: string[], + opts: RunWslOptions = {}, + defaultTimeoutMs: number, +) { return new Promise((resolve, reject) => { const child = pty.spawn(command, args, { name: "xterm-color", @@ -385,7 +390,7 @@ export function shellEscape(value: string) { return `'${value.replace(/'/g, `'"'"'`)}'` } -function resolveSystem32Command(command: string) { +export function resolveSystem32Command(command: string) { const root = process.env.SystemRoot ?? process.env.windir if (!root) return command const resolved = join(root, "System32", command) diff --git a/packages/desktop/src/main/wsl/servers.ts b/packages/desktop/src/main/wsl/servers.ts index deba0598e1..be1e7570dc 100644 --- a/packages/desktop/src/main/wsl/servers.ts +++ b/packages/desktop/src/main/wsl/servers.ts @@ -15,10 +15,9 @@ import { WSL_SERVERS_KEY } from "../store-keys" import { getStore } from "../store" import { expectCodevVersion, pendingRestartAfterWslInstall, wslServerIdsToStartOnInitialize } from "./startup" import { clearWslDistroState, wslServerIdToRestart } from "./policy" +import { installWslDistroDualPath, installWslRuntimeDualPath } from "./install-bundle" import { - installWslDistro, installWslCodev, - installWslRuntimeElevated, listInstalledWslDistros, listOnlineWslDistros, openWslTerminal, @@ -325,7 +324,7 @@ export function createWslServersController( async installWsl() { await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => { - const result = await installWslRuntimeElevated({ signal: abort.signal }) + const result = await installWslRuntimeDualPath({ signal: abort.signal }, logger) if (result.code !== 0) { const message = summarize(result.stderr || result.stdout) || "WSL installation failed" throw new Error(message) @@ -337,7 +336,7 @@ export function createWslServersController( async installDistro(name: string) { await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => { - const result = await installWslDistro(name, { signal: abort.signal }) + const result = await installWslDistroDualPath(name, { signal: abort.signal }, logger) if (result.code !== 0) { const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}` throw new Error(message)