diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eecc4f1b03..bdadd22059d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Control UI: rotate browser service-worker caches per build so updated Gateways are less likely to keep serving stale dashboard bundles that trigger protocol mismatch errors. - Config/doctor: rotate capped `.clobbered.*` repair snapshots by artifact timestamp so repeated repairs keep the newest forensic copy instead of preserving only the first capped set. (#82012) Thanks @Kaspre. - Telegram: initialize the bot before isolated polling drains spooled updates so default isolated polling no longer retries every update with `Bot not initialized` and stalls replies. Fixes #81973. (#81975) Thanks @neeravmakwana. - Telegram: apply method-aware Bot API request timeouts to direct message/action clients so `openclaw message delete --channel telegram` no longer waits on grammY's 500-second default when the API request wedges. Fixes #81908. Thanks @DashLabsDev. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 172eadc1b54..73293eb1148 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -202,6 +202,8 @@ Imported themes are stored only in the current browser profile. They are not wri The Control UI ships a `manifest.webmanifest` and a service worker, so modern browsers can install it as a standalone PWA. Web Push lets the Gateway wake the installed PWA with notifications even when the tab or browser window is not open. +If the page shows **Protocol mismatch** right after an OpenClaw update, first reopen the dashboard with `openclaw dashboard` and hard-refresh the page. If it still fails, clear site data for the dashboard origin or test in a private browser window; an old tab or browser service-worker cache can keep running a pre-update Control UI bundle against the newer Gateway. + | Surface | What it does | | ----------------------------------------------------- | ------------------------------------------------------------------ | | `ui/public/manifest.webmanifest` | PWA manifest. Browsers offer "Install app" once it is reachable. | diff --git a/ui/public/sw.js b/ui/public/sw.js index 6c8fcbb3a20..45ef839ef2c 100644 --- a/ui/public/sw.js +++ b/ui/public/sw.js @@ -1,7 +1,17 @@ // OpenClaw Control – Service Worker // Handles offline caching and push notifications. -const CACHE_NAME = "openclaw-control-v1"; +const CACHE_PREFIX = "openclaw-control-"; +const EMBEDDED_CACHE_VERSION = "__OPENCLAW_CONTROL_UI_BUILD_ID__"; +const URL_CACHE_VERSION = new URL(self.location.href).searchParams + .get("v") + ?.replace(/[^a-zA-Z0-9._-]/g, "-"); +const CACHE_VERSION = + (EMBEDDED_CACHE_VERSION !== "__OPENCLAW_CONTROL_UI_BUILD_ID__" + ? EMBEDDED_CACHE_VERSION + : URL_CACHE_VERSION) || "dev"; +const CACHE_NAME = `${CACHE_PREFIX}${CACHE_VERSION}`; +const CONTROL_CACHE_LIMIT = 3; // Minimal app-shell files to precache. const PRECACHE_URLS = ["./"]; @@ -12,14 +22,23 @@ self.addEventListener("install", (event) => { }); self.addEventListener("activate", (event) => { + // Keep a small prior-build window so open tabs can still load old hashed chunks after updates. event.waitUntil( - caches - .keys() - .then((keys) => - Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))), - ), + Promise.all([ + self.clients.claim(), + caches.keys().then((keys) => { + const controlKeys = keys.filter((key) => key.startsWith(CACHE_PREFIX)); + const priorCacheLimit = Math.max(0, CONTROL_CACHE_LIMIT - 1); + const retained = new Set([ + ...controlKeys.filter((key) => key !== CACHE_NAME).slice(-priorCacheLimit), + CACHE_NAME, + ]); + return Promise.all( + controlKeys.filter((key) => !retained.has(key)).map((key) => caches.delete(key)), + ); + }), + ]), ); - self.clients.claim(); }); self.addEventListener("fetch", (event) => { diff --git a/ui/src/main.ts b/ui/src/main.ts index ac37a40db44..6a6f5f1ebde 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -7,10 +7,14 @@ type ViteImportMeta = ImportMeta & { }; }; +declare const __OPENCLAW_CONTROL_UI_BUILD_ID__: string | undefined; + const isProd = (import.meta as ViteImportMeta).env?.PROD === true; if (isProd && "serviceWorker" in navigator) { - void navigator.serviceWorker.register("./sw.js"); + const swUrl = new URL("./sw.js", window.location.href); + swUrl.searchParams.set("v", __OPENCLAW_CONTROL_UI_BUILD_ID__ || "dev"); + void navigator.serviceWorker.register(swUrl, { updateViaCache: "none" }); } else if (!isProd && "serviceWorker" in navigator) { // Unregister any leftover dev SW to avoid stale cache issues. void navigator.serviceWorker.getRegistrations().then((registrations) => { diff --git a/ui/src/ui/service-worker-cache.test.ts b/ui/src/ui/service-worker-cache.test.ts new file mode 100644 index 00000000000..1ccab79aa9c --- /dev/null +++ b/ui/src/ui/service-worker-cache.test.ts @@ -0,0 +1,26 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const here = path.dirname(fileURLToPath(import.meta.url)); + +describe("Control UI service worker cache versioning", () => { + it("registers the service worker with a build id and bounds prior build caches", () => { + const mainSource = fs.readFileSync(path.join(here, "../main.ts"), "utf8"); + const serviceWorkerSource = fs.readFileSync(path.join(here, "../../public/sw.js"), "utf8"); + const viteConfigSource = fs.readFileSync(path.join(here, "../../vite.config.ts"), "utf8"); + + expect(mainSource).toContain('swUrl.searchParams.set("v"'); + expect(mainSource).toContain('updateViaCache: "none"'); + expect(serviceWorkerSource).toContain( + 'const EMBEDDED_CACHE_VERSION = "__OPENCLAW_CONTROL_UI_BUILD_ID__"', + ); + expect(serviceWorkerSource).toContain("URL_CACHE_VERSION"); + expect(serviceWorkerSource).toContain("CONTROL_CACHE_LIMIT = 3"); + expect(serviceWorkerSource).toContain("slice(-priorCacheLimit)"); + expect(serviceWorkerSource).toContain("caches.delete"); + expect(viteConfigSource).toContain("source.replace(placeholder, JSON.stringify(buildId))"); + expect(serviceWorkerSource).not.toContain('const CACHE_NAME = "openclaw-control-v1"'); + }); +}); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 77b50d200ae..2698f5e99a2 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,8 +1,12 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { defineConfig } from "vite"; +import { defineConfig, type Plugin } from "vite"; const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, ".."); +const outDir = path.resolve(here, "../dist/control-ui"); function normalizeBase(input: string): string { const trimmed = input.trim(); @@ -18,17 +22,78 @@ function normalizeBase(input: string): string { return `${trimmed}/`; } +function normalizeBuildId(input: string): string { + const normalized = input.trim().replace(/[^a-zA-Z0-9._-]+/g, "-"); + return normalized.slice(0, 96) || "dev"; +} + +function readPackageVersion(): string { + try { + const raw = fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"); + const parsed = JSON.parse(raw) as { version?: unknown }; + return typeof parsed.version === "string" && parsed.version.trim() + ? parsed.version.trim() + : "dev"; + } catch { + return "dev"; + } +} + +function readGitShortSha(): string | null { + try { + const raw = execFileSync("git", ["-C", repoRoot, "rev-parse", "--short=12", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + return raw.trim() || null; + } catch { + return null; + } +} + +function resolveControlUiBuildId(): string { + const explicit = + process.env.OPENCLAW_CONTROL_UI_BUILD_ID?.trim() || process.env.OPENCLAW_VERSION?.trim(); + if (explicit) { + return normalizeBuildId(explicit); + } + const version = readPackageVersion(); + const gitSha = readGitShortSha(); + return normalizeBuildId(gitSha ? `${version}-${gitSha}` : version); +} + +function controlUiServiceWorkerBuildIdPlugin(buildId: string): Plugin { + return { + name: "control-ui-service-worker-build-id", + apply: "build", + closeBundle() { + const swPath = path.join(outDir, "sw.js"); + const source = fs.readFileSync(swPath, "utf8"); + const placeholder = '"__OPENCLAW_CONTROL_UI_BUILD_ID__"'; + const updated = source.replace(placeholder, JSON.stringify(buildId)); + if (updated === source) { + throw new Error(`Control UI service worker build id placeholder missing in ${swPath}`); + } + fs.writeFileSync(swPath, updated); + }, + }; +} + export default defineConfig(() => { const envBase = process.env.OPENCLAW_CONTROL_UI_BASE_PATH?.trim(); const base = envBase ? normalizeBase(envBase) : "./"; + const controlUiBuildId = resolveControlUiBuildId(); return { base, + define: { + __OPENCLAW_CONTROL_UI_BUILD_ID__: JSON.stringify(controlUiBuildId), + }, publicDir: path.resolve(here, "public"), optimizeDeps: { include: ["lit/directives/repeat.js"], }, build: { - outDir: path.resolve(here, "../dist/control-ui"), + outDir, emptyOutDir: true, sourcemap: true, // Keep CI/onboard logs clean; current control UI chunking is intentionally above 500 kB. @@ -40,6 +105,7 @@ export default defineConfig(() => { strictPort: true, }, plugins: [ + controlUiServiceWorkerBuildIdPlugin(controlUiBuildId), { name: "control-ui-dev-stubs", configureServer(server) {