From 041a589afb58b2250c7a888b194e293c8ee2f525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Wed, 22 Jul 2026 22:40:59 -0300 Subject: [PATCH 1/5] feat(tauri-updater): add shared update controller and dialog --- bun.lock | 21 ++ package.json | 3 +- packages/tauri-updater/README.md | 31 ++ packages/tauri-updater/fixture/index.html | 54 ++++ packages/tauri-updater/package.json | 25 ++ packages/tauri-updater/src/controller.ts | 242 +++++++++++++++ packages/tauri-updater/src/element.ts | 279 ++++++++++++++++++ packages/tauri-updater/src/index.ts | 3 + packages/tauri-updater/src/tauri-adapter.ts | 52 ++++ .../tauri-updater/test/controller.test.ts | 257 ++++++++++++++++ packages/tauri-updater/test/element.test.ts | 127 ++++++++ .../tauri-updater/test/tauri-adapter.test.ts | 41 +++ packages/tauri-updater/tsconfig.json | 10 + 13 files changed, 1144 insertions(+), 1 deletion(-) create mode 100644 packages/tauri-updater/README.md create mode 100644 packages/tauri-updater/fixture/index.html create mode 100644 packages/tauri-updater/package.json create mode 100644 packages/tauri-updater/src/controller.ts create mode 100644 packages/tauri-updater/src/element.ts create mode 100644 packages/tauri-updater/src/index.ts create mode 100644 packages/tauri-updater/src/tauri-adapter.ts create mode 100644 packages/tauri-updater/test/controller.test.ts create mode 100644 packages/tauri-updater/test/element.test.ts create mode 100644 packages/tauri-updater/test/tauri-adapter.test.ts create mode 100644 packages/tauri-updater/tsconfig.json diff --git a/bun.lock b/bun.lock index 8196bb9..6b64a08 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@types/bun": "^1.3.5", "@types/node": "^24.10.1", "@vitest/coverage-v8": "^4.0.14", + "happy-dom": "^20.8.4", "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^4.0.14", @@ -51,6 +52,10 @@ "pickforge-tauri-release": "dist/cli.js", }, }, + "packages/tauri-updater": { + "name": "@pickforge/tauri-updater", + "version": "0.12.0", + }, }, "packages": { "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], @@ -153,6 +158,8 @@ "@pickforge/tauri-release": ["@pickforge/tauri-release@workspace:packages/tauri-release"], + "@pickforge/tauri-updater": ["@pickforge/tauri-updater@workspace:packages/tauri-updater"], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.4", "", { "os": "android", "cpu": "arm64" }, "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ=="], @@ -263,6 +270,10 @@ "@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.1.10", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.10", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", "tinyrainbow": "^3.1.0" }, "peerDependencies": { "@vitest/browser": "4.1.10", "vitest": "4.1.10" }, "optionalPeers": ["@vitest/browser"] }, "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g=="], "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], @@ -287,6 +298,8 @@ "ast-v8-to-istanbul": ["ast-v8-to-istanbul@1.0.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], @@ -309,6 +322,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "es-module-lexer": ["es-module-lexer@2.3.0", "", {}, "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw=="], "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], @@ -323,6 +338,8 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "happy-dom": ["happy-dom@20.11.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], @@ -457,8 +474,12 @@ "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "vitest/tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], diff --git a/package.json b/package.json index a51d6bc..d5c2aa5 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "packages/*" ], "scripts": { - "build": "bun run --cwd packages/tauri-release build && bun run --cwd packages/auth build && bun run --cwd packages/brand build && bun run --cwd packages/flags build && bun run --cwd packages/billing build && bun run --cwd packages/edge-shared build && bun run --cwd packages/sync build", + "build": "bun run --cwd packages/tauri-release build && bun run --cwd packages/tauri-updater build && bun run --cwd packages/auth build && bun run --cwd packages/brand build && bun run --cwd packages/flags build && bun run --cwd packages/billing build && bun run --cwd packages/edge-shared build && bun run --cwd packages/sync build", "test": "vitest run", "test:supabase": "supabase test db supabase/tests/database --local && bun run supabase/tests/welcome-credits-concurrency.ts && bun test packages/billing/test/checkout-lifecycle.contract.test.ts && bun test packages/sync/test/lww.contract.test.ts && bun test packages/edge-shared/test/router-attempt.contract.test.ts", "test:coverage": "vitest run --coverage", @@ -19,6 +19,7 @@ "@types/bun": "^1.3.5", "@types/node": "^24.10.1", "@vitest/coverage-v8": "^4.0.14", + "happy-dom": "^20.8.4", "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^4.0.14" diff --git a/packages/tauri-updater/README.md b/packages/tauri-updater/README.md new file mode 100644 index 0000000..b33b5ff --- /dev/null +++ b/packages/tauri-updater/README.md @@ -0,0 +1,31 @@ +# @pickforge/tauri-updater + +Framework-neutral update control and a branded `` Web Component for Tauri desktop apps. + +```ts +import { check } from "@tauri-apps/plugin-updater"; +import { relaunch } from "@tauri-apps/plugin-process"; +import { + createTauriUpdaterAdapter, + createUpdateController, + definePickforgeUpdaterElement, +} from "@pickforge/tauri-updater"; + +const controller = createUpdateController({ + adapter: createTauriUpdaterAdapter({ check, relaunch }), + eligibility: { + // Resolve only after the packaged app's focused, visible main window is ready. + whenEligible: async () => true, + }, +}); + +definePickforgeUpdaterElement(); +const dialog = document.querySelector("pickforge-update-dialog"); +dialog.metadata = { productName: "PickForge", currentVersion: "1.0.0" }; +dialog.controller = controller; +void controller.start(); +``` + +`start()` silently ignores unavailable feeds and no-update responses. `check({ silent: false })` is available for a manual surface. The default process gate shares the first check across controllers; inject `createProcessCheckGate()` to isolate deterministic tests. + +Build the package and serve `fixture/` from the repository root to inspect the standalone states. diff --git a/packages/tauri-updater/fixture/index.html b/packages/tauri-updater/fixture/index.html new file mode 100644 index 0000000..c216af7 --- /dev/null +++ b/packages/tauri-updater/fixture/index.html @@ -0,0 +1,54 @@ + + + + + + Pickforge updater fixture + + + + + +

Updater fixture

The app remains usable behind the modal.

+ + + + diff --git a/packages/tauri-updater/package.json b/packages/tauri-updater/package.json new file mode 100644 index 0000000..3e26042 --- /dev/null +++ b/packages/tauri-updater/package.json @@ -0,0 +1,25 @@ +{ + "name": "@pickforge/tauri-updater", + "version": "0.12.0", + "description": "Framework-neutral Tauri update controller and Pickforge update dialog.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/pickforge/pickforge-platform.git", + "directory": "packages/tauri-updater" + }, + "type": "module", + "files": ["dist"], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean --splitting false --out-dir dist" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/tauri-updater/src/controller.ts b/packages/tauri-updater/src/controller.ts new file mode 100644 index 0000000..9f1d0cd --- /dev/null +++ b/packages/tauri-updater/src/controller.ts @@ -0,0 +1,242 @@ +export interface UpdateInfo { + version: string; + notes?: string; +} + +export type UpdateDownloadEvent = + | { type: "started"; contentLength?: number } + | { type: "progress"; chunkLength: number } + | { type: "finished" }; + +export interface UpdateAdapter { + check(): Promise; + downloadAndInstall(onEvent: (event: UpdateDownloadEvent) => void): Promise; + relaunch(): Promise; +} + +export interface UpdateEligibility { + whenEligible(): Promise; +} + +export interface StaticEligibility { + packaged: boolean; + mainWindow: boolean; + visible: boolean; + focused: boolean; +} + +export interface DownloadProgress { + downloaded: number; + contentLength: number | null; + percent: number | null; +} + +export type UpdateState = + | { status: "idle" } + | { status: "checking" } + | { status: "available"; update: UpdateInfo } + | { status: "downloading"; update: UpdateInfo; progress: DownloadProgress } + | { status: "installing"; update: UpdateInfo } + | { status: "restarting" } + | { status: "dismissed"; update?: UpdateInfo } + | { + status: "error"; + update?: UpdateInfo; + message: string; + retry: "check" | "install" | "relaunch"; + }; + +export interface ProcessCheckGate { + run(check: () => Promise): Promise; +} + +export interface UpdateController { + getState(): UpdateState; + start(): Promise; + check(options?: { silent?: boolean }): Promise; + install(): Promise; + retry(): Promise; + dismiss(): void; + subscribe(listener: (state: UpdateState) => void): () => void; +} + +const defaultProcessGate = createProcessCheckGate(); + +export function createProcessCheckGate(): ProcessCheckGate { + let result: Promise | undefined; + + return { + run(check) { + result ??= Promise.resolve().then(check).catch((error: unknown) => { + result = undefined; + throw error; + }); + return result; + }, + }; +} + +export function createEligibility(value: StaticEligibility): UpdateEligibility { + return { + async whenEligible() { + return value.packaged && value.mainWindow && value.visible && value.focused; + }, + }; +} + +export function createUpdateController(options: { + adapter: UpdateAdapter; + eligibility: UpdateEligibility; + gate?: ProcessCheckGate; +}): UpdateController { + const { adapter, eligibility } = options; + const gate = options.gate ?? defaultProcessGate; + const listeners = new Set<(state: UpdateState) => void>(); + let state: UpdateState = { status: "idle" }; + let update: UpdateInfo | undefined; + let started = false; + + const setState = (next: UpdateState): void => { + state = next; + for (const listener of listeners) listener(state); + }; + + const check = async ({ silent = false }: { silent?: boolean } = {}): Promise => { + if (state.status === "dismissed" || (started && state.status !== "idle")) return; + started = true; + setState({ status: "checking" }); + try { + const found = await gate.run(() => adapter.check()); + if (found === null) { + setState({ status: "idle" }); + return; + } + update = found; + setState({ status: "available", update: found }); + } catch (error) { + setState( + silent + ? { status: "idle" } + : { status: "error", message: errorMessage(error), retry: "check" }, + ); + } + }; + + const install = async (): Promise => { + if (update === undefined) return; + const selected = update; + let downloaded = 0; + let contentLength: number | null = null; + let finished = false; + setState({ + status: "downloading", + update: selected, + progress: { downloaded, contentLength, percent: null }, + }); + + try { + await adapter.downloadAndInstall((event) => { + if (event.type === "started") { + contentLength = + event.contentLength !== undefined && event.contentLength > 0 + ? event.contentLength + : null; + downloaded = 0; + setState({ + status: "downloading", + update: selected, + progress: progress(downloaded, contentLength), + }); + return; + } + if (event.type === "progress") { + downloaded += Math.max(0, event.chunkLength); + setState({ + status: "downloading", + update: selected, + progress: progress(downloaded, contentLength), + }); + return; + } + finished = true; + setState({ status: "installing", update: selected }); + }); + if (!finished) setState({ status: "installing", update: selected }); + setState({ status: "restarting" }); + await adapter.relaunch(); + } catch (error) { + setState({ + status: "error", + update: selected, + message: errorMessage(error), + retry: state.status === "restarting" ? "relaunch" : "install", + }); + } + }; + + const retry = async (): Promise => { + if (state.status !== "error") return; + if (state.retry === "install") { + await install(); + return; + } + if (state.retry === "relaunch") { + setState({ status: "restarting" }); + try { + await adapter.relaunch(); + } catch (error) { + setState({ + status: "error", + update, + message: errorMessage(error), + retry: "relaunch", + }); + } + return; + } + started = false; + await check(); + }; + + return { + getState: () => state, + async start() { + if (!(await eligibility.whenEligible())) return; + await check({ silent: true }); + }, + check, + install, + retry, + dismiss() { + if (state.status === "downloading" || state.status === "installing" || state.status === "restarting") { + return; + } + setState( + update === undefined + ? { status: "dismissed" } + : { status: "dismissed", update }, + ); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +function progress(downloaded: number, contentLength: number | null): DownloadProgress { + return { + downloaded, + contentLength, + percent: + contentLength === null + ? null + : Math.min(100, Math.round((downloaded / contentLength) * 100)), + }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "The update could not be completed."; +} diff --git a/packages/tauri-updater/src/element.ts b/packages/tauri-updater/src/element.ts new file mode 100644 index 0000000..e1a23f9 --- /dev/null +++ b/packages/tauri-updater/src/element.ts @@ -0,0 +1,279 @@ +import type { UpdateController, UpdateState } from "./controller"; + +export interface UpdateDialogMetadata { + productName: string; + currentVersion: string; + productMark?: string; +} + +const DEFAULT_METADATA: UpdateDialogMetadata = { + productName: "Pickforge", + currentVersion: "—", + productMark: "PF", +}; + +const HTMLElementBase = (globalThis.HTMLElement ?? class extends EventTarget {}) as typeof HTMLElement; + +export class PickforgeUpdateDialogElement extends HTMLElementBase { + readonly #root = this.attachShadow({ mode: "open" }); + #controller: UpdateController | undefined; + #metadata = DEFAULT_METADATA; + #unsubscribe: (() => void) | undefined; + #state: UpdateState = { status: "idle" }; + #restoreFocus: HTMLElement | null = null; + + set controller(value: UpdateController | undefined) { + if (this.#controller === value) return; + this.#unsubscribe?.(); + this.#controller = value; + this.#state = value?.getState() ?? { status: "idle" }; + if (this.isConnected && value !== undefined) { + this.#unsubscribe = value.subscribe((state) => { + this.#state = state; + this.#render(); + }); + } + this.#render(); + } + + get controller(): UpdateController | undefined { + return this.#controller; + } + + set metadata(value: UpdateDialogMetadata) { + this.#metadata = { ...DEFAULT_METADATA, ...value }; + this.#render(); + } + + get metadata(): UpdateDialogMetadata { + return this.#metadata; + } + + connectedCallback(): void { + if (this.#controller !== undefined && this.#unsubscribe === undefined) { + this.#unsubscribe = this.#controller.subscribe((state) => { + this.#state = state; + this.#render(); + }); + } + this.#render(); + } + + disconnectedCallback(): void { + this.#unsubscribe?.(); + this.#unsubscribe = undefined; + this.#close(); + } + + #render(): void { + const previousDialog = this.#root.querySelector("dialog"); + const wasOpen = previousDialog?.hasAttribute("open") === true; + if (!isVisibleState(this.#state)) { + this.#root.innerHTML = ``; + if (wasOpen) this.#restoreFocus?.focus(); + this.#restoreFocus = null; + return; + } + + const state = this.#state; + const update = "update" in state ? state.update : undefined; + const notes = update?.notes?.trim(); + const isAvailable = state.status === "available"; + const isError = state.status === "error"; + const isDownloading = state.status === "downloading"; + const isInstalling = state.status === "installing"; + const isRestarting = state.status === "restarting"; + const title = isError + ? "Update paused" + : isRestarting + ? "Update installed" + : isDownloading || isInstalling + ? "Updating your app" + : "A new version is ready"; + const description = isError + ? "The app remains available. Retry when you’re ready." + : isRestarting + ? "Restarting to finish the update." + : notes ?? "Download the latest Pickforge Studio release."; + + this.#root.innerHTML = ` + + +
+
+ + UPDATE AVAILABLE +
+

+

+
+ Current + + New +
+
+
+
+
`; + + setText(this.#root, ".mark", this.#metadata.productMark ?? "PF"); + setText(this.#root, "#pf-updater-title", title); + setText(this.#root, "#pf-updater-description", description); + setText(this.#root, ".current-version", this.#metadata.currentVersion); + setText(this.#root, ".new-version", update?.version ?? "Installed"); + + const status = this.#root.querySelector(".status"); + const footer = this.#root.querySelector("footer"); + if (status === null || footer === null) return; + + if (notes !== undefined && (isAvailable || isError)) { + const notesElement = document.createElement("pre"); + notesElement.className = "notes"; + notesElement.textContent = notes; + status.append(notesElement); + } + if (isDownloading) { + status.append(createProgress(state.progress.percent)); + } else if (isInstalling) { + status.textContent = "Installing update…"; + } else if (isRestarting) { + status.textContent = "Installation complete. Restarting…"; + } else if (isError) { + const error = document.createElement("p"); + error.className = "error"; + error.setAttribute("role", "alert"); + error.textContent = state.message; + status.prepend(error); + } + + if (isAvailable) { + footer.append( + actionButton("Later", "later", "secondary"), + actionButton("Update & restart", "install", "primary"), + ); + } else if (isError) { + footer.append( + actionButton("Later", "later", "secondary"), + actionButton("Retry", "retry", "primary"), + ); + } + + this.#wireActions(); + const dialog = this.#root.querySelector("dialog"); + if (dialog === null) return; + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + if ( + this.#state.status === "available" || + (this.#state.status === "error" && this.#state.retry === "check") + ) { + this.#controller?.dismiss(); + } + }); + if (!wasOpen) this.#restoreFocus = activeElement(); + this.#open(dialog); + if (!wasOpen) this.#root.querySelector(".primary")?.focus(); + } + + #wireActions(): void { + this.#root.querySelector('[data-action="later"]')?.addEventListener("click", () => { + this.#controller?.dismiss(); + }); + this.#root.querySelector('[data-action="install"]')?.addEventListener("click", () => { + void this.#controller?.install(); + }); + this.#root.querySelector('[data-action="retry"]')?.addEventListener("click", () => { + void this.#controller?.retry(); + }); + } + + #open(dialog: HTMLDialogElement): void { + if (dialog.hasAttribute("open")) return; + if (typeof dialog.showModal === "function") dialog.showModal(); + else dialog.setAttribute("open", ""); + } + + #close(): void { + const dialog = this.#root.querySelector("dialog"); + if (dialog?.hasAttribute("open")) { + if (typeof dialog.close === "function") dialog.close(); + else dialog.removeAttribute("open"); + } + this.#restoreFocus?.focus(); + this.#restoreFocus = null; + } +} + +export function definePickforgeUpdaterElement( + name = "pickforge-update-dialog", + registry: CustomElementRegistry = customElements, +): void { + if (registry.get(name) === undefined) registry.define(name, PickforgeUpdateDialogElement); +} + +function isVisibleState(state: UpdateState): boolean { + return ["available", "downloading", "installing", "restarting", "error"].includes(state.status); +} + +function setText(root: ShadowRoot, selector: string, value: string): void { + const element = root.querySelector(selector); + if (element !== null) element.textContent = value; +} + +function actionButton(label: string, action: string, className: string): HTMLButtonElement { + const button = document.createElement("button"); + button.type = "button"; + button.className = className; + button.dataset.action = action; + button.textContent = label; + return button; +} + +function createProgress(percent: number | null): HTMLElement { + const wrapper = document.createElement("div"); + wrapper.className = "progress-wrap"; + const label = document.createElement("span"); + label.textContent = percent === null ? "Downloading update…" : `Downloading… ${percent}%`; + const progress = document.createElement("progress"); + progress.max = 100; + if (percent !== null) progress.value = percent; + progress.setAttribute("aria-label", label.textContent); + wrapper.append(label, progress); + return wrapper; +} + +function activeElement(): HTMLElement | null { + const element = document.activeElement; + return element instanceof HTMLElement ? element : null; +} + +const STYLES = ` + :host { color: var(--pf-text-hi, #f2f2f3); font-family: var(--pf-font-sans, "Geist Sans", system-ui, sans-serif); } + dialog { background: transparent; border: 0; color: inherit; margin: auto; max-height: calc(100vh - 32px); max-width: min(560px, calc(100vw - 32px)); padding: 0; width: 100%; } + dialog::backdrop { background: rgba(0, 0, 0, .62); } + .card { animation: pf-updater-enter var(--pf-dur-fast, 180ms) var(--pf-ease-forge, ease-out) both; backdrop-filter: blur(24px); background: color-mix(in srgb, var(--pf-surface-2, #141417) 92%, transparent); border: 1px solid var(--pf-hairline-strong, rgba(255,255,255,.14)); border-radius: var(--pf-radius-lg, 14px); box-shadow: var(--pf-shadow-overlay, 0 16px 48px -10px rgba(0,0,0,.6)); box-sizing: border-box; max-height: calc(100vh - 32px); overflow: auto; padding: var(--pf-space-xl, 24px); } + header { align-items: center; display: flex; gap: var(--pf-space-sm, 8px); } + .mark { align-items: center; background: var(--pf-item-fill, rgba(255,255,255,.03)); border: 1px solid var(--pf-hairline-strong, rgba(255,255,255,.14)); border-radius: var(--pf-radius-sm, 6px); color: var(--pf-ember, #ff7a1a); display: inline-flex; font-family: var(--pf-font-mono, monospace); font-size: 10px; font-weight: 700; height: 26px; justify-content: center; width: 26px; } + .eyebrow, small { color: var(--pf-text-low, #6e6e75); font-family: var(--pf-font-mono, monospace); font-size: var(--pf-size-eyebrow, 10px); font-weight: 500; letter-spacing: var(--pf-tracking-eyebrow, 1.8px); text-transform: uppercase; } + h2 { font-size: var(--pf-size-headline-md, 21px); line-height: 1.2; margin: var(--pf-space-lg, 16px) 0 var(--pf-space-sm, 8px); } + .description { color: var(--pf-text-med, #a0a0a6); font-size: var(--pf-size-body-md, 13px); line-height: 1.5; margin: 0; } + .version-rail { align-items: center; background: var(--pf-item-fill, rgba(255,255,255,.03)); border: 1px solid var(--pf-hairline, rgba(255,255,255,.08)); border-radius: var(--pf-radius-md, 10px); display: grid; gap: var(--pf-space-md, 12px); grid-template-columns: 1fr auto 1fr; margin-top: var(--pf-space-lg, 16px); padding: var(--pf-space-md, 12px) var(--pf-space-lg, 16px); } + .version-rail span:not(.tick) { display: grid; gap: 3px; } + .version-rail b { font-family: var(--pf-font-mono, monospace); font-size: var(--pf-size-mono, 12px); font-variant-numeric: tabular-nums; } + .version-rail span:last-child { text-align: right; } + .tick { color: var(--pf-ember, #ff7a1a); } + .status { min-height: 20px; } + .notes { background: transparent; color: var(--pf-text-med, #a0a0a6); font: 400 var(--pf-size-body-sm, 12px)/1.5 var(--pf-font-sans, system-ui); margin: var(--pf-space-lg, 16px) 0 0; max-height: min(180px, 28vh); overflow: auto; white-space: pre-wrap; } + .error { color: var(--pf-error, #ff6b5c); font-size: var(--pf-size-body-md, 13px); margin: var(--pf-space-lg, 16px) 0 0; } + .progress-wrap { display: grid; gap: var(--pf-space-sm, 8px); margin-top: var(--pf-space-lg, 16px); } + .progress-wrap span { color: var(--pf-text-med, #a0a0a6); font-size: var(--pf-size-body-sm, 12px); } + progress { accent-color: var(--pf-ember, #ff7a1a); height: 6px; width: 100%; } + footer { display: flex; gap: var(--pf-space-sm, 8px); justify-content: flex-end; margin-top: var(--pf-space-xl, 24px); } + button { border-radius: var(--pf-radius-sm, 6px); cursor: pointer; font: 600 var(--pf-size-label-md, 12px) var(--pf-font-sans, system-ui); min-height: 36px; padding: 0 var(--pf-space-lg, 16px); } + button:focus-visible { outline: 2px solid var(--pf-ember-soft, #ff9a4a); outline-offset: 2px; } + .secondary { background: transparent; border: 1px solid var(--pf-hairline-strong, rgba(255,255,255,.14)); color: var(--pf-text-med, #a0a0a6); } + .primary { background: var(--pf-ember, #ff7a1a); border: 1px solid var(--pf-ember, #ff7a1a); color: #15100c; } + @keyframes pf-updater-enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } + @media (max-height: 580px) { .card { padding: var(--pf-space-lg, 16px); } .notes { max-height: 120px; } footer { margin-top: var(--pf-space-lg, 16px); } } + @media (prefers-reduced-motion: reduce) { .card { animation: none; } } +`; diff --git a/packages/tauri-updater/src/index.ts b/packages/tauri-updater/src/index.ts new file mode 100644 index 0000000..7cac58a --- /dev/null +++ b/packages/tauri-updater/src/index.ts @@ -0,0 +1,3 @@ +export * from "./controller"; +export * from "./element"; +export * from "./tauri-adapter"; diff --git a/packages/tauri-updater/src/tauri-adapter.ts b/packages/tauri-updater/src/tauri-adapter.ts new file mode 100644 index 0000000..b9f0a6f --- /dev/null +++ b/packages/tauri-updater/src/tauri-adapter.ts @@ -0,0 +1,52 @@ +import type { UpdateAdapter, UpdateDownloadEvent } from "./controller"; + +export type TauriDownloadEvent = + | { event: "Started"; data: { contentLength?: number } } + | { event: "Progress"; data: { chunkLength: number } } + | { event: "Finished" }; + +export interface TauriUpdate { + version: string; + body?: string; + downloadAndInstall(onEvent: (event: TauriDownloadEvent) => void): Promise; +} + +export interface TauriUpdaterBindings { + check(): Promise; + relaunch(): Promise; +} + +export function createTauriUpdaterAdapter(bindings: TauriUpdaterBindings): UpdateAdapter { + let selected: TauriUpdate | null = null; + + return { + async check() { + selected = await bindings.check(); + if (selected === null) return null; + return { + version: selected.version, + ...(selected.body === undefined ? {} : { notes: selected.body }), + }; + }, + async downloadAndInstall(onEvent) { + if (selected === null) throw new Error("No update is available to install."); + await selected.downloadAndInstall((event) => onEvent(normalizeEvent(event))); + }, + relaunch: () => bindings.relaunch(), + }; +} + +function normalizeEvent(event: TauriDownloadEvent): UpdateDownloadEvent { + if (event.event === "Started") { + return { + type: "started", + ...(event.data.contentLength === undefined + ? {} + : { contentLength: event.data.contentLength }), + }; + } + if (event.event === "Progress") { + return { type: "progress", chunkLength: event.data.chunkLength }; + } + return { type: "finished" }; +} diff --git a/packages/tauri-updater/test/controller.test.ts b/packages/tauri-updater/test/controller.test.ts new file mode 100644 index 0000000..16f9956 --- /dev/null +++ b/packages/tauri-updater/test/controller.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createEligibility, + createProcessCheckGate, + createUpdateController, + type UpdateAdapter, + type UpdateDownloadEvent, +} from "../src/index"; + +function adapter(options: { + update?: { version: string; notes?: string } | null; + checkError?: Error; + events?: UpdateDownloadEvent[]; +} = {}): UpdateAdapter { + return { + check: vi.fn(async () => { + if (options.checkError !== undefined) throw options.checkError; + return options.update === undefined + ? { version: "2.0.0", notes: "Safer updates" } + : options.update; + }), + downloadAndInstall: vi.fn(async (onEvent) => { + for (const event of options.events ?? []) onEvent(event); + }), + relaunch: vi.fn(async () => undefined), + }; +} + +const eligible = createEligibility({ + packaged: true, + mainWindow: true, + visible: true, + focused: true, +}); + +describe("createUpdateController", () => { + it("does nothing outside packaged visible focused main windows", async () => { + for (const eligibility of [ + createEligibility({ packaged: false, mainWindow: true, visible: true, focused: true }), + createEligibility({ packaged: true, mainWindow: false, visible: true, focused: true }), + createEligibility({ packaged: true, mainWindow: true, visible: false, focused: true }), + createEligibility({ packaged: true, mainWindow: true, visible: true, focused: false }), + ]) { + const updateAdapter = adapter(); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility, + gate: createProcessCheckGate(), + }); + await controller.start(); + expect(updateAdapter.check).not.toHaveBeenCalled(); + expect(controller.getState()).toEqual({ status: "idle" }); + } + }); + + it("defers startup until the main window becomes visible and focused", async () => { + let release!: (eligible: boolean) => void; + const updateAdapter = adapter(); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: { + whenEligible: () => new Promise((resolve) => { release = resolve; }), + }, + gate: createProcessCheckGate(), + }); + + const starting = controller.start(); + await Promise.resolve(); + expect(updateAdapter.check).not.toHaveBeenCalled(); + release(true); + await starting; + expect(controller.getState()).toMatchObject({ + status: "available", + update: { version: "2.0.0" }, + }); + }); + + it("checks once per process gate across controllers", async () => { + const updateAdapter = adapter(); + const gate = createProcessCheckGate(); + const first = createUpdateController({ adapter: updateAdapter, eligibility: eligible, gate }); + const second = createUpdateController({ adapter: updateAdapter, eligibility: eligible, gate }); + + await Promise.all([first.start(), second.start()]); + expect(updateAdapter.check).toHaveBeenCalledTimes(1); + expect(first.getState().status).toBe("available"); + expect(second.getState().status).toBe("available"); + }); + + it("stays idle when no update exists or a startup check fails", async () => { + const noUpdate = createUpdateController({ + adapter: adapter({ update: null }), + eligibility: eligible, + gate: createProcessCheckGate(), + }); + const failed = createUpdateController({ + adapter: adapter({ checkError: new Error("offline") }), + eligibility: eligible, + gate: createProcessCheckGate(), + }); + + await expect(noUpdate.start()).resolves.toBeUndefined(); + await expect(failed.start()).resolves.toBeUndefined(); + expect(noUpdate.getState()).toEqual({ status: "idle" }); + expect(failed.getState()).toEqual({ status: "idle" }); + }); + + it("shows manual check failures and retries them", async () => { + const updateAdapter = adapter(); + vi.mocked(updateAdapter.check) + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce({ version: "2.0.0" }); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + + await controller.check(); + expect(controller.getState()).toMatchObject({ + status: "error", + message: "offline", + retry: "check", + }); + await controller.retry(); + expect(controller.getState()).toMatchObject({ status: "available" }); + expect(updateAdapter.check).toHaveBeenCalledTimes(2); + }); + + it("dismisses for this process and does not check again", async () => { + const updateAdapter = adapter(); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + + await controller.start(); + controller.dismiss(); + await controller.check(); + expect(controller.getState().status).toBe("dismissed"); + expect(updateAdapter.check).toHaveBeenCalledTimes(1); + }); + + it("reports determinate progress before installing and relaunching", async () => { + const updateAdapter = adapter({ + events: [ + { type: "started", contentLength: 100 }, + { type: "progress", chunkLength: 25 }, + { type: "progress", chunkLength: 50 }, + { type: "finished" }, + ], + }); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + const states: string[] = []; + const progresses: Array = []; + controller.subscribe((state) => { + states.push(state.status); + if (state.status === "downloading") progresses.push(state.progress.percent); + }); + + await controller.start(); + await controller.install(); + + expect(states).toEqual(expect.arrayContaining([ + "checking", + "available", + "downloading", + "installing", + "restarting", + ])); + expect(progresses).toEqual([null, 0, 25, 75]); + expect(updateAdapter.relaunch).toHaveBeenCalledTimes(1); + expect(controller.getState()).toEqual({ status: "restarting" }); + }); + + it("uses indeterminate progress when content length is absent", async () => { + const updateAdapter = adapter({ + events: [{ type: "started" }, { type: "progress", chunkLength: 25 }], + }); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + const progresses: Array = []; + controller.subscribe((state) => { + if (state.status === "downloading") progresses.push(state.progress.percent); + }); + + await controller.start(); + await controller.install(); + expect(progresses).toEqual([null, null, null]); + }); + + it("surfaces install errors and retries the retained update", async () => { + let attempts = 0; + const updateAdapter = adapter(); + vi.mocked(updateAdapter.downloadAndInstall).mockImplementation(async () => { + attempts += 1; + if (attempts === 1) throw new Error("disk full"); + }); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + + await controller.start(); + await controller.install(); + expect(controller.getState()).toMatchObject({ + status: "error", + message: "disk full", + retry: "install", + }); + await controller.retry(); + expect(updateAdapter.downloadAndInstall).toHaveBeenCalledTimes(2); + expect(controller.getState()).toEqual({ status: "restarting" }); + }); + + it("protects in-progress installation from dismissal", async () => { + let release!: () => void; + const updateAdapter = adapter(); + vi.mocked(updateAdapter.downloadAndInstall).mockImplementation( + () => new Promise((resolve) => { release = resolve; }), + ); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + await controller.start(); + const installing = controller.install(); + controller.dismiss(); + expect(controller.getState().status).toBe("downloading"); + release(); + await installing; + }); + + it("stops notifications after unsubscribe", async () => { + const controller = createUpdateController({ + adapter: adapter(), + eligibility: eligible, + gate: createProcessCheckGate(), + }); + const listener = vi.fn(); + const unsubscribe = controller.subscribe(listener); + unsubscribe(); + await controller.start(); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/tauri-updater/test/element.test.ts b/packages/tauri-updater/test/element.test.ts new file mode 100644 index 0000000..6e0fb43 --- /dev/null +++ b/packages/tauri-updater/test/element.test.ts @@ -0,0 +1,127 @@ +// @vitest-environment happy-dom +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + createEligibility, + createProcessCheckGate, + createUpdateController, + definePickforgeUpdaterElement, + type UpdateAdapter, + type UpdateController, +} from "../src/index"; + +const elements: Element[] = []; +afterEach(() => { + for (const element of elements.splice(0)) element.remove(); +}); + +function setup(options: { notes?: string } = {}): { + adapter: UpdateAdapter; + controller: UpdateController; + root: ShadowRoot; +} { + definePickforgeUpdaterElement(); + const adapter: UpdateAdapter = { + check: vi.fn(async () => ({ version: "2.0.0", notes: options.notes })), + downloadAndInstall: vi.fn(async () => undefined), + relaunch: vi.fn(async () => undefined), + }; + const controller = createUpdateController({ + adapter, + eligibility: createEligibility({ + packaged: true, + mainWindow: true, + visible: true, + focused: true, + }), + gate: createProcessCheckGate(), + }); + const element = document.createElement("pickforge-update-dialog") as HTMLElement & { + controller: UpdateController; + metadata: { productName: string; currentVersion: string; productMark: string }; + }; + element.metadata = { + productName: "PickForge", + currentVersion: "1.0.0", + productMark: "PF", + }; + element.controller = controller; + document.body.append(element); + elements.push(element); + return { adapter, controller, root: element.shadowRoot! }; +} + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe("pickforge-update-dialog", () => { + it("renders a labelled native dialog with plain-text notes and primary focus", async () => { + const { controller, root } = setup({ + notes: "\nA useful fix", + }); + await controller.start(); + await flush(); + + const dialog = root.querySelector("dialog")!; + expect(dialog.hasAttribute("open")).toBe(true); + expect(dialog.getAttribute("aria-labelledby")).toBe("pf-updater-title"); + expect(dialog.getAttribute("aria-describedby")).toBe("pf-updater-description"); + expect(root.querySelector(".notes")?.textContent).toContain(" { + const later = setup(); + await later.controller.start(); + await flush(); + (later.root.querySelector('[data-action="later"]') as HTMLButtonElement).click(); + expect(later.controller.getState().status).toBe("dismissed"); + + const escape = setup(); + await escape.controller.start(); + await flush(); + escape.root + .querySelector("dialog")! + .dispatchEvent(new Event("cancel", { cancelable: true })); + expect(escape.controller.getState().status).toBe("dismissed"); + }); + + it("does not dismiss from Escape after download begins or from backdrop clicks", async () => { + let release!: () => void; + const { adapter, controller, root } = setup(); + vi.mocked(adapter.downloadAndInstall).mockImplementation( + () => new Promise((resolve) => { release = resolve; }), + ); + await controller.start(); + await flush(); + (root.querySelector('[data-action="install"]') as HTMLButtonElement).click(); + await flush(); + const dialog = root.querySelector("dialog")!; + dialog.dispatchEvent(new Event("cancel", { cancelable: true })); + dialog.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(controller.getState().status).toBe("downloading"); + release(); + }); + + it("renders progress and retryable errors", async () => { + const { adapter, controller, root } = setup(); + vi.mocked(adapter.downloadAndInstall).mockImplementation(async (onEvent) => { + onEvent({ type: "started", contentLength: 100 }); + onEvent({ type: "progress", chunkLength: 40 }); + throw new Error("Could not install"); + }); + await controller.start(); + await flush(); + (root.querySelector('[data-action="install"]') as HTMLButtonElement).click(); + await flush(); + + expect(root.querySelector('[role="alert"]')?.textContent).toContain("Could not install"); + expect(root.querySelector('[data-action="retry"]')).not.toBeNull(); + expect(root.querySelector('[data-action="later"]')).not.toBeNull(); + expect(root.querySelector('[aria-live="polite"]')).not.toBeNull(); + }); +}); diff --git a/packages/tauri-updater/test/tauri-adapter.test.ts b/packages/tauri-updater/test/tauri-adapter.test.ts new file mode 100644 index 0000000..7ce6e40 --- /dev/null +++ b/packages/tauri-updater/test/tauri-adapter.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createTauriUpdaterAdapter } from "../src/index"; + +describe("createTauriUpdaterAdapter", () => { + it("maps Tauri metadata and delegates install events and relaunch", async () => { + const downloadAndInstall = vi.fn(async (callback: (event: never) => void) => { + callback({ event: "Started", data: { contentLength: 10 } } as never); + callback({ event: "Progress", data: { chunkLength: 4 } } as never); + callback({ event: "Finished" } as never); + }); + const relaunch = vi.fn(async () => undefined); + const adapter = createTauriUpdaterAdapter({ + check: vi.fn(async () => ({ version: "2.0.0", body: "Notes", downloadAndInstall })), + relaunch, + }); + const events: unknown[] = []; + + await expect(adapter.check()).resolves.toEqual({ version: "2.0.0", notes: "Notes" }); + await adapter.downloadAndInstall((event) => events.push(event)); + await adapter.relaunch(); + + expect(events).toEqual([ + { type: "started", contentLength: 10 }, + { type: "progress", chunkLength: 4 }, + { type: "finished" }, + ]); + expect(relaunch).toHaveBeenCalledTimes(1); + }); + + it("rejects installation before a successful check", async () => { + const adapter = createTauriUpdaterAdapter({ + check: vi.fn(async () => null), + relaunch: vi.fn(async () => undefined), + }); + await adapter.check(); + await expect(adapter.downloadAndInstall(() => undefined)).rejects.toThrow( + "No update is available", + ); + }); +}); diff --git a/packages/tauri-updater/tsconfig.json b/packages/tauri-updater/tsconfig.json new file mode 100644 index 0000000..9058ee1 --- /dev/null +++ b/packages/tauri-updater/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": [ + "src/**/*.ts", + "test/**/*.ts" + ] +} From 4861d831615d1638c99f349fb671480c651ed785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Wed, 22 Jul 2026 22:49:51 -0300 Subject: [PATCH 2/5] fix(tauri-updater): support manual update checks after dismissal Add check({ manual: true }) so PickForge's Settings action can clear a dismissed state, bypass the process-gate cache with a fresh adapter.check(), and surface errors non-silently. Also drop a late-resolving automatic check's transition to available if the user dismissed meanwhile, keeping the dismissed state's stored update. --- packages/tauri-updater/README.md | 2 +- packages/tauri-updater/src/controller.ts | 26 ++++- .../tauri-updater/test/controller.test.ts | 100 ++++++++++++++++++ 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/packages/tauri-updater/README.md b/packages/tauri-updater/README.md index b33b5ff..889b884 100644 --- a/packages/tauri-updater/README.md +++ b/packages/tauri-updater/README.md @@ -26,6 +26,6 @@ dialog.controller = controller; void controller.start(); ``` -`start()` silently ignores unavailable feeds and no-update responses. `check({ silent: false })` is available for a manual surface. The default process gate shares the first check across controllers; inject `createProcessCheckGate()` to isolate deterministic tests. +`start()` silently ignores unavailable feeds and no-update responses, checking once per process through the shared gate. `check({ manual: true })` is for a Settings-style "check for updates" action: it clears a `dismissed` state, always performs a fresh `adapter.check()` bypassing the process gate's cached result, and reports failures as a retryable `error` state instead of staying silent. A `dismissed` state from `dismiss()` only blocks the automatic `start()`/`check()` path; a late-resolving automatic check that finds an update after the user has since dismissed keeps the `dismissed` status (retaining the update for bookkeeping) rather than reopening the dialog. The default process gate shares the first check across controllers; inject `createProcessCheckGate()` to isolate deterministic tests. Build the package and serve `fixture/` from the repository root to inspect the standalone states. diff --git a/packages/tauri-updater/src/controller.ts b/packages/tauri-updater/src/controller.ts index 9f1d0cd..e41d0e3 100644 --- a/packages/tauri-updater/src/controller.ts +++ b/packages/tauri-updater/src/controller.ts @@ -53,7 +53,7 @@ export interface ProcessCheckGate { export interface UpdateController { getState(): UpdateState; start(): Promise; - check(options?: { silent?: boolean }): Promise; + check(options?: { silent?: boolean; manual?: boolean }): Promise; install(): Promise; retry(): Promise; dismiss(): void; @@ -101,12 +101,28 @@ export function createUpdateController(options: { for (const listener of listeners) listener(state); }; - const check = async ({ silent = false }: { silent?: boolean } = {}): Promise => { - if (state.status === "dismissed" || (started && state.status !== "idle")) return; + const check = async ({ + silent = false, + manual = false, + }: { silent?: boolean; manual?: boolean } = {}): Promise => { + if (manual) { + if (state.status === "downloading" || state.status === "installing" || state.status === "restarting") { + return; + } + } else if (state.status === "dismissed" || (started && state.status !== "idle")) { + return; + } started = true; setState({ status: "checking" }); try { - const found = await gate.run(() => adapter.check()); + const found = manual ? await adapter.check() : await gate.run(() => adapter.check()); + if (!manual && state.status === "dismissed") { + if (found !== null) { + update = found; + setState({ status: "dismissed", update: found }); + } + return; + } if (found === null) { setState({ status: "idle" }); return; @@ -115,7 +131,7 @@ export function createUpdateController(options: { setState({ status: "available", update: found }); } catch (error) { setState( - silent + !manual && silent ? { status: "idle" } : { status: "error", message: errorMessage(error), retry: "check" }, ); diff --git a/packages/tauri-updater/test/controller.test.ts b/packages/tauri-updater/test/controller.test.ts index 16f9956..b26bde7 100644 --- a/packages/tauri-updater/test/controller.test.ts +++ b/packages/tauri-updater/test/controller.test.ts @@ -143,6 +143,106 @@ describe("createUpdateController", () => { expect(updateAdapter.check).toHaveBeenCalledTimes(1); }); + it("surfaces the update again when a manual check follows dismissal", async () => { + const updateAdapter = adapter(); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + + await controller.start(); + controller.dismiss(); + expect(controller.getState().status).toBe("dismissed"); + + await controller.check({ manual: true }); + expect(controller.getState()).toMatchObject({ + status: "available", + update: { version: "2.0.0" }, + }); + expect(updateAdapter.check).toHaveBeenCalledTimes(2); + }); + + it("calls the adapter again on a manual check after a cached no-update result", async () => { + const updateAdapter = adapter({ update: null }); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + + await controller.start(); + expect(controller.getState()).toEqual({ status: "idle" }); + expect(updateAdapter.check).toHaveBeenCalledTimes(1); + + vi.mocked(updateAdapter.check).mockResolvedValueOnce({ version: "2.0.0" }); + await controller.check({ manual: true }); + expect(controller.getState()).toMatchObject({ + status: "available", + update: { version: "2.0.0" }, + }); + expect(updateAdapter.check).toHaveBeenCalledTimes(2); + }); + + it("still results in a single adapter call when start is invoked twice", async () => { + const updateAdapter = adapter(); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + + await Promise.all([controller.start(), controller.start()]); + expect(updateAdapter.check).toHaveBeenCalledTimes(1); + expect(controller.getState()).toMatchObject({ status: "available" }); + }); + + it("keeps the dismissed state when an in-flight automatic check resolves after dismissal", async () => { + let resolveCheck: ((value: { version: string } | null) => void) | undefined; + const updateAdapter: UpdateAdapter = { + check: vi.fn(() => new Promise<{ version: string } | null>((resolve) => { resolveCheck = resolve; })), + downloadAndInstall: vi.fn(async () => undefined), + relaunch: vi.fn(async () => undefined), + }; + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: { run: (check) => check() }, + }); + + const starting = controller.start(); + while (resolveCheck === undefined) { + await Promise.resolve(); + } + expect(controller.getState().status).toBe("checking"); + controller.dismiss(); + expect(controller.getState()).toEqual({ status: "dismissed" }); + + resolveCheck({ version: "2.0.0" }); + await starting; + + expect(controller.getState()).toEqual({ + status: "dismissed", + update: { version: "2.0.0" }, + }); + }); + + it("reports a manual check failure even when silent is requested", async () => { + const updateAdapter = adapter({ checkError: new Error("offline") }); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + + await controller.check({ manual: true, silent: true }); + expect(controller.getState()).toMatchObject({ + status: "error", + message: "offline", + retry: "check", + }); + }); + it("reports determinate progress before installing and relaunching", async () => { const updateAdapter = adapter({ events: [ From 217e1f239604b34945cd5203a81bc1830572599a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Wed, 22 Jul 2026 22:54:28 -0300 Subject: [PATCH 3/5] fix(tauri-updater): align dialog backdrop and glass card to brand spec Swap the flat backdrop scrim and blur(24px) card treatment for the canonical color-mix(--pf-surface) scrim and glass-strong recipe (blur(20px) saturate(160%), --pf-surface-1 at 85%) per the branding-visual PR-1 audit. --- packages/tauri-updater/src/element.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tauri-updater/src/element.ts b/packages/tauri-updater/src/element.ts index e1a23f9..6fbc66a 100644 --- a/packages/tauri-updater/src/element.ts +++ b/packages/tauri-updater/src/element.ts @@ -250,8 +250,8 @@ function activeElement(): HTMLElement | null { const STYLES = ` :host { color: var(--pf-text-hi, #f2f2f3); font-family: var(--pf-font-sans, "Geist Sans", system-ui, sans-serif); } dialog { background: transparent; border: 0; color: inherit; margin: auto; max-height: calc(100vh - 32px); max-width: min(560px, calc(100vw - 32px)); padding: 0; width: 100%; } - dialog::backdrop { background: rgba(0, 0, 0, .62); } - .card { animation: pf-updater-enter var(--pf-dur-fast, 180ms) var(--pf-ease-forge, ease-out) both; backdrop-filter: blur(24px); background: color-mix(in srgb, var(--pf-surface-2, #141417) 92%, transparent); border: 1px solid var(--pf-hairline-strong, rgba(255,255,255,.14)); border-radius: var(--pf-radius-lg, 14px); box-shadow: var(--pf-shadow-overlay, 0 16px 48px -10px rgba(0,0,0,.6)); box-sizing: border-box; max-height: calc(100vh - 32px); overflow: auto; padding: var(--pf-space-xl, 24px); } + dialog::backdrop { background: color-mix(in srgb, var(--pf-surface, #0a0a0b) 62%, transparent); } + .card { animation: pf-updater-enter var(--pf-dur-fast, 180ms) var(--pf-ease-forge, ease-out) both; backdrop-filter: blur(20px) saturate(160%); background: color-mix(in srgb, var(--pf-surface-1, #0f0f11) 85%, transparent); border: 1px solid var(--pf-hairline-strong, rgba(255,255,255,.14)); border-radius: var(--pf-radius-lg, 14px); box-shadow: var(--pf-shadow-overlay, 0 16px 48px -10px rgba(0,0,0,.6)); box-sizing: border-box; max-height: calc(100vh - 32px); overflow: auto; padding: var(--pf-space-xl, 24px); } header { align-items: center; display: flex; gap: var(--pf-space-sm, 8px); } .mark { align-items: center; background: var(--pf-item-fill, rgba(255,255,255,.03)); border: 1px solid var(--pf-hairline-strong, rgba(255,255,255,.14)); border-radius: var(--pf-radius-sm, 6px); color: var(--pf-ember, #ff7a1a); display: inline-flex; font-family: var(--pf-font-mono, monospace); font-size: 10px; font-weight: 700; height: 26px; justify-content: center; width: 26px; } .eyebrow, small { color: var(--pf-text-low, #6e6e75); font-family: var(--pf-font-mono, monospace); font-size: var(--pf-size-eyebrow, 10px); font-weight: 500; letter-spacing: var(--pf-tracking-eyebrow, 1.8px); text-transform: uppercase; } From 97527f37c7016d013526405c5fb237bbb81818a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Wed, 22 Jul 2026 23:16:34 -0300 Subject: [PATCH 4/5] fix(tauri-updater): guard concurrent checks and fresh manual retries Add a monotonic check-generation guard so a stale silent/auto check result can never clobber a newer manual (or newer manual) result, and apply the same dismissed-preserving bookkeeping to the failure path that already existed for the success path. Retry after a failed check now replays the check with the same manual/auto mode it originally ran with, so retrying a failed manual check performs a fresh adapter.check() instead of reusing the process gate's cached result. Also add a one-line re-entrancy guard so a second install() call while one is in flight is a no-op. --- packages/tauri-updater/src/controller.ts | 27 ++- .../tauri-updater/test/controller.test.ts | 189 ++++++++++++++++++ 2 files changed, 209 insertions(+), 7 deletions(-) diff --git a/packages/tauri-updater/src/controller.ts b/packages/tauri-updater/src/controller.ts index e41d0e3..9c1c2ed 100644 --- a/packages/tauri-updater/src/controller.ts +++ b/packages/tauri-updater/src/controller.ts @@ -95,6 +95,8 @@ export function createUpdateController(options: { let state: UpdateState = { status: "idle" }; let update: UpdateInfo | undefined; let started = false; + let lastManual = false; + let generation = 0; const setState = (next: UpdateState): void => { state = next; @@ -106,16 +108,18 @@ export function createUpdateController(options: { manual = false, }: { silent?: boolean; manual?: boolean } = {}): Promise => { if (manual) { - if (state.status === "downloading" || state.status === "installing" || state.status === "restarting") { - return; - } + if (isBusy(state)) return; } else if (state.status === "dismissed" || (started && state.status !== "idle")) { return; } started = true; + lastManual = manual; + generation += 1; + const epoch = generation; setState({ status: "checking" }); try { const found = manual ? await adapter.check() : await gate.run(() => adapter.check()); + if (epoch !== generation) return; if (!manual && state.status === "dismissed") { if (found !== null) { update = found; @@ -130,6 +134,8 @@ export function createUpdateController(options: { update = found; setState({ status: "available", update: found }); } catch (error) { + if (epoch !== generation) return; + if (!manual && state.status === "dismissed") return; setState( !manual && silent ? { status: "idle" } @@ -140,6 +146,7 @@ export function createUpdateController(options: { const install = async (): Promise => { if (update === undefined) return; + if (isBusy(state)) return; const selected = update; let downloaded = 0; let contentLength: number | null = null; @@ -211,7 +218,7 @@ export function createUpdateController(options: { return; } started = false; - await check(); + await check({ manual: lastManual }); }; return { @@ -224,9 +231,7 @@ export function createUpdateController(options: { install, retry, dismiss() { - if (state.status === "downloading" || state.status === "installing" || state.status === "restarting") { - return; - } + if (isBusy(state)) return; setState( update === undefined ? { status: "dismissed" } @@ -240,6 +245,14 @@ export function createUpdateController(options: { }; } +function isBusy(current: UpdateState): boolean { + return ( + current.status === "downloading" || + current.status === "installing" || + current.status === "restarting" + ); +} + function progress(downloaded: number, contentLength: number | null): DownloadProgress { return { downloaded, diff --git a/packages/tauri-updater/test/controller.test.ts b/packages/tauri-updater/test/controller.test.ts index b26bde7..5ad5932 100644 --- a/packages/tauri-updater/test/controller.test.ts +++ b/packages/tauri-updater/test/controller.test.ts @@ -243,6 +243,195 @@ describe("createUpdateController", () => { }); }); + it("keeps the dismissed state when an in-flight automatic check rejects after dismissal", async () => { + let rejectCheck!: (error: Error) => void; + const updateAdapter: UpdateAdapter = { + check: vi.fn(() => new Promise<{ version: string } | null>((_resolve, reject) => { rejectCheck = reject; })), + downloadAndInstall: vi.fn(async () => undefined), + relaunch: vi.fn(async () => undefined), + }; + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: { run: (check) => check() }, + }); + + const starting = controller.start(); + while (rejectCheck === undefined) { + await Promise.resolve(); + } + expect(controller.getState().status).toBe("checking"); + controller.dismiss(); + expect(controller.getState()).toEqual({ status: "dismissed" }); + + rejectCheck(new Error("offline")); + await starting; + + expect(controller.getState()).toEqual({ status: "dismissed" }); + }); + + it("retries a failed manual check with fresh manual semantics instead of the cached gate result", async () => { + const updateAdapter = adapter({ update: null }); + const gate = createProcessCheckGate(); + const controller = createUpdateController({ adapter: updateAdapter, eligibility: eligible, gate }); + + await controller.start(); + expect(controller.getState()).toEqual({ status: "idle" }); + expect(updateAdapter.check).toHaveBeenCalledTimes(1); + + vi.mocked(updateAdapter.check).mockRejectedValueOnce(new Error("offline")); + await controller.check({ manual: true }); + expect(controller.getState()).toMatchObject({ status: "error", retry: "check" }); + expect(updateAdapter.check).toHaveBeenCalledTimes(2); + + vi.mocked(updateAdapter.check).mockResolvedValueOnce({ version: "5.0.0" }); + await controller.retry(); + expect(controller.getState()).toMatchObject({ + status: "available", + update: { version: "5.0.0" }, + }); + expect(updateAdapter.check).toHaveBeenCalledTimes(3); + }); + + it("keeps the manual result when a late silent startup check resolves after it", async () => { + let resolveStartup!: (value: { version: string } | null) => void; + const updateAdapter: UpdateAdapter = { + check: vi + .fn() + .mockImplementationOnce( + () => new Promise<{ version: string } | null>((resolve) => { resolveStartup = resolve; }), + ) + .mockResolvedValueOnce({ version: "3.0.0" }), + downloadAndInstall: vi.fn(async () => undefined), + relaunch: vi.fn(async () => undefined), + }; + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: { run: (check) => check() }, + }); + + const starting = controller.start(); + while (resolveStartup === undefined) { + await Promise.resolve(); + } + expect(controller.getState().status).toBe("checking"); + + await controller.check({ manual: true }); + expect(controller.getState()).toMatchObject({ + status: "available", + update: { version: "3.0.0" }, + }); + + resolveStartup(null); + await starting; + + expect(controller.getState()).toMatchObject({ + status: "available", + update: { version: "3.0.0" }, + }); + }); + + it("only applies the newest of two concurrent manual checks", async () => { + let resolveFirst!: (value: { version: string } | null) => void; + const updateAdapter: UpdateAdapter = { + check: vi + .fn() + .mockImplementationOnce( + () => new Promise<{ version: string } | null>((resolve) => { resolveFirst = resolve; }), + ) + .mockResolvedValueOnce({ version: "4.0.0" }), + downloadAndInstall: vi.fn(async () => undefined), + relaunch: vi.fn(async () => undefined), + }; + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + + const first = controller.check({ manual: true }); + await Promise.resolve(); + const second = controller.check({ manual: true }); + await second; + expect(controller.getState()).toMatchObject({ + status: "available", + update: { version: "4.0.0" }, + }); + + resolveFirst({ version: "1.0.0" }); + await first; + + expect(controller.getState()).toMatchObject({ + status: "available", + update: { version: "4.0.0" }, + }); + }); + + it("surfaces relaunch failures and retries them", async () => { + const updateAdapter = adapter(); + vi.mocked(updateAdapter.relaunch).mockRejectedValueOnce(new Error("relaunch failed")); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + + await controller.start(); + await controller.install(); + expect(controller.getState()).toMatchObject({ + status: "error", + message: "relaunch failed", + retry: "relaunch", + }); + + await controller.retry(); + expect(updateAdapter.relaunch).toHaveBeenCalledTimes(2); + expect(controller.getState()).toEqual({ status: "restarting" }); + }); + + it("pins progress at 100 percent when downloaded chunks exceed content length", async () => { + const updateAdapter = adapter({ + events: [ + { type: "started", contentLength: 50 }, + { type: "progress", chunkLength: 30 }, + { type: "progress", chunkLength: 40 }, + ], + }); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + const progresses: Array = []; + controller.subscribe((state) => { + if (state.status === "downloading") progresses.push(state.progress.percent); + }); + + await controller.start(); + await controller.install(); + expect(progresses).toEqual([null, 0, 60, 100]); + }); + + it("ignores a second install call while one is already in flight", async () => { + let release!: () => void; + const updateAdapter = adapter(); + vi.mocked(updateAdapter.downloadAndInstall).mockImplementation( + () => new Promise((resolve) => { release = resolve; }), + ); + const controller = createUpdateController({ + adapter: updateAdapter, + eligibility: eligible, + gate: createProcessCheckGate(), + }); + await controller.start(); + const first = controller.install(); + const second = controller.install(); + release(); + await Promise.all([first, second]); + expect(updateAdapter.downloadAndInstall).toHaveBeenCalledTimes(1); + }); + it("reports determinate progress before installing and relaunching", async () => { const updateAdapter = adapter({ events: [ From 90adf95ec8f34fda896be337269978f8cdde9e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Wed, 22 Jul 2026 23:16:44 -0300 Subject: [PATCH 5/5] fix(tauri-updater): keep the dialog DOM stable across renders Build the subtree once and mutate its text/children in place instead of rebuilding the whole shadow tree via innerHTML on every state tick, so showModal() and the entrance animation only fire on the closed-to-open transition and the aria-live status node keeps its identity for assistive tech. The description paragraph now shows a fixed sentence per state; the bounded/scrollable notes
 is the
sole place release notes render. Add -webkit-backdrop-filter for
WebKit webviews, and extend the fixture with scenario toggles that
reach a failed check, a failed install, and an indeterminate download.
---
 AGENTS.md                                   |   5 +
 packages/tauri-updater/fixture/index.html   |  43 +++-
 packages/tauri-updater/src/element.ts       | 213 +++++++++++---------
 packages/tauri-updater/test/element.test.ts |  40 ++++
 4 files changed, 204 insertions(+), 97 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 1fc3ee3..522e840 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,6 +6,11 @@
 - Do not lower coverage thresholds without explicit maintainer approval.
 - Keep durable domain and business behavior out of UI components where applicable, without adding DDD ceremony.
 - Use Bun workspace commands from the repo root: `bun run typecheck`, `bun run test`, `bun run test:coverage`, and `bun run build`.
+
+## Web Components
+
+- Stateful DOM nodes (dialog, live regions) must stay stable across renders — full innerHTML rebuilds on state ticks replay showModal/animations and break assistive-tech announcements.
+
 ## Workspace policy
 
 For substantial work, read `../AGENTS.md` (workspace root) and use the `plan-issue` workflow — GitHub Issues are the canonical plan/progress tracker.
diff --git a/packages/tauri-updater/fixture/index.html b/packages/tauri-updater/fixture/index.html
index c216af7..6eef538 100644
--- a/packages/tauri-updater/fixture/index.html
+++ b/packages/tauri-updater/fixture/index.html
@@ -9,10 +9,23 @@
     
   
   
-    

Updater fixture

The app remains usable behind the modal.

+
+

Updater fixture

+

The app remains usable behind the modal.

+ +
diff --git a/packages/tauri-updater/src/element.ts b/packages/tauri-updater/src/element.ts index 6fbc66a..ccea5c5 100644 --- a/packages/tauri-updater/src/element.ts +++ b/packages/tauri-updater/src/element.ts @@ -21,6 +21,21 @@ export class PickforgeUpdateDialogElement extends HTMLElementBase { #unsubscribe: (() => void) | undefined; #state: UpdateState = { status: "idle" }; #restoreFocus: HTMLElement | null = null; + #dialog: HTMLDialogElement | undefined; + #markEl: HTMLElement | undefined; + #titleEl: HTMLElement | undefined; + #descriptionEl: HTMLElement | undefined; + #currentVersionEl: HTMLElement | undefined; + #newVersionEl: HTMLElement | undefined; + #statusEl: HTMLElement | undefined; + #footerEl: HTMLElement | undefined; + + constructor() { + super(); + const style = document.createElement("style"); + style.textContent = STYLES; + this.#root.append(style); + } set controller(value: UpdateController | undefined) { if (this.#controller === value) return; @@ -65,17 +80,61 @@ export class PickforgeUpdateDialogElement extends HTMLElementBase { this.#close(); } + #ensureDialog(): HTMLDialogElement { + if (this.#dialog !== undefined) return this.#dialog; + const dialog = document.createElement("dialog"); + dialog.setAttribute("aria-labelledby", "pf-updater-title"); + dialog.setAttribute("aria-describedby", "pf-updater-description"); + dialog.innerHTML = ` +
+
+ + UPDATE AVAILABLE +
+

+

+
+ Current + + New +
+
+
+
`; + + this.#markEl = dialog.querySelector(".mark") ?? undefined; + this.#titleEl = dialog.querySelector("#pf-updater-title") ?? undefined; + this.#descriptionEl = dialog.querySelector("#pf-updater-description") ?? undefined; + this.#currentVersionEl = dialog.querySelector(".current-version") ?? undefined; + this.#newVersionEl = dialog.querySelector(".new-version") ?? undefined; + this.#statusEl = dialog.querySelector(".status") ?? undefined; + this.#footerEl = dialog.querySelector("footer") ?? undefined; + + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + if ( + this.#state.status === "available" || + (this.#state.status === "error" && this.#state.retry === "check") + ) { + this.#controller?.dismiss(); + } + }); + + this.#root.append(dialog); + this.#dialog = dialog; + return dialog; + } + #render(): void { - const previousDialog = this.#root.querySelector("dialog"); - const wasOpen = previousDialog?.hasAttribute("open") === true; - if (!isVisibleState(this.#state)) { - this.#root.innerHTML = ``; - if (wasOpen) this.#restoreFocus?.focus(); - this.#restoreFocus = null; + const state = this.#state; + if (!isVisibleState(state)) { + this.#close(); return; } - const state = this.#state; + const dialog = this.#ensureDialog(); + const wasOpen = dialog.hasAttribute("open"); + const update = "update" in state ? state.update : undefined; const notes = update?.notes?.trim(); const isAvailable = state.status === "available"; @@ -94,97 +153,60 @@ export class PickforgeUpdateDialogElement extends HTMLElementBase { ? "The app remains available. Retry when you’re ready." : isRestarting ? "Restarting to finish the update." - : notes ?? "Download the latest Pickforge Studio release."; + : "A new version is ready to install."; - this.#root.innerHTML = ` - - -
-
- - UPDATE AVAILABLE -
-

-

-
- Current - - New -
-
-
-
-
`; + if (this.#markEl !== undefined) this.#markEl.textContent = this.#metadata.productMark ?? "PF"; + if (this.#titleEl !== undefined) this.#titleEl.textContent = title; + if (this.#descriptionEl !== undefined) this.#descriptionEl.textContent = description; + if (this.#currentVersionEl !== undefined) this.#currentVersionEl.textContent = this.#metadata.currentVersion; + if (this.#newVersionEl !== undefined) this.#newVersionEl.textContent = update?.version ?? "Installed"; - setText(this.#root, ".mark", this.#metadata.productMark ?? "PF"); - setText(this.#root, "#pf-updater-title", title); - setText(this.#root, "#pf-updater-description", description); - setText(this.#root, ".current-version", this.#metadata.currentVersion); - setText(this.#root, ".new-version", update?.version ?? "Installed"); + const status = this.#statusEl; + if (status !== undefined) { + status.replaceChildren(); + if (notes !== undefined && (isAvailable || isError)) { + const notesElement = document.createElement("pre"); + notesElement.className = "notes"; + notesElement.textContent = notes; + status.append(notesElement); + } + if (isDownloading) { + status.append(createProgress(state.progress.percent)); + } else if (isInstalling) { + status.textContent = "Installing update…"; + } else if (isRestarting) { + status.textContent = "Installation complete. Restarting…"; + } else if (isError) { + const error = document.createElement("p"); + error.className = "error"; + error.setAttribute("role", "alert"); + error.textContent = state.message; + status.prepend(error); + } + } - const status = this.#root.querySelector(".status"); - const footer = this.#root.querySelector("footer"); - if (status === null || footer === null) return; + this.#renderFooter(isAvailable, isError); - if (notes !== undefined && (isAvailable || isError)) { - const notesElement = document.createElement("pre"); - notesElement.className = "notes"; - notesElement.textContent = notes; - status.append(notesElement); - } - if (isDownloading) { - status.append(createProgress(state.progress.percent)); - } else if (isInstalling) { - status.textContent = "Installing update…"; - } else if (isRestarting) { - status.textContent = "Installation complete. Restarting…"; - } else if (isError) { - const error = document.createElement("p"); - error.className = "error"; - error.setAttribute("role", "alert"); - error.textContent = state.message; - status.prepend(error); - } + if (!wasOpen) this.#restoreFocus = activeElement(); + this.#open(dialog); + if (!wasOpen) this.#root.querySelector(".primary")?.focus(); + } + #renderFooter(isAvailable: boolean, isError: boolean): void { + const footer = this.#footerEl; + if (footer === undefined) return; + footer.replaceChildren(); if (isAvailable) { footer.append( - actionButton("Later", "later", "secondary"), - actionButton("Update & restart", "install", "primary"), + actionButton("Later", "later", "secondary", () => this.#controller?.dismiss()), + actionButton("Update & restart", "install", "primary", () => void this.#controller?.install()), ); } else if (isError) { footer.append( - actionButton("Later", "later", "secondary"), - actionButton("Retry", "retry", "primary"), + actionButton("Later", "later", "secondary", () => this.#controller?.dismiss()), + actionButton("Retry", "retry", "primary", () => void this.#controller?.retry()), ); } - - this.#wireActions(); - const dialog = this.#root.querySelector("dialog"); - if (dialog === null) return; - dialog.addEventListener("cancel", (event) => { - event.preventDefault(); - if ( - this.#state.status === "available" || - (this.#state.status === "error" && this.#state.retry === "check") - ) { - this.#controller?.dismiss(); - } - }); - if (!wasOpen) this.#restoreFocus = activeElement(); - this.#open(dialog); - if (!wasOpen) this.#root.querySelector(".primary")?.focus(); - } - - #wireActions(): void { - this.#root.querySelector('[data-action="later"]')?.addEventListener("click", () => { - this.#controller?.dismiss(); - }); - this.#root.querySelector('[data-action="install"]')?.addEventListener("click", () => { - void this.#controller?.install(); - }); - this.#root.querySelector('[data-action="retry"]')?.addEventListener("click", () => { - void this.#controller?.retry(); - }); } #open(dialog: HTMLDialogElement): void { @@ -194,8 +216,8 @@ export class PickforgeUpdateDialogElement extends HTMLElementBase { } #close(): void { - const dialog = this.#root.querySelector("dialog"); - if (dialog?.hasAttribute("open")) { + const dialog = this.#dialog; + if (dialog?.hasAttribute("open") === true) { if (typeof dialog.close === "function") dialog.close(); else dialog.removeAttribute("open"); } @@ -215,17 +237,18 @@ function isVisibleState(state: UpdateState): boolean { return ["available", "downloading", "installing", "restarting", "error"].includes(state.status); } -function setText(root: ShadowRoot, selector: string, value: string): void { - const element = root.querySelector(selector); - if (element !== null) element.textContent = value; -} - -function actionButton(label: string, action: string, className: string): HTMLButtonElement { +function actionButton( + label: string, + action: string, + className: string, + onClick: () => void, +): HTMLButtonElement { const button = document.createElement("button"); button.type = "button"; button.className = className; button.dataset.action = action; button.textContent = label; + button.addEventListener("click", onClick); return button; } @@ -251,7 +274,7 @@ const STYLES = ` :host { color: var(--pf-text-hi, #f2f2f3); font-family: var(--pf-font-sans, "Geist Sans", system-ui, sans-serif); } dialog { background: transparent; border: 0; color: inherit; margin: auto; max-height: calc(100vh - 32px); max-width: min(560px, calc(100vw - 32px)); padding: 0; width: 100%; } dialog::backdrop { background: color-mix(in srgb, var(--pf-surface, #0a0a0b) 62%, transparent); } - .card { animation: pf-updater-enter var(--pf-dur-fast, 180ms) var(--pf-ease-forge, ease-out) both; backdrop-filter: blur(20px) saturate(160%); background: color-mix(in srgb, var(--pf-surface-1, #0f0f11) 85%, transparent); border: 1px solid var(--pf-hairline-strong, rgba(255,255,255,.14)); border-radius: var(--pf-radius-lg, 14px); box-shadow: var(--pf-shadow-overlay, 0 16px 48px -10px rgba(0,0,0,.6)); box-sizing: border-box; max-height: calc(100vh - 32px); overflow: auto; padding: var(--pf-space-xl, 24px); } + .card { animation: pf-updater-enter var(--pf-dur-fast, 180ms) var(--pf-ease-forge, ease-out) both; backdrop-filter: blur(20px) saturate(160%); -webkit-backdrop-filter: blur(20px) saturate(160%); background: color-mix(in srgb, var(--pf-surface-1, #0f0f11) 85%, transparent); border: 1px solid var(--pf-hairline-strong, rgba(255,255,255,.14)); border-radius: var(--pf-radius-lg, 14px); box-shadow: var(--pf-shadow-overlay, 0 16px 48px -10px rgba(0,0,0,.6)); box-sizing: border-box; max-height: calc(100vh - 32px); overflow: auto; padding: var(--pf-space-xl, 24px); } header { align-items: center; display: flex; gap: var(--pf-space-sm, 8px); } .mark { align-items: center; background: var(--pf-item-fill, rgba(255,255,255,.03)); border: 1px solid var(--pf-hairline-strong, rgba(255,255,255,.14)); border-radius: var(--pf-radius-sm, 6px); color: var(--pf-ember, #ff7a1a); display: inline-flex; font-family: var(--pf-font-mono, monospace); font-size: 10px; font-weight: 700; height: 26px; justify-content: center; width: 26px; } .eyebrow, small { color: var(--pf-text-low, #6e6e75); font-family: var(--pf-font-mono, monospace); font-size: var(--pf-size-eyebrow, 10px); font-weight: 500; letter-spacing: var(--pf-tracking-eyebrow, 1.8px); text-transform: uppercase; } diff --git a/packages/tauri-updater/test/element.test.ts b/packages/tauri-updater/test/element.test.ts index 6e0fb43..fc5fc2e 100644 --- a/packages/tauri-updater/test/element.test.ts +++ b/packages/tauri-updater/test/element.test.ts @@ -107,6 +107,46 @@ describe("pickforge-update-dialog", () => { release(); }); + it("keeps release notes out of the description and uses a fixed sentence instead", async () => { + const { controller, root } = setup({ notes: "Fixes a startup crash" }); + await controller.start(); + await flush(); + + const description = root.querySelector("#pf-updater-description"); + expect(description?.textContent).toBe("A new version is ready to install."); + expect(description?.textContent).not.toContain("Fixes a startup crash"); + expect(root.querySelector(".notes")?.textContent).toBe("Fixes a startup crash"); + }); + + it("keeps the dialog and live-region nodes stable across progress ticks", async () => { + const { adapter, controller, root } = setup(); + let release!: () => void; + vi.mocked(adapter.downloadAndInstall).mockImplementation( + (onEvent) => + new Promise((resolve) => { + onEvent({ type: "started", contentLength: 100 }); + release = () => { + onEvent({ type: "progress", chunkLength: 50 }); + resolve(); + }; + }), + ); + await controller.start(); + await flush(); + (root.querySelector('[data-action="install"]') as HTMLButtonElement).click(); + await flush(); + + const dialog = root.querySelector("dialog"); + const status = root.querySelector('[aria-live="polite"]'); + expect(dialog).not.toBeNull(); + + release(); + await flush(); + + expect(root.querySelector("dialog")).toBe(dialog); + expect(root.querySelector('[aria-live="polite"]')).toBe(status); + }); + it("renders progress and retryable errors", async () => { const { adapter, controller, root } = setup(); vi.mocked(adapter.downloadAndInstall).mockImplementation(async (onEvent) => {