diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0bb679..05ceb6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }) diff --git a/README.md b/README.md index f337a64..eb23c65 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.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" } @@ -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 ``` @@ -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. @@ -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. diff --git a/docs/extension-development.md b/docs/extension-development.md index 91cb0a0..6a18887 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.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. @@ -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. diff --git a/package.json b/package.json index 136ebc5..da60a21 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-sdk-package.ts b/scripts/check-sdk-package.ts index 98dc247..bf641c1 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.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"]) { diff --git a/scripts/package-release.ts b/scripts/package-release.ts index c27ef0b..3d9d9df 100644 --- a/scripts/package-release.ts +++ b/scripts/package-release.ts @@ -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}); @@ -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); @@ -129,3 +146,14 @@ async function sha256(filePath: string): Promise { for await (const chunk of createReadStream(filePath)) hash.update(chunk); return hash.digest("hex"); } + +async function collectManagedFiles(directory: string, relativeRoot = ""): Promise> { + 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)); +} diff --git a/sdk/package.json b/sdk/package.json index f92ad6a..8171c95 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -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", diff --git a/src/cli/guardian.ts b/src/cli/guardian.ts index ce1b921..4b982a3 100644 --- a/src/cli/guardian.ts +++ b/src/cli/guardian.ts @@ -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 { 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; @@ -20,6 +22,13 @@ export async function guard(parentPid: number, zcodeRoot: string): Promise if (!isZCodeRunning()) await installOrRepair(zcodeRoot, {skipProcessCheck: true}).catch(() => undefined); } +async function hostUpdatePending(): Promise { + 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; } } diff --git a/src/cli/host-update-apply.ts b/src/cli/host-update-apply.ts new file mode 100644 index 0000000..bff7274 --- /dev/null +++ b/src/cli/host-update-apply.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { 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 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/cli/index.ts b/src/cli/index.ts index 5303927..fdb8124 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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"; @@ -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 --root "); + await applyHostUpdate(parent, root); + break; + } case "help": case "--help": case "-h": printHelp(); break; diff --git a/src/cli/installer.ts b/src/cli/installer.ts index 99554c7..263df23 100644 --- a/src/cli/installer.ts +++ b/src/cli/installer.ts @@ -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 { +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(); @@ -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), }); diff --git a/src/host/bootstrap.ts b/src/host/bootstrap.ts index 863ee9d..facbbb9 100644 --- a/src/host/bootstrap.ts +++ b/src/host/bootstrap.ts @@ -2,7 +2,7 @@ import {spawn} from "node:child_process"; import {readFile} from "node:fs/promises"; import path from "node:path"; import {fileURLToPath} from "node:url"; -import {app, dialog, ipcMain, powerMonitor, protocol, session, type WebContents} from "electron"; +import {app, dialog, ipcMain, powerMonitor, protocol, session, shell, type WebContents} from "electron"; import {HOST_NAME, HOST_VERSION, getPaths, resolveZdpRoot} from "../shared/constants.ts"; import {writeJsonAtomic} from "../shared/atomic.ts"; import {JsonLogger} from "../shared/logger.ts"; @@ -13,6 +13,7 @@ import {ZCodeGateway} from "../protocol/zcode-gateway.ts"; import {ExtensionZCodeService} from "../protocol/extension-service.ts"; import {PluginManager} from "./plugin-manager.ts"; import {observeRendererLoads} from "./renderer-loads.ts"; +import {HostUpdater} from "./host-updater.ts"; protocol.registerSchemesAsPrivileged([{ scheme: "zdp", @@ -41,6 +42,11 @@ let pluginManager: PluginManager; let initialized: Promise; let shutdownStarted = false; let shutdownComplete = false; +const hostUpdater = new HostUpdater({ + root, zcodeRoot, zcodeVersion, logger: logger.child("host-updater"), + onStateChanged: () => emit("host-state-changed"), + allowHttpLocalhost: process.env.ZDP_ALLOW_HTTP_LOCALHOST === "1", +}); const zcodeGateway = new ZCodeGateway({ vendorAsar: path.join(process.resourcesPath, "zcode.original.asar"), @@ -89,6 +95,8 @@ initialized = (async () => { timestamp: new Date().toISOString(), }); await pluginManager.initialize(); + await hostUpdater.initialize(); + hostUpdater.startAutomaticChecks(); await writeJsonAtomic(paths.bootState, { phase: "host-ready", pid: process.pid, @@ -115,6 +123,18 @@ ipcMain.handle("zdp:invoke", async (event, request: unknown) => { switch (method) { case "host:getState": return hostState(); case "host:getLogs": return logger.tail(200); + case "host:checkUpdate": await hostUpdater.check(); return hostState(); + case "host:viewUpdate": { + const releaseUrl = hostUpdater.status().releaseUrl; + if (!releaseUrl) throw new Error("No host release page is available"); + await shell.openExternal(releaseUrl); + return undefined; + } + case "host:applyUpdate": { + await hostUpdater.prepareAndRestart(process.pid); + setImmediate(() => app.quit()); + return hostState(); + } case "host:choosePluginFolder": return chooseDirectory("Choose a ZCode Desktop Extension folder"); case "host:chooseDirectory": return chooseDirectory("Choose a workspace folder"); case "extension:checkUpdates": await pluginManager.checkUpdates(); return hostState(); @@ -210,6 +230,7 @@ app.on("before-quit", (event) => { await pluginManager.deactivateAll(); await taskService.shutdown(); await zcodeGateway.shutdown(); + hostUpdater.dispose(); })(), new Promise((resolve) => setTimeout(resolve, 10_000)), ]); @@ -230,6 +251,7 @@ function hostState(): HostState { dataDir: paths.data, plugins: pluginManager.list(), catalog: pluginManager.catalog(), + hostUpdate: hostUpdater.status(), health: {protocol: protocolStatus, ...(protocolError ? {protocolError} : {})}, }; } diff --git a/src/host/extension-updater.ts b/src/host/extension-updater.ts index 9c4e026..15b5fd2 100644 --- a/src/host/extension-updater.ts +++ b/src/host/extension-updater.ts @@ -441,7 +441,10 @@ export async function readUpdateSource(root: string) { } } -async function extractArchive(archive: string, destination: string): Promise { +export async function extractArchive(archive: string, destination: string, limits: {maxEntries?: number; maxExtractedBytes?: number; label?: string} = {}): Promise { + const maxEntries = limits.maxEntries ?? MAX_ARCHIVE_ENTRIES; + const maxExtractedBytes = limits.maxExtractedBytes ?? MAX_EXTRACTED_BYTES; + const label = limits.label ?? "Extension"; const zip = await openZip(archive); const names = new Set(); let entries = 0; @@ -463,14 +466,14 @@ async function extractArchive(archive: string, destination: string): Promise { void (async () => { entries += 1; - if (entries > MAX_ARCHIVE_ENTRIES) throw new Error("Extension archive contains too many entries"); + if (entries > maxEntries) throw new Error(`${label} archive contains too many entries`); const relative = validateArchiveEntryPath(entry.fileName); const key = process.platform === "win32" ? relative.toLowerCase() : relative; if (names.has(key)) throw new Error(`Extension archive contains a duplicate path: ${relative}`); names.add(key); if (isArchiveLink(entry)) throw new Error(`Extension archive contains a link: ${relative}`); totalBytes += entry.uncompressedSize; - if (totalBytes > MAX_EXTRACTED_BYTES) throw new Error("Extension archive expands beyond the allowed size"); + if (totalBytes > maxExtractedBytes) throw new Error(`${label} archive expands beyond the allowed size`); const target = path.resolve(destination, ...relative.split("/")); assertContained(destination, target); if (entry.fileName.endsWith("/")) { @@ -536,14 +539,14 @@ function isArchiveLink(entry: yauzl.Entry): boolean { return (mode & 0o170000) === 0o120000; } -function assertRemoteUrl(value: string, allowHttpLocalhost = false): void { +export function assertRemoteUrl(value: string, allowHttpLocalhost = false): void { const url = new URL(value); if (url.protocol === "https:") return; if (allowHttpLocalhost && url.protocol === "http:" && (url.hostname === "127.0.0.1" || url.hostname === "localhost")) return; throw new Error(`Extension updates require HTTPS: ${value}`); } -async function fetchRemote( +export async function fetchRemote( initialUrl: string, init: RequestInit, allowHttpLocalhost = false, @@ -564,7 +567,7 @@ async function fetchRemote( throw new Error("Extension update redirected too many times"); } -async function readBoundedResponse(response: Response, maximumBytes: number, message: string): Promise { +export async function readBoundedResponse(response: Response, maximumBytes: number, message: string): Promise { const contentLength = response.headers.get("content-length"); if (contentLength !== null) { const declared = Number(contentLength); diff --git a/src/host/host-updater.ts b/src/host/host-updater.ts new file mode 100644 index 0000000..2667458 --- /dev/null +++ b/src/host/host-updater.ts @@ -0,0 +1,227 @@ +import {createHash, randomUUID} from "node:crypto"; +import {createReadStream} from "node:fs"; +import {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 type {JsonLogger} from "../shared/logger.ts"; +import type {HostUpdateStatus} from "../shared/schemas.ts"; +import {assertRemoteUrl, extractArchive, fetchRemote, readBoundedResponse} from "./extension-updater.ts"; + +const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1_000; +const MAX_FEED_BYTES = 256 * 1024; +const MAX_ARCHIVE_BYTES = 150 * 1024 * 1024; + +export const managedFileSchema = z.object({ + path: z.string().min(1), + sha256: z.string().regex(/^[a-f0-9]{64}$/i), + size: z.number().int().nonnegative(), +}).strict(); + +export const releaseManifestSchema = z.object({ + schemaVersion: z.literal(1), + version: z.string().refine((value) => Boolean(semver.valid(value))), + files: z.array(managedFileSchema).max(10_000), +}).strict(); +export type ReleaseManifest = z.infer; + +export const hostReleaseSchema = z.object({ + schemaVersion: z.literal(1), + version: z.string().refine((value) => Boolean(semver.valid(value))), + engines: z.object({zcode: z.string().min(1)}).strict(), + archive: z.object({ + url: z.string().url(), + sha256: z.string().regex(/^[a-f0-9]{64}$/i), + size: z.number().int().positive().max(MAX_ARCHIVE_BYTES), + }).strict(), + releaseUrl: z.string().url(), + publishedAt: z.string().datetime(), +}).strict(); +export type HostRelease = z.infer; + +export const hostUpdateTransactionSchema = z.object({ + schemaVersion: z.literal(1), + phase: z.enum(["ready", "applying", "failed"]), + currentVersion: z.string(), + targetVersion: z.string(), + stagingRoot: z.string().min(1), + zcodeRoot: z.string().min(1), + releaseUrl: z.string().url(), + error: z.string().optional(), +}).strict(); +export type HostUpdateTransaction = z.infer; + +export type HostUpdaterOptions = { + root: string; + zcodeRoot: string; + zcodeVersion: string; + logger: JsonLogger; + onStateChanged: () => void; + feedUrl?: string; + allowHttpLocalhost?: boolean; + launchHelper?: (executable: string, args: string[]) => void; +}; + +export class HostUpdater { + readonly #options: HostUpdaterOptions; + readonly #paths; + #status: HostUpdateStatus = {state: "unknown", currentVersion: HOST_VERSION, installable: false}; + #release?: HostRelease; + #checking = false; + #timer?: NodeJS.Timeout; + + constructor(options: HostUpdaterOptions) { + this.#options = options; + this.#paths = getPaths(options.root); + } + + async initialize(): Promise { + await mkdir(this.#paths.hostUpdateStaging, {recursive: true}); + await cleanupOldHelpers(); + const installable = await this.#installedManifest().then(() => true).catch(() => false); + this.#status = {...this.#status, installable}; + await this.#recoverStatus(); + } + + status(): HostUpdateStatus { return {...this.#status}; } + + startAutomaticChecks(): void { + void this.check(); + this.#timer = setInterval(() => void this.check(), CHECK_INTERVAL_MS); + this.#timer.unref?.(); + } + + dispose(): void { if (this.#timer) clearInterval(this.#timer); } + + async check(): Promise { + if (this.#checking || ["downloading", "ready", "applying"].includes(this.#status.state)) return; + this.#checking = true; + this.#set({state: "checking", error: undefined}); + try { + const feedUrl = this.#options.feedUrl ?? process.env.ZDP_HOST_UPDATE_URL ?? HOST_UPDATE_URL; + const response = await fetchRemote(feedUrl, {headers: {accept: "application/json"}}, this.#options.allowHttpLocalhost); + if (!response.ok) throw new Error(`Host update feed returned HTTP ${response.status}`); + const release = hostReleaseSchema.parse(JSON.parse((await readBoundedResponse(response, MAX_FEED_BYTES, "Host update feed is too large")).toString("utf8"))); + assertRemoteUrl(release.archive.url, this.#options.allowHttpLocalhost); + assertRemoteUrl(release.releaseUrl, this.#options.allowHttpLocalhost); + this.#release = release; + const checkedAt = new Date().toISOString(); + if (semver.lte(release.version, HOST_VERSION)) this.#set({state: "up-to-date", latestVersion: release.version, releaseUrl: release.releaseUrl, checkedAt}); + else if (semver.valid(this.#options.zcodeVersion) && !semver.satisfies(this.#options.zcodeVersion, release.engines.zcode, {includePrerelease: true})) { + this.#set({state: "incompatible", latestVersion: release.version, releaseUrl: release.releaseUrl, checkedAt, error: `Requires ZCode ${release.engines.zcode}`}); + } else this.#set({state: "available", latestVersion: release.version, releaseUrl: release.releaseUrl, checkedAt}); + } catch (error) { + this.#set({state: "error", checkedAt: new Date().toISOString(), error: errorText(error)}); + await this.#options.logger.warn("Host update check failed", {error}); + } finally { this.#checking = false; } + } + + async prepareAndRestart(parentPid: number): Promise { + if (!this.#release || this.#status.state !== "available") throw new Error("No compatible host update is available"); + if (!this.#status.installable) throw new Error("Self-update is available only for packaged installs; open the release page to update this development checkout"); + const release = this.#release; + this.#set({state: "downloading"}); + const transactionRoot = path.join(this.#paths.hostUpdateStaging, randomUUID()); + const archive = path.join(transactionRoot, "update.zip"); + const extracted = path.join(transactionRoot, "extracted"); + try { + await mkdir(extracted, {recursive: true}); + const response = await fetchRemote(release.archive.url, {headers: {accept: "application/zip"}}, this.#options.allowHttpLocalhost); + if (!response.ok) throw new Error(`Host archive returned HTTP ${response.status}`); + const bytes = await readBoundedResponse(response, release.archive.size, "Host update archive is too large"); + if (bytes.length !== release.archive.size) throw new Error(`Host archive size mismatch: expected ${release.archive.size}, received ${bytes.length}`); + if (hash(bytes) !== release.archive.sha256.toLowerCase()) throw new Error("Host archive checksum mismatch"); + await writeFile(archive, bytes); + await extractArchive(archive, extracted, {maxEntries: 10_000, maxExtractedBytes: 500 * 1024 * 1024, label: "Host update"}); + const stagingRoot = await locateReleaseRoot(extracted); + const manifest = releaseManifestSchema.parse(JSON.parse(await readFile(path.join(stagingRoot, "release-manifest.json"), "utf8"))); + if (manifest.version !== release.version) throw new Error(`Host payload version ${manifest.version} does not match feed ${release.version}`); + await verifyManagedFiles(stagingRoot, manifest); + const transaction: HostUpdateTransaction = { + schemaVersion: 1, phase: "ready", currentVersion: HOST_VERSION, targetVersion: release.version, + 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 helperArgs = ["apply-update", "--parent", String(parentPid), "--root", this.#options.root, "--zcode", this.#options.zcodeRoot]; + if (this.#options.launchHelper) this.#options.launchHelper(helper, helperArgs); + else { + const child = spawn(helper, helperArgs, {detached: true, stdio: "ignore", windowsHide: true, env: {...process.env, ZDP_ROOT: this.#options.root}}); + child.unref(); + } + this.#set({state: "applying"}); + } catch (error) { + await rm(transactionRoot, {recursive: true, force: true}).catch(() => undefined); + await rm(this.#paths.hostUpdateState, {force: true}).catch(() => undefined); + this.#set({state: "error", error: errorText(error)}); + throw error; + } + } + + async #installedManifest(): Promise { + const value = releaseManifestSchema.parse(JSON.parse(await readFile(this.#paths.releaseManifest, "utf8"))); + if (value.version !== HOST_VERSION) throw new Error("Installed release manifest does not match this host"); + return value; + } + + async #recoverStatus(): Promise { + try { + const transaction = hostUpdateTransactionSchema.parse(JSON.parse(await readFile(this.#paths.hostUpdateState, "utf8"))); + if (transaction.phase === "failed") this.#set({state: "error", latestVersion: transaction.targetVersion, releaseUrl: transaction.releaseUrl, error: transaction.error ?? "The previous host update was rolled back"}); + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") await this.#options.logger.warn("Ignored invalid host update state", {error}); } + } + + #set(next: Partial): void { + this.#status = {...this.#status, ...next}; + this.#options.onStateChanged(); + } +} + +export async function verifyManagedFiles(root: string, manifest: ReleaseManifest): Promise { + const seen = new Set(); + for (const file of manifest.files) { + const relative = safeManagedPath(file.path); + const key = relative.toLowerCase(); + if (seen.has(key)) throw new Error(`Duplicate managed file: ${relative}`); + seen.add(key); + const target = path.join(root, ...relative.split("/")); + const info = await stat(target); + if (!info.isFile() || info.size !== file.size || await sha256(target) !== file.sha256.toLowerCase()) throw new Error(`Managed file verification failed: ${relative}`); + } +} + +export function safeManagedPath(value: string): string { + if (!value || value.includes("\\") || value.startsWith("/") || /^[A-Za-z]:/.test(value)) throw new Error(`Unsafe managed path: ${value}`); + const parts = value.split("/"); + if (parts.some((part) => !part || part === "." || part === "..")) throw new Error(`Unsafe managed path: ${value}`); + if (parts[0]?.toLowerCase() === "data" || parts[0]?.toLowerCase() === ".git") throw new Error(`Managed path may not enter preserved storage: ${value}`); + return value; +} + +async function locateReleaseRoot(extracted: string): Promise { + if (await exists(path.join(extracted, "release-manifest.json"))) return extracted; + const entries = await readdir(extracted, {withFileTypes: true}); + if (entries.length !== 1 || !entries[0]?.isDirectory()) throw new Error("Host archive must contain exactly one root folder"); + const root = path.join(extracted, entries[0].name); + if (!await exists(path.join(root, "release-manifest.json"))) throw new Error("Host archive has no release-manifest.json"); + return root; +} + +async function sha256(filePath: string): Promise { + const digest = createHash("sha256"); + for await (const chunk of createReadStream(filePath)) digest.update(chunk); + return digest.digest("hex"); +} +function hash(value: Buffer): string { return createHash("sha256").update(value).digest("hex"); } +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)) + .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/src/loader/runtime-bootstrap.mjs b/src/loader/runtime-bootstrap.mjs index fdc4181..616f39e 100644 --- a/src/loader/runtime-bootstrap.mjs +++ b/src/loader/runtime-bootstrap.mjs @@ -1,9 +1,29 @@ -import {readFile} from "node:fs/promises"; +import {readFile, writeFile} from "node:fs/promises"; import path from "node:path"; import {fileURLToPath, pathToFileURL} from "node:url"; const runtimeDir = path.dirname(fileURLToPath(import.meta.url)); const current = JSON.parse(await readFile(path.join(runtimeDir, "current.json"), "utf8")); -if (!current.version || !/^[0-9A-Za-z.-]+$/.test(current.version)) throw new Error("Invalid ZDP runtime version pointer"); -const bootstrap = path.join(runtimeDir, "versions", current.version, "host", "bootstrap.mjs"); -await import(pathToFileURL(bootstrap).href); +const validVersion = (value) => typeof value === "string" && /^[0-9A-Za-z.-]+$/.test(value); +if (!validVersion(current.version)) throw new Error("Invalid ZDP runtime version pointer"); +try { + await load(current.version); +} catch (error) { + if (!validVersion(current.previousVersion) || current.previousVersion === current.version) throw error; + await writeFile(path.join(runtimeDir, "..", "data", "host-update.json"), `${JSON.stringify({ + schemaVersion: 1, + phase: "failed", + currentVersion: current.previousVersion, + targetVersion: current.version, + stagingRoot: runtimeDir, + zcodeRoot: process.env.ZDP_ZCODE_ROOT ?? path.dirname(process.execPath), + releaseUrl: "https://github.com/notmike101/zcode-extensions/releases", + error: error instanceof Error ? error.message : String(error), + }, null, 2)}\n`, "utf8").catch(() => undefined); + await load(current.previousVersion); +} + +async function load(version) { + const bootstrap = path.join(runtimeDir, "versions", version, "host", "bootstrap.mjs"); + await import(pathToFileURL(bootstrap).href); +} diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index c07e486..4b4b2c6 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -38,6 +38,7 @@ function DesktopPluginsApp({bridge}: {bridge: ZdpBridge}) { const [error, setError] = useState(); const [busy, setBusy] = useState(); const [rendererRevision, setRendererRevision] = useState(0); + const [updateNotice, setUpdateNotice] = useState(); const refresh = async () => { try { @@ -68,8 +69,20 @@ function DesktopPluginsApp({bridge}: {bridge: ZdpBridge}) { useEffect(() => installNativeNavigationItem(() => setOpen(true)), []); - const availableUpdateCount = state?.plugins.filter((plugin) => plugin.update.state === "available").length ?? 0; + const extensionUpdateCount = state?.plugins.filter((plugin) => plugin.update.state === "available").length ?? 0; + const hostUpdateAvailable = state?.hostUpdate.state === "available" ? 1 : 0; + const availableUpdateCount = extensionUpdateCount + hostUpdateAvailable; useEffect(() => updateNativeNavigationBadge(availableUpdateCount), [availableUpdateCount]); + useEffect(() => { + const version = state?.hostUpdate.state === "available" ? state.hostUpdate.latestVersion : undefined; + if (!version) return; + const key = `zdp-host-update-noticed:${version}`; + try { + if (window.localStorage.getItem(key)) return; + window.localStorage.setItem(key, "1"); + } catch { /* notification still works when storage is unavailable */ } + setUpdateNotice(version); + }, [state?.hostUpdate.state, state?.hostUpdate.latestVersion]); const pages = useMemo(() => state?.plugins.flatMap((plugin) => plugin.enabled ? plugin.manifest.pages.map((page) => ({...page, pluginId: plugin.manifest.id})) @@ -83,6 +96,7 @@ function DesktopPluginsApp({bridge}: {bridge: ZdpBridge}) { }; return <> + {updateNotice &&
Host update {updateNotice} is availableOpen Extensions to review and install it.
} {open &&
event.target === event.currentTarget && setOpen(false)}>