From fd22a48118d8b96d312d723e758fa09b492dee03 Mon Sep 17 00:00:00 2001 From: elton costa Date: Sun, 13 Sep 2026 18:17:54 -0300 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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(); }); From 149f9d3dd3ea225fe550de4b346369fb459a9e68 Mon Sep 17 00:00:00 2001 From: elton costa Date: Sun, 13 Sep 2026 20:07:40 -0300 Subject: [PATCH 5/5] fix: remove generated desktop entries on uninstall Write updater test archives with an in-process ZIP writer so Linux tests no longer shell out to bsdtar, and make the in-process archive the only fixture source on every platform. Record whether the installer generated the ZCode shortcut, remove that entry on uninstall, and fall back to matching the stored target against the launcher path for install states written before this change. Relax the install state schema so a generated shortcut can carry an empty original target, and cover fresh installs, legacy states without shortcut data, and restored pre-existing desktop entries in the installer tests. --- src/cli/installer.ts | 18 +++-- src/cli/shortcut.ts | 51 ++++++++++++- src/shared/schemas.ts | 3 +- tests/extension-updater.test.ts | 32 +++++---- tests/fixtures/archive.ts | 98 +++++++++++++++++++++++++ tests/host-updater.test.ts | 7 +- tests/installer.test.ts | 124 ++++++++++++++++++++++++++------ 7 files changed, 284 insertions(+), 49 deletions(-) create mode 100644 tests/fixtures/archive.ts diff --git a/src/cli/installer.ts b/src/cli/installer.ts index 092ef91..739c2d4 100644 --- a/src/cli/installer.ts +++ b/src/cli/installer.ts @@ -8,7 +8,7 @@ import {FuseState, FuseV1Options, getCurrentFuseWire} from "@electron/fuses"; 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"; +import {captureShortcut, defaultShortcutPath, readShortcut, removeLauncherShortcut, writeLauncherShortcut} from "./shortcut.ts"; type VendorPackage = {name?: string; productName?: string; version: string; type?: string; main?: string}; @@ -56,8 +56,9 @@ export async function doctor(zcodeRoot = DEFAULT_ZCODE_ROOT, zdpRoot = resolveZd const loaderPresent = await isManagedLoader(path.join(resources, "app")); const runtimePresent = await exists(paths.runtimeBootstrap) && await exists(paths.runtimeCurrent); const installState = await readInstallState(paths.installState); + const launcher = path.join(paths.bin, ZDP_LAUNCHER_EXECUTABLE); const shortcut = await readShortcut().catch(() => undefined); - const shortcutManaged = Boolean(shortcut && path.resolve(shortcut.originalTarget) === path.resolve(path.join(paths.bin, ZDP_LAUNCHER_EXECUTABLE))); + const shortcutManaged = Boolean(shortcut && path.resolve(shortcut.originalTarget) === path.resolve(launcher)); return { ok: errors.length === 0 && runtimePresent, installed: loaderPresent && Boolean(await exists(originalAsar)), @@ -112,9 +113,11 @@ export async function installOrRepair(zcodeRoot = DEFAULT_ZCODE_ROOT, options: { const existingState = await readInstallState(paths.installState); 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_EXECUTABLE); - if (manageShortcut && await exists(launcher)) await writeLauncherShortcut(shortcutPath, launcher, root, layout.electronExecutable); + const shortcut = manageShortcut && await exists(launcher) + ? existingState?.shortcut ?? await captureShortcut(shortcutPath) + : undefined; + if (shortcut) await writeLauncherShortcut(shortcutPath, launcher, root, layout.electronExecutable); const wire = await getCurrentFuseWire(layout.electronExecutable); const now = new Date().toISOString(); const state: InstallState = installStateSchema.parse({ @@ -126,7 +129,7 @@ export async function installOrRepair(zcodeRoot = DEFAULT_ZCODE_ROOT, options: { installedAt: existingState?.installedAt ?? now, ...(existingState ? {repairedAt: now} : {}), loaderVersion: options.loaderVersion ?? HOST_VERSION, - ...(originalShortcut ? {shortcut: originalShortcut} : {}), + ...(shortcut ? {shortcut} : {}), fuses: fuseReport(wire as unknown as Record), }); await writeJsonAtomic(paths.installState, state); @@ -159,7 +162,10 @@ export async function uninstall(zcodeRoot = DEFAULT_ZCODE_ROOT, purgeData = fals await rm(originalUnpacked, {recursive: true, force: true}); } const state = await readInstallState(paths.installState); - if ((options.manageShortcut ?? true) && state?.shortcut) await restoreShortcut(state.shortcut); + if (options.manageShortcut ?? true) { + const launcher = path.join(paths.bin, ZDP_LAUNCHER_EXECUTABLE); + await removeLauncherShortcut(state?.shortcut, launcher, state?.shortcut?.path ?? defaultShortcutPath()); + } await rm(paths.installState, {force: true}); await rm(paths.bootState, {force: true}); if (purgeData) await rm(paths.data, {recursive: true, force: true}); diff --git a/src/cli/shortcut.ts b/src/cli/shortcut.ts index bfeaf4e..629079a 100644 --- a/src/cli/shortcut.ts +++ b/src/cli/shortcut.ts @@ -1,5 +1,5 @@ import {spawn} from "node:child_process"; -import {mkdir, readFile, writeFile} from "node:fs/promises"; +import {mkdir, readFile, rm, writeFile} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -10,6 +10,7 @@ export type ShortcutState = { originalWorkingDirectory: string; originalIconLocation: string; originalContents?: string; + created?: boolean; }; export function defaultShortcutPath(): string { @@ -80,6 +81,54 @@ $sc.Save() if (result.code !== 0) throw new Error(`Failed to create ZDP shortcut: ${result.stderr || result.stdout}`); } +export async function captureShortcut(shortcutPath: string): Promise { + const existing = await readShortcut(shortcutPath); + return existing ? {...existing, created: false} : { + path: shortcutPath, + originalTarget: "", + originalArguments: "", + originalWorkingDirectory: "", + originalIconLocation: "", + created: true, + }; +} + +export async function removeLauncherShortcut(state: ShortcutState | undefined, launcher: string, shortcutPath = defaultShortcutPath()): Promise { + if (state && !wasCreatedByLauncher(state, launcher)) { + await restoreShortcut(state); + return; + } + const pathValue = state?.path ?? shortcutPath; + if (!state && !await isLauncherEntry(pathValue, launcher)) return; + await removeLauncherEntry(pathValue); +} + +function wasCreatedByLauncher(state: ShortcutState, launcher: string): boolean { + if (state.created !== undefined) return state.created; + if (state.originalContents !== undefined) return false; + return path.resolve(state.originalTarget) === path.resolve(launcher); +} + +async function isLauncherEntry(shortcutPath: string, launcher: string): Promise { + const existing = await readShortcut(shortcutPath).catch(() => undefined); + return Boolean(existing && path.resolve(existing.originalTarget) === path.resolve(launcher)); +} + +async function removeLauncherEntry(shortcutPath: string): Promise { + if (process.platform !== "win32") { + await rm(shortcutPath, {force: true}); + return; + } + const script = ` +$ErrorActionPreference='Stop' +$path=$env:ZDP_SHORTCUT_PATH +if(-not (Test-Path -LiteralPath $path)){ exit 0 } +Remove-Item -LiteralPath $path -Force +`; + const result = await powershell(script, {ZDP_SHORTCUT_PATH: shortcutPath}); + if (result.code !== 0) throw new Error(`Failed to remove ZDP shortcut: ${result.stderr || result.stdout}`); +} + 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"); diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 6a9ec0c..e14c31b 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -92,11 +92,12 @@ export const installStateSchema = z.object({ loaderVersion: z.string().min(1), shortcut: z.object({ path: z.string().min(1), - originalTarget: z.string().min(1), + originalTarget: z.string(), originalArguments: z.string(), originalWorkingDirectory: z.string(), originalIconLocation: z.string(), originalContents: z.string().optional(), + created: z.boolean().optional(), }).optional(), fuses: z.record(z.string(), z.string()), }).strict(); diff --git a/tests/extension-updater.test.ts b/tests/extension-updater.test.ts index 696d924..e7ae3fb 100644 --- a/tests/extension-updater.test.ts +++ b/tests/extension-updater.test.ts @@ -3,10 +3,11 @@ import {afterEach, describe, expect, test} from "bun:test"; import {mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import {ExtensionUpdater, moveOrCopyForUpdate, renameWithRetry, validateArchiveEntryPath} from "../src/host/extension-updater.ts"; +import {ExtensionUpdater, extractArchive, moveOrCopyForUpdate, renameWithRetry, validateArchiveEntryPath} from "../src/host/extension-updater.ts"; import {readExtensionManifest} from "../src/host/extension-bundle.ts"; import {JsonLogger} from "../src/shared/logger.ts"; import type {PluginManifest} from "../src/shared/schemas.ts"; +import {writeZipFromDirectory} from "./fixtures/archive.ts"; const roots: string[] = []; const servers: Bun.Server[] = []; @@ -123,6 +124,21 @@ describe("extension updater", () => { restarted.dispose(); }); + test("extracts the in-process test archives with the production reader", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "zdp-updater-fixture-")); + roots.push(root); + const stage = path.join(root, "archive-stage", "scheduler"); + await writeBundle(stage, "0.1.3", "new"); + const archive = path.join(root, "scheduler.zip"); + await compress(stage, archive); + + const extracted = path.join(root, "extracted"); + await mkdir(extracted, {recursive: true}); + await extractArchive(archive, extracted); + expect((await readExtensionManifest(path.join(extracted, "scheduler"))).version).toBe("0.1.3"); + expect(await readFile(path.join(extracted, "scheduler", "dist", "main.cjs"), "utf8")).toContain("new"); + }); + test("rejects archive paths that can escape or alias the extraction root", () => { for (const value of [ "../escape", @@ -271,18 +287,6 @@ function manifestFor(version: string): PluginManifest { } async function compress(source: string, destination: string): Promise { - 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", - }); - if (await child.exited !== 0) throw new Error("Could not create updater test archive"); + await writeZipFromDirectory(source, destination); expect((await stat(destination)).isFile()).toBe(true); } - -function quote(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} diff --git a/tests/fixtures/archive.ts b/tests/fixtures/archive.ts new file mode 100644 index 0000000..662c691 --- /dev/null +++ b/tests/fixtures/archive.ts @@ -0,0 +1,98 @@ +import {deflateRawSync} from "node:zlib"; +import {readFile, readdir, stat, writeFile} from "node:fs/promises"; +import path from "node:path"; + +const FORMAT_VERSION = 20; +const DEFLATE = 8; +const UTF8_NAME_FLAG = 0x0800; +const DOS_TIME = ((12 * 60 + 0) * 32 + 0) * 2; +const DOS_DATE = ((2026 - 1980) * 512) + (7 * 32) + 22; +const UNIX_REGULAR_FILE = (0o100644 << 16) >>> 0; +const UNIX_DIRECTORY = (0o40755 << 16) >>> 0; + +type ArchiveEntry = {name: string; bytes: Buffer; directory: boolean}; + +export async function writeZipFromDirectory(source: string, destination: string): Promise { + const root = path.basename(path.resolve(source)); + const entries = await collectEntries(source, root); + await writeFile(destination, buildZip(entries)); +} + +async function collectEntries(source: string, name: string): Promise { + const info = await stat(source); + if (!info.isDirectory()) return [{name, bytes: await readFile(source), directory: false}]; + const entries: ArchiveEntry[] = [{name: `${name}/`, bytes: Buffer.alloc(0), directory: true}]; + const children = (await readdir(source, {withFileTypes: true})).sort((left, right) => left.name.localeCompare(right.name)); + for (const child of children) { + if (child.isSymbolicLink()) throw new Error(`Test archive fixtures may not contain links: ${path.join(source, child.name)}`); + if (!child.isDirectory() && !child.isFile()) throw new Error(`Unsupported archive fixture entry: ${path.join(source, child.name)}`); + entries.push(...await collectEntries(path.join(source, child.name), `${name}/${child.name}`)); + } + return entries; +} + +function buildZip(entries: ArchiveEntry[]): Buffer { + const chunks: Buffer[] = []; + const central: Buffer[] = []; + let offset = 0; + for (const entry of entries) { + const name = Buffer.from(entry.name, "utf8"); + const compressed = entry.directory ? entry.bytes : deflateRawSync(entry.bytes); + const uncompressed = entry.directory || compressed.length >= entry.bytes.length; + const method = uncompressed ? 0 : DEFLATE; + const payload = uncompressed ? entry.bytes : compressed; + const crc = crc32(entry.bytes); + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(FORMAT_VERSION, 4); + local.writeUInt16LE(UTF8_NAME_FLAG, 6); + local.writeUInt16LE(method, 8); + local.writeUInt16LE(DOS_TIME, 10); + local.writeUInt16LE(DOS_DATE, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(payload.length, 18); + local.writeUInt32LE(entry.bytes.length, 22); + local.writeUInt16LE(name.length, 26); + chunks.push(local, name, payload); + const header = Buffer.alloc(46); + header.writeUInt32LE(0x02014b50, 0); + header.writeUInt16LE(FORMAT_VERSION | 0x0300, 4); + header.writeUInt16LE(FORMAT_VERSION, 6); + header.writeUInt16LE(UTF8_NAME_FLAG, 8); + header.writeUInt16LE(method, 10); + header.writeUInt16LE(DOS_TIME, 12); + header.writeUInt16LE(DOS_DATE, 14); + header.writeUInt32LE(crc, 16); + header.writeUInt32LE(payload.length, 20); + header.writeUInt32LE(entry.bytes.length, 24); + header.writeUInt16LE(name.length, 28); + header.writeUInt32LE(entry.directory ? UNIX_DIRECTORY : UNIX_REGULAR_FILE, 38); + header.writeUInt32LE(offset, 42); + central.push(header, name); + offset += local.length + name.length + payload.length; + } + const directory = Buffer.concat(central); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50, 0); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...chunks, directory, end]); +} + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let index = 0; index < 256; index += 1) { + let value = index; + for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? (0xedb88320 ^ (value >>> 1)) >>> 0 : value >>> 1; + table[index] = value >>> 0; + } + return table; +})(); + +function crc32(bytes: Buffer): number { + let crc = 0xffffffff; + for (const byte of bytes) crc = (CRC_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8)) >>> 0; + return (crc ^ 0xffffffff) >>> 0; +} \ No newline at end of file diff --git a/tests/host-updater.test.ts b/tests/host-updater.test.ts index ac4c0b1..b421615 100644 --- a/tests/host-updater.test.ts +++ b/tests/host-updater.test.ts @@ -7,6 +7,7 @@ import semver from "semver"; import {HostUpdater, type ReleaseManifest} from "../src/host/host-updater.ts"; import {JsonLogger} from "../src/shared/logger.ts"; import {HOST_VERSION} from "../src/shared/constants.ts"; +import {writeZipFromDirectory} from "./fixtures/archive.ts"; const roots: string[] = []; const servers: Bun.Server[] = []; @@ -112,9 +113,5 @@ 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 = 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"); + await writeZipFromDirectory(source, destination); } diff --git a/tests/installer.test.ts b/tests/installer.test.ts index f65c43e..1dbfffb 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -30,28 +30,8 @@ describe("loader installer", () => { }); test("installs, repairs idempotently, and restores the exact vendor ASAR", async () => { - const fixture = await mkdtemp(path.join(os.tmpdir(), "zdp-installer-")); - temporaryDirectories.push(fixture); - const zcodeRoot = path.join(fixture, "ZCode"); - const resources = path.join(zcodeRoot, "resources"); - const zdpRoot = path.join(fixture, "zdp"); - await mkdir(resources, {recursive: true}); - await mkdir(path.join(zdpRoot, "runtime"), {recursive: true}); - 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({ - name: "@zcode/desktop", - productName: "ZCode", - version: "3.3.6", - main: "out/main/index.js", - })); - 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 fixture = await installFixture(); + const {zcodeRoot, zdpRoot, resources} = fixture; const originalHash = await sha256(path.join(resources, "app.asar")); const installed = await installOrRepair(zcodeRoot, {skipProcessCheck: true, manageShortcut: false, stateRoot: zdpRoot}); @@ -72,8 +52,108 @@ describe("loader installer", () => { 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); + + test("removes a desktop entry it generated when no original shortcut existed", async () => { + const fixture = await installFixture(); + const shortcutPath = path.join(fixture.dataHome, "applications", process.platform === "win32" ? "ZCode.lnk" : "zcode.desktop"); + const previousDataHome = process.env.XDG_DATA_HOME; + const previousAppData = process.env.APPDATA; + process.env.XDG_DATA_HOME = fixture.dataHome; + process.env.APPDATA = fixture.dataHome; + try { + await installOrRepair(fixture.zcodeRoot, {skipProcessCheck: true, stateRoot: fixture.zdpRoot}); + const state = JSON.parse(await readFile(path.join(fixture.zdpRoot, "data", "install-state.json"), "utf8")) as {shortcut?: {path: string; created?: boolean}}; + expect(state.shortcut?.path).toBe(shortcutPath); + expect(state.shortcut?.created).toBe(true); + expect(await exists(shortcutPath)).toBe(true); + + await uninstall(fixture.zcodeRoot, false, {skipProcessCheck: true, stateRoot: fixture.zdpRoot}); + expect(await exists(shortcutPath)).toBe(false); + } finally { + restoreEnv("XDG_DATA_HOME", previousDataHome); + restoreEnv("APPDATA", previousAppData); + } + }, 60_000); + + test("removes a generated desktop entry left by installs that recorded no shortcut state", async () => { + const fixture = await installFixture(); + const shortcutPath = path.join(fixture.dataHome, "applications", "zcode.desktop"); + const previousDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = fixture.dataHome; + try { + await installOrRepair(fixture.zcodeRoot, {skipProcessCheck: true, stateRoot: fixture.zdpRoot}); + const stateFile = path.join(fixture.zdpRoot, "data", "install-state.json"); + const state = JSON.parse(await readFile(stateFile, "utf8")) as {shortcut?: unknown}; + delete state.shortcut; + await writeFile(stateFile, JSON.stringify(state, null, 2), "utf8"); + expect(await exists(shortcutPath)).toBe(true); + + await uninstall(fixture.zcodeRoot, false, {skipProcessCheck: true, stateRoot: fixture.zdpRoot}); + expect(await exists(shortcutPath)).toBe(false); + } finally { + restoreEnv("XDG_DATA_HOME", previousDataHome); + } + }, 60_000); + + test("restores the previous desktop entry on uninstall", async () => { + const fixture = await installFixture(); + const shortcutPath = path.join(fixture.dataHome, "applications", "zcode.desktop"); + const original = "[Desktop Entry]\nName=ZCode\nExec=\"/opt/zcode.AppImage\" %U\nType=Application\n"; + await mkdir(path.dirname(shortcutPath), {recursive: true}); + await writeFile(shortcutPath, original, "utf8"); + const previousDataHome = process.env.XDG_DATA_HOME; + process.env.XDG_DATA_HOME = fixture.dataHome; + try { + await installOrRepair(fixture.zcodeRoot, {skipProcessCheck: true, stateRoot: fixture.zdpRoot}); + const state = JSON.parse(await readFile(path.join(fixture.zdpRoot, "data", "install-state.json"), "utf8")) as {shortcut?: {created?: boolean}}; + expect(state.shortcut?.created).toBe(false); + if (process.platform !== "win32") expect(await readFile(shortcutPath, "utf8")).toContain(path.join(fixture.zdpRoot, "bin", ZDP_LAUNCHER_EXECUTABLE)); + + await uninstall(fixture.zcodeRoot, false, {skipProcessCheck: true, stateRoot: fixture.zdpRoot}); + if (process.platform !== "win32") { + expect(await readFile(shortcutPath, "utf8")).toBe(original); + expect((await doctor(fixture.zcodeRoot, fixture.zdpRoot)).shortcutManaged).toBe(false); + } + } finally { + restoreEnv("XDG_DATA_HOME", previousDataHome); + } + }, 60_000); }); +function restoreEnv(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + +async function installFixture(): Promise<{zcodeRoot: string; zdpRoot: string; resources: string; dataHome: string}> { + const fixture = await mkdtemp(path.join(os.tmpdir(), "zdp-installer-")); + temporaryDirectories.push(fixture); + const zcodeRoot = path.join(fixture, "ZCode"); + const resources = path.join(zcodeRoot, "resources"); + const zdpRoot = path.join(fixture, "zdp"); + const dataHome = path.join(fixture, "data-home"); + await mkdir(resources, {recursive: true}); + await mkdir(path.join(zdpRoot, "runtime"), {recursive: true}); + await mkdir(path.join(zdpRoot, "bin"), {recursive: true}); + await writeFile(path.join(zdpRoot, "bin", ZDP_LAUNCHER_EXECUTABLE), "launcher"); + 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({ + name: "@zcode/desktop", + productName: "ZCode", + version: "3.3.6", + main: "out/main/index.js", + })); + 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'); + return {zcodeRoot, zdpRoot, resources, dataHome}; +} + function electronFuseFixture(): Buffer { return Buffer.concat([ Buffer.from("MZ"),