fix: address update-card review findings (channel normalization, hidden-nav affordance, prerelease pinning, policy-aware bridge) (#104316)

* fix(mac): prerelease-exact launch resolution and policy-aware update ownership

* fix(ui): keep update affordance when nav is hidden and fall back on native decline

* chore(i18n): sync native inventory
This commit is contained in:
Peter Steinberger
2026-07-11 01:44:26 -07:00
committed by GitHub
parent 236d1b2484
commit 88c563b241
19 changed files with 312 additions and 51 deletions
+9 -9
View File
@@ -15187,7 +15187,7 @@
},
{
"kind": "conditional-branch",
"line": 173,
"line": 177,
"path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift",
"source": "CLI install failed",
"surface": "apple",
@@ -15195,7 +15195,7 @@
},
{
"kind": "conditional-branch",
"line": 173,
"line": 177,
"path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift",
"source": "CLI install finished",
"surface": "apple",
@@ -15203,7 +15203,7 @@
},
{
"kind": "conditional-branch",
"line": 46,
"line": 50,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Stable",
"surface": "apple",
@@ -15211,7 +15211,7 @@
},
{
"kind": "conditional-branch",
"line": 47,
"line": 51,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Beta",
"surface": "apple",
@@ -15219,7 +15219,7 @@
},
{
"kind": "conditional-branch",
"line": 48,
"line": 52,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Dev (Git main)",
"surface": "apple",
@@ -15227,7 +15227,7 @@
},
{
"kind": "conditional-branch",
"line": 101,
"line": 105,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "OpenClaw Gateway \\(version) is ready.",
"surface": "apple",
@@ -15235,7 +15235,7 @@
},
{
"kind": "conditional-branch",
"line": 103,
"line": 107,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "OpenClaw Gateway is not installed yet.",
"surface": "apple",
@@ -15243,7 +15243,7 @@
},
{
"kind": "conditional-branch",
"line": 105,
"line": 109,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "The OpenClaw Gateway could not be verified. Setup will repair it.",
"surface": "apple",
@@ -15251,7 +15251,7 @@
},
{
"kind": "conditional-branch",
"line": 107,
"line": 111,
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
"source": "Gateway \\(found) does not match app \\(required). Setup will update it.",
"surface": "apple",
@@ -30,6 +30,7 @@ final class CLIInstallPrompter {
launchAgentUsesManagedCLI: Self.launchAgentUsesManagedCLI(
programArguments: GatewayLaunchAgentManager.launchdConfigSnapshot()?.programArguments ?? []),
gatewayUpdateChannel: OpenClawConfigFile.gatewayUpdateChannel(),
installPolicy: CLIInstallPolicy.storedPolicy(),
launchAgentWriteDisabled: GatewayLaunchAgentManager.isLaunchAgentWriteDisabled())
if await self.completePendingManagedRestartIfNeeded(managedStatus: managedStatus) {
return
@@ -48,6 +49,9 @@ final class CLIInstallPrompter {
{
return
}
// A completed install with an unverified restart owns recovery on
// the next trigger; the stale pre-install status must not prompt again.
if Self.hasPendingManagedRestart() { return }
}
guard !status.isReady else { return }
let lastPrompt = UserDefaults.standard.string(forKey: cliInstallPromptedVersionKey)
@@ -269,24 +273,30 @@ final class CLIInstallPrompter {
static func managedRepairGatesOpen(
launchAgentUsesManagedCLI: Bool,
gatewayUpdateChannel: String?,
installPolicy: String?,
launchAgentWriteDisabled: Bool) -> Bool
{
guard !launchAgentWriteDisabled else { return false }
guard launchAgentUsesManagedCLI else { return false }
// Exact pins make the app the Gateway version owner; channel policies
// leave updates to gateway update.run instead of Sparkle repair.
guard installPolicy == nil || installPolicy == "exact" 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"
return gatewayUpdateChannel != "extended-stable"
}
static func shouldAutomaticallyRepair(
status: CLIInstaller.Status,
launchAgentUsesManagedCLI: Bool,
gatewayUpdateChannel: String? = nil,
installPolicy: String? = nil,
launchAgentWriteDisabled: Bool = GatewayLaunchAgentManager.isLaunchAgentWriteDisabled()) -> Bool
{
guard self.managedRepairGatesOpen(
launchAgentUsesManagedCLI: launchAgentUsesManagedCLI,
gatewayUpdateChannel: gatewayUpdateChannel,
installPolicy: installPolicy,
launchAgentWriteDisabled: launchAgentWriteDisabled)
else { return false }
guard case let .incompatible(location, found, required) = status else { return false }
+9 -19
View File
@@ -18,6 +18,10 @@ enum CLIInstallBuild {
}
enum CLIInstallPolicy {
static func storedPolicy(defaults: UserDefaults = .standard) -> String? {
defaults.string(forKey: cliInstallPolicyKey)
}
static func requiredGatewayVersionString(
appVersion: String?,
isDebug: Bool,
@@ -26,7 +30,7 @@ enum CLIInstallPolicy {
guard !CLIInstallBuild.isStable(appVersion: appVersion, isDebug: isDebug) else {
return appVersion
}
return switch defaults.string(forKey: cliInstallPolicyKey) {
return switch self.storedPolicy(defaults: defaults) {
case "stable", "beta", "dev": nil
case "exact", nil: appVersion
default: appVersion
@@ -235,35 +239,21 @@ enum CLIInstaller {
expectedVersion: String?) -> Status
{
let normalized = GatewayEnvironment.normalizeGatewayVersionOutput(output)
guard let normalized, let installed = Semver.parse(normalized) else {
guard let normalized, Semver.parse(normalized) != nil else {
return .unusable(location: location)
}
guard let required = Semver.parse(expectedVersion) else {
guard Semver.parse(expectedVersion) != nil else {
return .ready(location: location, version: normalized)
}
let requiresExactVersion = Self.isPrerelease(expectedVersion) || Self.isPrerelease(normalized)
if requiresExactVersion, normalized != expectedVersion {
guard Semver.satisfiesExpectedGatewayVersion(installed: normalized, expected: expectedVersion) else {
return .incompatible(
location: location,
found: normalized,
required: expectedVersion ?? required.description)
}
guard installed.compatible(with: required) else {
return .incompatible(
location: location,
found: normalized,
required: expectedVersion ?? required.description)
required: expectedVersion ?? "unknown")
}
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,
@@ -126,12 +126,14 @@ enum CommandResolver {
{
guard let executable = defaults.string(forKey: cliValidatedExecutableKey),
fileManager.isExecutableFile(atPath: executable),
let validatedVersion = Semver.parse(defaults.string(forKey: cliValidatedVersionKey))
let validatedVersion = defaults.string(forKey: cliValidatedVersionKey),
Semver.parse(validatedVersion) != nil
else {
return nil
}
guard let required = Semver.parse(requiredVersion) else { return executable }
return validatedVersion.compatible(with: required) ? executable : nil
return Semver.satisfiesExpectedGatewayVersion(
installed: validatedVersion,
expected: requiredVersion) ? executable : nil
}
private static func openclawManagedPaths(home: URL) -> [String] {
@@ -30,6 +30,7 @@ final class DashboardManager {
launchAgentUsesManagedCLI: CLIInstallPrompter.launchAgentUsesManagedCLI(
programArguments: GatewayLaunchAgentManager.launchdConfigSnapshot()?.programArguments ?? []),
gatewayUpdateChannel: OpenClawConfigFile.gatewayUpdateChannel(),
installPolicy: CLIInstallPolicy.storedPolicy(),
launchAgentWriteDisabled: GatewayLaunchAgentManager.isLaunchAgentWriteDisabled())
}
@@ -376,11 +376,13 @@ final class DashboardWindowController: NSWindowController, WKNavigationDelegate,
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.
// ownership can change while the dashboard stays open. Revalidate here.
guard DashboardManager.updateBridgeEnabled(mode: AppStateStore.shared.connectionMode) else {
self.setUpdateBridgeEnabled(false)
// JS treated its posted message as handled; return this click to
// the gateway updater after withdrawing the native bridge.
self.webView.evaluateJavaScript(
"window.dispatchEvent(new CustomEvent('openclaw:native-update-declined'))")
return
}
updater.checkForUpdates(nil)
@@ -41,6 +41,33 @@ struct Semver: Comparable, CustomStringConvertible {
// Same major and not older than required.
self.major == required.major && self >= required
}
static func satisfiesExpectedGatewayVersion(installed: String, expected: String?) -> Bool {
let installed = installed.trimmingCharacters(in: .whitespacesAndNewlines)
let expected = expected?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let installedVersion = Self.parse(installed) else { return false }
guard let expectedVersion = Self.parse(expected) else { return true }
if Self.isPrerelease(installed) || Self.isPrerelease(expected) {
return installed == expected
}
return installedVersion.compatible(with: expectedVersion)
}
static func isPrerelease(_ version: String?) -> Bool {
guard let version = version?.lowercased() else { return false }
let versionCore = version.split(
separator: "+",
maxSplits: 1,
omittingEmptySubsequences: false)[0]
if ["alpha", "beta"].contains(where: { lane in
versionCore.contains("-\(lane).") || versionCore.contains(".\(lane).")
}) {
return true
}
guard let separator = versionCore.firstIndex(of: "-") else { return false }
let suffix = versionCore[versionCore.index(after: separator)...]
return !suffix.split(separator: ".").allSatisfy { Int($0) != nil }
}
}
enum GatewayEnvironmentKind: Equatable {
@@ -142,24 +169,27 @@ enum GatewayEnvironment {
message: "openclaw CLI not found in PATH; install the CLI.")
}
let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) }
let installedRaw = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) }
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
let installed = Semver.parse(installedRaw)
if let expected, let installed, !installed.compatible(with: expected) {
if let expected, let installedRaw, installed != nil,
!Semver.satisfiesExpectedGatewayVersion(installed: installedRaw, expected: expectedString)
{
let expectedText = expectedString ?? expected.description
return GatewayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expectedText),
kind: .incompatible(found: installedRaw, required: expectedText),
nodeVersion: runtime.version.description,
gatewayVersion: installed.description,
gatewayVersion: installedRaw,
requiredGateway: expectedText,
message: """
Gateway version \(installed.description) is incompatible with app \(expectedText);
Gateway version \(installedRaw) is incompatible with app \(expectedText);
install or update the global package.
""")
}
let gatewayLabel = gatewayBin != nil ? "global" : "local"
let gatewayVersionText = installed?.description ?? "unknown"
let gatewayVersionText = installedRaw ?? "unknown"
// Avoid repeating "(local)" twice; if using the local entrypoint, show the path once.
let localPathHint = gatewayBin == nil && projectEntrypoint != nil
? " (local: \(projectEntrypoint ?? "unknown"))"
@@ -312,7 +342,7 @@ enum GatewayEnvironment {
return normalized
}
private static func readGatewayVersion(binary: String) -> Semver? {
private static func readGatewayVersion(binary: String) -> String? {
let start = Date()
let process = Process()
process.executableURL = URL(fileURLWithPath: binary)
@@ -339,7 +369,10 @@ enum GatewayEnvironment {
""")
}
let raw = String(data: data, encoding: .utf8)
return Semver.parse(self.normalizeGatewayVersionOutput(raw))
guard let normalized = self.normalizeGatewayVersionOutput(raw),
Semver.parse(normalized) != nil
else { return nil }
return normalized
} catch {
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
self.logger.error(
@@ -352,13 +385,14 @@ enum GatewayEnvironment {
}
}
private static func readLocalGatewayVersion(projectRoot: URL) -> Semver? {
private static func readLocalGatewayVersion(projectRoot: URL) -> String? {
let pkg = projectRoot.appendingPathComponent("package.json")
guard let data = try? Data(contentsOf: pkg) else { return nil }
guard
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let version = json["version"] as? String
else { return nil }
return Semver.parse(version)
guard Semver.parse(version) != nil else { return nil }
return version.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
+1 -1
View File
@@ -679,7 +679,7 @@ final class SparkleUpdaterController: NSObject, UpdaterProviding {
}
func allowedSparkleChannels(forGatewayUpdateChannel channel: String?) -> Set<String> {
switch channel?.lowercased() {
switch channel {
case "beta", "dev":
["beta"]
default:
@@ -205,7 +205,12 @@ enum OpenClawConfigFile {
static func gatewayUpdateChannel() -> String? {
let root = self.loadDict()
let update = root["update"] as? [String: Any]
return update?["channel"] as? String
return self.normalizedGatewayUpdateChannel(update?["channel"] as? String)
}
static func normalizedGatewayUpdateChannel(_ channel: String?) -> String? {
let normalized = channel?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized?.isEmpty == false ? normalized : nil
}
static func browserControlEnabled(defaultValue: Bool = true) -> Bool {
@@ -93,11 +93,13 @@ struct CLIInstallerTests {
let defaults = try #require(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
#expect(CLIInstallPolicy.storedPolicy(defaults: defaults) == nil)
#expect(CLIInstallPolicy.requiredGatewayVersionString(
appVersion: "2026.7.2",
isDebug: true,
defaults: defaults) == "2026.7.2")
defaults.set("beta", forKey: cliInstallPolicyKey)
#expect(CLIInstallPolicy.storedPolicy(defaults: defaults) == "beta")
#expect(CLIInstallPolicy.requiredGatewayVersionString(
appVersion: "2026.7.2",
isDebug: true,
@@ -144,6 +146,13 @@ struct CLIInstallerTests {
location: location,
found: "2026.7.3-beta.2",
required: "2026.7.3"))
#expect(CLIInstaller.classifyVersion(
location: location,
output: "2026.7.3\n",
expectedVersion: "2026.7.3-beta.2") == .incompatible(
location: location,
found: "2026.7.3",
required: "2026.7.3-beta.2"))
#expect(CLIInstaller.classifyVersion(
location: location,
output: "2026.7.3-alpha.1\n",
@@ -233,6 +233,17 @@ import Testing
defaults: defaults,
fileManager: .default,
requiredVersion: "2026.8.0") == nil)
defaults.set("2026.7.3-beta.1", forKey: cliValidatedVersionKey)
#expect(CommandResolver.validatedOpenClawExecutable(
defaults: defaults,
fileManager: .default,
requiredVersion: "2026.7.3") == nil)
defaults.set("2026.7.3", forKey: cliValidatedVersionKey)
#expect(CommandResolver.validatedOpenClawExecutable(
defaults: defaults,
fileManager: .default,
requiredVersion: "2026.7.3-beta.1") == nil)
}
@Test func `builds SSH command for remote mode`() {
@@ -18,4 +18,25 @@ struct SemverTests {
let v = Semver(major: 3, minor: 2, patch: 1)
#expect(v.description == "3.2.1")
}
@Test func `expected prerelease pins require exact raw version`() {
#expect(Semver.satisfiesExpectedGatewayVersion(
installed: "2026.7.2-beta.1",
expected: "2026.7.2-beta.1"))
#expect(!Semver.satisfiesExpectedGatewayVersion(
installed: "2026.7.2",
expected: "2026.7.2-beta.1"))
#expect(!Semver.satisfiesExpectedGatewayVersion(
installed: "2026.7.2-beta.1",
expected: "2026.7.2"))
#expect(Semver.satisfiesExpectedGatewayVersion(
installed: "2026.7.3",
expected: "2026.7.2"))
#expect(!Semver.satisfiesExpectedGatewayVersion(
installed: "2026.7.2-rc.1",
expected: "2026.7.2"))
#expect(Semver.satisfiesExpectedGatewayVersion(
installed: "2026.7.2+build-foo",
expected: "2026.7.2"))
}
}
@@ -8,6 +8,9 @@ struct UpdateOrchestrationTests {
@Test func `Sparkle channels follow the Gateway update channel`() {
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "beta") == ["beta"])
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "dev") == ["beta"])
#expect(allowedSparkleChannels(forGatewayUpdateChannel:
OpenClawConfigFile.normalizedGatewayUpdateChannel(" BETA \n")) == ["beta"])
#expect(OpenClawConfigFile.normalizedGatewayUpdateChannel(" \n") == nil)
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "stable").isEmpty)
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "extended-stable").isEmpty)
#expect(allowedSparkleChannels(forGatewayUpdateChannel: "future").isEmpty)
@@ -138,23 +141,43 @@ struct UpdateOrchestrationTests {
#expect(CLIInstallPrompter.managedRepairGatesOpen(
launchAgentUsesManagedCLI: true,
gatewayUpdateChannel: nil,
installPolicy: nil,
launchAgentWriteDisabled: false))
#expect(CLIInstallPrompter.managedRepairGatesOpen(
launchAgentUsesManagedCLI: true,
gatewayUpdateChannel: "beta",
installPolicy: "exact",
launchAgentWriteDisabled: false))
#expect(!CLIInstallPrompter.managedRepairGatesOpen(
launchAgentUsesManagedCLI: false,
gatewayUpdateChannel: nil,
installPolicy: nil,
launchAgentWriteDisabled: false))
#expect(!CLIInstallPrompter.managedRepairGatesOpen(
launchAgentUsesManagedCLI: true,
gatewayUpdateChannel: "extended-stable",
installPolicy: nil,
launchAgentWriteDisabled: false))
#expect(!CLIInstallPrompter.managedRepairGatesOpen(
launchAgentUsesManagedCLI: true,
gatewayUpdateChannel: nil,
installPolicy: nil,
launchAgentWriteDisabled: true))
#expect(!CLIInstallPrompter.managedRepairGatesOpen(
launchAgentUsesManagedCLI: true,
gatewayUpdateChannel: nil,
installPolicy: "stable",
launchAgentWriteDisabled: false))
#expect(!CLIInstallPrompter.managedRepairGatesOpen(
launchAgentUsesManagedCLI: true,
gatewayUpdateChannel: nil,
installPolicy: "beta",
launchAgentWriteDisabled: false))
#expect(!CLIInstallPrompter.managedRepairGatesOpen(
launchAgentUsesManagedCLI: true,
gatewayUpdateChannel: nil,
installPolicy: "dev",
launchAgentWriteDisabled: false))
}
@Test func `pending managed restart marker round trips`() {
+52 -1
View File
@@ -1,13 +1,14 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { navigationSurfaceIsHidden, renderFloatingUpdateCard } from "./app-host.ts";
import type {
ApplicationContext,
ApplicationGateway,
ApplicationGatewaySnapshot,
} from "./context.ts";
import "./app-host.ts";
type AppLifecycleState = {
loginToken: string;
@@ -215,3 +216,53 @@ describe("OpenClaw shell keyboard shortcuts", () => {
expect(navigate).not.toHaveBeenCalled();
});
});
describe("OpenClaw shell update affordance", () => {
it("renders a floating card only while desktop navigation is collapsed", () => {
const container = document.createElement("div");
const updateAvailable = {
currentVersion: "2026.7.1",
latestVersion: "2026.7.2",
channel: "stable",
};
const shared = {
onboarding: false,
updateAvailable,
updateRunning: false,
onUpdate: vi.fn(),
};
const collapsed = navigationSurfaceIsHidden({
navCollapsed: true,
navDrawerOpen: false,
mobileNavLayout: false,
});
render(renderFloatingUpdateCard({ ...shared, navigationSurfaceHidden: collapsed }), container);
expect(container.querySelector("openclaw-sidebar-update-card")).not.toBeNull();
const visible = navigationSurfaceIsHidden({
navCollapsed: false,
navDrawerOpen: false,
mobileNavLayout: false,
});
render(renderFloatingUpdateCard({ ...shared, navigationSurfaceHidden: visible }), container);
expect(container.querySelector("openclaw-sidebar-update-card")).toBeNull();
});
it("treats the mobile navigation surface as hidden while its drawer is closed", () => {
expect(
navigationSurfaceIsHidden({
navCollapsed: false,
navDrawerOpen: false,
mobileNavLayout: true,
}),
).toBe(true);
expect(
navigationSurfaceIsHidden({
navCollapsed: false,
navDrawerOpen: true,
mobileNavLayout: true,
}),
).toBe(false);
});
});
+41 -2
View File
@@ -12,6 +12,7 @@ import "../components/github-link-hovercard.ts";
import "../components/login-gate.ts";
import "../components/browser/browser-panel.ts";
import "../components/resizable-divider.ts";
import "../components/sidebar-update-card.ts";
import "../components/terminal/terminal-panel.ts";
import "../components/tooltip.ts";
import "../components/update-banner.ts";
@@ -137,6 +138,32 @@ function isMobileNavLayout(): boolean {
return globalThis.matchMedia?.("(max-width: 1100px)").matches ?? false;
}
export function navigationSurfaceIsHidden(params: {
navCollapsed: boolean;
navDrawerOpen: boolean;
mobileNavLayout: boolean;
}): boolean {
return params.mobileNavLayout ? !params.navDrawerOpen : params.navCollapsed;
}
export function renderFloatingUpdateCard(params: {
navigationSurfaceHidden: boolean;
onboarding: boolean;
updateAvailable: ApplicationContext["overlays"]["snapshot"]["updateAvailable"];
updateRunning: boolean;
onUpdate: () => void;
}) {
if (!params.navigationSurfaceHidden || params.onboarding) {
return nothing;
}
return html`<openclaw-sidebar-update-card
class="sidebar-update-card--floating"
.updateAvailable=${params.updateAvailable}
.updateRunning=${params.updateRunning}
.onUpdate=${params.onUpdate}
></openclaw-sidebar-update-card>`;
}
class OpenClawApp extends OpenClawLightDomElement {
// Pinned while a connect submitted from the visible login gate is in
// flight, so a failed manual attempt cannot flash the shell in between.
@@ -836,6 +863,11 @@ class OpenClawShell extends OpenClawLightDomElement {
// stays persisted for when the viewport returns to the desktop layout.
// The settings sidebar has a fixed width, so the collapse state pauses too.
const navCollapsed = navigationSnapshot.navCollapsed && !navDrawerOpen && !settingsTakeover;
const navigationSurfaceHidden = navigationSurfaceIsHidden({
navCollapsed,
navDrawerOpen,
mobileNavLayout: isMobileNavLayout(),
});
const shellWidth = Math.max(globalThis.innerWidth || 0, NAV_WIDTH_MAX);
// One storage read per render; theme.refresh() re-renders on pref changes.
const uiSettings = loadSettings();
@@ -897,7 +929,7 @@ class OpenClawShell extends OpenClawLightDomElement {
context.config.current.serverVersion ??
gatewaySnapshot.hello?.server?.version ??
"",
updateAvailable: overlaySnapshot.updateAvailable,
updateAvailable: navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable,
updateRunning: overlaySnapshot.updateRunning,
onUpdate: () => void context.overlays.runUpdate(),
searchQuery: this.settingsSearchQuery,
@@ -927,7 +959,7 @@ class OpenClawShell extends OpenClawLightDomElement {
gatewaySnapshot.hello?.server?.version ??
null}
.devGitBranch=${context.config.current.devGitBranch}
.updateAvailable=${overlaySnapshot.updateAvailable}
.updateAvailable=${navigationSurfaceHidden ? null : overlaySnapshot.updateAvailable}
.updateRunning=${overlaySnapshot.updateRunning}
.onUpdate=${() => void context.overlays.runUpdate()}
.onOpenPalette=${this.openPalette}
@@ -985,6 +1017,13 @@ class OpenClawShell extends OpenClawLightDomElement {
statusBanner: overlaySnapshot.updateStatusBanner,
}}
></openclaw-update-banner>
${renderFloatingUpdateCard({
navigationSurfaceHidden,
onboarding: this.onboarding,
updateAvailable: overlaySnapshot.updateAvailable,
updateRunning: overlaySnapshot.updateRunning,
onUpdate: () => void context.overlays.runUpdate(),
})}
<openclaw-router-outlet
.router=${runtime.router}
.retryContext=${context}
+2
View File
@@ -21,6 +21,8 @@ type WebKitUpdateMessageHandler = {
postMessage(message: NativeUpdateMessage): void;
};
export const NATIVE_UPDATE_DECLINED_EVENT = "openclaw:native-update-declined";
export type NativeLinkRouting = {
dispose(): void;
};
@@ -2,6 +2,7 @@
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 { createStorageMock } from "../test-helpers/storage.ts";
import "./sidebar-update-card.ts";
@@ -110,6 +111,32 @@ describe("SidebarUpdateCard", () => {
expect(onUpdate).not.toHaveBeenCalled();
});
it("returns a declined native click to the gateway while connected", async () => {
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));
expect(onUpdate).toHaveBeenCalledOnce();
element.updateRunning = true;
window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT));
expect(onUpdate).toHaveBeenCalledOnce();
element.updateRunning = false;
element.updateAvailable = null;
window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT));
expect(onUpdate).toHaveBeenCalledOnce();
element.remove();
window.dispatchEvent(new CustomEvent(NATIVE_UPDATE_DECLINED_EVENT));
expect(onUpdate).toHaveBeenCalledOnce();
});
it("disables the action while updating", async () => {
const element = await mount({
currentVersion: "1.0.0",
+17 -1
View File
@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import type { UpdateAvailable } from "../api/types.ts";
import { postNativeUpdate } from "../app/native-link-routing.ts";
import { 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";
@@ -53,6 +53,22 @@ class SidebarUpdateCard extends OpenClawLightDomContentsElement {
@property({ attribute: false }) onUpdate: () => void = () => undefined;
@state() private dismissedUpdateKey: string | null = null;
private readonly handleNativeUpdateDeclined = () => {
if (this.updateAvailable && !this.updateRunning) {
this.onUpdate();
}
};
override connectedCallback() {
super.connectedCallback();
window.addEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined);
}
override disconnectedCallback() {
window.removeEventListener(NATIVE_UPDATE_DECLINED_EVENT, this.handleNativeUpdateDeclined);
super.disconnectedCallback();
}
override render() {
const update = this.updateAvailable;
if (
+18
View File
@@ -810,6 +810,24 @@ html.openclaw-native-macos .shell-nav-expand {
margin: 0 8px 10px;
}
.sidebar-update-card--floating .sidebar-update-card {
width: min(360px, calc(100% - 32px));
margin: 12px 16px 0;
}
@media (min-width: 1101px) {
/* Keep the card beside the collapsed-nav control so their hit targets stay distinct. */
.sidebar-update-card--floating .sidebar-update-card {
width: min(360px, calc(100% - 48px));
margin-left: 32px;
}
/* Native dashboard actions must also clear AppKit's drag strip. */
html.openclaw-native-macos .sidebar-update-card--floating .sidebar-update-card {
margin-top: calc(var(--openclaw-native-titlebar-height, 50px) + 2px);
}
}
.sidebar-update-card__action {
width: 100%;
min-height: 62px;