Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions scripts/build.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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}`);
Expand Down
4 changes: 2 additions & 2 deletions src/cli/guardian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ export async function guard(parentPid: number, zcodeRoot: string): Promise<void>
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;
previousSize = size;
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<boolean> {
Expand Down
16 changes: 9 additions & 7 deletions src/cli/host-update-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<void> {
Expand All @@ -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<void> {
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);
Expand All @@ -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<void> {
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);
Expand Down Expand Up @@ -120,6 +121,7 @@ function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); retu
function delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); }
async function exists(value: string): Promise<boolean> { 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}`);
Expand Down
18 changes: 15 additions & 3 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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)"}.`);
Expand Down Expand Up @@ -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 <path> Override the ZCode installation directory`);
}
70 changes: 48 additions & 22 deletions src/cli/installer.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -29,9 +29,10 @@ export type DoctorReport = {
export async function doctor(zcodeRoot = DEFAULT_ZCODE_ROOT, zdpRoot = resolveZdpRoot()): Promise<DoctorReport> {
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;
Expand All @@ -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)),
Expand All @@ -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<DoctorReport> {
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);
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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<void> {
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);
Expand All @@ -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<void> {
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<void> {
if (!isZCodeRunning(zcodeRoot)) await installOrRepair(zcodeRoot);
const layout = getZCodeLayout(zcodeRoot);
const child = spawn(layout.launchExecutable, [...layout.launchArgs, ...args], {
cwd: zcodeRoot,
detached: true,
stdio: "ignore",
Expand All @@ -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<InstallState | undefined> {
Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion src/cli/launcher.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading