Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ jobs:
}

$assets = @(Get-ChildItem "dist/release" -File | Where-Object {
$_.Name -like "*.zip" -or $_.Name -like "*.zip.sha256"
$_.Name -like "*.zip" -or $_.Name -like "*.zip.sha256" -or $_.Name -eq "host-update.json"
})
if ($assets.Count -ne 2) {
throw "Expected exactly one ZIP and one SHA-256 asset; found $($assets.Count)."
if ($assets.Count -ne 3) {
throw "Expected one ZIP, one SHA-256 asset, and host-update.json; found $($assets.Count)."
}

$assetPaths = @($assets | ForEach-Object { $_.FullName })
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Release executables are currently unsigned. Windows SmartScreen may warn before
3. Verify the archive:

```powershell
$zip = ".\zcode-extensions-v0.3.5-windows-x64.zip"
$zip = ".\zcode-extensions-v0.3.6-windows-x64.zip"
$expected = (Get-Content "$zip.sha256").Split()[0].ToLowerInvariant()
$actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $expected) { throw "Checksum mismatch" }
Expand All @@ -52,7 +52,7 @@ Release executables are currently unsigned. Windows SmartScreen may warn before
4. Extract the archive to a permanent location. The ZIP contains a stable `zcode-extensions` directory:

```powershell
Expand-Archive .\zcode-extensions-v0.3.5-windows-x64.zip -DestinationPath D:\
Expand-Archive .\zcode-extensions-v0.3.6-windows-x64.zip -DestinationPath D:\
Set-Location D:\zcode-extensions
```

Expand Down Expand Up @@ -107,16 +107,18 @@ Queued updates replace only the extension bundle on startup. The previous bundle

## Updating ZCode Desktop Extensions

Close ZCode, download the new release, and extract it over the same parent directory:
Packaged installs check the stable host feed at startup, every six hours, and with **Extensions → Installed → Check for updates**. When a release is available, choose **Update and restart**. The host downloads and verifies the release while ZCode remains usable, then uses a detached helper to restart, repair the loader, and roll back automatically if validation or installation fails. The updater changes only files declared by the release; `data`, extension configuration, Scheduler jobs, and unknown files are preserved.

Source/development checkouts show the release notification but do not overwrite themselves. For those installs—or as a recovery path—close ZCode, download the new release, and extract it over the same parent directory:

```powershell
Expand-Archive .\zcode-extensions-v0.3.5-windows-x64.zip -DestinationPath D:\ -Force
Expand-Archive .\zcode-extensions-v0.3.6-windows-x64.zip -DestinationPath D:\ -Force
Set-Location D:\zcode-extensions
.\bin\zdp.exe repair
.\bin\zdp.exe launch
```

The release archive does not contain `data`, so extension configuration and Scheduler jobs remain in place. Repair preserves the untouched ZCode vendor bundle, refreshes the managed loader, and retains bounded backups.
Release executables are currently unsigned, and updates are never installed without the explicit **Update and restart** action. Each download is bounded, path-validated, and checked against the release feed SHA-256 before ZCode exits.

After a ZCode application update, allow ZCode to exit normally so the guardian can detect the new vendor `app.asar`. Run `doctor` and then `repair` if the health check reports a problem.

Expand Down Expand Up @@ -181,7 +183,7 @@ bun run build:example
bun run build
bun run build:sdk
bun run pack:sdk
bun run release:package -- --tag v0.3.5
bun run release:package -- --tag v0.3.6
```

See [Developing extensions](docs/extension-development.md) for the public API and [Hello Extension](examples/hello-extension) for a complete minimal project.
Expand Down
4 changes: 2 additions & 2 deletions docs/extension-development.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Developing ZCode Desktop Extensions

This guide describes extension API version 1 as implemented by host/SDK 0.3.5. The public package is [`@notmike101/zcode-extension-sdk`](https://www.npmjs.com/package/@notmike101/zcode-extension-sdk), the source contract is [`sdk/index.ts`](../sdk/index.ts), and [Hello Extension](../examples/hello-extension) remains a complete legacy-lifecycle example.
This guide describes extension API version 1 as implemented by host/SDK 0.3.6. The public package is [`@notmike101/zcode-extension-sdk`](https://www.npmjs.com/package/@notmike101/zcode-extension-sdk), the source contract is [`sdk/index.ts`](../sdk/index.ts), and [Hello Extension](../examples/hello-extension) remains a complete legacy-lifecycle example.

Extensions are trusted local code. A main entrypoint runs in ZCode's Electron main process with Node.js access, while an optional renderer entrypoint runs inside the ZCode renderer. Declared capabilities control access through the SDK and are shown during installation; they do not sandbox trusted Node or renderer code.

Expand All @@ -9,7 +9,7 @@ Extensions are trusted local code. A main entrypoint runs in ZCode's Electron ma
For a separate Bun or TypeScript project:

```powershell
bun add -d @notmike101/zcode-extension-sdk@0.3.5
bun add -d @notmike101/zcode-extension-sdk@0.3.6
```

Import main-only types and helpers from `@notmike101/zcode-extension-sdk/main`, browser-safe renderer types and helpers from `/renderer`, and unstable raw-channel types from `/experimental`. The root export is also browser-safe. A JSON Schema is available at `/manifest.schema.json`, and `validateExtensionManifest` or `assertExtensionManifest` can validate manifests at runtime.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "zcode-desktop-extensions",
"version": "0.3.5",
"version": "0.3.6",
"description": "An update-resistant extension host for the ZCode Electron desktop application.",
"private": true,
"license": "MIT",
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-sdk-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const packageJson = JSON.parse(await readFile(path.join(sdk, "package.json"), "u
exports: Record<string, unknown>;
};

if (packageJson.name !== "@notmike101/zcode-extension-sdk" || packageJson.version !== "0.3.5") {
if (packageJson.name !== "@notmike101/zcode-extension-sdk" || packageJson.version !== "0.3.6") {
throw new Error("Unexpected SDK package identity");
}
for (const entry of [".", "./main", "./renderer", "./experimental", "./manifest.schema.json"]) {
Expand Down
32 changes: 30 additions & 2 deletions scripts/package-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const outputRoot = path.resolve(valueAfter("--output") ?? path.join(root, "dist"
const stage = path.join(outputRoot, "zcode-extensions");
const archive = path.join(outputRoot, `${baseName}.zip`);
const checksum = `${archive}.sha256`;
const updateFeed = path.join(outputRoot, "host-update.json");

await rm(outputRoot, {recursive: true, force: true});
await mkdir(stage, {recursive: true});
Expand All @@ -71,11 +72,27 @@ await copyTree(
(filePath) => !filePath.endsWith(".map") && !filePath.includes(`${path.sep}node_modules${path.sep}`),
);

const managedFiles = await collectManagedFiles(stage);
await writeFile(path.join(stage, "release-manifest.json"), `${JSON.stringify({schemaVersion: 1, version, files: managedFiles}, null, 2)}\n`, "utf8");

await compress(stage, archive);
const digest = await sha256(archive);
const archiveSize = (await stat(archive)).size;
await writeFile(checksum, `${digest} *${path.basename(archive)}\n`, "utf8");

console.log(JSON.stringify({tag, version, stage, archive, checksum, sha256: digest}, null, 2));
await writeFile(updateFeed, `${JSON.stringify({
schemaVersion: 1,
version,
engines: {zcode: ">=3.3.6"},
archive: {
url: `https://github.com/notmike101/zcode-extensions/releases/download/${tag}/${path.basename(archive)}`,
sha256: digest,
size: archiveSize,
},
releaseUrl: `https://github.com/notmike101/zcode-extensions/releases/tag/${tag}`,
publishedAt: new Date().toISOString(),
}, null, 2)}\n`, "utf8");

console.log(JSON.stringify({tag, version, stage, archive, checksum, updateFeed, sha256: digest}, null, 2));

function valueAfter(flag: string): string | undefined {
const index = args.indexOf(flag);
Expand Down Expand Up @@ -129,3 +146,14 @@ async function sha256(filePath: string): Promise<string> {
for await (const chunk of createReadStream(filePath)) hash.update(chunk);
return hash.digest("hex");
}

async function collectManagedFiles(directory: string, relativeRoot = ""): Promise<Array<{path: string; sha256: string; size: number}>> {
const files: Array<{path: string; sha256: string; size: number}> = [];
for (const entry of await readdir(directory, {withFileTypes: true})) {
const relative = relativeRoot ? `${relativeRoot}/${entry.name}` : entry.name;
const target = path.join(directory, entry.name);
if (entry.isDirectory()) files.push(...await collectManagedFiles(target, relative));
else if (entry.isFile()) files.push({path: relative, sha256: await sha256(target), size: (await stat(target)).size});
}
return files.sort((left, right) => left.path.localeCompare(right.path));
}
2 changes: 1 addition & 1 deletion sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@notmike101/zcode-extension-sdk",
"version": "0.3.5",
"version": "0.3.6",
"description": "Public TypeScript SDK and authoring helpers for ZCode Desktop Extensions.",
"license": "MIT",
"type": "module",
Expand Down
11 changes: 10 additions & 1 deletion src/cli/guardian.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {stat} from "node:fs/promises";
import {readFile, stat} from "node:fs/promises";
import path from "node:path";
import {installOrRepair, isZCodeRunning} from "./installer.ts";
import {getPaths, resolveZdpRoot} from "../shared/constants.ts";

export async function guard(parentPid: number, zcodeRoot: string): Promise<void> {
while (isProcessAlive(parentPid)) await delay(1_000);
if (await hostUpdatePending()) return;
const appAsar = path.join(zcodeRoot, "resources", "app.asar");
let stableSamples = 0;
let previousSize = -1;
Expand All @@ -20,6 +22,13 @@ export async function guard(parentPid: number, zcodeRoot: string): Promise<void>
if (!isZCodeRunning()) await installOrRepair(zcodeRoot, {skipProcessCheck: true}).catch(() => undefined);
}

async function hostUpdatePending(): Promise<boolean> {
try {
const value = JSON.parse(await readFile(getPaths(resolveZdpRoot()).hostUpdateState, "utf8")) as {phase?: unknown};
return value.phase === "ready" || value.phase === "applying";
} catch { return false; }
}

function isProcessAlive(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch { return false; }
}
Expand Down
126 changes: 126 additions & 0 deletions src/cli/host-update-apply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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 {hostUpdateTransactionSchema, releaseManifestSchema, safeManagedPath, verifyManagedFiles, type ReleaseManifest} from "../host/host-updater.ts";
import {installOrRepair, isZCodeRunning} from "./installer.ts";

type ApplyDependencies = {
processAlive?: (pid: number) => boolean;
zcodeRunning?: () => boolean;
repair?: typeof installOrRepair;
launch?: (executable: string) => void;
};

export async function applyHostUpdate(parentPid: number, root: string, dependencies: ApplyDependencies = {}): Promise<void> {
const processAlive = dependencies.processAlive ?? isProcessAlive;
const zcodeRunning = dependencies.zcodeRunning ?? isZCodeRunning;
const repair = dependencies.repair ?? installOrRepair;
const paths = getPaths(root);
const transaction = hostUpdateTransactionSchema.parse(JSON.parse(await readFile(paths.hostUpdateState, "utf8")));
if (transaction.phase !== "ready") throw new Error(`Host update is not ready: ${transaction.phase}`);
assertChild(paths.hostUpdateStaging, transaction.stagingRoot);
await writeJsonAtomic(paths.hostUpdateState, {...transaction, phase: "applying"});
let incoming: ReleaseManifest | undefined;
let current: ReleaseManifest | undefined;
let backup: string | undefined;
try {
while (processAlive(parentPid)) await delay(500);
const deadline = Date.now() + 60_000;
while (zcodeRunning() && Date.now() < deadline) await delay(500);
if (zcodeRunning()) throw new Error("ZCode did not close in time for the host update");
incoming = releaseManifestSchema.parse(JSON.parse(await readFile(path.join(transaction.stagingRoot, "release-manifest.json"), "utf8")));
current = releaseManifestSchema.parse(JSON.parse(await readFile(paths.releaseManifest, "utf8")));
await verifyManagedFiles(transaction.stagingRoot, incoming);
backup = path.join(paths.hostUpdateStaging, "backup", current.version);
await rm(backup, {recursive: true, force: true});
await mkdir(backup, {recursive: true});
await backupManaged(root, backup, current);
await copyFileRetry(paths.releaseManifest, path.join(backup, "release-manifest.json"));
await installManaged(root, transaction.stagingRoot, current, incoming);
await copyFileRetry(path.join(transaction.stagingRoot, "release-manifest.json"), paths.releaseManifest);
await verifyManagedFiles(root, incoming);
await writeJsonAtomic(paths.runtimeCurrent, {version: incoming.version, previousVersion: current.version});
await repair(transaction.zcodeRoot, {skipProcessCheck: true, stateRoot: root, loaderVersion: incoming.version});
await rm(paths.hostUpdateState, {force: true});
await pruneTransaction(paths.hostUpdateStaging, transaction.stagingRoot, backup);
} catch (error) {
if (backup && current && incoming) {
await restoreManaged(root, backup, current, incoming).catch(() => undefined);
await copyFileRetry(path.join(backup, "release-manifest.json"), paths.releaseManifest).catch(() => undefined);
}
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();
}

async function backupManaged(root: string, backup: string, manifest: ReleaseManifest): Promise<void> {
for (const file of manifest.files) {
const relative = safeManagedPath(file.path);
const source = path.join(root, ...relative.split("/"));
if (!await exists(source)) continue;
const destination = path.join(backup, ...relative.split("/"));
await mkdir(path.dirname(destination), {recursive: true});
await copyFileRetry(source, destination);
}
}

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()));
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});
}
for (const file of incoming.files) {
const relative = safeManagedPath(file.path);
const destination = path.join(root, ...relative.split("/"));
await mkdir(path.dirname(destination), {recursive: true});
await copyFileRetry(path.join(staging, ...relative.split("/")), destination);
}
}

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()));
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});
}
for (const file of current.files) {
const relative = safeManagedPath(file.path);
const source = path.join(backup, ...relative.split("/"));
if (!await exists(source)) continue;
const destination = path.join(root, ...relative.split("/"));
await mkdir(path.dirname(destination), {recursive: true});
await copyFileRetry(source, destination);
}
}

async function copyFileRetry(source: string, destination: string): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < 30; attempt += 1) {
try { await copyFile(source, destination); return; }
catch (error) { lastError = error; await delay(250); }
}
throw lastError;
}

async function pruneTransaction(stagingRoot: string, payloadRoot: string, backup: string): Promise<void> {
const transactionRoot = path.dirname(path.dirname(payloadRoot));
if (path.relative(stagingRoot, transactionRoot).startsWith("..")) return;
await rm(transactionRoot, {recursive: true, force: true});
const backupParent = path.dirname(backup);
const entries = await import("node:fs/promises").then(({readdir}) => readdir(backupParent, {withFileTypes: true})).catch(() => []);
for (const entry of entries) if (entry.isDirectory() && entry.name !== path.basename(backup)) await rm(path.join(backupParent, entry.name), {recursive: true, force: true});
}

function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } }
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 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}`);
}
8 changes: 8 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import {DEFAULT_ZCODE_ROOT, HOST_VERSION} from "../shared/constants.ts";
import {doctor, installOrRepair, launch, 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";
Expand Down Expand Up @@ -33,6 +34,13 @@ try {
await guard(parent, zcodeRoot);
break;
}
case "apply-update": {
const parent = Number(valueAfter("--parent"));
const root = valueAfter("--root");
if (!Number.isInteger(parent) || parent <= 0 || !root) throw new Error("apply-update requires --parent <pid> --root <path>");
await applyHostUpdate(parent, root);
break;
}
case "help":
case "--help":
case "-h": printHelp(); break;
Expand Down
4 changes: 2 additions & 2 deletions src/cli/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ 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} = {}): Promise<DoctorReport> {
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();
Expand Down Expand Up @@ -119,7 +119,7 @@ export async function installOrRepair(zcodeRoot = DEFAULT_ZCODE_ROOT, options: {
vendorAsarSha256: vendorHash,
installedAt: existingState?.installedAt ?? now,
...(existingState ? {repairedAt: now} : {}),
loaderVersion: HOST_VERSION,
loaderVersion: options.loaderVersion ?? HOST_VERSION,
...(originalShortcut ? {shortcut: originalShortcut} : {}),
fuses: fuseReport(wire as unknown as Record<number, FuseState>),
});
Expand Down
Loading