mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
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:
committed by
GitHub
parent
236d1b2484
commit
88c563b241
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`() {
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user