diff --git a/README.md b/README.md index eb23c65..854b485 100644 --- a/README.md +++ b/README.md @@ -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" } @@ -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 ``` @@ -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 @@ -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. diff --git a/docs/extension-development.md b/docs/extension-development.md index 6a18887..1c5c3ee 100644 --- a/docs/extension-development.md +++ b/docs/extension-development.md @@ -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. @@ -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. diff --git a/package.json b/package.json index da60a21..42dbb91 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-sdk-package.ts b/scripts/check-sdk-package.ts index bf641c1..c701f7f 100644 --- a/scripts/check-sdk-package.ts +++ b/scripts/check-sdk-package.ts @@ -11,7 +11,7 @@ const packageJson = JSON.parse(await readFile(path.join(sdk, "package.json"), "u exports: Record; }; -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"]) { diff --git a/sdk/package.json b/sdk/package.json index 8171c95..b70da0b 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -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", diff --git a/src/host/extension-updater.ts b/src/host/extension-updater.ts index 15b5fd2..7898182 100644 --- a/src/host/extension-updater.ts +++ b/src/host/extension-updater.ts @@ -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}; @@ -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); @@ -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}`); @@ -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(); @@ -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 { + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7ba9fbf..72e4d9b 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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; diff --git a/tests/extension-updater.test.ts b/tests/extension-updater.test.ts index 01c542f..e1bbbb7 100644 --- a/tests/extension-updater.test.ts +++ b/tests/extension-updater.test.ts @@ -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"; @@ -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); diff --git a/tests/fixtures/serve-renderer-harness.ts b/tests/fixtures/serve-renderer-harness.ts index 791898c..b38922d 100644 --- a/tests/fixtures/serve-renderer-harness.ts +++ b/tests/fixtures/serve-renderer-harness.ts @@ -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"}, }); } diff --git a/tests/host-updater.test.ts b/tests/host-updater.test.ts index 5746b4c..2ec728a 100644 --- a/tests/host-updater.test.ts +++ b/tests/host-updater.test.ts @@ -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(); @@ -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(); });