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
8 changes: 4 additions & 4 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.6-windows-x64.zip"
$zip = ".\zcode-extensions-v0.3.7-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.6-windows-x64.zip -DestinationPath D:\
Expand-Archive .\zcode-extensions-v0.3.7-windows-x64.zip -DestinationPath D:\
Set-Location D:\zcode-extensions
```

Expand Down Expand Up @@ -112,7 +112,7 @@ Packaged installs check the stable host feed at startup, every six hours, and wi
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.6-windows-x64.zip -DestinationPath D:\ -Force
Expand-Archive .\zcode-extensions-v0.3.7-windows-x64.zip -DestinationPath D:\ -Force
Set-Location D:\zcode-extensions
.\bin\zdp.exe repair
.\bin\zdp.exe launch
Expand Down Expand Up @@ -183,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.6
bun run release:package -- --tag v0.3.7
```

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.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.
This guide describes extension API version 1 as implemented by host/SDK 0.3.7. 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.6
bun add -d @notmike101/zcode-extension-sdk@0.3.7
```

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.6",
"version": "0.3.7",
"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.6") {
if (packageJson.name !== "@notmike101/zcode-extension-sdk" || packageJson.version !== "0.3.7") {
throw new Error("Unexpected SDK package identity");
}
for (const entry of [".", "./main", "./renderer", "./experimental", "./manifest.schema.json"]) {
Expand Down
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.6",
"version": "0.3.7",
"description": "Public TypeScript SDK and authoring helpers for ZCode Desktop Extensions.",
"license": "MIT",
"type": "module",
Expand Down
42 changes: 37 additions & 5 deletions src/host/extension-updater.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const MAX_EXTRACTED_BYTES = 200 * 1024 * 1024;
const MAX_ARCHIVE_ENTRIES = 2_000;
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000;
const MAX_REDIRECTS = 5;
const RENAME_RETRY_ATTEMPTS = 40;
const RENAME_RETRY_DELAY_MS = 250;

type InstalledExtension = {root: string; manifest: PluginManifest};

Expand Down Expand Up @@ -268,8 +270,8 @@ export class ExtensionUpdater {
assertChild(this.#paths.trash, pending.rollbackPath);
const destination = path.join(this.#paths.plugins, pluginId);
const failed = path.join(this.#paths.trash, `${pluginId}-${pending.version}-failed-${timestamp()}`);
if (await exists(destination)) await rename(destination, failed);
if (await exists(pending.rollbackPath)) await rename(pending.rollbackPath, destination);
if (await exists(destination)) await renameWithRetry(destination, failed);
if (await exists(pending.rollbackPath)) await renameWithRetry(pending.rollbackPath, destination);
delete this.#state.pending[pluginId];
await this.#persistState();
await rm(pending.stagingContainer, {recursive: true, force: true}).catch(() => undefined);
Expand Down Expand Up @@ -301,9 +303,9 @@ export class ExtensionUpdater {
if (pending.phase === "applying") {
if (await exists(destination)) {
if (!pending.rollbackPath) throw new Error(`Missing rollback path for ${pending.pluginId}`);
await rename(destination, pending.rollbackPath);
await renameWithRetry(destination, pending.rollbackPath);
}
if (await exists(pending.stagingRoot)) await rename(pending.stagingRoot, destination);
if (await exists(pending.stagingRoot)) await renameWithRetry(pending.stagingRoot, destination);
const installed = await readExtensionManifest(destination);
if (installed.id !== pending.pluginId || installed.version !== pending.version) {
throw new Error(`Applied bundle identity mismatch for ${pending.pluginId}`);
Expand All @@ -323,7 +325,7 @@ export class ExtensionUpdater {
if (pending.rollbackPath && await exists(pending.rollbackPath)) {
const destination = path.join(this.#paths.plugins, pending.pluginId);
if (await exists(destination)) await rm(destination, {recursive: true, force: true});
await rename(pending.rollbackPath, destination);
await renameWithRetry(pending.rollbackPath, destination);
}
delete this.#state.pending[pending.pluginId];
await this.#persistState();
Expand Down Expand Up @@ -617,3 +619,33 @@ function timestamp(): string {
function errorText(value: unknown): string {
return value instanceof Error ? value.message : String(value);
}

export async function renameWithRetry(
source: string,
destination: string,
operation: typeof rename = rename,
attempts = RENAME_RETRY_ATTEMPTS,
delayMs = RENAME_RETRY_DELAY_MS,
): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
await operation(source, destination);
return;
} catch (error) {
lastError = error;
if (!isTransientRenameError(error) || attempt === attempts - 1) throw error;
await delay(delayMs);
}
}
throw lastError;
}

function isTransientRenameError(value: unknown): boolean {
const code = (value as NodeJS.ErrnoException | undefined)?.code;
return code === "EPERM" || code === "EBUSY" || code === "EACCES";
}

function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
2 changes: 1 addition & 1 deletion src/shared/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import path from "node:path";

export const HOST_NAME = "ZCode Desktop Extensions";
export const HOST_VERSION = "0.3.6";
export const HOST_VERSION = "0.3.7";
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;
Expand Down
20 changes: 19 additions & 1 deletion tests/extension-updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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, validateArchiveEntryPath} from "../src/host/extension-updater.ts";
import {ExtensionUpdater, 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";
Expand All @@ -17,6 +17,24 @@ afterEach(async () => {
});

describe("extension updater", () => {
test("retries transient Windows rename failures while applying updates", async () => {
let attempts = 0;
await renameWithRetry("installed", "rollback", async () => {
attempts += 1;
if (attempts < 3) throw Object.assign(new Error("file is temporarily locked"), {code: "EPERM"});
}, 3, 0);
expect(attempts).toBe(3);
});

test("does not retry permanent rename failures", async () => {
let attempts = 0;
await expect(renameWithRetry("installed", "rollback", async () => {
attempts += 1;
throw Object.assign(new Error("missing source"), {code: "ENOENT"});
}, 3, 0)).rejects.toThrow("missing source");
expect(attempts).toBe(1);
});

test("verifies, queues, and applies an extension update on the next launch", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "zdp-updater-"));
roots.push(root);
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/serve-renderer-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const server = Bun.serve({
});
}
if (url.pathname === "/renderer/index.js") {
return new Response(await readFile(path.join(root, "runtime", "versions", "0.3.6", "renderer", "index.js")), {
return new Response(await readFile(path.join(root, "runtime", "versions", "0.3.7", "renderer", "index.js")), {
headers: {"content-type": "text/javascript; charset=utf-8"},
});
}
Expand Down
8 changes: 4 additions & 4 deletions tests/host-updater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,18 @@ describe("host updater", () => {
await mkdir(path.join(root, "data"), {recursive: true});
await writeFile(path.join(root, "bin", "zdp.exe"), "old helper");
await writeFile(path.join(root, "data", "keep.txt"), "preserved");
await writeManifest(root, "0.3.6", [{path: "bin/zdp.exe", bytes: Buffer.from("old helper")}]);
await writeManifest(root, "0.3.7", [{path: "bin/zdp.exe", bytes: Buffer.from("old helper")}]);

const archiveStage = path.join(root, "archive-stage", "zcode-extensions");
const incoming = Buffer.from("new helper");
await mkdir(path.join(archiveStage, "bin"), {recursive: true});
await writeFile(path.join(archiveStage, "bin", "zdp.exe"), incoming);
await writeManifest(archiveStage, "0.3.7", [{path: "bin/zdp.exe", bytes: incoming}]);
await writeManifest(archiveStage, "0.3.8", [{path: "bin/zdp.exe", bytes: incoming}]);
const archive = path.join(root, "host.zip");
await compress(archiveStage, archive);
const bytes = Buffer.from(await Bun.file(archive).arrayBuffer());
const digest = hash(bytes);
const server = serveRelease(bytes, digest, "0.3.7", bytes.length);
const server = serveRelease(bytes, digest, "0.3.8", bytes.length);
let launched: {executable: string; args: string[]} | undefined;
const updater = createUpdater(root, `${server.url}host-update.json`, (executable, args) => { launched = {executable, args}; });
await updater.initialize();
Expand All @@ -54,7 +54,7 @@ describe("host updater", () => {
expect(launched?.args).toContain("4242");
expect((await stat(launched!.executable)).isFile()).toBe(true);
const transaction = JSON.parse(await readFile(path.join(root, "data", "host-update.json"), "utf8")) as {phase: string; targetVersion: string};
expect(transaction).toMatchObject({phase: "ready", targetVersion: "0.3.7"});
expect(transaction).toMatchObject({phase: "ready", targetVersion: "0.3.8"});
expect(await readFile(path.join(root, "data", "keep.txt"), "utf8")).toBe("preserved");
updater.dispose();
});
Expand Down