diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 08fad6fcac79..6164db50cd7e 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -28691,7 +28691,7 @@ }, { "kind": "conditional-branch", - "line": 811, + "line": 815, "path": "apps/macos/Sources/OpenClaw/DashboardWindowController.swift", "source": "[\\(host)]", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift index 2ddca527aa42..e1f3fe5b110a 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -196,6 +196,10 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, if nextEnabled { controller.add(self.updateMessageHandler, name: Self.updateMessageHandlerName) } + // The handler is the dashboard's ownership probe. Notify the live page so + // its update target stays correct when connection mode or ownership changes. + self.webView.evaluateJavaScript( + "window.dispatchEvent(new CustomEvent('openclaw:native-update-availability-changed'))") } // MARK: - WKUIDelegate diff --git a/docs/install/updating.md b/docs/install/updating.md index e1c6322900ad..c95c3433a009 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -241,14 +241,18 @@ LaunchAgent when possible. If the Gateway cannot make that handoff safely, `update.run` reports a safe shell command instead of running the package manager in-process. -The Control UI sidebar update card starts this same `update.run` flow. In the -signed macOS app, the card or menu bar update action updates the app through -Sparkle first. After relaunch, a setup-style progress window runs -`openclaw update --tag --json` for the app-managed Gateway, -restarts it, and verifies health. Failure details stay visible with Retry, -[Update guide](/install/updating), and [Discord](https://discord.gg/clawd) -actions. The noninteractive run does not accept downgrades. Externally managed -installs remain unchanged. +The Control UI sidebar update card shows **Update Gateway** when it will start +this `update.run` flow directly. This covers browser-hosted Control UI, remote +Gateways, and manually managed local Gateways. + +In the signed macOS app, a local app-owned Gateway changes that card to +**Update Mac app + Gateway**. Sparkle updates the app first; after relaunch, the +app runs `openclaw update --tag --json`, restarts its Gateway, +and verifies health in a setup-style progress window. Failure details stay +visible with Retry, [Update guide](/install/updating), and +[Discord](https://discord.gg/clawd) actions. The app never uses this coordinated +path for a remote or externally managed Gateway, never downgrades a newer +Gateway, and never overrides an `extended-stable` channel pin. When the update succeeds, the app queues a one-time welcome event for the most recent top-level direct session with a real user/channel interaction. Cron runs, diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index ad07962bac64..4836a65252d0 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -46,14 +46,19 @@ For permission recovery, use [macOS permissions](/platforms/mac/permissions). ## Updates -The dashboard update card and menu bar update action update the signed macOS -app through Sparkle first. After relaunch, a setup-style window runs the -canonical `openclaw update` flow for an app-managed Gateway, pins it to the Mac -app version, restarts it, and verifies the connection. A failed update stays in -the window with retry, [update guide](/install/updating), and Discord actions. -Homebrew and other user-managed CLI installs remain under their current owner; -the app never downgrades a newer Gateway or overrides an `extended-stable` -channel pin. +The dashboard update card names what the app will update: + +- **Update Mac app + Gateway** means the signed app owns the local launchd + Gateway. Sparkle updates the app first; after relaunch, the app automatically + updates and restarts its Gateway at the matching version, then verifies the + connection. +- **Update Gateway** means the app is connected to a remote Gateway, a manually + managed local Gateway, or another install the app does not own. The button + runs that Gateway's normal update flow instead of changing the Mac app. + +A failed coordinated update stays in its setup-style window with retry, +[update guide](/install/updating), and Discord actions. Automatic repair never +downgrades a newer Gateway or overrides an `extended-stable` channel pin. After a successful update, the app finds the most recently human-used, top-level direct session and gives that agent a one-time update event. Heartbeat diff --git a/ui/src/app/native-link-routing.ts b/ui/src/app/native-link-routing.ts index 52a99798699b..d53f4a85a091 100644 --- a/ui/src/app/native-link-routing.ts +++ b/ui/src/app/native-link-routing.ts @@ -23,6 +23,8 @@ type WebKitUpdateMessageHandler = { }; export const NATIVE_UPDATE_DECLINED_EVENT = "openclaw:native-update-declined"; +export const NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT = + "openclaw:native-update-availability-changed"; type NativeLinkRouting = { dispose(): void; @@ -38,12 +40,20 @@ function getNativeLinkPoster(): WebKitMessageHandler["postMessage"] | undefined return handler?.postMessage.bind(handler); } -export function postNativeUpdate(): boolean { - const handler = ( +function getNativeUpdateHandler(): WebKitUpdateMessageHandler | undefined { + return ( window as unknown as { webkit?: { messageHandlers?: { openclawUpdate?: WebKitUpdateMessageHandler } }; } ).webkit?.messageHandlers?.openclawUpdate; +} + +export function hasNativeUpdateBridge(): boolean { + return getNativeUpdateHandler() !== undefined; +} + +export function postNativeUpdate(): boolean { + const handler = getNativeUpdateHandler(); if (!handler) { return false; } diff --git a/ui/src/components/sidebar-update-card.test.ts b/ui/src/components/sidebar-update-card.test.ts index fe0b4727bdad..3895809ebf86 100644 --- a/ui/src/components/sidebar-update-card.test.ts +++ b/ui/src/components/sidebar-update-card.test.ts @@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { UpdateAvailable } from "../api/types.ts"; -import { NATIVE_UPDATE_DECLINED_EVENT } from "../app/native-link-routing.ts"; +import { + NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT, + NATIVE_UPDATE_DECLINED_EVENT, +} from "../app/native-link-routing.ts"; import { createStorageMock } from "../test-helpers/storage.ts"; import "./sidebar-update-card.ts"; @@ -52,7 +55,7 @@ afterEach(() => { }); describe("SidebarUpdateCard", () => { - it("renders an available update and invokes the gateway action", async () => { + it("labels a direct Gateway update and invokes its action", async () => { const element = await mount({ currentVersion: "1.0.0", latestVersion: "2.0.0", @@ -63,7 +66,7 @@ describe("SidebarUpdateCard", () => { const action = element.querySelector(".sidebar-update-card__action"); expect(element.querySelector(".sidebar-update-card")?.getAttribute("role")).toBe("status"); - expect(action?.textContent).toContain("Update available"); + expect(action?.textContent).toContain("Update Gateway"); expect(action?.textContent).toContain("v2.0.0"); action?.click(); @@ -91,7 +94,7 @@ describe("SidebarUpdateCard", () => { expect(element.querySelector(".sidebar-update-card")).toBeNull(); }); - it("routes updates to the native bridge when present", async () => { + it("labels and routes a coordinated Mac app and managed Gateway update", async () => { const postMessage = vi.fn(); Object.defineProperty(window, "webkit", { configurable: true, @@ -105,6 +108,52 @@ describe("SidebarUpdateCard", () => { const onUpdate = vi.fn(); element.onUpdate = onUpdate; + const action = element.querySelector(".sidebar-update-card__action"); + expect(action?.textContent).toContain("Update Mac app + Gateway"); + expect(action?.textContent).toContain("v2.0.0"); + action?.click(); + + expect(postMessage).toHaveBeenCalledWith({ type: "start-update" }); + expect(onUpdate).not.toHaveBeenCalled(); + }); + + it("updates the visible target when native ownership changes", async () => { + const element = await mount({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "stable", + }); + expect(element.textContent).toContain("Update Gateway"); + + Object.defineProperty(window, "webkit", { + configurable: true, + value: { messageHandlers: { openclawUpdate: { postMessage: vi.fn() } } }, + }); + window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT)); + await element.updateComplete; + expect(element.textContent).toContain("Update Mac app + Gateway"); + + Reflect.deleteProperty(window, "webkit"); + window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT)); + await element.updateComplete; + expect(element.textContent).toContain("Update Gateway"); + }); + + it("uses a newly installed native bridge before its availability event arrives", async () => { + const element = await mount({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "stable", + }); + const onUpdate = vi.fn(); + const postMessage = vi.fn(); + element.onUpdate = onUpdate; + expect(element.textContent).toContain("Update Gateway"); + + Object.defineProperty(window, "webkit", { + configurable: true, + value: { messageHandlers: { openclawUpdate: { postMessage } } }, + }); element.querySelector(".sidebar-update-card__action")?.click(); expect(postMessage).toHaveBeenCalledWith({ type: "start-update" }); @@ -137,6 +186,62 @@ describe("SidebarUpdateCard", () => { expect(onUpdate).toHaveBeenCalledOnce(); }); + it("keeps later clicks on the displayed Gateway route after a native decline", async () => { + const postMessage = vi.fn(); + Object.defineProperty(window, "webkit", { + configurable: true, + value: { messageHandlers: { openclawUpdate: { postMessage } } }, + }); + const element = await mount({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "stable", + }); + const onUpdate = vi.fn(); + element.onUpdate = onUpdate; + + window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT)); + await element.updateComplete; + expect(element.textContent).toContain("Update Gateway"); + + element.querySelector(".sidebar-update-card__action")?.click(); + expect(onUpdate).toHaveBeenCalledTimes(2); + expect(postMessage).not.toHaveBeenCalled(); + + window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT)); + await element.updateComplete; + expect(element.textContent).toContain("Update Mac app + Gateway"); + element.querySelector(".sidebar-update-card__action")?.click(); + expect(postMessage).toHaveBeenCalledWith({ type: "start-update" }); + expect(onUpdate).toHaveBeenCalledTimes(2); + }); + + it("keeps a declined Gateway route consistent across reconnection", async () => { + const postMessage = vi.fn(); + Object.defineProperty(window, "webkit", { + configurable: true, + value: { messageHandlers: { openclawUpdate: { postMessage } } }, + }); + const element = await mount({ + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "stable", + }); + const onUpdate = vi.fn(); + element.onUpdate = onUpdate; + + window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT)); + await element.updateComplete; + element.remove(); + document.body.append(element); + await element.updateComplete; + + expect(element.textContent).toContain("Update Gateway"); + element.querySelector(".sidebar-update-card__action")?.click(); + expect(onUpdate).toHaveBeenCalledTimes(2); + expect(postMessage).not.toHaveBeenCalled(); + }); + it("disables the action while updating", async () => { const element = await mount({ currentVersion: "1.0.0", diff --git a/ui/src/components/sidebar-update-card.ts b/ui/src/components/sidebar-update-card.ts index 14d1d2c84ec4..4d06a417edcf 100644 --- a/ui/src/components/sidebar-update-card.ts +++ b/ui/src/components/sidebar-update-card.ts @@ -1,7 +1,12 @@ import { html, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import type { UpdateAvailable } from "../api/types.ts"; -import { NATIVE_UPDATE_DECLINED_EVENT, postNativeUpdate } from "../app/native-link-routing.ts"; +import { + hasNativeUpdateBridge, + NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT, + NATIVE_UPDATE_DECLINED_EVENT, + postNativeUpdate, +} from "../app/native-link-routing.ts"; import { t } from "../i18n/index.ts"; import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts"; import { getSafeLocalStorage } from "../local-storage.ts"; @@ -52,8 +57,17 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement { @property({ attribute: false }) updateRunning = false; @property({ attribute: false }) onUpdate: () => void = () => undefined; @state() private dismissedUpdateKey: string | null = null; + @state() private nativeUpdateAvailable = hasNativeUpdateBridge(); + private nativeUpdateDeclined = false; + + private readonly handleNativeUpdateAvailabilityChanged = () => { + this.nativeUpdateDeclined = false; + this.nativeUpdateAvailable = hasNativeUpdateBridge(); + }; private readonly handleNativeUpdateDeclined = () => { + this.nativeUpdateDeclined = true; + this.nativeUpdateAvailable = false; if (this.updateAvailable && !this.updateRunning) { this.onUpdate(); } @@ -61,10 +75,19 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement { override connectedCallback() { super.connectedCallback(); + this.nativeUpdateAvailable = !this.nativeUpdateDeclined && hasNativeUpdateBridge(); + window.addEventListener( + NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT, + this.handleNativeUpdateAvailabilityChanged, + ); window.addEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined); } override disconnectedCallback() { + window.removeEventListener( + NATIVE_UPDATE_AVAILABILITY_CHANGED_EVENT, + this.handleNativeUpdateAvailabilityChanged, + ); window.removeEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined); super.disconnectedCallback(); } @@ -79,7 +102,11 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement { ) { return nothing; } - const title = this.updateRunning ? t("chat.updating") : t("chat.sidebar.updateAvailable"); + const title = this.updateRunning + ? t("chat.updating") + : this.nativeUpdateAvailable + ? t("chat.sidebar.updateMacAndGateway") + : t("chat.sidebar.updateGateway"); return html`