mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
fix(macos): offer CLI channels for unreleased builds (#104178)
* fix(macos): choose CLI channel for dev builds * chore: leave release notes to automation * chore(macos): sync native string inventory * style(macos): wrap CLI channel guidance * fix(macos): return CLI version policy
This commit is contained in:
committed by
GitHub
parent
79bfa3771c
commit
4b3d4020d3
@@ -15195,7 +15195,47 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 34,
|
||||
"line": 107,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift",
|
||||
"source": "CLI install failed",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.b9da2c873ad24b0f"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 107,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift",
|
||||
"source": "CLI install finished",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.506cd570a8f8a80d"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 46,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "Stable",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.a952bff9aa7c7e9b"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 47,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "Beta",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.a80cbef551294895"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 48,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "Dev (Git main)",
|
||||
"surface": "apple",
|
||||
"id": "native.apple.dac0bc4221a6e965"
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 101,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "OpenClaw Gateway \\(version) is ready.",
|
||||
"surface": "apple",
|
||||
@@ -15203,7 +15243,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 36,
|
||||
"line": 103,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "OpenClaw Gateway is not installed yet.",
|
||||
"surface": "apple",
|
||||
@@ -15211,7 +15251,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 38,
|
||||
"line": 105,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "The OpenClaw Gateway could not be verified. Setup will repair it.",
|
||||
"surface": "apple",
|
||||
@@ -15219,7 +15259,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 40,
|
||||
"line": 107,
|
||||
"path": "apps/macos/Sources/OpenClaw/CLIInstaller.swift",
|
||||
"source": "Gateway \\(found) does not match app \\(required). Setup will update it.",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -29,29 +29,64 @@ final class CLIInstallPrompter {
|
||||
guard lastPrompt != version else { return }
|
||||
UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Install OpenClaw CLI?"
|
||||
alert.informativeText = "Local mode needs the CLI so launchd can run the gateway."
|
||||
alert.addButton(withTitle: "Install CLI")
|
||||
alert.addButton(withTitle: "Not now")
|
||||
alert.addButton(withTitle: "Open Settings")
|
||||
let response = alert.runModal()
|
||||
|
||||
switch response {
|
||||
case .alertFirstButtonReturn:
|
||||
Task { await self.installCLI() }
|
||||
case .alertThirdButtonReturn:
|
||||
self.openSettings(tab: .connection)
|
||||
default:
|
||||
break
|
||||
if let target = self.installTargetForCurrentBuild(confirmStable: true) {
|
||||
Task { await self.installCLI(target: target) }
|
||||
}
|
||||
|
||||
self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)")
|
||||
}
|
||||
|
||||
private func installCLI() async {
|
||||
func installTargetForCurrentBuild(confirmStable: Bool = false) -> CLIInstaller.InstallTarget? {
|
||||
let appVersion = Self.appVersion()
|
||||
if let target = CLIInstaller.automaticInstallTarget(
|
||||
appVersion: appVersion,
|
||||
isDebug: CLIInstallBuild.isDebug)
|
||||
{
|
||||
guard confirmStable else { return target }
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Install OpenClaw CLI?"
|
||||
alert.informativeText = "Local mode needs the CLI so launchd can run the Gateway."
|
||||
alert.addButton(withTitle: "Install CLI")
|
||||
alert.addButton(withTitle: "Not Now")
|
||||
alert.addButton(withTitle: "Open Settings")
|
||||
switch alert.runModal() {
|
||||
case .alertFirstButtonReturn:
|
||||
return target
|
||||
case .alertThirdButtonReturn:
|
||||
self.openSettings(tab: .connection)
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return self.chooseChannel(
|
||||
suggested: CLIInstaller.suggestedChannel(
|
||||
appVersion: appVersion,
|
||||
isDebug: CLIInstallBuild.isDebug))
|
||||
.map(CLIInstaller.InstallTarget.channel)
|
||||
}
|
||||
|
||||
private func chooseChannel(suggested: CLIInstaller.Channel) -> CLIInstaller.Channel? {
|
||||
let channels = [suggested] + CLIInstaller.Channel.allCases.filter { $0 != suggested }
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Choose OpenClaw CLI channel"
|
||||
alert.informativeText =
|
||||
"This is an unreleased OpenClaw build. " +
|
||||
"Local mode can use Stable, Beta, or Dev from Git main."
|
||||
for channel in channels {
|
||||
alert.addButton(withTitle: channel.label)
|
||||
}
|
||||
alert.addButton(withTitle: "Not Now")
|
||||
let response = alert.runModal()
|
||||
let index = response.rawValue - NSApplication.ModalResponse.alertFirstButtonReturn.rawValue
|
||||
guard channels.indices.contains(index) else { return nil }
|
||||
return channels[index]
|
||||
}
|
||||
|
||||
private func installCLI(target: CLIInstaller.InstallTarget) async {
|
||||
let status = StatusBox()
|
||||
let installed = await CLIInstaller.install { message in
|
||||
let installed = await CLIInstaller.install(target: target) { message in
|
||||
await status.set(message)
|
||||
}
|
||||
if installed {
|
||||
@@ -69,7 +104,7 @@ final class CLIInstallPrompter {
|
||||
}
|
||||
if let message = await status.get() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "CLI install finished"
|
||||
alert.messageText = installed ? "CLI install finished" : "CLI install failed"
|
||||
alert.informativeText = message
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,74 @@
|
||||
import Foundation
|
||||
|
||||
enum CLIInstallBuild {
|
||||
static var isDebug: Bool {
|
||||
#if DEBUG
|
||||
true
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
|
||||
static func isStable(appVersion: String?, isDebug: Bool) -> Bool {
|
||||
guard let appVersion, !isDebug else { return false }
|
||||
guard let separator = appVersion.firstIndex(of: "-") else { return true }
|
||||
let suffix = appVersion[appVersion.index(after: separator)...]
|
||||
return suffix.split(separator: ".").allSatisfy { Int($0) != nil }
|
||||
}
|
||||
}
|
||||
|
||||
enum CLIInstallPolicy {
|
||||
static func requiredGatewayVersionString(
|
||||
appVersion: String?,
|
||||
isDebug: Bool,
|
||||
defaults: UserDefaults = .standard) -> String?
|
||||
{
|
||||
guard !CLIInstallBuild.isStable(appVersion: appVersion, isDebug: isDebug) else {
|
||||
return appVersion
|
||||
}
|
||||
return switch defaults.string(forKey: cliInstallPolicyKey) {
|
||||
case "stable", "beta", "dev": nil
|
||||
case "exact", nil: appVersion
|
||||
default: appVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum CLIInstaller {
|
||||
enum Channel: String, CaseIterable, Equatable {
|
||||
case stable
|
||||
case beta
|
||||
case dev
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .stable: "Stable"
|
||||
case .beta: "Beta"
|
||||
case .dev: "Dev (Git main)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum InstallTarget: Equatable {
|
||||
case exact(String)
|
||||
case channel(Channel)
|
||||
|
||||
var selector: String {
|
||||
switch self {
|
||||
case let .exact(version): version
|
||||
case .channel(.stable): "latest"
|
||||
case .channel(.beta): "beta"
|
||||
case .channel(.dev): "main"
|
||||
}
|
||||
}
|
||||
|
||||
var requiresExactVersion: Bool {
|
||||
if case .exact = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
enum LocalGatewayActivation: Equatable {
|
||||
case ready
|
||||
case deferred
|
||||
@@ -109,12 +176,16 @@ enum CLIInstaller {
|
||||
}
|
||||
|
||||
static func managedStatus() async -> Status {
|
||||
await self.managedStatus(expectedVersion: GatewayEnvironment.expectedGatewayVersionString())
|
||||
}
|
||||
|
||||
private static func managedStatus(expectedVersion: String?) async -> Status {
|
||||
let location = self.managedExecutableLocation()
|
||||
guard FileManager.default.isExecutableFile(atPath: location) else {
|
||||
return .missing(location: location)
|
||||
}
|
||||
|
||||
let status = await self.status(location: location)
|
||||
let status = await self.status(location: location, expectedVersion: expectedVersion)
|
||||
if status.isReady {
|
||||
self.rememberValidated(status)
|
||||
}
|
||||
@@ -122,6 +193,12 @@ enum CLIInstaller {
|
||||
}
|
||||
|
||||
static func status(location: String) async -> Status {
|
||||
await self.status(
|
||||
location: location,
|
||||
expectedVersion: GatewayEnvironment.expectedGatewayVersionString())
|
||||
}
|
||||
|
||||
private static func status(location: String, expectedVersion: String?) async -> Status {
|
||||
let environment = self.probeEnvironment(location: location)
|
||||
let response = await ShellExecutor.runDetailed(
|
||||
command: [location, "--version"],
|
||||
@@ -134,7 +211,7 @@ enum CLIInstaller {
|
||||
let versionStatus = self.classifyVersion(
|
||||
location: location,
|
||||
output: response.stdout,
|
||||
expectedVersion: GatewayEnvironment.expectedGatewayVersionString())
|
||||
expectedVersion: expectedVersion)
|
||||
guard versionStatus.isReady else { return versionStatus }
|
||||
guard await self.runtimeIsCompatible(environment: environment) else {
|
||||
return .unusable(location: location)
|
||||
@@ -202,22 +279,25 @@ enum CLIInstaller {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async -> Bool {
|
||||
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||
static func install(
|
||||
target: InstallTarget,
|
||||
statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async -> Bool
|
||||
{
|
||||
let prefix = Self.installPrefix()
|
||||
await statusHandler("Installing openclaw CLI…")
|
||||
await statusHandler("Installing OpenClaw CLI (\(target.selector))…")
|
||||
guard let installerURL = Bundle.main.url(forResource: "install-cli", withExtension: "sh") else {
|
||||
await statusHandler("Install failed: installer resource is missing. Reinstall OpenClaw.")
|
||||
return false
|
||||
}
|
||||
let cmd = self.installScriptCommand(
|
||||
version: expected,
|
||||
target: target,
|
||||
prefix: prefix,
|
||||
scriptPath: installerURL.path)
|
||||
let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: nil, timeout: 900)
|
||||
|
||||
if response.success {
|
||||
let managedStatus = await self.managedStatus()
|
||||
let expectedVersion = target.requiresExactVersion ? GatewayEnvironment.appVersionString() : nil
|
||||
let managedStatus = await self.managedStatus(expectedVersion: expectedVersion)
|
||||
guard managedStatus.isReady else {
|
||||
await statusHandler("Install failed: \(managedStatus.message)")
|
||||
return false
|
||||
@@ -225,6 +305,7 @@ enum CLIInstaller {
|
||||
let parsed = self.parseInstallEvents(response.stdout)
|
||||
let installedVersion = parsed.last { $0.event == "done" }?.version
|
||||
let summary = installedVersion.map { "Installed openclaw \($0)." } ?? "Installed openclaw."
|
||||
self.rememberInstallPolicy(target)
|
||||
await statusHandler(summary)
|
||||
return true
|
||||
}
|
||||
@@ -247,8 +328,8 @@ enum CLIInstaller {
|
||||
.path
|
||||
}
|
||||
|
||||
static func installScriptCommand(version: String, prefix: String, scriptPath: String) -> [String] {
|
||||
[
|
||||
static func installScriptCommand(target: InstallTarget, prefix: String, scriptPath: String) -> [String] {
|
||||
var command = [
|
||||
"/bin/bash",
|
||||
scriptPath,
|
||||
"--json",
|
||||
@@ -256,8 +337,43 @@ enum CLIInstaller {
|
||||
"--prefix",
|
||||
prefix,
|
||||
"--version",
|
||||
version,
|
||||
target.selector,
|
||||
]
|
||||
if target == .channel(.dev) {
|
||||
command.append(contentsOf: [
|
||||
"--install-method",
|
||||
"git",
|
||||
"--git-dir",
|
||||
self.devCheckoutLocation(prefix: prefix),
|
||||
])
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
static func automaticInstallTarget(appVersion: String?, isDebug: Bool) -> InstallTarget? {
|
||||
guard let appVersion else { return .channel(.stable) }
|
||||
guard CLIInstallBuild.isStable(appVersion: appVersion, isDebug: isDebug) else { return nil }
|
||||
return .exact(appVersion)
|
||||
}
|
||||
|
||||
static func suggestedChannel(appVersion: String?, isDebug: Bool) -> Channel {
|
||||
if isDebug { return .dev }
|
||||
if appVersion?.localizedCaseInsensitiveContains("beta") == true { return .beta }
|
||||
return .dev
|
||||
}
|
||||
|
||||
private static func rememberInstallPolicy(_ target: InstallTarget) {
|
||||
let policy = switch target {
|
||||
case .exact: "exact"
|
||||
case let .channel(channel): channel.rawValue
|
||||
}
|
||||
UserDefaults.standard.set(policy, forKey: cliInstallPolicyKey)
|
||||
}
|
||||
|
||||
private static func devCheckoutLocation(prefix: String) -> String {
|
||||
URL(fileURLWithPath: prefix)
|
||||
.appendingPathComponent("dev/openclaw")
|
||||
.path
|
||||
}
|
||||
|
||||
static func activateLocalGateway(
|
||||
|
||||
@@ -43,6 +43,7 @@ let locationPreciseKey = "openclaw.locationPreciseEnabled"
|
||||
let peekabooBridgeEnabledKey = "openclaw.peekabooBridgeEnabled"
|
||||
let deepLinkKeyKey = "openclaw.deepLinkKey"
|
||||
let cliInstallPromptedVersionKey = "openclaw.cliInstallPromptedVersion"
|
||||
let cliInstallPolicyKey = "openclaw.cliInstallPolicy"
|
||||
let cliValidatedExecutableKey = "openclaw.cliValidatedExecutable"
|
||||
let cliValidatedVersionKey = "openclaw.cliValidatedVersion"
|
||||
let macNodeIdentityProfileKey = "openclaw.macNodeIdentityProfile"
|
||||
|
||||
@@ -89,12 +89,18 @@ enum GatewayEnvironment {
|
||||
Semver.parse(self.expectedGatewayVersionString())
|
||||
}
|
||||
|
||||
static func expectedGatewayVersionString() -> String? {
|
||||
static func appVersionString() -> String? {
|
||||
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (trimmed?.isEmpty == false) ? trimmed : nil
|
||||
}
|
||||
|
||||
static func expectedGatewayVersionString() -> String? {
|
||||
CLIInstallPolicy.requiredGatewayVersionString(
|
||||
appVersion: self.appVersionString(),
|
||||
isDebug: CLIInstallBuild.isDebug)
|
||||
}
|
||||
|
||||
/// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
|
||||
static func expectedGatewayVersion(from versionString: String?) -> Semver? {
|
||||
Semver.parse(versionString)
|
||||
|
||||
@@ -648,7 +648,7 @@ private final class CodexAppServerThreadListSession: @unchecked Sendable {
|
||||
"clientInfo": [
|
||||
"name": "openclaw_macos",
|
||||
"title": "OpenClaw macOS Node",
|
||||
"version": GatewayEnvironment.expectedGatewayVersionString() ?? "unknown",
|
||||
"version": GatewayEnvironment.appVersionString() ?? "unknown",
|
||||
],
|
||||
],
|
||||
])
|
||||
|
||||
@@ -157,7 +157,11 @@ extension OnboardingView {
|
||||
OnboardingController.shared.setWindowCloseEnabled(true)
|
||||
OnboardingController.shared.busyReason = nil
|
||||
}
|
||||
let installed = await CLIInstaller.install { message in
|
||||
guard let target = CLIInstallPrompter.shared.installTargetForCurrentBuild() else {
|
||||
cliStatus = "CLI installation cancelled."
|
||||
return
|
||||
}
|
||||
let installed = await CLIInstaller.install(target: target) { message in
|
||||
self.cliStatus = message
|
||||
}
|
||||
guard installed else { return }
|
||||
|
||||
@@ -34,7 +34,7 @@ struct CLIInstallerTests {
|
||||
|
||||
@Test func `installer command runs the signed bundled script without a shell pipeline`() {
|
||||
let command = CLIInstaller.installScriptCommand(
|
||||
version: "2026.7.3-beta.1",
|
||||
target: .exact("2026.7.3-beta.1"),
|
||||
prefix: "/Users/Test User/.openclaw",
|
||||
scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh")
|
||||
|
||||
@@ -51,6 +51,67 @@ struct CLIInstallerTests {
|
||||
#expect(!command.contains("curl"))
|
||||
}
|
||||
|
||||
@Test func `dev installer uses a managed git main checkout`() {
|
||||
let command = CLIInstaller.installScriptCommand(
|
||||
target: .channel(.dev),
|
||||
prefix: "/Users/Test User/.openclaw",
|
||||
scriptPath: "/Applications/OpenClaw.app/Contents/Resources/install-cli.sh")
|
||||
|
||||
#expect(command.suffix(6) == [
|
||||
"--version",
|
||||
"main",
|
||||
"--install-method",
|
||||
"git",
|
||||
"--git-dir",
|
||||
"/Users/Test User/.openclaw/dev/openclaw",
|
||||
])
|
||||
}
|
||||
|
||||
@Test func `release builds install exact while unreleased builds choose a channel`() {
|
||||
#expect(CLIInstaller.automaticInstallTarget(
|
||||
appVersion: "2026.7.2",
|
||||
isDebug: false) == .exact("2026.7.2"))
|
||||
#expect(CLIInstaller.automaticInstallTarget(
|
||||
appVersion: "2026.7.2-1",
|
||||
isDebug: false) == .exact("2026.7.2-1"))
|
||||
#expect(CLIInstaller.automaticInstallTarget(
|
||||
appVersion: "2026.7.2-beta.1",
|
||||
isDebug: false) == nil)
|
||||
#expect(CLIInstaller.automaticInstallTarget(
|
||||
appVersion: "2026.7.2",
|
||||
isDebug: true) == nil)
|
||||
#expect(CLIInstaller.suggestedChannel(
|
||||
appVersion: "2026.7.2-beta.1",
|
||||
isDebug: false) == .beta)
|
||||
#expect(CLIInstaller.suggestedChannel(
|
||||
appVersion: "2026.7.2",
|
||||
isDebug: true) == .dev)
|
||||
}
|
||||
|
||||
@Test func `channel policy accepts the selected channel version`() throws {
|
||||
let suite = "CLIInstallerTests.channel-policy.\(UUID().uuidString)"
|
||||
let defaults = try #require(UserDefaults(suiteName: suite))
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
|
||||
#expect(CLIInstallPolicy.requiredGatewayVersionString(
|
||||
appVersion: "2026.7.2",
|
||||
isDebug: true,
|
||||
defaults: defaults) == "2026.7.2")
|
||||
defaults.set("beta", forKey: cliInstallPolicyKey)
|
||||
#expect(CLIInstallPolicy.requiredGatewayVersionString(
|
||||
appVersion: "2026.7.2",
|
||||
isDebug: true,
|
||||
defaults: defaults) == nil)
|
||||
#expect(CLIInstallPolicy.requiredGatewayVersionString(
|
||||
appVersion: "2026.7.2-beta.1",
|
||||
isDebug: false,
|
||||
defaults: defaults) == nil)
|
||||
#expect(CLIInstallPolicy.requiredGatewayVersionString(
|
||||
appVersion: "2026.7.2",
|
||||
isDebug: false,
|
||||
defaults: defaults) == "2026.7.2")
|
||||
}
|
||||
|
||||
@Test func `managed setup requires a parseable compatible version`() {
|
||||
let location = "/Users/test/.openclaw/bin/openclaw"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user