From fd22a48118d8b96d312d723e758fa09b492dee03 Mon Sep 17 00:00:00 2001 From: elton costa Date: Sun, 13 Sep 2026 18:17:54 -0300 Subject: [PATCH 1/4] feat: add platform-specific desktop binaries --- scripts/build.ts | 15 ++++++++++----- src/cli/launcher.ts | 3 ++- src/host/bootstrap.ts | 4 ++-- src/shared/constants.ts | 24 ++++++++++++++++++------ 4 files changed, 32 insertions(+), 14 deletions(-) diff --git a/scripts/build.ts b/scripts/build.ts index 999cce1..418293a 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -1,6 +1,6 @@ -import {cp, mkdir, readFile, rm, writeFile} from "node:fs/promises"; +import {chmod, cp, mkdir, readFile, rm, writeFile} from "node:fs/promises"; import path from "node:path"; -import {HOST_VERSION} from "../src/shared/constants.ts"; +import {HOST_VERSION, ZDP_EXECUTABLE, ZDP_LAUNCHER_EXECUTABLE} from "../src/shared/constants.ts"; const root = path.resolve(import.meta.dir, ".."); const runtime = path.join(root, "runtime"); @@ -47,10 +47,15 @@ await cp(path.join(root, "src", "loader", "runtime-bootstrap.mjs"), path.join(ru await writeFile(path.join(runtime, "current.json"), `${JSON.stringify({version: HOST_VERSION}, null, 2)}\n`, "utf8"); await runBunBuild([ - "build", path.join(root, "src", "cli", "index.ts"), "--compile", "--minify", "--sourcemap", "--outfile", path.join(bin, "zdp.exe"), + "build", path.join(root, "src", "cli", "index.ts"), "--compile", "--minify", "--sourcemap", "--outfile", path.join(bin, ZDP_EXECUTABLE), ]); -await runBunBuild([ - "build", path.join(root, "src", "cli", "launcher.ts"), "--compile", "--minify", "--windows-hide-console", "--outfile", path.join(bin, "zdp-launcher.exe"), +const launcherArgs = ["build", path.join(root, "src", "cli", "launcher.ts"), "--compile", "--minify"]; +if (process.platform === "win32") launcherArgs.push("--windows-hide-console"); +launcherArgs.push("--outfile", path.join(bin, ZDP_LAUNCHER_EXECUTABLE)); +await runBunBuild(launcherArgs); +if (process.platform !== "win32") await Promise.all([ + chmod(path.join(bin, ZDP_EXECUTABLE), 0o755), + chmod(path.join(bin, ZDP_LAUNCHER_EXECUTABLE), 0o755), ]); console.log(`Built ${HOST_VERSION} to ${versionDir}`); diff --git a/src/cli/launcher.ts b/src/cli/launcher.ts index 0b3e668..d9129ff 100644 --- a/src/cli/launcher.ts +++ b/src/cli/launcher.ts @@ -1,8 +1,9 @@ import {spawn} from "node:child_process"; import path from "node:path"; +import {ZDP_EXECUTABLE} from "../shared/constants.ts"; const root = path.dirname(path.dirname(process.execPath)); -const child = spawn(path.join(root, "bin", "zdp.exe"), ["launch", ...process.argv.slice(2)], { +const child = spawn(path.join(root, "bin", ZDP_EXECUTABLE), ["launch", ...process.argv.slice(2)], { cwd: root, detached: true, stdio: "ignore", diff --git a/src/host/bootstrap.ts b/src/host/bootstrap.ts index facbbb9..2086593 100644 --- a/src/host/bootstrap.ts +++ b/src/host/bootstrap.ts @@ -3,7 +3,7 @@ import {readFile} from "node:fs/promises"; import path from "node:path"; import {fileURLToPath} from "node:url"; import {app, dialog, ipcMain, powerMonitor, protocol, session, shell, type WebContents} from "electron"; -import {HOST_NAME, HOST_VERSION, getPaths, resolveZdpRoot} from "../shared/constants.ts"; +import {HOST_NAME, HOST_VERSION, ZDP_EXECUTABLE, getPaths, resolveZdpRoot} from "../shared/constants.ts"; import {writeJsonAtomic} from "../shared/atomic.ts"; import {JsonLogger} from "../shared/logger.ts"; import type {HostState} from "../shared/schemas.ts"; @@ -263,7 +263,7 @@ async function chooseDirectory(title: string): Promise { function startGuardian(): void { if (process.env.ZDP_DISABLE_GUARD === "1") return; - const executable = path.join(paths.bin, "zdp.exe"); + const executable = path.join(paths.bin, ZDP_EXECUTABLE); const child = spawn(executable, ["guard", "--parent", String(process.pid), "--zcode", zcodeRoot], { detached: true, stdio: "ignore", diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 4a9bfe7..2e5780d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,22 +1,34 @@ import path from "node:path"; +import os from "node:os"; export const HOST_NAME = "ZCode Desktop Extensions"; export const HOST_VERSION = "0.3.9"; export const HOST_UPDATE_URL = "https://github.com/notmike101/zcode-extensions/releases/latest/download/host-update.json"; export const API_VERSION = 1; export const INSTALL_STATE_VERSION = 1; -export const DEFAULT_ZCODE_ROOT = path.join( - process.env.LOCALAPPDATA ?? "C:\\Users\\me\\AppData\\Local", - "Programs", - "ZCode", -); +export const ZDP_EXECUTABLE = process.platform === "win32" ? "zdp.exe" : "zdp"; +export const ZDP_LAUNCHER_EXECUTABLE = process.platform === "win32" ? "zdp-launcher.exe" : "zdp-launcher"; +export const DEFAULT_ZCODE_ROOT = process.platform === "win32" + ? path.join(process.env.LOCALAPPDATA ?? "C:\\Users\\me\\AppData\\Local", "Programs", "ZCode") + : path.join(os.homedir(), ".local", "opt", "ZCode"); + +export function getZCodeLayout(root: string) { + const electronExecutable = path.join(root, process.platform === "win32" ? "ZCode.exe" : "zcode"); + return { + root, + resources: path.join(root, "resources"), + electronExecutable, + launchExecutable: electronExecutable, + launchArgs: process.platform === "win32" ? [] : ["--no-sandbox"], + }; +} export const PLUGIN_ID_PATTERN = /^[a-z0-9](?:[a-z0-9.-]{0,62}[a-z0-9])?$/; export function resolveZdpRoot(): string { if (process.env.ZDP_ROOT) return path.resolve(process.env.ZDP_ROOT); const executable = path.basename(process.execPath).toLowerCase(); - if (executable === "zdp.exe" || executable === "zdp-launcher.exe") { + if (["zdp", "zdp.exe", "zdp-launcher", "zdp-launcher.exe"].includes(executable)) { return path.dirname(path.dirname(process.execPath)); } return process.cwd(); From b53d643dac6db9131331a58c755dfd3f87e668db Mon Sep 17 00:00:00 2001 From: elton costa Date: Sun, 13 Sep 2026 18:18:21 -0300 Subject: [PATCH 2/4] feat: support Linux desktop installation --- src/cli/guardian.ts | 4 +-- src/cli/index.ts | 18 +++++++++-- src/cli/installer.ts | 70 ++++++++++++++++++++++++++++------------- src/cli/shortcut.ts | 49 +++++++++++++++++++++++++++++ src/shared/schemas.ts | 1 + tests/installer.test.ts | 25 +++++++++++++-- 6 files changed, 138 insertions(+), 29 deletions(-) diff --git a/src/cli/guardian.ts b/src/cli/guardian.ts index 4b982a3..6dff6fc 100644 --- a/src/cli/guardian.ts +++ b/src/cli/guardian.ts @@ -11,7 +11,7 @@ export async function guard(parentPid: number, zcodeRoot: string): Promise let previousSize = -1; const deadline = Date.now() + 5 * 60_000; while (Date.now() < deadline) { - if (isZCodeRunning()) { await delay(1_000); continue; } + if (isZCodeRunning(zcodeRoot)) { await delay(1_000); continue; } const size = await stat(appAsar).then((value) => value.size).catch(() => -1); if (size === previousSize) stableSamples += 1; else stableSamples = 0; @@ -19,7 +19,7 @@ export async function guard(parentPid: number, zcodeRoot: string): Promise if (stableSamples >= 2) break; await delay(1_000); } - if (!isZCodeRunning()) await installOrRepair(zcodeRoot, {skipProcessCheck: true}).catch(() => undefined); + if (!isZCodeRunning(zcodeRoot)) await installOrRepair(zcodeRoot, {skipProcessCheck: true}).catch(() => undefined); } async function hostUpdatePending(): Promise { diff --git a/src/cli/index.ts b/src/cli/index.ts index fdb8124..379b9d7 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,12 +1,13 @@ #!/usr/bin/env bun import {DEFAULT_ZCODE_ROOT, HOST_VERSION} from "../shared/constants.ts"; -import {doctor, installOrRepair, launch, uninstall} from "./installer.ts"; +import {doctor, installOrRepair, launch, readInstallState, uninstall} from "./installer.ts"; import {guard} from "./guardian.ts"; import {applyHostUpdate} from "./host-update-apply.ts"; const args = process.argv.slice(2); const command = args[0] ?? "help"; -const zcodeRoot = valueAfter("--zcode") ?? DEFAULT_ZCODE_ROOT; +const installed = await readInstallState(); +const zcodeRoot = valueAfter("--zcode") ?? installed?.zcodeRoot ?? DEFAULT_ZCODE_ROOT; try { switch (command) { @@ -23,7 +24,7 @@ try { console.log(`Vendor ASAR: ${report.vendorAsarSha256}`); break; } - case "launch": await launch(zcodeRoot, args.includes("--safe")); break; + case "launch": await launch(zcodeRoot, args.includes("--safe"), launchArguments()); break; case "uninstall": await uninstall(zcodeRoot, args.includes("--purge-data")); console.log(`Uninstalled ZCode Desktop Extensions${args.includes("--purge-data") ? " and removed its data" : " (data preserved)"}.`); @@ -56,6 +57,17 @@ function valueAfter(flag: string): string | undefined { return index >= 0 ? args[index + 1] : undefined; } +function launchArguments(): string[] { + const result: string[] = []; + for (let index = 1; index < args.length; index += 1) { + const value = args[index]; + if (value === "--safe") continue; + if (value === "--zcode") { index += 1; continue; } + if (value) result.push(value); + } + return result; +} + function printHelp(): void { console.log(`ZCode Desktop Extensions ${HOST_VERSION}\n\nCommands:\n doctor\n install\n repair\n launch [--safe]\n uninstall [--purge-data]\n\nOptions:\n --zcode Override the ZCode installation directory`); } diff --git a/src/cli/installer.ts b/src/cli/installer.ts index 263df23..092ef91 100644 --- a/src/cli/installer.ts +++ b/src/cli/installer.ts @@ -1,11 +1,11 @@ import {createHash} from "node:crypto"; -import {createReadStream} from "node:fs"; +import {createReadStream, readdirSync, readlinkSync} from "node:fs"; import {copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile} from "node:fs/promises"; import path from "node:path"; import {spawn, spawnSync} from "node:child_process"; import {extractFile} from "@electron/asar"; import {FuseState, FuseV1Options, getCurrentFuseWire} from "@electron/fuses"; -import {DEFAULT_ZCODE_ROOT, HOST_VERSION, getPaths, resolveZdpRoot} from "../shared/constants.ts"; +import {DEFAULT_ZCODE_ROOT, HOST_VERSION, ZDP_LAUNCHER_EXECUTABLE, getPaths, getZCodeLayout, resolveZdpRoot} from "../shared/constants.ts"; import {writeJsonAtomic} from "../shared/atomic.ts"; import {installStateSchema, type InstallState} from "../shared/schemas.ts"; import {defaultShortcutPath, readShortcut, restoreShortcut, writeLauncherShortcut} from "./shortcut.ts"; @@ -29,9 +29,10 @@ export type DoctorReport = { export async function doctor(zcodeRoot = DEFAULT_ZCODE_ROOT, zdpRoot = resolveZdpRoot()): Promise { const root = zdpRoot; const paths = getPaths(root); + const layout = getZCodeLayout(zcodeRoot); const errors: string[] = []; - const resources = path.join(zcodeRoot, "resources"); - const exe = path.join(zcodeRoot, "ZCode.exe"); + const resources = layout.resources; + const exe = layout.electronExecutable; const appAsar = path.join(resources, "app.asar"); const originalAsar = path.join(resources, "zcode.original.asar"); const vendorAsar = await exists(originalAsar) ? originalAsar : await exists(appAsar) ? appAsar : undefined; @@ -56,7 +57,7 @@ export async function doctor(zcodeRoot = DEFAULT_ZCODE_ROOT, zdpRoot = resolveZd const runtimePresent = await exists(paths.runtimeBootstrap) && await exists(paths.runtimeCurrent); const installState = await readInstallState(paths.installState); const shortcut = await readShortcut().catch(() => undefined); - const shortcutManaged = Boolean(shortcut && path.resolve(shortcut.originalTarget) === path.resolve(path.join(paths.bin, "zdp-launcher.exe"))); + const shortcutManaged = Boolean(shortcut && path.resolve(shortcut.originalTarget) === path.resolve(path.join(paths.bin, ZDP_LAUNCHER_EXECUTABLE))); return { ok: errors.length === 0 && runtimePresent, installed: loaderPresent && Boolean(await exists(originalAsar)), @@ -75,13 +76,16 @@ export async function doctor(zcodeRoot = DEFAULT_ZCODE_ROOT, zdpRoot = resolveZd export async function installOrRepair(zcodeRoot = DEFAULT_ZCODE_ROOT, options: {skipProcessCheck?: boolean; manageShortcut?: boolean; stateRoot?: string; loaderVersion?: string} = {}): Promise { const root = options.stateRoot ?? resolveZdpRoot(); const paths = getPaths(root); - if (!options.skipProcessCheck) assertZCodeClosed(); + if (!options.skipProcessCheck) assertZCodeClosed(zcodeRoot); if (!await exists(paths.runtimeBootstrap) || !await exists(paths.runtimeCurrent)) throw new Error("Build the ZDP runtime before installing"); const before = await doctor(zcodeRoot, root); if (before.errors.length) throw new Error(`ZCode is not patchable:\n- ${before.errors.join("\n- ")}`); - const resources = path.join(zcodeRoot, "resources"); + const layout = getZCodeLayout(zcodeRoot); + const resources = layout.resources; const appAsar = path.join(resources, "app.asar"); const originalAsar = path.join(resources, "zcode.original.asar"); + const appUnpacked = `${appAsar}.unpacked`; + const originalUnpacked = `${originalAsar}.unpacked`; let vendorAsar: string; if (await exists(appAsar)) { const incomingPackage = readPackage(appAsar); @@ -90,8 +94,10 @@ export async function installOrRepair(zcodeRoot = DEFAULT_ZCODE_ROOT, options: { const previousPackage = readPackage(originalAsar); await backupVendor(originalAsar, previousPackage.version, paths.backups); await rm(originalAsar, {force: true}); + await rm(originalUnpacked, {recursive: true, force: true}); } await rename(appAsar, originalAsar); + if (await exists(appUnpacked)) await rename(appUnpacked, originalUnpacked); vendorAsar = originalAsar; } else if (await exists(originalAsar)) { vendorAsar = originalAsar; @@ -107,9 +113,9 @@ export async function installOrRepair(zcodeRoot = DEFAULT_ZCODE_ROOT, options: { const manageShortcut = options.manageShortcut ?? true; const shortcutPath = existingState?.shortcut?.path ?? defaultShortcutPath(); const originalShortcut = manageShortcut ? existingState?.shortcut ?? await readShortcut(shortcutPath) : undefined; - const launcher = path.join(paths.bin, "zdp-launcher.exe"); - if (manageShortcut && await exists(launcher)) await writeLauncherShortcut(shortcutPath, launcher, root, path.join(zcodeRoot, "ZCode.exe")); - const wire = await getCurrentFuseWire(path.join(zcodeRoot, "ZCode.exe")); + const launcher = path.join(paths.bin, ZDP_LAUNCHER_EXECUTABLE); + if (manageShortcut && await exists(launcher)) await writeLauncherShortcut(shortcutPath, launcher, root, layout.electronExecutable); + const wire = await getCurrentFuseWire(layout.electronExecutable); const now = new Date().toISOString(); const state: InstallState = installStateSchema.parse({ schemaVersion: 1, @@ -131,20 +137,26 @@ export async function installOrRepair(zcodeRoot = DEFAULT_ZCODE_ROOT, options: { export async function uninstall(zcodeRoot = DEFAULT_ZCODE_ROOT, purgeData = false, options: {skipProcessCheck?: boolean; manageShortcut?: boolean; stateRoot?: string} = {}): Promise { const root = options.stateRoot ?? resolveZdpRoot(); const paths = getPaths(root); - if (!options.skipProcessCheck) assertZCodeClosed(); - const resources = path.join(zcodeRoot, "resources"); + if (!options.skipProcessCheck) assertZCodeClosed(zcodeRoot); + const resources = getZCodeLayout(zcodeRoot).resources; const appDir = path.join(resources, "app"); const appAsar = path.join(resources, "app.asar"); const originalAsar = path.join(resources, "zcode.original.asar"); + const appUnpacked = `${appAsar}.unpacked`; + const originalUnpacked = `${originalAsar}.unpacked`; if (await exists(appDir)) { if (!await isManagedLoader(appDir)) throw new Error(`Refusing to remove unmanaged directory: ${appDir}`); await rm(appDir, {recursive: true, force: true}); } - if (!await exists(appAsar) && await exists(originalAsar)) await rename(originalAsar, appAsar); + if (!await exists(appAsar) && await exists(originalAsar)) { + await rename(originalAsar, appAsar); + if (await exists(originalUnpacked)) await rename(originalUnpacked, appUnpacked); + } else if (await exists(appAsar) && await exists(originalAsar)) { const previousPackage = readPackage(originalAsar); await backupVendor(originalAsar, previousPackage.version, paths.backups); await rm(originalAsar, {force: true}); + await rm(originalUnpacked, {recursive: true, force: true}); } const state = await readInstallState(paths.installState); if ((options.manageShortcut ?? true) && state?.shortcut) await restoreShortcut(state.shortcut); @@ -153,9 +165,10 @@ export async function uninstall(zcodeRoot = DEFAULT_ZCODE_ROOT, purgeData = fals if (purgeData) await rm(paths.data, {recursive: true, force: true}); } -export async function launch(zcodeRoot = DEFAULT_ZCODE_ROOT, safe = false): Promise { - if (!isZCodeRunning()) await installOrRepair(zcodeRoot); - const child = spawn(path.join(zcodeRoot, "ZCode.exe"), [], { +export async function launch(zcodeRoot = DEFAULT_ZCODE_ROOT, safe = false, args: string[] = []): Promise { + if (!isZCodeRunning(zcodeRoot)) await installOrRepair(zcodeRoot); + const layout = getZCodeLayout(zcodeRoot); + const child = spawn(layout.launchExecutable, [...layout.launchArgs, ...args], { cwd: zcodeRoot, detached: true, stdio: "ignore", @@ -165,14 +178,24 @@ export async function launch(zcodeRoot = DEFAULT_ZCODE_ROOT, safe = false): Prom child.unref(); } -export function assertZCodeClosed(): void { - if (isZCodeRunning()) throw new Error("ZCode is running. Close it before install, repair, or uninstall."); +export function assertZCodeClosed(zcodeRoot = DEFAULT_ZCODE_ROOT): void { + if (isZCodeRunning(zcodeRoot)) throw new Error("ZCode is running. Close it before install, repair, or uninstall."); } -export function isZCodeRunning(): boolean { - if (process.platform !== "win32") return false; - const result = spawnSync("tasklist.exe", ["/FI", "IMAGENAME eq ZCode.exe", "/NH", "/FO", "CSV"], {encoding: "utf8", windowsHide: true}); - return /"ZCode\.exe"/i.test(result.stdout ?? ""); +export function isZCodeRunning(zcodeRoot = DEFAULT_ZCODE_ROOT): boolean { + if (process.platform === "win32") { + const result = spawnSync("tasklist.exe", ["/FI", "IMAGENAME eq ZCode.exe", "/NH", "/FO", "CSV"], {encoding: "utf8", windowsHide: true}); + return /"ZCode\.exe"/i.test(result.stdout ?? ""); + } + if (process.platform !== "linux") return false; + const executable = path.resolve(getZCodeLayout(zcodeRoot).electronExecutable); + try { + return readdirSync("/proc").some((entry) => { + if (!/^\d+$/.test(entry) || Number(entry) === process.pid) return false; + try { return path.resolve(readlinkSync(path.join("/proc", entry, "exe"))) === executable; } + catch { return false; } + }); + } catch { return false; } } export async function readInstallState(filePath = getPaths(resolveZdpRoot()).installState): Promise { @@ -216,6 +239,9 @@ async function backupVendor(source: string, version: string, backupRoot: string) const target = path.join(directory, "app.asar"); await mkdir(directory, {recursive: true}); if (!await exists(target)) await copyFile(source, target); + const unpacked = `${source}.unpacked`; + const backupUnpacked = path.join(directory, "app.asar.unpacked"); + if (await exists(unpacked) && !await exists(backupUnpacked)) await cp(unpacked, backupUnpacked, {recursive: true}); await writeJsonAtomic(path.join(directory, "metadata.json"), {version, sha256: hash, backedUpAt: new Date().toISOString()}); return target; } diff --git a/src/cli/shortcut.ts b/src/cli/shortcut.ts index 57e4c2a..bfeaf4e 100644 --- a/src/cli/shortcut.ts +++ b/src/cli/shortcut.ts @@ -1,4 +1,6 @@ import {spawn} from "node:child_process"; +import {mkdir, readFile, writeFile} from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; export type ShortcutState = { @@ -7,13 +9,32 @@ export type ShortcutState = { originalArguments: string; originalWorkingDirectory: string; originalIconLocation: string; + originalContents?: string; }; export function defaultShortcutPath(): string { + if (process.platform !== "win32") return path.join(process.env.XDG_DATA_HOME ?? path.join(os.homedir(), ".local", "share"), "applications", "zcode.desktop"); return path.join(process.env.APPDATA ?? "", "Microsoft", "Windows", "Start Menu", "Programs", "ZCode.lnk"); } export async function readShortcut(shortcutPath = defaultShortcutPath()): Promise { + if (process.platform !== "win32") { + const originalContents = await readFile(shortcutPath, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return undefined; + throw error; + }); + if (originalContents === undefined) return undefined; + const exec = /^Exec=(.*)$/m.exec(originalContents)?.[1]?.trim() ?? ""; + const parsed = parseDesktopExec(exec); + return { + path: shortcutPath, + originalTarget: parsed.target, + originalArguments: parsed.arguments, + originalWorkingDirectory: /^Path=(.*)$/m.exec(originalContents)?.[1]?.trim() ?? "", + originalIconLocation: /^Icon=(.*)$/m.exec(originalContents)?.[1]?.trim() ?? "", + originalContents, + }; + } const script = ` $ErrorActionPreference='Stop' $shortcutPath=$env:ZDP_SHORTCUT_PATH @@ -30,6 +51,16 @@ $sc=$ws.CreateShortcut($shortcutPath) } export async function writeLauncherShortcut(shortcutPath: string, launcher: string, workingDirectory: string, iconPath: string): Promise { + if (process.platform !== "win32") { + const existing = await readFile(shortcutPath, "utf8").catch(() => "[Desktop Entry]\nName=ZCode\nType=Application\nTerminal=false\nCategories=Development;\nMimeType=x-scheme-handler/zcode;\n"); + const exec = `Exec=${quoteDesktopValue(launcher)} %U`; + const tryExec = `TryExec=${launcher}`; + let contents = /^Exec=.*$/m.test(existing) ? existing.replace(/^Exec=.*$/m, exec) : `${existing.trimEnd()}\n${exec}\n`; + contents = /^TryExec=.*$/m.test(contents) ? contents.replace(/^TryExec=.*$/m, tryExec) : `${contents.trimEnd()}\n${tryExec}\n`; + await mkdir(path.dirname(shortcutPath), {recursive: true}); + await writeFile(shortcutPath, contents, "utf8"); + return; + } const script = ` $ErrorActionPreference='Stop' $ws=New-Object -ComObject WScript.Shell @@ -50,6 +81,12 @@ $sc.Save() } export async function restoreShortcut(state: ShortcutState): Promise { + if (process.platform !== "win32") { + if (state.originalContents === undefined) throw new Error("The original desktop entry was not preserved"); + await mkdir(path.dirname(state.path), {recursive: true}); + await writeFile(state.path, state.originalContents, "utf8"); + return; + } const script = ` $ErrorActionPreference='Stop' $ws=New-Object -ComObject WScript.Shell @@ -70,6 +107,18 @@ $sc.Save() if (result.code !== 0) throw new Error(`Failed to restore ZCode shortcut: ${result.stderr || result.stdout}`); } +function quoteDesktopValue(value: string): string { + return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`; +} + +function parseDesktopExec(value: string): {target: string; arguments: string} { + if (!value) return {target: "", arguments: ""}; + const quoted = /^"((?:\\.|[^"])*)"\s*(.*)$/.exec(value); + if (quoted) return {target: quoted[1]!.replace(/\\([\\"])/g, "$1"), arguments: quoted[2] ?? ""}; + const plain = /^(\S+)\s*(.*)$/.exec(value); + return {target: plain?.[1] ?? "", arguments: plain?.[2] ?? ""}; +} + async function powershell(script: string, env: Record, allowNonzero = false): Promise<{code: number; stdout: string; stderr: string}> { const encoded = Buffer.from(script, "utf16le").toString("base64"); return new Promise((resolve, reject) => { diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index d188ef2..6a9ec0c 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -96,6 +96,7 @@ export const installStateSchema = z.object({ originalArguments: z.string(), originalWorkingDirectory: z.string(), originalIconLocation: z.string(), + originalContents: z.string().optional(), }).optional(), fuses: z.record(z.string(), z.string()), }).strict(); diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 7bb2a9d..f65c43e 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -6,12 +6,29 @@ import {mkdir, mkdtemp, readFile, rm, stat, writeFile} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import {doctor, installOrRepair, uninstall} from "../src/cli/installer.ts"; +import {DEFAULT_ZCODE_ROOT, ZDP_EXECUTABLE, ZDP_LAUNCHER_EXECUTABLE, getZCodeLayout} from "../src/shared/constants.ts"; const temporaryDirectories: string[] = []; const ELECTRON_FUSE_SENTINEL = "dL7pKGdnNz796PbbjQWNKmHXBZaB9tsX"; afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, {recursive: true, force: true}))); }); -describe("Windows loader installer", () => { +describe("loader installer", () => { + test("keeps platform-specific executable names and layout", () => { + const layout = getZCodeLayout("/zcode-root"); + if (process.platform === "win32") { + expect(DEFAULT_ZCODE_ROOT).toContain("Programs"); + expect(ZDP_EXECUTABLE).toBe("zdp.exe"); + expect(ZDP_LAUNCHER_EXECUTABLE).toBe("zdp-launcher.exe"); + expect(layout.electronExecutable).toEndWith("ZCode.exe"); + expect(layout.launchArgs).toEqual([]); + } else { + expect(ZDP_EXECUTABLE).toBe("zdp"); + expect(ZDP_LAUNCHER_EXECUTABLE).toBe("zdp-launcher"); + expect(layout.electronExecutable).toEndWith("zcode"); + expect(layout.launchArgs).toEqual(["--no-sandbox"]); + } + }); + test("installs, repairs idempotently, and restores the exact vendor ASAR", async () => { const fixture = await mkdtemp(path.join(os.tmpdir(), "zdp-installer-")); temporaryDirectories.push(fixture); @@ -20,7 +37,7 @@ describe("Windows loader installer", () => { const zdpRoot = path.join(fixture, "zdp"); await mkdir(resources, {recursive: true}); await mkdir(path.join(zdpRoot, "runtime"), {recursive: true}); - await writeFile(path.join(zcodeRoot, "ZCode.exe"), electronFuseFixture()); + await writeFile(getZCodeLayout(zcodeRoot).electronExecutable, electronFuseFixture()); const vendorApp = path.join(fixture, "vendor-app"); await mkdir(path.join(vendorApp, "out", "main"), {recursive: true}); await writeFile(path.join(vendorApp, "package.json"), JSON.stringify({ @@ -31,6 +48,8 @@ describe("Windows loader installer", () => { })); await writeFile(path.join(vendorApp, "out", "main", "index.js"), "export {};\n"); await createPackage(vendorApp, path.join(resources, "app.asar")); + await mkdir(path.join(resources, "app.asar.unpacked"), {recursive: true}); + await writeFile(path.join(resources, "app.asar.unpacked", "native.node"), "native"); await writeFile(path.join(zdpRoot, "runtime", "bootstrap.mjs"), "export {};\n"); await writeFile(path.join(zdpRoot, "runtime", "current.json"), '{"version":"test"}\n'); const originalHash = await sha256(path.join(resources, "app.asar")); @@ -39,6 +58,7 @@ describe("Windows loader installer", () => { expect(installed.installed).toBe(true); expect(await exists(path.join(resources, "app.asar"))).toBe(false); expect(await exists(path.join(resources, "zcode.original.asar"))).toBe(true); + expect(await readFile(path.join(resources, "zcode.original.asar.unpacked", "native.node"), "utf8")).toBe("native"); expect(JSON.parse(await readFile(path.join(resources, "app", "package.json"), "utf8")).zdpLoader).toBe(true); expect(await sha256(path.join(resources, "zcode.original.asar"))).toBe(originalHash); @@ -49,6 +69,7 @@ describe("Windows loader installer", () => { await uninstall(zcodeRoot, false, {skipProcessCheck: true, manageShortcut: false, stateRoot: zdpRoot}); expect(await exists(path.join(resources, "app"))).toBe(false); expect(await exists(path.join(resources, "zcode.original.asar"))).toBe(false); + expect(await readFile(path.join(resources, "app.asar.unpacked", "native.node"), "utf8")).toBe("native"); expect(await sha256(path.join(resources, "app.asar"))).toBe(originalHash); }, 60_000); }); From 3c6a4f5e1294994c92c089c9ba73711f83aa95b2 Mon Sep 17 00:00:00 2001 From: elton costa Date: Sun, 13 Sep 2026 18:19:02 -0300 Subject: [PATCH 3/4] fix: guard host updates across platforms --- src/cli/host-update-apply.ts | 16 +++++++++------- src/host/host-updater.ts | 15 ++++++++------- tests/host-update-apply.test.ts | 3 ++- tests/host-updater.test.ts | 13 +++++++++++-- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/cli/host-update-apply.ts b/src/cli/host-update-apply.ts index bff7274..a46cfe9 100644 --- a/src/cli/host-update-apply.ts +++ b/src/cli/host-update-apply.ts @@ -2,7 +2,7 @@ import {copyFile, mkdir, readFile, rm, stat} from "node:fs/promises"; import path from "node:path"; import {spawn} from "node:child_process"; import {writeJsonAtomic} from "../shared/atomic.ts"; -import {getPaths} from "../shared/constants.ts"; +import {getPaths, getZCodeLayout} from "../shared/constants.ts"; import {hostUpdateTransactionSchema, releaseManifestSchema, safeManagedPath, verifyManagedFiles, type ReleaseManifest} from "../host/host-updater.ts"; import {installOrRepair, isZCodeRunning} from "./installer.ts"; @@ -53,8 +53,9 @@ export async function applyHostUpdate(parentPid: number, root: string, dependenc await writeJsonAtomic(paths.hostUpdateState, {...transaction, phase: "failed", error: errorText(error)}).catch(() => undefined); await repair(transaction.zcodeRoot, {skipProcessCheck: true, stateRoot: root}).catch(() => undefined); } - if (dependencies.launch) dependencies.launch(path.join(transaction.zcodeRoot, "ZCode.exe")); - else spawn(path.join(transaction.zcodeRoot, "ZCode.exe"), [], {detached: true, stdio: "ignore", windowsHide: true}).unref(); + const layout = getZCodeLayout(transaction.zcodeRoot); + if (dependencies.launch) dependencies.launch(layout.launchExecutable); + else spawn(layout.launchExecutable, layout.launchArgs, {detached: true, stdio: "ignore", windowsHide: true}).unref(); } async function backupManaged(root: string, backup: string, manifest: ReleaseManifest): Promise { @@ -69,10 +70,10 @@ async function backupManaged(root: string, backup: string, manifest: ReleaseMani } async function installManaged(root: string, staging: string, current: ReleaseManifest, incoming: ReleaseManifest): Promise { - const incomingPaths = new Set(incoming.files.map((file) => safeManagedPath(file.path).toLowerCase())); + const incomingPaths = new Set(incoming.files.map((file) => managedPathKey(safeManagedPath(file.path)))); for (const file of current.files) { const relative = safeManagedPath(file.path); - if (!incomingPaths.has(relative.toLowerCase())) await rm(path.join(root, ...relative.split("/")), {force: true}); + if (!incomingPaths.has(managedPathKey(relative))) await rm(path.join(root, ...relative.split("/")), {force: true}); } for (const file of incoming.files) { const relative = safeManagedPath(file.path); @@ -83,10 +84,10 @@ async function installManaged(root: string, staging: string, current: ReleaseMan } async function restoreManaged(root: string, backup: string, current: ReleaseManifest, incoming: ReleaseManifest): Promise { - const currentPaths = new Set(current.files.map((file) => safeManagedPath(file.path).toLowerCase())); + const currentPaths = new Set(current.files.map((file) => managedPathKey(safeManagedPath(file.path)))); for (const file of incoming.files) { const relative = safeManagedPath(file.path); - if (!currentPaths.has(relative.toLowerCase())) await rm(path.join(root, ...relative.split("/")), {force: true}); + if (!currentPaths.has(managedPathKey(relative))) await rm(path.join(root, ...relative.split("/")), {force: true}); } for (const file of current.files) { const relative = safeManagedPath(file.path); @@ -120,6 +121,7 @@ function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); retu function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function exists(value: string): Promise { return stat(value).then(() => true).catch(() => false); } function errorText(value: unknown): string { return value instanceof Error ? value.message : String(value); } +function managedPathKey(value: string): string { return process.platform === "win32" ? value.toLowerCase() : value; } function assertChild(root: string, target: string): void { const relative = path.relative(path.resolve(root), path.resolve(target)); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Update payload is outside host staging: ${target}`); diff --git a/src/host/host-updater.ts b/src/host/host-updater.ts index 2667458..cc7ab6d 100644 --- a/src/host/host-updater.ts +++ b/src/host/host-updater.ts @@ -1,13 +1,13 @@ import {createHash, randomUUID} from "node:crypto"; import {createReadStream} from "node:fs"; -import {copyFile, mkdir, readFile, readdir, rm, stat, writeFile} from "node:fs/promises"; +import {chmod, copyFile, mkdir, readFile, readdir, rm, stat, writeFile} from "node:fs/promises"; import {tmpdir} from "node:os"; import path from "node:path"; import {spawn} from "node:child_process"; import semver from "semver"; import {z} from "zod"; import {writeJsonAtomic} from "../shared/atomic.ts"; -import {HOST_UPDATE_URL, HOST_VERSION, getPaths} from "../shared/constants.ts"; +import {HOST_UPDATE_URL, HOST_VERSION, ZDP_EXECUTABLE, getPaths} from "../shared/constants.ts"; import type {JsonLogger} from "../shared/logger.ts"; import type {HostUpdateStatus} from "../shared/schemas.ts"; import {assertRemoteUrl, extractArchive, fetchRemote, readBoundedResponse} from "./extension-updater.ts"; @@ -82,7 +82,7 @@ export class HostUpdater { async initialize(): Promise { await mkdir(this.#paths.hostUpdateStaging, {recursive: true}); await cleanupOldHelpers(); - const installable = await this.#installedManifest().then(() => true).catch(() => false); + const installable = process.platform === "win32" && await this.#installedManifest().then(() => true).catch(() => false); this.#status = {...this.#status, installable}; await this.#recoverStatus(); } @@ -146,8 +146,9 @@ export class HostUpdater { stagingRoot, zcodeRoot: this.#options.zcodeRoot, releaseUrl: release.releaseUrl, }; await writeJsonAtomic(this.#paths.hostUpdateState, transaction); - const helper = path.join(tmpdir(), `zdp-update-${randomUUID()}.exe`); - await copyFile(path.join(this.#paths.bin, "zdp.exe"), helper); + const helper = path.join(tmpdir(), `zdp-update-${randomUUID()}${process.platform === "win32" ? ".exe" : ""}`); + await copyFile(path.join(this.#paths.bin, ZDP_EXECUTABLE), helper); + if (process.platform !== "win32") await chmod(helper, 0o755); const helperArgs = ["apply-update", "--parent", String(parentPid), "--root", this.#options.root, "--zcode", this.#options.zcodeRoot]; if (this.#options.launchHelper) this.#options.launchHelper(helper, helperArgs); else { @@ -186,7 +187,7 @@ export async function verifyManagedFiles(root: string, manifest: ReleaseManifest const seen = new Set(); for (const file of manifest.files) { const relative = safeManagedPath(file.path); - const key = relative.toLowerCase(); + const key = process.platform === "win32" ? relative.toLowerCase() : relative; if (seen.has(key)) throw new Error(`Duplicate managed file: ${relative}`); seen.add(key); const target = path.join(root, ...relative.split("/")); @@ -221,7 +222,7 @@ function hash(value: Buffer): string { return createHash("sha256").update(value) async function exists(value: string): Promise { return stat(value).then(() => true).catch(() => false); } async function cleanupOldHelpers(): Promise { const entries = await readdir(tmpdir(), {withFileTypes: true}).catch(() => []); - await Promise.all(entries.filter((entry) => entry.isFile() && /^zdp-update-[0-9a-f-]+\.exe$/i.test(entry.name)) + await Promise.all(entries.filter((entry) => entry.isFile() && /^zdp-update-[0-9a-f-]+(?:\.exe)?$/i.test(entry.name)) .map((entry) => rm(path.join(tmpdir(), entry.name), {force: true}).catch(() => undefined))); } function errorText(value: unknown): string { return value instanceof Error ? value.message : String(value); } diff --git a/tests/host-update-apply.test.ts b/tests/host-update-apply.test.ts index c5842cb..2dfb37d 100644 --- a/tests/host-update-apply.test.ts +++ b/tests/host-update-apply.test.ts @@ -5,6 +5,7 @@ import os from "node:os"; import path from "node:path"; import {applyHostUpdate} from "../src/cli/host-update-apply.ts"; import type {ReleaseManifest} from "../src/host/host-updater.ts"; +import {getZCodeLayout} from "../src/shared/constants.ts"; const roots: string[] = []; afterEach(async () => Promise.all(roots.splice(0).map((root) => rm(root, {recursive: true, force: true})))); @@ -26,7 +27,7 @@ describe("host update apply transaction", () => { expect(await readFile(path.join(fixture.root, "unknown.txt"), "utf8")).toBe("unknown"); expect(JSON.parse(await readFile(path.join(fixture.root, "runtime", "current.json"), "utf8"))).toEqual({version: "0.3.7", previousVersion: "0.3.6"}); expect(repairedVersion).toBe("0.3.7"); - expect(launched).toBe(path.join(fixture.zcodeRoot, "ZCode.exe")); + expect(launched).toBe(getZCodeLayout(fixture.zcodeRoot).launchExecutable); expect(await exists(path.join(fixture.root, "data", "host-update.json"))).toBe(false); expect(await readFile(path.join(fixture.root, "data", ".host-update", "backup", "0.3.6", "bin", "zdp.exe"), "utf8")).toBe("old"); }); diff --git a/tests/host-updater.test.ts b/tests/host-updater.test.ts index 54169cb..ac4c0b1 100644 --- a/tests/host-updater.test.ts +++ b/tests/host-updater.test.ts @@ -35,6 +35,13 @@ describe("host updater", () => { await writeFile(path.join(root, "bin", "zdp.exe"), "old helper"); await writeFile(path.join(root, "data", "keep.txt"), "preserved"); await writeManifest(root, HOST_VERSION, [{path: "bin/zdp.exe", bytes: Buffer.from("old helper")}]); + if (process.platform !== "win32") { + const updater = createUpdater(root, "http://localhost/unused"); + await updater.initialize(); + expect(updater.status().installable).toBe(false); + updater.dispose(); + return; + } const archiveStage = path.join(root, "archive-stage", "zcode-extensions"); const incoming = Buffer.from("new helper"); @@ -105,7 +112,9 @@ async function writeManifest(root: string, version: string, files: Array<{path: async function tempRoot(prefix: string): Promise { const root = await mkdtemp(path.join(os.tmpdir(), prefix)); roots.push(root); return root; } function hash(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); } async function compress(source: string, destination: string): Promise { - const command = `Compress-Archive -LiteralPath '${source.replaceAll("'", "''")}' -DestinationPath '${destination.replaceAll("'", "''")}' -Force`; - const child = Bun.spawn(["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {stdout: "ignore", stderr: "inherit"}); + const command = process.platform === "win32" + ? ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", `Compress-Archive -LiteralPath '${source.replaceAll("'", "''")}' -DestinationPath '${destination.replaceAll("'", "''")}' -Force`] + : ["bsdtar", "-a", "-cf", destination, path.basename(source)]; + const child = Bun.spawn(command, {cwd: process.platform === "win32" ? undefined : path.dirname(source), stdout: "ignore", stderr: "inherit"}); if (await child.exited !== 0) throw new Error("Could not create host updater test archive"); } From cedb1cadc0997063ee29c94dfb707dc12fd69eca Mon Sep 17 00:00:00 2001 From: elton costa Date: Sun, 13 Sep 2026 18:19:29 -0300 Subject: [PATCH 4/4] test: run updater fixtures on Linux --- tests/extension-updater.test.ts | 7 ++++-- tests/task-service.test.ts | 40 +++++++++++++++++---------------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/tests/extension-updater.test.ts b/tests/extension-updater.test.ts index d775e19..696d924 100644 --- a/tests/extension-updater.test.ts +++ b/tests/extension-updater.test.ts @@ -271,8 +271,11 @@ function manifestFor(version: string): PluginManifest { } async function compress(source: string, destination: string): Promise { - const command = `Compress-Archive -LiteralPath ${quote(source)} -DestinationPath ${quote(destination)} -CompressionLevel Optimal -Force`; - const child = Bun.spawn(["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], { + const command = process.platform === "win32" + ? ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", `Compress-Archive -LiteralPath ${quote(source)} -DestinationPath ${quote(destination)} -CompressionLevel Optimal -Force`] + : ["bsdtar", "-a", "-cf", destination, path.basename(source)]; + const child = Bun.spawn(command, { + cwd: process.platform === "win32" ? undefined : path.dirname(source), stdout: "ignore", stderr: "inherit", }); diff --git a/tests/task-service.test.ts b/tests/task-service.test.ts index 0e4b323..4e27875 100644 --- a/tests/task-service.test.ts +++ b/tests/task-service.test.ts @@ -5,12 +5,14 @@ import type {DesktopServiceConnection, DesktopServicePort} from "../src/protocol import {TaskService} from "../src/protocol/task-service.ts"; import {JsonLogger} from "../src/shared/logger.ts"; +const workspacePath = path.join(os.tmpdir(), "project"); + describe("native desktop task service", () => { test("creates, titles, and completes a persistent native sidebar task", async () => { const harness = createHarness(); const service = harness.service; const handle = await service.run({ - workspacePath: "D:\\project", + workspacePath, prompt: "Review open work", title: "⏰ Morning review", mode: "plan", @@ -18,11 +20,11 @@ describe("native desktop task service", () => { expect(handle.sessionId).toBe("session-1"); expect(harness.created[0]).toMatchObject({ - workspacePath: "D:\\project", + workspacePath, mode: "plan", }); expect(harness.sent[0]).toMatchObject({ - workspacePath: "D:\\project", + workspacePath, sessionId: "session-1", content: "Review open work", }); @@ -37,7 +39,7 @@ describe("native desktop task service", () => { expect(harness.broadcasts[0]).toMatchObject({ channel: "bots:task", payload: { - workspacePath: "D:\\project", + workspacePath, taskId: "session-1", event: "created", task: {title: "⏰ Morning review"}, @@ -59,23 +61,23 @@ describe("native desktop task service", () => { const harness = createHarness(); await harness.service.ensureVisible({ sessionId: "session-old", - workspacePath: "D:\\project", + workspacePath, title: "⏰ Legacy review", }); expect(harness.resumed[0]).toMatchObject({ sessionId: "session-old", - workspacePath: "D:\\project", + workspacePath, broadcastSnapshot: true, }); expect(harness.created[0]).toMatchObject({ draftSessionId: "session-old", - workspacePath: "D:\\project", + workspacePath, mode: "plan", }); expect(harness.renamed.at(-1)).toMatchObject({taskId: "session-old", title: "⏰ Legacy review"}); expect(harness.broadcasts[0]?.payload.event).toBe("created"); - const run = await harness.service.run({workspacePath: "D:\\project", prompt: "Ask if needed", mode: "plan"}); + const run = await harness.service.run({workspacePath, prompt: "Ask if needed", mode: "plan"}); harness.emit({type: "session.event", event: {type: "permission.requested", payload: {inputId: harness.sent[0]!.inputId}}}); harness.emit({ type: "session.event", @@ -88,13 +90,13 @@ describe("native desktop task service", () => { test("uses the ZCode 3.4.2 V4 task facade and accepts direct terminal events", async () => { const harness = createHarness("3.4.2"); const handle = await harness.service.run({ - workspacePath: "D:\\project", + workspacePath, prompt: "Reply with ready", title: "V4 task", mode: "plan", }); - expect(harness.created[0]).toMatchObject({workspacePath: "D:\\project", mode: "plan", v4Create: true}); + expect(harness.created[0]).toMatchObject({workspacePath, mode: "plan", v4Create: true}); expect(harness.sentVia).toEqual(["task"]); expect(harness.sent[0]).toMatchObject({ taskId: "session-1", @@ -106,7 +108,7 @@ describe("native desktop task service", () => { channel: "zcode-task", event: "onDynamicTaskEvent", argument: { - workspacePath: "D:\\project", + workspacePath, taskId: "session-1", deliveryKind: "desktop-continuous", }, @@ -121,7 +123,7 @@ describe("native desktop task service", () => { test("returns the V4 run handle immediately after prompt acceptance", async () => { const harness = createHarness("3.4.2", true); const handle = await Promise.race([ - harness.service.run({workspacePath: "D:\\project", prompt: "Reply with ready", title: "V4 task", mode: "plan"}), + harness.service.run({workspacePath, prompt: "Reply with ready", title: "V4 task", mode: "plan"}), new Promise((_, reject) => setTimeout(() => reject(new Error("run handle did not return")), 100)), ]); @@ -134,14 +136,14 @@ describe("native desktop task service", () => { test("maps direct V4 interaction, failure, and cancellation outcomes", async () => { const attention = createHarness("3.4.2"); - const attentionRun = await attention.service.run({workspacePath: "D:\\project", prompt: "Ask", mode: "plan"}); + const attentionRun = await attention.service.run({workspacePath, prompt: "Ask", mode: "plan"}); attention.emit({type: "permission_request"}); attention.emit({type: "task_complete", inputId: "trace-1", stopReason: "complete"}); await expect(attentionRun.completion).resolves.toEqual({sessionId: "session-1", status: "needs_attention"}); await attention.service.shutdown(); const failed = createHarness("3.4.2"); - const failedRun = await failed.service.run({workspacePath: "D:\\project", prompt: "Fail", mode: "plan"}); + const failedRun = await failed.service.run({workspacePath, prompt: "Fail", mode: "plan"}); failed.emit({type: "task_error", inputId: "trace-1", error: "provider unavailable"}); await expect(failedRun.completion).resolves.toEqual({ sessionId: "session-1", @@ -151,15 +153,15 @@ describe("native desktop task service", () => { await failed.service.shutdown(); const cancelled = createHarness("3.4.2"); - const cancelledRun = await cancelled.service.run({workspacePath: "D:\\project", prompt: "Wait", mode: "plan"}); + const cancelledRun = await cancelled.service.run({workspacePath, prompt: "Wait", mode: "plan"}); cancelled.emit({type: "task_complete", inputId: "trace-1", stopReason: "cancelled"}); await expect(cancelledRun.completion).resolves.toEqual({sessionId: "session-1", status: "cancelled"}); const stopped = createHarness("3.4.2"); - const stoppedRun = await stopped.service.run({workspacePath: "D:\\project", prompt: "Wait", mode: "plan"}); + const stoppedRun = await stopped.service.run({workspacePath, prompt: "Wait", mode: "plan"}); await stoppedRun.stop(); await expect(stoppedRun.completion).resolves.toEqual({sessionId: "session-1", status: "cancelled"}); - expect(stopped.stopped[0]).toMatchObject({sessionId: "session-1", workspacePath: "D:\\project"}); + expect(stopped.stopped[0]).toMatchObject({sessionId: "session-1", workspacePath}); await cancelled.service.shutdown(); await stopped.service.shutdown(); }); @@ -167,7 +169,7 @@ describe("native desktop task service", () => { test("stops and cleans up a V4 run after its timeout", async () => { const harness = createHarness("3.4.2"); const run = await harness.service.run({ - workspacePath: "D:\\project", + workspacePath, prompt: "Wait", mode: "plan", timeoutMs: 5, @@ -178,7 +180,7 @@ describe("native desktop task service", () => { status: "timed_out", error: "Task exceeded 5 ms", }); - expect(harness.stopped[0]).toMatchObject({sessionId: "session-1", workspacePath: "D:\\project"}); + expect(harness.stopped[0]).toMatchObject({sessionId: "session-1", workspacePath}); expect(harness.subscriptionDisposed).toBe(1); await harness.service.shutdown(); });