Files
openclaw/apps/macos/Sources/OpenClaw/CLIInstaller.swift
chelsealong b886eed3de fix(macos): surface concrete Gateway start failure reason in onboarding (#121306)
* fix(macos): surface concrete Gateway start failure reason in onboarding

GatewayProcessManager already retains the specific registration/readiness
failure (e.g. "launchd disabled", a launchd enable error, a readiness
timeout) in lastFailureReason, and Settings/menu bar UI already read it.
Onboarding discarded it: LocalGatewayActivation.failed collapses every
cause to the same generic "Retry setup" message, so a missing LaunchAgent
registration is indistinguishable from any other startup failure.

Surface the retained reason in the onboarding status text so the failure
is diagnosable without going through Settings.

* fix(macos): record command-resolution failures in lastFailureReason

GatewayProcessManager set status but not lastFailureReason when
GatewayEnvironment.resolveGatewayCommand() returns no command (missing
runtime/CLI), unlike the launchd-disabled and launchd-enable-error
branches a few lines below. Onboarding's new failure message therefore
rendered the generic text or a stale reason from an earlier attempt
for this failure class. Mirror the sibling branches and record
resolution.status.message.

Also fixes the macos-swift SwiftFormat lint failure: the comment block
directly above gatewayStartFailureMessage needed to be a doc comment
(///), matching the repo's existing convention for declaration-adjacent
comments.

* fix(macos): bind Gateway start failure reason to its activation attempt

LocalGatewayActivation.failed carried no data, so both onboarding call
sites reread the mutable GatewayProcessManager.shared.lastFailureReason
singleton after activateLocalGateway() returned. A later gateway-start
attempt can overwrite that singleton before the caller gets around to
reading it, so a stale wait could surface a newer attempt's reason (or
vice versa) attributed to the wrong onboarding attempt.

Widen LocalGatewayActivation.failed to carry reason: String?, captured
inside activateLocalGateway() the instant waitUntilReady() resolves to
false, and have both onboarding call sites map that bound value instead
of rereading the singleton. CLIInstallPrompter's two `!= .failed`
comparisons become `if case .failed = activation` pattern matches since
`.failed` is no longer a payload-free value; its existing `case .failed:`
message switch is unaffected, since bare-case patterns still match
regardless of associated data.

* fix(macos): satisfy SwiftFormat lint on CLIInstaller.swift

Converts the LocalGatewayActivation.failed declaration comment to a
doc comment and wraps activateLocalGateway's closing signature per
config/swiftformat, matching the same docComments convention already
applied elsewhere in this PR. No behavior change.
2026-08-20 01:08:12 -07:00

688 lines
26 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
extension Notification.Name {
static let openclawCLIInstalled = Notification.Name("openclaw.cli.installed")
}
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 storedPolicy(defaults: UserDefaults = AppDefaults.standard) -> String? {
defaults.string(forKey: cliInstallPolicyKey)
}
static func requiredGatewayVersionString(
appVersion: String?,
isDebug: Bool,
defaults: UserDefaults = AppDefaults.standard) -> String?
{
guard !CLIInstallBuild.isStable(appVersion: appVersion, isDebug: isDebug) else {
return appVersion
}
return switch self.storedPolicy(defaults: defaults) {
case "stable", "beta", "dev": nil
case "exact", nil: appVersion
default: appVersion
}
}
}
struct ManagedCLIUpdateSummary: Decodable, Equatable {
struct Version: Decodable, Equatable {
let version: String?
}
struct Step: Decodable, Equatable {
let name: String
let exitCode: Int?
let stderrTail: String?
}
let status: String
let reason: String?
let before: Version?
let after: Version?
let steps: [Step]?
}
enum ManagedCLIUpdateOutcome: Equatable {
case success(fromVersion: String?, toVersion: String)
case failure(message: String, details: String?)
}
@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
/// Binds the concrete failure to this activation attempt: GatewayProcessManager's
/// lastFailureReason is mutable shared state that a later attempt can overwrite before
/// a caller gets around to rereading it, misattributing a stale or newer reason.
case failed(reason: String?)
}
enum Status: Equatable {
case ready(location: String, version: String)
case missing(location: String)
case unusable(location: String)
case incompatible(location: String, found: String, required: String)
var isReady: Bool {
if case .ready = self { return true }
return false
}
var location: String {
switch self {
case let .ready(location, _),
let .missing(location),
let .unusable(location),
let .incompatible(location, _, _):
location
}
}
var message: String {
switch self {
case let .ready(_, version):
"OpenClaw Gateway \(version) is ready."
case .missing:
"OpenClaw Gateway is not installed yet."
case .unusable:
"The OpenClaw Gateway could not be verified. Setup will repair it."
case let .incompatible(_, found, required):
"Gateway \(found) does not match app \(required). Setup will update it."
}
}
}
static func installedLocation() -> String? {
self.installedLocations(
searchPaths: CommandResolver.preferredPaths(),
fileManager: .default).first
}
static func installedLocation(
searchPaths: [String],
fileManager: FileManager) -> String?
{
self.installedLocations(searchPaths: searchPaths, fileManager: fileManager).first
}
static func installedLocations(
searchPaths: [String],
fileManager: FileManager) -> [String]
{
var locations: [String] = []
for basePath in searchPaths {
let candidate = URL(fileURLWithPath: basePath).appendingPathComponent("openclaw").path
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: candidate, isDirectory: &isDirectory),
!isDirectory.boolValue
else {
continue
}
guard fileManager.isExecutableFile(atPath: candidate) else { continue }
locations.append(candidate)
}
return locations
}
static func managedExecutableLocation(
homeDirectory: URL = FileManager().homeDirectoryForCurrentUser,
profile: AppProfile = .current) -> String
{
URL(fileURLWithPath: self.installPrefix(homeDirectory: homeDirectory, profile: profile))
.appendingPathComponent("bin/openclaw")
.path
}
static func status() async -> Status {
let preferredPaths = await CommandResolver.preferredPathsAsync()
let locations = self.installedLocations(
searchPaths: preferredPaths,
fileManager: .default)
guard !locations.isEmpty else {
return .missing(location: self.managedExecutableLocation())
}
var fallbackStatus: Status?
for location in locations {
let status = await self.status(
location: location,
expectedVersion: GatewayEnvironment.expectedGatewayVersionString(),
preferredPaths: preferredPaths)
if status.isReady {
self.rememberValidated(status, defaults: AppDefaults.standard)
return status
}
fallbackStatus = fallbackStatus ?? status
}
return fallbackStatus ?? .missing(location: self.managedExecutableLocation())
}
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 preferredPaths = await CommandResolver.preferredPathsAsync()
let status = await self.status(
location: location,
expectedVersion: expectedVersion,
preferredPaths: preferredPaths)
if status.isReady {
self.rememberValidated(status, defaults: AppDefaults.standard)
}
return status
}
static func status(location: String) async -> Status {
let preferredPaths = await CommandResolver.preferredPathsAsync()
return await self.status(
location: location,
expectedVersion: GatewayEnvironment.expectedGatewayVersionString(),
preferredPaths: preferredPaths)
}
private static func status(
location: String,
expectedVersion: String?,
preferredPaths: [String]) async -> Status
{
let environment = self.probeEnvironment(
location: location,
preferredPaths: preferredPaths)
let response = await ShellExecutor.runDetailed(
command: [location, "--version"],
cwd: nil,
env: environment,
timeout: 5)
guard response.success else {
return .unusable(location: location)
}
let versionStatus = self.classifyVersion(
location: location,
output: response.stdout,
expectedVersion: expectedVersion)
guard versionStatus.isReady else { return versionStatus }
guard await self.runtimeIsCompatible(environment: environment) else {
return .unusable(location: location)
}
return versionStatus
}
private static func runtimeIsCompatible(environment: [String: String]) async -> Bool {
let paths = environment["PATH"]?.split(separator: ":").map(String.init) ?? []
if case .success = await RuntimeLocator.resolve(searchPaths: paths) {
return true
}
return false
}
static func classifyVersion(
location: String,
output: String?,
expectedVersion: String?) -> Status
{
let normalized = GatewayEnvironment.normalizeGatewayVersionOutput(output)
guard let normalized, Semver.parse(normalized) != nil else {
return .unusable(location: location)
}
guard Semver.parse(expectedVersion) != nil else {
return .ready(location: location, version: normalized)
}
guard Semver.satisfiesExpectedGatewayVersion(installed: normalized, expected: expectedVersion) else {
return .incompatible(
location: location,
found: normalized,
required: expectedVersion ?? "unknown")
}
return .ready(location: location, version: normalized)
}
static func probeEnvironment(
location: String,
processEnvironment: [String: String] = ProcessInfo.processInfo.environment,
preferredPaths: [String] = CommandResolver.preferredPaths(),
managedExecutable: String? = nil,
managedRuntimeDirectory: String? = nil) -> [String: String]
{
var environment = processEnvironment
let executableDirectory = URL(fileURLWithPath: location).deletingLastPathComponent().path
let effectiveManagedExecutable = managedExecutable ?? self.managedExecutableLocation()
let effectiveManagedRuntimeDirectory = managedRuntimeDirectory ?? URL(fileURLWithPath: self.installPrefix())
.appendingPathComponent("tools/node/bin")
.path
let initialPaths = location == effectiveManagedExecutable
? [executableDirectory, effectiveManagedRuntimeDirectory]
: [executableDirectory]
var seen = Set<String>()
let paths = (initialPaths + preferredPaths).filter { seen.insert($0).inserted }
environment["PATH"] = paths.joined(separator: ":")
return environment
}
static func rememberValidated(_ status: Status, defaults: UserDefaults) {
guard case let .ready(location, version) = status else { return }
if defaults.string(forKey: cliValidatedExecutableKey) != location {
defaults.set(location, forKey: cliValidatedExecutableKey)
}
if defaults.string(forKey: cliValidatedVersionKey) != version {
defaults.set(version, forKey: cliValidatedVersionKey)
}
}
@discardableResult
static func install(
target: InstallTarget,
statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async -> Bool
{
let prefix = Self.installPrefix()
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 appVersion = GatewayEnvironment.appVersionString()
let cmd = self.installScriptCommand(
target: target,
prefix: prefix,
scriptPath: installerURL.path,
compatibleWith: target.requiresExactVersion ? nil : appVersion)
let response = await ShellExecutor.runStreamingDetailed(
command: cmd,
cwd: nil,
env: nil,
timeout: self.installWatchdogTimeout(for: target))
{ line in
guard let status = self.installStatus(forEventLine: line) else { return }
await statusHandler(status)
}
if response.success {
let expectedVersion = target.requiresExactVersion ? GatewayEnvironment.appVersionString() : nil
let managedStatus = await self.managedStatus(expectedVersion: expectedVersion)
guard case let .ready(_, verifiedVersion) = managedStatus else {
await statusHandler("Install failed: \(managedStatus.message)")
return false
}
if case let .channel(channel) = target,
let appVersion,
!self.channelInstallIsCompatible(
installedVersion: verifiedVersion,
appVersion: appVersion)
{
await statusHandler(
"Install failed: \(channel.label) resolved to Gateway \(verifiedVersion), " +
"which is older than this app (\(appVersion)). Choose a newer CLI channel " +
"or retry after the channel is updated.")
return false
}
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)
NotificationCenter.default.post(name: .openclawCLIInstalled, object: nil)
return true
}
if let error = self.installErrorMessage(from: response.stdout) {
await statusHandler("Install failed: \(error)")
return false
}
let detail = response.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
let fallback = response.errorMessage ?? "install failed"
await statusHandler("Install failed: \(detail.isEmpty ? fallback : detail)")
return false
}
static func channelInstallIsCompatible(
installedVersion: String,
appVersion: String) -> Bool
{
guard let installed = Semver.parse(installedVersion),
let app = Semver.parse(appVersion)
else {
return false
}
if installed != app { return installed > app }
// The CLI's future-config guard permits all same-base stable/correction families.
// For prerelease app builds, only an older prerelease would block the service write.
guard let appPrerelease = self.prereleaseTail(appVersion),
!self.isCorrectionPrerelease(appPrerelease)
else {
return true
}
guard let installedPrerelease = self.prereleaseTail(installedVersion),
!self.isCorrectionPrerelease(installedPrerelease)
else {
return true
}
return installedPrerelease.compare(appPrerelease, options: .numeric) != .orderedAscending
}
private static func prereleaseTail(_ version: String) -> String? {
let withoutBuild = version
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: "+", maxSplits: 1, omittingEmptySubsequences: false)[0]
guard let separator = withoutBuild.firstIndex(of: "-") else { return nil }
let tail = String(withoutBuild[withoutBuild.index(after: separator)...])
return tail.isEmpty ? nil : tail
}
private static func isCorrectionPrerelease(_ prerelease: String) -> Bool {
!prerelease.isEmpty && prerelease.allSatisfy(\.isNumber)
}
static func installWatchdogTimeout(for target: InstallTarget) -> TimeInterval {
// Dev installs clone/fetch source, install dependencies, and build the UI
// plus CLI. Keep that workflow bounded without killing healthy cold builds.
target == .channel(.dev) ? 7200 : 900
}
static func installPrefix(
homeDirectory: URL = FileManager().homeDirectoryForCurrentUser,
profile: AppProfile = .current) -> String
{
// Managed install identity follows only the profile; a state override must not split
// the install used by its LaunchAgent.
profile.stateDirectoryURL(homeDirectory: homeDirectory).path
}
static func installScriptCommand(
target: InstallTarget,
prefix: String,
scriptPath: String,
compatibleWith appVersion: String? = nil) -> [String]
{
var command = [
"/bin/bash",
scriptPath,
"--json",
"--no-onboard",
"--prefix",
prefix,
"--version",
target.selector,
]
if let appVersion, !target.requiresExactVersion {
command.append(contentsOf: ["--compatible-with", appVersion])
}
if target == .channel(.dev) {
command.append(contentsOf: [
"--install-method",
"git",
"--git-dir",
self.devCheckoutLocation(prefix: prefix),
])
}
return command
}
static func managedUpdateCommand(
executable: String,
targetVersion: String,
restartGateway: Bool = true,
repair: Bool = false,
profile: AppProfile = .current) -> [String]
{
let arguments = repair
? ["update", "repair", "--json", "--timeout", "900", "--yes"]
: ["update", "--tag", targetVersion, "--json", "--timeout", "900"]
var command = profile.localCLICommand(prefix: [executable], arguments: arguments)
if !restartGateway {
command.append("--no-restart")
}
return command
}
static func updateManaged(
targetVersion: String,
restartGateway: Bool = true,
repair: Bool = false,
statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async
-> ManagedCLIUpdateOutcome
{
let executable = self.managedExecutableLocation()
await statusHandler(repair
? String(localized: "Repairing the OpenClaw Gateway update…")
: String(localized: "Updating the OpenClaw Gateway to \(targetVersion)…"))
let command = self.managedUpdateCommand(
executable: executable,
targetVersion: targetVersion,
restartGateway: restartGateway,
repair: repair)
let environment = self.probeEnvironment(location: executable)
let response = await ShellExecutor.runDetailed(
command: command,
cwd: nil,
env: environment,
// The CLI timeout is per step. Keep the aggregate watchdog above
// the full package, plugin, doctor, and restart sequence.
timeout: 7200)
let summary = self.parseManagedUpdateSummary(response.stdout)
let reportedStatus = summary?.status
guard response.success, reportedStatus != "error", reportedStatus != "warning" else {
let reason = summary?.reason?.trimmingCharacters(in: .whitespacesAndNewlines)
let failedStep = summary?.steps?.last(where: { ($0.exitCode ?? 0) != 0 })
let message = if reportedStatus == "warning" {
String(localized: "Gateway update needs attention.")
} else {
String(localized: "Gateway update failed.")
}
let details = self.firstNonEmpty([
reason,
failedStep.map { "\($0.name): \($0.stderrTail ?? "exit \($0.exitCode ?? -1)")" },
response.stderr,
response.errorMessage,
response.stdout,
])
await statusHandler(message)
return .failure(message: message, details: details.map(self.limitDiagnostic))
}
let managedStatus = await self.managedStatus(expectedVersion: targetVersion)
guard case let .ready(_, installedVersion) = managedStatus else {
let message = String(localized: "Gateway update finished, but verification failed.")
await statusHandler(message)
return .failure(message: message, details: managedStatus.message)
}
self.rememberInstallPolicy(.exact(targetVersion))
NotificationCenter.default.post(name: .openclawCLIInstalled, object: nil)
await statusHandler(String(localized: "OpenClaw Gateway \(installedVersion) is installed."))
return .success(
fromVersion: summary?.before?.version,
toVersion: installedVersion)
}
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
}
AppDefaults.standard.set(policy, forKey: cliInstallPolicyKey)
}
private static func devCheckoutLocation(prefix: String) -> String {
URL(fileURLWithPath: prefix)
.appendingPathComponent("dev/openclaw")
.path
}
static func activateLocalGateway(
mode: AppState.ConnectionMode = AppStateStore.shared.connectionMode,
paused: Bool = AppStateStore.shared.isPaused,
start: @MainActor () -> Void = { GatewayProcessManager.shared.setActive(true) },
waitUntilReady: @MainActor () async -> Bool = {
await GatewayProcessManager.shared.waitForGatewayReady(
timeout: GatewayLaunchAgentManager.startupMigrationTolerance)
},
failureReason: @MainActor () -> String? = { GatewayProcessManager.shared.lastFailureReason }) async
-> LocalGatewayActivation
{
guard mode == .local, !paused else { return .deferred }
start()
guard await waitUntilReady() else { return .failed(reason: failureReason()) }
return .ready
}
private static func parseInstallEvents(_ output: String) -> [InstallEvent] {
let decoder = JSONDecoder()
let lines = output
.split(whereSeparator: \.isNewline)
.map { String($0) }
var events: [InstallEvent] = []
for line in lines {
guard let data = line.data(using: .utf8) else { continue }
if let event = try? decoder.decode(InstallEvent.self, from: data) {
events.append(event)
}
}
return events
}
nonisolated static func installStatus(forEventLine line: String) -> String? {
guard let data = line.data(using: .utf8),
let event = try? JSONDecoder().decode(InstallEvent.self, from: data),
event.event == "step",
let name = event.name,
let status = event.status
else {
return nil
}
return switch (name, status) {
case ("disk-space", "start"): "Checking available disk space…"
case ("node", "start"): "Installing Node.js runtime…"
case ("git-tools", "start"): "Preparing Git and pnpm…"
case ("git-clone", "start"): "Downloading OpenClaw source…"
case ("git-update", "start"): "Updating OpenClaw source…"
case ("dependencies", "start"): "Installing dependencies…"
case ("control-ui", "start"): "Building interface…"
case ("cli-build", "start"): "Building OpenClaw CLI…"
case ("openclaw", "retry"): "Retrying OpenClaw CLI install…"
case ("disk-space", "warn"): "Couldnt verify free disk space; continuing…"
case ("git-update", "warn"): "Using the existing modified OpenClaw source…"
case ("control-ui", "warn"): "Interface build did not finish; continuing…"
default: nil
}
}
static func installErrorMessage(from output: String) -> String? {
self.parseInstallEvents(output).last(where: { $0.event == "error" })?.message
}
static func parseManagedUpdateSummary(_ output: String) -> ManagedCLIUpdateSummary? {
let trimmed = output.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let decoder = JSONDecoder()
if let data = trimmed.data(using: .utf8),
let result = try? decoder.decode(ManagedCLIUpdateSummary.self, from: data)
{
return result
}
for line in trimmed.split(whereSeparator: \.isNewline).reversed() {
guard let data = String(line).data(using: .utf8),
let result = try? decoder.decode(ManagedCLIUpdateSummary.self, from: data)
else { continue }
return result
}
return nil
}
private static func firstNonEmpty(_ values: [String?]) -> String? {
values.compactMap { value in
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed?.isEmpty == false ? trimmed : nil
}.first
}
private static func limitDiagnostic(_ value: String) -> String {
let maximumCharacters = 4000
guard value.count > maximumCharacters else { return value }
return String(value.suffix(maximumCharacters))
}
}
private struct InstallEvent: Decodable {
let event: String
let name: String?
let status: String?
let version: String?
let message: String?
}