diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index f042cafe1e56..472f9fa25b91 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -15195,7 +15195,7 @@ }, { "kind": "conditional-branch", - "line": 107, + "line": 173, "path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift", "source": "CLI install failed", "surface": "apple", @@ -15203,7 +15203,7 @@ }, { "kind": "conditional-branch", - "line": 107, + "line": 173, "path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift", "source": "CLI install finished", "surface": "apple", @@ -18347,7 +18347,7 @@ }, { "kind": "conditional-branch", - "line": 322, + "line": 327, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Close Canvas", "surface": "apple", @@ -18355,7 +18355,7 @@ }, { "kind": "conditional-branch", - "line": 322, + "line": 327, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Open Canvas", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift index 9fea7818c555..73561438f1c7 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift @@ -22,15 +22,40 @@ final class CLIInstallPrompter { guard AppStateStore.shared.connectionMode == .local else { return } guard let version = Self.appVersion() else { return } let status = await CLIInstaller.status() + let managedStatus = await CLIInstaller.managedStatus() guard AppStateStore.shared.onboardingSeen else { return } guard AppStateStore.shared.connectionMode == .local else { return } + let shouldRepairManaged = Self.shouldAutomaticallyRepair( + status: managedStatus, + launchAgentUsesManagedCLI: Self.launchAgentUsesManagedCLI( + programArguments: GatewayLaunchAgentManager.launchdConfigSnapshot()?.programArguments ?? []), + gatewayUpdateChannel: OpenClawConfigFile.gatewayUpdateChannel(), + launchAgentWriteDisabled: GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()) + if await self.completePendingManagedRestartIfNeeded(managedStatus: managedStatus) { + return + } + if shouldRepairManaged { + // Only repair the app-owned install; external package-manager installs + // remain under their owner's control. Repair restores the exact pin + // that produced the incompatible status (channel policies never pin, + // so they never reach this branch). No persisted attempt marker: + // a transient failure must retry on the next launch/mode change, and + // success clears the incompatible status that gates this branch. + if await self.installCLI( + target: .exact(version), + showCompletionAlert: false, + restartManagedGateway: !AppStateStore.shared.isPaused) + { + return + } + } guard !status.isReady else { return } let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey) guard lastPrompt != version else { return } UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey) if let target = self.installTargetForCurrentBuild(confirmStable: true) { - Task { await self.installCLI(target: target) } + Task { _ = await self.installCLI(target: target) } } self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)") @@ -84,14 +109,52 @@ final class CLIInstallPrompter { return channels[index] } - private func installCLI(target: CLIInstaller.InstallTarget) async { + private func installCLI( + target: CLIInstaller.InstallTarget, + showCompletionAlert: Bool = true, + restartManagedGateway: Bool = false) async -> Bool + { let status = StatusBox() + let previousPID = restartManagedGateway + ? await GatewayLaunchAgentManager.runningGatewayPID() + : nil let installed = await CLIInstaller.install(target: target) { message in await status.set(message) + if !showCompletionAlert { + self.logger.info("managed CLI repair: \(message, privacy: .public)") + } } + var activated = false if installed { + if restartManagedGateway { + let restarted = await self.ensureManagedGatewayRestarted( + previousPID: previousPID, + status: status) + guard restarted else { + // The on-disk CLI is already replaced, so the incompatible + // status that gates auto-repair will read ready next launch. + // Persist the unfinished restart or the old gateway process + // would keep running the previous version indefinitely. + Self.setPendingManagedRestart() + return false + } + } await status.set("Starting OpenClaw Gateway…") + if !showCompletionAlert { + self.logger.info("managed CLI repair: Starting OpenClaw Gateway…") + } let activation = await CLIInstaller.activateLocalGateway() + activated = activation != .failed + if restartManagedGateway { + // Only proven gateway health closes the recovery loop; the + // on-disk CLI already reads ready, so a lost marker here means + // no later trigger would ever restart a failed gateway. + if activated { + Self.clearPendingManagedRestart() + } else { + Self.setPendingManagedRestart() + } + } let message = switch activation { case .ready: "OpenClaw Gateway is ready." @@ -101,13 +164,91 @@ final class CLIInstallPrompter { "OpenClaw was installed, but the Gateway did not start. Open Settings to retry." } await status.set(message) + if !showCompletionAlert { + self.logger.info("managed CLI repair: \(message, privacy: .public)") + } } - if let message = await status.get() { + if showCompletionAlert, let message = await status.get() { let alert = NSAlert() alert.messageText = installed ? "CLI install finished" : "CLI install failed" alert.informativeText = message alert.runModal() } + return installed && activated + } + + /// Finishes an update whose install succeeded but whose gateway restart did + /// not verify: by then the on-disk CLI reads ready, so the auto-repair gate + /// can never fire again for that version while the old process keeps running. + private func completePendingManagedRestartIfNeeded(managedStatus: CLIInstaller.Status) async -> Bool { + guard Self.hasPendingManagedRestart() else { return false } + guard case .ready = managedStatus else { + // A new incompatible/missing cycle owns the next repair. + Self.clearPendingManagedRestart() + return false + } + guard Self.launchAgentUsesManagedCLI( + programArguments: GatewayLaunchAgentManager.launchdConfigSnapshot()?.programArguments ?? []), + !GatewayLaunchAgentManager.isLaunchAgentWriteDisabled(), + !AppStateStore.shared.isPaused + else { return false } + if let error = await GatewayLaunchAgentManager.kickstart() { + self.logger.error("pending managed Gateway restart failed: \(error, privacy: .public)") + return false + } + await GatewayConnection.shared.shutdown() + guard await CLIInstaller.activateLocalGateway() != .failed else { return false } + Self.clearPendingManagedRestart() + self.logger.info("pending managed Gateway restart completed") + return true + } + + static func hasPendingManagedRestart() -> Bool { + UserDefaults.standard.bool(forKey: cliManagedRestartPendingKey) + } + + static func setPendingManagedRestart() { + UserDefaults.standard.set(true, forKey: cliManagedRestartPendingKey) + } + + static func clearPendingManagedRestart() { + UserDefaults.standard.removeObject(forKey: cliManagedRestartPendingKey) + } + + private func ensureManagedGatewayRestarted(previousPID: Int32?, status: StatusBox) async -> Bool { + guard previousPID != nil else { + await GatewayConnection.shared.shutdown() + return true + } + if await self.waitForManagedGatewayRestart(previousPID: previousPID) { + await GatewayConnection.shared.shutdown() + return true + } + if let error = await GatewayLaunchAgentManager.kickstart() { + let message = "Managed Gateway restart failed: \(error)" + await status.set(message) + self.logger.error("\(message, privacy: .public)") + return false + } + await GatewayConnection.shared.shutdown() + guard await self.waitForManagedGatewayRestart(previousPID: previousPID) else { + let message = "Managed Gateway restart could not be verified." + await status.set(message) + self.logger.error("\(message, privacy: .public)") + return false + } + return true + } + + private func waitForManagedGatewayRestart(previousPID: Int32?) async -> Bool { + for _ in 0..<20 { + let currentPID = await GatewayLaunchAgentManager.runningGatewayPID() + if Self.didManagedGatewayRestart(previousPID: previousPID, currentPID: currentPID) { + return true + } + try? await Task.sleep(nanoseconds: 150_000_000) + } + return false } private func openSettings(tab: SettingsTab) { @@ -121,6 +262,97 @@ final class CLIInstallPrompter { private static func appVersion() -> String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String } + + /// Shared gate for auto-repair and the dashboard's native update bridge. + /// If these drift apart, the card can route to Sparkle while the + /// post-relaunch gateway repair refuses, stranding an old gateway. + static func managedRepairGatesOpen( + launchAgentUsesManagedCLI: Bool, + gatewayUpdateChannel: String?, + launchAgentWriteDisabled: Bool) -> Bool + { + guard !launchAgentWriteDisabled else { return false } + guard launchAgentUsesManagedCLI else { return false } + // Extended-stable pins an intentionally older gateway; moving it to the + // app's newer stable version without consent keeps the prompt instead. + return gatewayUpdateChannel?.lowercased() != "extended-stable" + } + + static func shouldAutomaticallyRepair( + status: CLIInstaller.Status, + launchAgentUsesManagedCLI: Bool, + gatewayUpdateChannel: String? = nil, + launchAgentWriteDisabled: Bool = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()) -> Bool + { + guard self.managedRepairGatesOpen( + launchAgentUsesManagedCLI: launchAgentUsesManagedCLI, + gatewayUpdateChannel: gatewayUpdateChannel, + launchAgentWriteDisabled: launchAgentWriteDisabled) + else { return false } + guard case let .incompatible(location, found, required) = status else { return false } + // Auto-repair only moves the managed install forward. A gateway newer + // than the app (e.g. beta channel ahead of the app track) was a user + // choice; silently downgrading it keeps the consent prompt instead. + guard Self.isManagedUpgrade(found: found, required: required) else { return false } + return location == CLIInstaller.managedExecutableLocation() + } + + static func isManagedUpgrade(found: String, required: String) -> Bool { + guard let foundVersion = Semver.parse(found), + let requiredVersion = Semver.parse(required) + else { return false } + if foundVersion != requiredVersion { return foundVersion < requiredVersion } + // Same numeric triple: a prerelease sorts below its release, so + // beta -> stable is an upgrade and stable -> beta is a downgrade. + switch (Self.prereleaseTail(found), Self.prereleaseTail(required)) { + case (nil, nil), (nil, .some): + return false + case (.some, nil): + return true + case let (.some(foundTail), .some(requiredTail)): + return foundTail.compare(requiredTail, options: .numeric) == .orderedAscending + } + } + + private static func prereleaseTail(_ version: String) -> String? { + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + guard let separator = trimmed.firstIndex(of: "-") else { return nil } + let tail = String(trimmed[trimmed.index(after: separator)...]) + return tail.isEmpty ? nil : tail + } + + static func launchAgentUsesManagedCLI(programArguments: [String]) -> Bool { + var command = programArguments[...] + if command.count >= 3, + command[command.startIndex] == "/bin/sh", + command[command.index(after: command.startIndex)].hasSuffix("-env-wrapper.sh") + { + command = command.dropFirst(3) + } else if command.count >= 2, + command[command.startIndex].hasSuffix("-env-wrapper.sh") + { + command = command.dropFirst(2) + } + let managedRoot = URL(fileURLWithPath: CLIInstaller.managedExecutableLocation()) + .deletingLastPathComponent() + .deletingLastPathComponent() + .standardizedFileURL.path + "/" + guard let executable = command.first else { return false } + let executablePath = URL(fileURLWithPath: executable).standardizedFileURL.path + let managedRuntimeRoot = managedRoot + "tools/node/" + if executablePath.hasPrefix(managedRoot), !executablePath.hasPrefix(managedRuntimeRoot) { + return true + } + guard command.count >= 2 else { return false } + let entrypoint = command[command.index(after: command.startIndex)] + return URL(fileURLWithPath: entrypoint).standardizedFileURL.path.hasPrefix(managedRoot) + } + + static func didManagedGatewayRestart(previousPID: Int32?, currentPID: Int32?) -> Bool { + guard let currentPID else { return false } + guard let previousPID else { return true } + return currentPID != previousPID + } } private actor StatusBox { diff --git a/apps/macos/Sources/OpenClaw/CLIInstaller.swift b/apps/macos/Sources/OpenClaw/CLIInstaller.swift index ddcc8339dfa9..43db07c26cfc 100644 --- a/apps/macos/Sources/OpenClaw/CLIInstaller.swift +++ b/apps/macos/Sources/OpenClaw/CLIInstaller.swift @@ -241,6 +241,13 @@ enum CLIInstaller { guard let required = Semver.parse(expectedVersion) else { return .ready(location: location, version: normalized) } + let requiresExactVersion = Self.isPrerelease(expectedVersion) || Self.isPrerelease(normalized) + if requiresExactVersion, normalized != expectedVersion { + return .incompatible( + location: location, + found: normalized, + required: expectedVersion ?? required.description) + } guard installed.compatible(with: required) else { return .incompatible( location: location, @@ -250,6 +257,13 @@ enum CLIInstaller { return .ready(location: location, version: normalized) } + private static func isPrerelease(_ version: String?) -> Bool { + guard let version = version?.lowercased() else { return false } + return ["alpha", "beta"].contains { lane in + version.contains("-\(lane).") || version.contains(".\(lane).") + } + } + static func probeEnvironment( location: String, processEnvironment: [String: String] = ProcessInfo.processInfo.environment, diff --git a/apps/macos/Sources/OpenClaw/Constants.swift b/apps/macos/Sources/OpenClaw/Constants.swift index 320571bddae7..d3db47be92eb 100644 --- a/apps/macos/Sources/OpenClaw/Constants.swift +++ b/apps/macos/Sources/OpenClaw/Constants.swift @@ -44,6 +44,7 @@ let peekabooBridgeEnabledKey = "openclaw.peekabooBridgeEnabled" let deepLinkKeyKey = "openclaw.deepLinkKey" let cliInstallPromptedVersionKey = "openclaw.cliInstallPromptedVersion" let cliInstallPolicyKey = "openclaw.cliInstallPolicy" +let cliManagedRestartPendingKey = "openclaw.cliManagedRestartPending" let cliValidatedExecutableKey = "openclaw.cliValidatedExecutable" let cliValidatedVersionKey = "openclaw.cliValidatedVersion" let macNodeIdentityProfileKey = "openclaw.macNodeIdentityProfile" diff --git a/apps/macos/Sources/OpenClaw/DashboardManager.swift b/apps/macos/Sources/OpenClaw/DashboardManager.swift index 3c4d5eba53e9..335e2e381886 100644 --- a/apps/macos/Sources/OpenClaw/DashboardManager.swift +++ b/apps/macos/Sources/OpenClaw/DashboardManager.swift @@ -11,10 +11,28 @@ final class DashboardManager { private var controller: DashboardWindowController? private var endpointTask: Task? + private var updater: UpdaterProviding? private static let failureURL = URL(string: "about:blank")! private init() {} + func configure(updater: UpdaterProviding) { + self.updater = updater + } + + /// The card's native update path only makes sense when the app owns the + /// local gateway and the post-relaunch repair is allowed to run; otherwise + /// (external CLI, write-disabled launchd, extended-stable pin) the card + /// must keep the direct gateway `update.run` flow, so no bridge is exposed. + static func updateBridgeEnabled(mode: AppState.ConnectionMode) -> Bool { + guard mode == .local else { return false } + return CLIInstallPrompter.managedRepairGatesOpen( + launchAgentUsesManagedCLI: CLIInstallPrompter.launchAgentUsesManagedCLI( + programArguments: GatewayLaunchAgentManager.launchdConfigSnapshot()?.programArguments ?? []), + gatewayUpdateChannel: OpenClawConfigFile.gatewayUpdateChannel(), + launchAgentWriteDisabled: GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()) + } + /// The remote SSH tunnel can be recreated on a new ephemeral local port while /// the dashboard stays open; without following endpoint changes the WebView /// keeps reconnecting to the dead old port forever (#100476). @@ -34,11 +52,17 @@ final class DashboardManager { guard let controller, controller.isWindowOpen else { return } let config: GatewayConnection.Config = (url, token, password) let authToken = await GatewayConnection.shared.controlUiAutoAuthToken(config: config) - guard let dashboardURL = try? GatewayEndpointStore.dashboardURL(for: config, mode: mode, authToken: authToken), - dashboardURL != controller.currentURL + guard let dashboardURL = try? GatewayEndpointStore.dashboardURL( + for: config, + mode: mode, + authToken: authToken) else { return } + if dashboardURL == controller.currentURL { + controller.setUpdateBridgeEnabled(Self.updateBridgeEnabled(mode: mode)) + return + } let auth = DashboardWindowAuth( gatewayUrl: Self.websocketURLString(for: dashboardURL), token: authToken, @@ -46,7 +70,7 @@ final class DashboardManager { guard auth.hasCredential, controller.isWindowOpen else { return } dashboardManagerLogger.info( "dashboard endpoint changed; reloading url=\(dashboardLogString(for: dashboardURL), privacy: .public)") - controller.update(url: dashboardURL, auth: auth) + controller.update(url: dashboardURL, auth: auth, updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode)) } @discardableResult @@ -68,9 +92,13 @@ final class DashboardManager { return false } if let controller { - controller.show(url: url, auth: auth) + controller.show(url: url, auth: auth, updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode)) } else { - let controller = DashboardWindowController(url: url, auth: auth) + let controller = DashboardWindowController( + url: url, + auth: auth, + updater: self.updater, + updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode)) self.controller = controller controller.show(url: url, auth: auth) } @@ -93,13 +121,17 @@ final class DashboardManager { if let controller { dashboardManagerLogger.info("dashboard reuse window url=\(dashboardLogString(for: url), privacy: .public)") - controller.show(url: url, auth: auth) + controller.show(url: url, auth: auth, updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode)) self.observeEndpointChanges() return } dashboardManagerLogger.info("dashboard create window url=\(dashboardLogString(for: url), privacy: .public)") - let controller = DashboardWindowController(url: url, auth: auth) + let controller = DashboardWindowController( + url: url, + auth: auth, + updater: self.updater, + updateBridgeEnabled: Self.updateBridgeEnabled(mode: mode)) self.controller = controller controller.show(url: url, auth: auth) self.observeEndpointChanges() @@ -113,7 +145,9 @@ final class DashboardManager { dashboardManagerLogger.error("dashboard setup failed error=\(message, privacy: .public)") let controller = self.controller ?? DashboardWindowController( url: Self.failureURL, - auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil)) + auth: DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil), + updater: self.updater, + updateBridgeEnabled: Self.updateBridgeEnabled(mode: AppStateStore.shared.connectionMode)) self.controller = controller // Keep observing while the failure page is up so a recovered tunnel // swaps the window back to the live dashboard. diff --git a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift index 1009eb400865..6c0fe6030154 100644 --- a/apps/macos/Sources/OpenClaw/DashboardWindowController.swift +++ b/apps/macos/Sources/OpenClaw/DashboardWindowController.swift @@ -36,25 +36,46 @@ private final class DashboardWindowDragMessageHandler: NSObject, WKScriptMessage } } +@MainActor +private final class DashboardUpdateMessageHandler: NSObject, WKScriptMessageHandler { + weak var owner: DashboardWindowController? + + func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { + self.owner?.receiveUpdateMessage(message) + } +} + @MainActor final class DashboardWindowController: NSWindowController, WKNavigationDelegate, WKUIDelegate, NSWindowDelegate { private static let linkMessageHandlerName = "openclawLink" private static let windowDragMessageHandlerName = "openclawWindowDrag" + private static let updateMessageHandlerName = "openclawUpdate" private let webView: WKWebView private let linkBrowser: DashboardLinkBrowserView private let linkBrowserItem: NSSplitViewItem private let splitViewController: NSSplitViewController + private let updateMessageHandler: DashboardUpdateMessageHandler private(set) var currentURL: URL private var auth: DashboardWindowAuth + private let updater: UpdaterProviding? + private var updateBridgeEnabled: Bool private var backButton: NSButton? private var forwardButton: NSButton? private var canGoBackObservation: NSKeyValueObservation? private var canGoForwardObservation: NSKeyValueObservation? - init(url: URL, auth: DashboardWindowAuth) { + init( + url: URL, + auth: DashboardWindowAuth, + updater: UpdaterProviding? = nil, + updateBridgeEnabled: Bool = true) + { + let shouldEnableUpdateBridge = updater?.isAvailable == true && updateBridgeEnabled self.currentURL = url self.auth = auth + self.updater = updater + self.updateBridgeEnabled = shouldEnableUpdateBridge let dataStore = WKWebsiteDataStore.default() let config = WKWebViewConfiguration() @@ -67,6 +88,13 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, config.userContentController.add(linkMessageHandler, name: Self.linkMessageHandlerName) let windowDragMessageHandler = DashboardWindowDragMessageHandler() config.userContentController.add(windowDragMessageHandler, name: Self.windowDragMessageHandlerName) + let updateMessageHandler = DashboardUpdateMessageHandler() + self.updateMessageHandler = updateMessageHandler + if shouldEnableUpdateBridge { + // Handler presence is the Control UI feature probe; unsigned builds + // and remote dashboards must not advertise a local app update. + config.userContentController.add(updateMessageHandler, name: Self.updateMessageHandlerName) + } Self.installNativeChromeScript(into: config.userContentController) Self.installNativeAuthScript(into: config.userContentController, url: url, auth: auth) @@ -117,6 +145,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, self.linkBrowserItem.isCollapsed = true linkMessageHandler.owner = self windowDragMessageHandler.owner = self + updateMessageHandler.owner = self self.webView.navigationDelegate = self self.webView.uiDelegate = self self.linkBrowser.webViewNavigationDelegate = self @@ -127,6 +156,17 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, self.installNavigationControls() } + func setUpdateBridgeEnabled(_ enabled: Bool) { + let nextEnabled = self.updater?.isAvailable == true && enabled + guard nextEnabled != self.updateBridgeEnabled else { return } + self.updateBridgeEnabled = nextEnabled + let controller = self.webView.configuration.userContentController + controller.removeScriptMessageHandler(forName: Self.updateMessageHandlerName) + if nextEnabled { + controller.add(self.updateMessageHandler, name: Self.updateMessageHandlerName) + } + } + // MARK: - WKUIDelegate /// Bridges `` clicks in the embedded Control UI to a native @@ -192,8 +232,8 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, fatalError("init(coder:) is not supported") } - func show(url: URL, auth: DashboardWindowAuth) { - self.update(url: url, auth: auth) + func show(url: URL, auth: DashboardWindowAuth, updateBridgeEnabled: Bool? = nil) { + self.update(url: url, auth: auth, updateBridgeEnabled: updateBridgeEnabled) self.show() } @@ -202,10 +242,13 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, /// the remote tunnel is recreated on a new local port while the window stays /// open; ordering the window front here would steal focus on background /// tunnel recreation. - func update(url: URL, auth: DashboardWindowAuth) { + func update(url: URL, auth: DashboardWindowAuth, updateBridgeEnabled: Bool? = nil) { self.currentURL = url self.auth = auth self.refreshNativeAuthScript(url: url, auth: auth) + if let updateBridgeEnabled { + self.setUpdateBridgeEnabled(updateBridgeEnabled) + } self.load(url) } @@ -239,6 +282,7 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, func showFailure(title: String, message: String, detail: String? = nil) { self.currentURL = URL(string: "about:blank")! self.auth = DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil) + self.setUpdateBridgeEnabled(false) self.refreshNativeAuthScript(url: self.currentURL, auth: self.auth) self.webView.stopLoading() self.webView.loadHTMLString( @@ -321,6 +365,32 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate, return payload["type"] as? String == "window-drag" } + fileprivate func receiveUpdateMessage(_ message: WKScriptMessage) { + guard message.name == Self.updateMessageHandlerName, + message.webView === self.webView, + message.frameInfo.isMainFrame, + Self.isTrustedLinkSource(message.frameInfo.request.url, dashboardURL: self.currentURL), + Self.isStartUpdateRequest(message.body), + let updater = self.updater + else { + return + } + // Eligibility is cached at window setup, but update.channel or launchd + // ownership can change while the dashboard stays open. Revalidate here; + // dropping the bridge makes the Control UI's next click fall back to + // the direct gateway update flow. + guard DashboardManager.updateBridgeEnabled(mode: AppStateStore.shared.connectionMode) else { + self.setUpdateBridgeEnabled(false) + return + } + updater.checkForUpdates(nil) + } + + static func isStartUpdateRequest(_ body: Any) -> Bool { + guard let payload = body as? [String: Any] else { return false } + return payload["type"] as? String == "start-update" + } + static func linkRequest(from body: Any) -> DashboardLinkRequest? { guard let payload = body as? [String: Any], payload["type"] as? String == "open-link", @@ -861,6 +931,10 @@ extension DashboardWindowController { self.webView.configuration.userContentController.userScripts } + var _testUpdateBridgeAvailable: Bool { + self.updateBridgeEnabled + } + var _testLinkBrowserIsCollapsed: Bool { self.linkBrowserItem.isCollapsed } diff --git a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift index 4ae377b7aad4..4c13e785eb78 100644 --- a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift @@ -95,8 +95,8 @@ enum GatewayLaunchAgentManager { return await self.runDaemonCommand(["uninstall"]) } - static func kickstart() async { - _ = await self.runDaemonCommand(["restart"], timeout: 20) + static func kickstart() async -> String? { + await self.runDaemonCommand(["restart"], timeout: 20) } static func launchdConfigSnapshot() -> LaunchAgentPlistSnapshot? { diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index c75d332b583c..6fdf6d4cc4f5 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -308,6 +308,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { var openDashboardAction: @MainActor () -> Void = { AppNavigationActions.openDashboard() } let updaterController: UpdaterProviding = makeUpdaterController() + func applicationWillFinishLaunching(_: Notification) { + // URL/reopen callbacks can create the dashboard before didFinishLaunching. + DashboardManager.shared.configure(updater: self.updaterController) + } + func applicationDockMenu(_: NSApplication) -> NSMenu? { let menu = NSMenu() menu.autoenablesItems = false @@ -673,7 +678,20 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding { } } -extension SparkleUpdaterController: SPUUpdaterDelegate {} +func allowedSparkleChannels(forGatewayUpdateChannel channel: String?) -> Set { + switch channel?.lowercased() { + case "beta", "dev": + ["beta"] + default: + [] + } +} + +extension SparkleUpdaterController: SPUUpdaterDelegate { + func allowedChannels(for _: SPUUpdater) -> Set { + allowedSparkleChannels(forGatewayUpdateChannel: OpenClawConfigFile.gatewayUpdateChannel()) + } +} private func isDeveloperIDSigned(bundleURL: URL) -> Bool { var staticCode: SecStaticCode? diff --git a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift index 4e2008e78e41..9e9aa53c5cad 100644 --- a/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift +++ b/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift @@ -202,6 +202,12 @@ enum OpenClawConfigFile { self.saveDict(root) } + static func gatewayUpdateChannel() -> String? { + let root = self.loadDict() + let update = root["update"] as? [String: Any] + return update?["channel"] as? String + } + static func browserControlEnabled(defaultValue: Bool = true) -> Bool { let root = self.loadDict() let browser = root["browser"] as? [String: Any] diff --git a/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift b/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift index dd8f8e66ad61..721aa61180c4 100644 --- a/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift +++ b/apps/macos/Sources/OpenClaw/TailscaleIntegrationSection.swift @@ -381,7 +381,7 @@ struct TailscaleIntegrationSection: View { private func restartGatewayIfNeeded() { guard self.connectionMode == .local, !self.isPaused else { return } - Task { await GatewayLaunchAgentManager.kickstart() } + Task { _ = await GatewayLaunchAgentManager.kickstart() } } private func currentSettingsSnapshot() -> GatewayTailscaleSettingsSnapshot { diff --git a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift index 5c1bbb3d8814..55c5edb7bfb1 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CLIInstallerTests.swift @@ -130,6 +130,27 @@ struct CLIInstallerTests { location: location, found: "2026.6.1", required: "2026.7.3")) + #expect(CLIInstaller.classifyVersion( + location: location, + output: "2026.7.3-beta.1\n", + expectedVersion: "2026.7.3-beta.2") == .incompatible( + location: location, + found: "2026.7.3-beta.1", + required: "2026.7.3-beta.2")) + #expect(CLIInstaller.classifyVersion( + location: location, + output: "2026.7.3-beta.2\n", + expectedVersion: "2026.7.3") == .incompatible( + location: location, + found: "2026.7.3-beta.2", + required: "2026.7.3")) + #expect(CLIInstaller.classifyVersion( + location: location, + output: "2026.7.3-alpha.1\n", + expectedVersion: "2026.7.3") == .incompatible( + location: location, + found: "2026.7.3-alpha.1", + required: "2026.7.3")) } @Test func `compatible external CLI satisfies setup`() async throws { diff --git a/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift b/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift new file mode 100644 index 000000000000..0ce1bfaa8896 --- /dev/null +++ b/apps/macos/Tests/OpenClawIPCTests/UpdateOrchestrationTests.swift @@ -0,0 +1,189 @@ +import Foundation +import Testing +@testable import OpenClaw + +@Suite(.serialized) +@MainActor +struct UpdateOrchestrationTests { + @Test func `Sparkle channels follow the Gateway update channel`() { + #expect(allowedSparkleChannels(forGatewayUpdateChannel: "beta") == ["beta"]) + #expect(allowedSparkleChannels(forGatewayUpdateChannel: "dev") == ["beta"]) + #expect(allowedSparkleChannels(forGatewayUpdateChannel: "stable").isEmpty) + #expect(allowedSparkleChannels(forGatewayUpdateChannel: "extended-stable").isEmpty) + #expect(allowedSparkleChannels(forGatewayUpdateChannel: "future").isEmpty) + #expect(allowedSparkleChannels(forGatewayUpdateChannel: nil).isEmpty) + } + + @Test func `dashboard accepts only start update payloads`() { + #expect(DashboardWindowController.isStartUpdateRequest(["type": "start-update"])) + #expect(!DashboardWindowController.isStartUpdateRequest(["type": "update.run"])) + #expect(!DashboardWindowController.isStartUpdateRequest("start-update")) + } + + @Test func `dashboard exposes update bridge only for available updater`() throws { + let url = try #require(URL(string: "http://127.0.0.1:18789/control/")) + let auth = DashboardWindowAuth(gatewayUrl: nil, token: nil, password: nil) + let available = TestUpdater(isAvailable: true) + let enabled = DashboardWindowController(url: url, auth: auth, updater: available) + let disabled = DashboardWindowController( + url: url, + auth: auth, + updater: TestUpdater(isAvailable: false)) + let remote = DashboardWindowController( + url: url, + auth: auth, + updater: available, + updateBridgeEnabled: false) + + #expect(enabled._testUpdateBridgeAvailable) + #expect(!disabled._testUpdateBridgeAvailable) + #expect(!remote._testUpdateBridgeAvailable) + remote.setUpdateBridgeEnabled(true) + #expect(remote._testUpdateBridgeAvailable) + } + + @Test func `automatic repair is limited to incompatible managed install`() { + let managed = CLIInstaller.managedExecutableLocation() + #expect(CLIInstallPrompter.shouldAutomaticallyRepair(status: .incompatible( + location: managed, + found: "2026.7.1", + required: "2026.7.2"), launchAgentUsesManagedCLI: true, launchAgentWriteDisabled: false)) + #expect(!CLIInstallPrompter.shouldAutomaticallyRepair(status: .incompatible( + location: "/opt/homebrew/bin/openclaw", + found: "2026.7.1", + required: "2026.7.2"), launchAgentUsesManagedCLI: true, launchAgentWriteDisabled: false)) + #expect(!CLIInstallPrompter.shouldAutomaticallyRepair( + status: .missing(location: managed), + launchAgentUsesManagedCLI: true, + launchAgentWriteDisabled: false)) + #expect(!CLIInstallPrompter.shouldAutomaticallyRepair( + status: .unusable(location: managed), + launchAgentUsesManagedCLI: true, + launchAgentWriteDisabled: false)) + #expect(!CLIInstallPrompter.shouldAutomaticallyRepair( + status: .incompatible(location: managed, found: "2026.7.1", required: "2026.7.2"), + launchAgentUsesManagedCLI: false, + launchAgentWriteDisabled: false)) + #expect(!CLIInstallPrompter.shouldAutomaticallyRepair( + status: .incompatible(location: managed, found: "2026.7.1", required: "2026.7.2"), + launchAgentUsesManagedCLI: true, + launchAgentWriteDisabled: true)) + // Never silently downgrade a gateway the user moved ahead of the app. + #expect(!CLIInstallPrompter.shouldAutomaticallyRepair(status: .incompatible( + location: managed, + found: "2026.7.3", + required: "2026.7.2"), launchAgentUsesManagedCLI: true, launchAgentWriteDisabled: false)) + // Extended-stable pins an older gateway on purpose; keep the prompt. + #expect(!CLIInstallPrompter.shouldAutomaticallyRepair( + status: .incompatible(location: managed, found: "2026.7.1", required: "2026.7.2"), + launchAgentUsesManagedCLI: true, + gatewayUpdateChannel: "extended-stable", + launchAgentWriteDisabled: false)) + #expect(CLIInstallPrompter.shouldAutomaticallyRepair( + status: .incompatible(location: managed, found: "2026.7.1", required: "2026.7.2"), + launchAgentUsesManagedCLI: true, + gatewayUpdateChannel: "beta", + launchAgentWriteDisabled: false)) + } + + @Test func `managed repair only upgrades`() { + #expect(CLIInstallPrompter.isManagedUpgrade(found: "2026.7.1", required: "2026.7.2")) + #expect(!CLIInstallPrompter.isManagedUpgrade(found: "2026.7.2", required: "2026.7.1")) + #expect(!CLIInstallPrompter.isManagedUpgrade(found: "2026.7.2", required: "2026.7.2")) + // Prerelease of the same triple sorts below its release. + #expect(CLIInstallPrompter.isManagedUpgrade(found: "2026.7.2-beta.1", required: "2026.7.2")) + #expect(!CLIInstallPrompter.isManagedUpgrade(found: "2026.7.2", required: "2026.7.2-beta.1")) + #expect(CLIInstallPrompter.isManagedUpgrade( + found: "2026.7.2-beta.1", + required: "2026.7.2-beta.2")) + #expect(CLIInstallPrompter.isManagedUpgrade( + found: "2026.7.2-beta.2", + required: "2026.7.2-beta.10")) + #expect(!CLIInstallPrompter.isManagedUpgrade( + found: "2026.7.2-beta.2", + required: "2026.7.2-beta.1")) + #expect(!CLIInstallPrompter.isManagedUpgrade(found: "garbage", required: "2026.7.2")) + } + + @Test func `managed Gateway ownership ignores the generated environment wrapper`() { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let wrapper = "\(home)/.openclaw/state/service-env/ai.openclaw.gateway-env-wrapper.sh" + let environment = "\(home)/.openclaw/state/service-env/ai.openclaw.gateway.env" + let managedEntry = "\(home)/.openclaw/lib/node_modules/openclaw/dist/index.js" + + #expect(CLIInstallPrompter.launchAgentUsesManagedCLI(programArguments: [ + wrapper, + environment, + "/usr/local/bin/node", + managedEntry, + "gateway", + ])) + #expect(!CLIInstallPrompter.launchAgentUsesManagedCLI(programArguments: [ + wrapper, + environment, + "/usr/local/bin/node", + "/opt/homebrew/lib/node_modules/openclaw/dist/index.js", + "gateway", + ])) + #expect(!CLIInstallPrompter.launchAgentUsesManagedCLI(programArguments: [ + wrapper, + environment, + "\(home)/.openclaw/tools/node/bin/node", + "/opt/homebrew/lib/node_modules/openclaw/dist/index.js", + "gateway", + ])) + } + + @Test func `managed repair gates cover bridge and repair alike`() { + #expect(CLIInstallPrompter.managedRepairGatesOpen( + launchAgentUsesManagedCLI: true, + gatewayUpdateChannel: nil, + launchAgentWriteDisabled: false)) + #expect(CLIInstallPrompter.managedRepairGatesOpen( + launchAgentUsesManagedCLI: true, + gatewayUpdateChannel: "beta", + launchAgentWriteDisabled: false)) + #expect(!CLIInstallPrompter.managedRepairGatesOpen( + launchAgentUsesManagedCLI: false, + gatewayUpdateChannel: nil, + launchAgentWriteDisabled: false)) + #expect(!CLIInstallPrompter.managedRepairGatesOpen( + launchAgentUsesManagedCLI: true, + gatewayUpdateChannel: "extended-stable", + launchAgentWriteDisabled: false)) + #expect(!CLIInstallPrompter.managedRepairGatesOpen( + launchAgentUsesManagedCLI: true, + gatewayUpdateChannel: nil, + launchAgentWriteDisabled: true)) + } + + @Test func `pending managed restart marker round trips`() { + CLIInstallPrompter.clearPendingManagedRestart() + #expect(!CLIInstallPrompter.hasPendingManagedRestart()) + CLIInstallPrompter.setPendingManagedRestart() + #expect(CLIInstallPrompter.hasPendingManagedRestart()) + CLIInstallPrompter.clearPendingManagedRestart() + #expect(!CLIInstallPrompter.hasPendingManagedRestart()) + } + + @Test func `managed Gateway restart requires a new running process`() { + #expect(CLIInstallPrompter.didManagedGatewayRestart(previousPID: nil, currentPID: 41)) + #expect(CLIInstallPrompter.didManagedGatewayRestart(previousPID: 40, currentPID: 41)) + #expect(!CLIInstallPrompter.didManagedGatewayRestart(previousPID: 41, currentPID: 41)) + #expect(!CLIInstallPrompter.didManagedGatewayRestart(previousPID: 41, currentPID: nil)) + } +} + +@MainActor +private final class TestUpdater: UpdaterProviding { + var automaticallyChecksForUpdates = false + var automaticallyDownloadsUpdates = false + let isAvailable: Bool + let updateStatus = UpdateStatus() + + init(isAvailable: Bool) { + self.isAvailable = isAvailable + } + + func checkForUpdates(_: Any?) {} +} diff --git a/docs/docs_map.md b/docs/docs_map.md index 8edd52b07404..28a5f00cc379 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -5177,6 +5177,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - Headings: - H2: Download - H2: First run + - H2: Updates - H2: Open dashboard links - H2: Choose a Gateway mode - H2: What the app owns diff --git a/docs/install/updating.md b/docs/install/updating.md index f4b588e2c98f..c446ca9f81d8 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -241,6 +241,10 @@ 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 updates the app through Sparkle first; after relaunch, +the app brings its managed local Gateway to the matching version. + ## After updating diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index cc865ae3cbf5..0d857fdf4ed3 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -41,6 +41,19 @@ available for recovery. For the CLI/Gateway setup path, use [Getting started](/start/getting-started). For permission recovery, use [macOS permissions](/platforms/mac/permissions). +## Updates + +The dashboard update card updates the signed macOS app through Sparkle first. +After the app relaunches, it automatically updates and restarts the matching +app-managed local Gateway. Homebrew and other user-managed CLI installs keep +the normal Gateway update flow (the card runs the Gateway update directly), +and the automatic repair never downgrades a newer Gateway or overrides an +`extended-stable` channel pin. + +Sparkle follows the Gateway's `update.channel` setting. `beta` and `dev` opt in +to beta app builds; `stable`, `extended-stable`, and missing or unknown values +stay on stable app builds. + ## Open dashboard links In the macOS app's embedded dashboard, clicking an external web link opens it in a resizable browser sidebar. Each link opens in its own tab; clicking the same link again reuses its existing tab. Drag tabs to reorder them, close them with the tab close button or a middle-click, and right-click a tab for **Open in Default Browser**, **Copy Link**, **Reload**, **Close Tab**, and **Close Other Tabs**. The window's titlebar back/forward controls and trackpad swipes navigate dashboard history; the sidebar's own back/forward controls navigate the active tab's history. The sidebar also has reload, open-in-default-browser, and close controls, and it remembers its width. diff --git a/scripts/make_appcast.sh b/scripts/make_appcast.sh index 5d158033f3e1..c4d29d03bbbb 100755 --- a/scripts/make_appcast.sh +++ b/scripts/make_appcast.sh @@ -38,6 +38,15 @@ if [[ -z "$VERSION" ]]; then fi fi +CHANNEL_ARGS=() +if [[ "$VERSION" == *-alpha.* || "$VERSION" == *.alpha.* ]]; then + echo "Alpha releases do not ship via Sparkle: $VERSION" >&2 + exit 1 +fi +if [[ "$VERSION" == *-beta.* || "$VERSION" == *.beta.* ]]; then + CHANNEL_ARGS=(--channel beta) +fi + TMP_DIR="$(mktemp -d)" NOTES_HTML="" cleanup() { @@ -74,6 +83,7 @@ fi --download-url-prefix "$DOWNLOAD_URL_PREFIX" \ --embed-release-notes \ --link "$FEED_URL" \ + "${CHANNEL_ARGS[@]}" \ "$TMP_DIR" cp -f "$TMP_DIR/appcast.xml" "$ROOT/appcast.xml" diff --git a/scripts/release-check.ts b/scripts/release-check.ts index 8114a67d03e8..56e58bc5b0dc 100755 --- a/scripts/release-check.ts +++ b/scripts/release-check.ts @@ -1107,6 +1107,7 @@ export function collectAppcastSparkleVersionErrors(xml: string): string[] { const title = extractTag(item, "title") ?? "unknown"; const shortVersion = extractTag(item, "sparkle:shortVersionString"); const sparkleVersion = extractTag(item, "sparkle:version"); + const sparkleChannel = extractTag(item, "sparkle:channel"); if (!sparkleVersion) { errors.push(`appcast item '${title}' is missing sparkle:version.`); @@ -1120,6 +1121,9 @@ export function collectAppcastSparkleVersionErrors(xml: string): string[] { if (!shortVersion) { continue; } + if (/(?:^|[.-])beta(?:[.-]|$)/i.test(shortVersion) && sparkleChannel !== "beta") { + errors.push(`appcast item '${title}' must set sparkle:channel to 'beta'.`); + } const floors = sparkleBuildFloorsFromShortVersion(shortVersion); if (floors === null) { errors.push( diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index fd6e66c0021f..29ed6833b080 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -482,7 +482,13 @@ describe("gateway broadcaster", () => { broadcast("health", { ok: true }); broadcast("tick", { ts: 2 }); broadcast("shutdown", { reason: "restart" }); - broadcast("update.available", { updateAvailable: { version: "2026.4.20" } }); + broadcast("update.available", { + updateAvailable: { + currentVersion: "2026.4.19", + latestVersion: "2026.4.20", + channel: "stable", + }, + }); broadcast("unknown.future.event", { hidden: true }); expectSentEvents(pairingSocket, [ diff --git a/test/release-check.test.ts b/test/release-check.test.ts index 512aac6e4c9c..67e801375af5 100644 --- a/test/release-check.test.ts +++ b/test/release-check.test.ts @@ -45,8 +45,9 @@ import { } from "../src/infra/package-dist-inventory.ts"; import { withEnv } from "../src/test-utils/env.js"; -function makeItem(shortVersion: string, sparkleVersion: string): string { - return `${shortVersion}${shortVersion}${sparkleVersion}`; +function makeItem(shortVersion: string, sparkleVersion: string, channel?: string): string { + const channelElement = channel ? `${channel}` : ""; + return `${shortVersion}${shortVersion}${sparkleVersion}${channelElement}`; } function makePackResult(filename: string, unpackedSize: number) { @@ -82,8 +83,22 @@ describe("collectAppcastSparkleVersionErrors", () => { expect(collectAppcastSparkleVersionErrors(xml)).toStrictEqual([]); }); + it("accepts canonical beta lane builds", () => { + const xml = `${makeItem("2026.6.5-beta.2", "2606000502", "beta")}`; + + expect(collectAppcastSparkleVersionErrors(xml)).toStrictEqual([]); + }); + + it("rejects beta builds on the default channel", () => { + const xml = `${makeItem("2026.6.5-beta.2", "2606000502")}`; + + expect(collectAppcastSparkleVersionErrors(xml)).toEqual([ + "appcast item '2026.6.5-beta.2' must set sparkle:channel to 'beta'.", + ]); + }); + it("rejects appcast entries with invalid prerelease lanes", () => { - const xml = `${makeItem("2026.6.5-beta.0", "2606000500")}`; + const xml = `${makeItem("2026.6.5-beta.0", "2606000500", "beta")}`; expect(collectAppcastSparkleVersionErrors(xml)).toEqual([ "appcast item '2026.6.5-beta.0' has invalid sparkle:shortVersionString '2026.6.5-beta.0'.", diff --git a/test/scripts/make-appcast.test.ts b/test/scripts/make-appcast.test.ts index 886434a634dc..010056cc9028 100644 --- a/test/scripts/make-appcast.test.ts +++ b/test/scripts/make-appcast.test.ts @@ -21,4 +21,13 @@ describe("make_appcast cleanup", () => { ); expect(setupBlock).toContain('rm -f "$NOTES_HTML"'); }); + + it("adds the beta channel and refuses alpha releases", () => { + const script = readFileSync(scriptPath, "utf8"); + + expect(script).toContain('if [[ "$VERSION" == *-beta.* || "$VERSION" == *.beta.* ]]; then'); + expect(script).toContain("CHANNEL_ARGS=(--channel beta)"); + expect(script).toContain('if [[ "$VERSION" == *-alpha.* || "$VERSION" == *.alpha.* ]]; then'); + expect(script).toContain('"${CHANNEL_ARGS[@]}"'); + }); }); diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index e621bd54a8c7..c9cf19cfe171 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -897,6 +897,9 @@ class OpenClawShell extends OpenClawLightDomElement { context.config.current.serverVersion ?? gatewaySnapshot.hello?.server?.version ?? "", + updateAvailable: overlaySnapshot.updateAvailable, + updateRunning: overlaySnapshot.updateRunning, + onUpdate: () => void context.overlays.runUpdate(), searchQuery: this.settingsSearchQuery, onExit: () => this.exitSettings(), onNavigate: (routeId) => this.navigate(routeId), @@ -924,6 +927,9 @@ class OpenClawShell extends OpenClawLightDomElement { gatewaySnapshot.hello?.server?.version ?? null} .devGitBranch=${context.config.current.devGitBranch} + .updateAvailable=${overlaySnapshot.updateAvailable} + .updateRunning=${overlaySnapshot.updateRunning} + .onUpdate=${() => void context.overlays.runUpdate()} .onOpenPalette=${this.openPalette} .onToggleSidebar=${() => this.toggleNavigationSurface()} .onOpenNewSession=${(agentId: string) => { @@ -977,11 +983,6 @@ class OpenClawShell extends OpenClawLightDomElement { context.overlays.runUpdate(), - onDismiss: () => context.overlays.dismissUpdate(), }} > void) => () => void; runUpdate: () => Promise; - dismissUpdate: () => void; decideApproval: (decision: ExecApprovalDecision) => Promise; openDevicePairSetup: () => Promise; refreshDevicePairSetup: () => Promise; @@ -611,10 +610,6 @@ export function createApplicationOverlays(gateway: ApplicationGateway): Applicat } } }, - dismissUpdate() { - snapshot = { ...snapshot, updateAvailable: null }; - publish(); - }, async decideApproval(decision) { const active = promptState.execApprovalQueue[0]; const client = gateway.snapshot.client; diff --git a/ui/src/components/app-sidebar.test.ts b/ui/src/components/app-sidebar.test.ts index 9d3d82f2620f..cca66d3767d4 100644 --- a/ui/src/components/app-sidebar.test.ts +++ b/ui/src/components/app-sidebar.test.ts @@ -2,7 +2,7 @@ import { ContextProvider } from "@lit/context"; import { LitElement } from "lit"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GatewayBrowserClient } from "../api/gateway.ts"; import type { SessionsListResult } from "../api/types.ts"; import type { RouteId } from "../app-route-paths.ts"; @@ -13,6 +13,7 @@ import { type ApplicationGatewaySnapshot, } from "../app/context.ts"; import type { SessionCapability, SessionState } from "../lib/sessions/index.ts"; +import { createStorageMock } from "../test-helpers/storage.ts"; import "./app-sidebar.ts"; const PROVIDER_ELEMENT_NAME = "test-app-sidebar-context-provider"; @@ -38,6 +39,9 @@ type SidebarLifecycleState = HTMLElement & { sessionsAgentId: string | null; sessionsResult: SessionsListResult | null; updateComplete: Promise; + updateAvailable: { currentVersion: string; latestVersion: string; channel: string } | null; + updateRunning: boolean; + onUpdate: () => void; variant: "panel" | "drawer"; }; @@ -154,6 +158,16 @@ function createSessions(agentId: string, keys: string[]): SessionCapability { return createSessionsHarness(agentId, keys).sessions; } +let originalLocalStorage: PropertyDescriptor | undefined; + +beforeEach(() => { + originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: createStorageMock(), + }); +}); + function createContext( gateway: ApplicationGateway, sessions: SessionCapability, @@ -192,6 +206,32 @@ async function mountSidebar( afterEach(() => { document.body.replaceChildren(); + if (originalLocalStorage) { + Object.defineProperty(globalThis, "localStorage", originalLocalStorage); + } else { + Reflect.deleteProperty(globalThis, "localStorage"); + } +}); + +describe("AppSidebar update card wiring", () => { + it("renders the update card first in the footer and forwards its action", async () => { + const gateway = createGateway({} as GatewayBrowserClient); + const { sidebar } = await mountSidebar(gateway, createSessions("main", ["agent:main:main"])); + const onUpdate = vi.fn(); + sidebar.updateAvailable = { + currentVersion: "1.0.0", + latestVersion: "2.0.0", + channel: "stable", + }; + sidebar.onUpdate = onUpdate; + await sidebar.updateComplete; + + const footer = sidebar.querySelector(".sidebar-shell__footer"); + const card = footer?.firstElementChild; + expect(card?.localName).toBe("openclaw-sidebar-update-card"); + card?.querySelector(".sidebar-update-card__action")?.click(); + expect(onUpdate).toHaveBeenCalledOnce(); + }); }); describe("AppSidebar lobster outcome wiring", () => { diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index c5df9ca63679..4eca5cc51787 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -3,7 +3,7 @@ import { html, nothing } from "lit"; import { property, state } from "lit/decorators.js"; import { keyed } from "lit/directives/keyed.js"; import type { GatewayBrowserClient, GatewayControlUiPluginTab } from "../api/gateway.ts"; -import type { SessionsListResult } from "../api/types.ts"; +import type { SessionsListResult, UpdateAvailable } from "../api/types.ts"; import { cancelRoutePreload, DEFAULT_SIDEBAR_PINNED_ROUTES, @@ -26,6 +26,7 @@ import { controlUiPublicAssetPath } from "../app/public-assets.ts"; import { isViteDevPage } from "../app/settings.ts"; import type { ThemeMode } from "../app/theme.ts"; import "./session-menu.ts"; +import "./sidebar-update-card.ts"; import "./theme-mode-toggle.ts"; import "./tooltip.ts"; import { CONTROL_UI_BUILD_INFO } from "../build-info.ts"; @@ -208,6 +209,9 @@ class AppSidebar extends OpenClawLightDomContentsElement { @property({ attribute: false }) lobsterPetSounds = false; @property({ attribute: false }) gatewayVersion: string | null = null; @property({ attribute: false }) devGitBranch: string | null = null; + @property({ attribute: false }) updateAvailable: UpdateAvailable | null = null; + @property({ attribute: false }) updateRunning = false; + @property({ attribute: false }) onUpdate: () => void = () => undefined; @property({ attribute: false }) onOpenPalette?: () => void; @property({ attribute: false }) onToggleSidebar?: () => void; @property({ attribute: false }) onOpenNewSession?: (agentId: string) => void; @@ -1924,6 +1928,11 @@ class AppSidebar extends OpenClawLightDomContentsElement { ${this.renderSessions()}