Files
openclaw/apps/macos/Sources/OpenClaw/CLIInstallPrompter.swift
Peter Steinberger 88c563b241 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
2026-07-11 01:44:26 -07:00

379 lines
17 KiB
Swift

import AppKit
import Foundation
import OSLog
@MainActor
final class CLIInstallPrompter {
static let shared = CLIInstallPrompter()
private let logger = Logger(subsystem: "ai.openclaw", category: "cli.prompt")
private var isPrompting = false
func checkAndPromptIfNeeded(reason: String) {
guard !self.isPrompting else { return }
self.isPrompting = true
Task { @MainActor in
await self.checkAndPromptIfNeededAsync(reason: reason)
self.isPrompting = false
}
}
private func checkAndPromptIfNeededAsync(reason: String) async {
guard AppStateStore.shared.onboardingSeen else { return }
guard AppStateStore.shared.connectionMode == .local else { return }
guard let version = Self.appVersion() else { return }
let status = await CLIInstaller.status()
let managedStatus = await CLIInstaller.managedStatus()
guard AppStateStore.shared.onboardingSeen else { return }
guard AppStateStore.shared.connectionMode == .local else { return }
let shouldRepairManaged = Self.shouldAutomaticallyRepair(
status: managedStatus,
launchAgentUsesManagedCLI: Self.launchAgentUsesManagedCLI(
programArguments: GatewayLaunchAgentManager.launchdConfigSnapshot()?.programArguments ?? []),
gatewayUpdateChannel: OpenClawConfigFile.gatewayUpdateChannel(),
installPolicy: CLIInstallPolicy.storedPolicy(),
launchAgentWriteDisabled: GatewayLaunchAgentManager.isLaunchAgentWriteDisabled())
if await self.completePendingManagedRestartIfNeeded(managedStatus: managedStatus) {
return
}
if shouldRepairManaged {
// Only repair the app-owned install; external package-manager installs
// remain under their owner's control. Repair restores the exact pin
// that produced the incompatible status (channel policies never pin,
// so they never reach this branch). No persisted attempt marker:
// a transient failure must retry on the next launch/mode change, and
// success clears the incompatible status that gates this branch.
if await self.installCLI(
target: .exact(version),
showCompletionAlert: false,
restartManagedGateway: !AppStateStore.shared.isPaused)
{
return
}
// 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)
guard lastPrompt != version else { return }
UserDefaults.standard.set(version, forKey: cliInstallPromptedVersionKey)
if let target = self.installTargetForCurrentBuild(confirmStable: true) {
Task { _ = await self.installCLI(target: target) }
}
self.logger.debug("cli install prompt handled reason=\(reason, privacy: .public)")
}
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,
showCompletionAlert: Bool = true,
restartManagedGateway: Bool = false) async -> Bool
{
let status = StatusBox()
let previousPID = restartManagedGateway
? await GatewayLaunchAgentManager.runningGatewayPID()
: nil
let installed = await CLIInstaller.install(target: target) { message in
await status.set(message)
if !showCompletionAlert {
self.logger.info("managed CLI repair: \(message, privacy: .public)")
}
}
var activated = false
if installed {
if restartManagedGateway {
let restarted = await self.ensureManagedGatewayRestarted(
previousPID: previousPID,
status: status)
guard restarted else {
// The on-disk CLI is already replaced, so the incompatible
// status that gates auto-repair will read ready next launch.
// Persist the unfinished restart or the old gateway process
// would keep running the previous version indefinitely.
Self.setPendingManagedRestart()
return false
}
}
await status.set("Starting OpenClaw Gateway…")
if !showCompletionAlert {
self.logger.info("managed CLI repair: Starting OpenClaw Gateway…")
}
let activation = await CLIInstaller.activateLocalGateway()
activated = activation != .failed
if restartManagedGateway {
// Only proven gateway health closes the recovery loop; the
// on-disk CLI already reads ready, so a lost marker here means
// no later trigger would ever restart a failed gateway.
if activated {
Self.clearPendingManagedRestart()
} else {
Self.setPendingManagedRestart()
}
}
let message = switch activation {
case .ready:
"OpenClaw Gateway is ready."
case .deferred:
"OpenClaw is installed. The Gateway will start when This Mac is active and resumed."
case .failed:
"OpenClaw was installed, but the Gateway did not start. Open Settings to retry."
}
await status.set(message)
if !showCompletionAlert {
self.logger.info("managed CLI repair: \(message, privacy: .public)")
}
}
if showCompletionAlert, let message = await status.get() {
let alert = NSAlert()
alert.messageText = installed ? "CLI install finished" : "CLI install failed"
alert.informativeText = message
alert.runModal()
}
return installed && activated
}
/// Finishes an update whose install succeeded but whose gateway restart did
/// not verify: by then the on-disk CLI reads ready, so the auto-repair gate
/// can never fire again for that version while the old process keeps running.
private func completePendingManagedRestartIfNeeded(managedStatus: CLIInstaller.Status) async -> Bool {
guard Self.hasPendingManagedRestart() else { return false }
guard case .ready = managedStatus else {
// A new incompatible/missing cycle owns the next repair.
Self.clearPendingManagedRestart()
return false
}
guard Self.launchAgentUsesManagedCLI(
programArguments: GatewayLaunchAgentManager.launchdConfigSnapshot()?.programArguments ?? []),
!GatewayLaunchAgentManager.isLaunchAgentWriteDisabled(),
!AppStateStore.shared.isPaused
else { return false }
if let error = await GatewayLaunchAgentManager.kickstart() {
self.logger.error("pending managed Gateway restart failed: \(error, privacy: .public)")
return false
}
await GatewayConnection.shared.shutdown()
guard await CLIInstaller.activateLocalGateway() != .failed else { return false }
Self.clearPendingManagedRestart()
self.logger.info("pending managed Gateway restart completed")
return true
}
static func hasPendingManagedRestart() -> Bool {
UserDefaults.standard.bool(forKey: cliManagedRestartPendingKey)
}
static func setPendingManagedRestart() {
UserDefaults.standard.set(true, forKey: cliManagedRestartPendingKey)
}
static func clearPendingManagedRestart() {
UserDefaults.standard.removeObject(forKey: cliManagedRestartPendingKey)
}
private func ensureManagedGatewayRestarted(previousPID: Int32?, status: StatusBox) async -> Bool {
guard previousPID != nil else {
await GatewayConnection.shared.shutdown()
return true
}
if await self.waitForManagedGatewayRestart(previousPID: previousPID) {
await GatewayConnection.shared.shutdown()
return true
}
if let error = await GatewayLaunchAgentManager.kickstart() {
let message = "Managed Gateway restart failed: \(error)"
await status.set(message)
self.logger.error("\(message, privacy: .public)")
return false
}
await GatewayConnection.shared.shutdown()
guard await self.waitForManagedGatewayRestart(previousPID: previousPID) else {
let message = "Managed Gateway restart could not be verified."
await status.set(message)
self.logger.error("\(message, privacy: .public)")
return false
}
return true
}
private func waitForManagedGatewayRestart(previousPID: Int32?) async -> Bool {
for _ in 0..<20 {
let currentPID = await GatewayLaunchAgentManager.runningGatewayPID()
if Self.didManagedGatewayRestart(previousPID: previousPID, currentPID: currentPID) {
return true
}
try? await Task.sleep(nanoseconds: 150_000_000)
}
return false
}
private func openSettings(tab: SettingsTab) {
SettingsTabRouter.request(tab)
SettingsWindowOpener.shared.open()
DispatchQueue.main.async {
NotificationCenter.default.post(name: .openclawSelectSettingsTab, object: tab)
}
}
private static func appVersion() -> String? {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
}
/// Shared gate for auto-repair and the dashboard's native update bridge.
/// If these drift apart, the card can route to Sparkle while the
/// post-relaunch gateway repair refuses, stranding an old gateway.
static func managedRepairGatesOpen(
launchAgentUsesManagedCLI: Bool,
gatewayUpdateChannel: String?,
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 != "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 }
// Auto-repair only moves the managed install forward. A gateway newer
// than the app (e.g. beta channel ahead of the app track) was a user
// choice; silently downgrading it keeps the consent prompt instead.
guard Self.isManagedUpgrade(found: found, required: required) else { return false }
return location == CLIInstaller.managedExecutableLocation()
}
static func isManagedUpgrade(found: String, required: String) -> Bool {
guard let foundVersion = Semver.parse(found),
let requiredVersion = Semver.parse(required)
else { return false }
if foundVersion != requiredVersion { return foundVersion < requiredVersion }
// Same numeric triple: a prerelease sorts below its release, so
// beta -> stable is an upgrade and stable -> beta is a downgrade.
switch (Self.prereleaseTail(found), Self.prereleaseTail(required)) {
case (nil, nil), (nil, .some):
return false
case (.some, nil):
return true
case let (.some(foundTail), .some(requiredTail)):
return foundTail.compare(requiredTail, options: .numeric) == .orderedAscending
}
}
private static func prereleaseTail(_ version: String) -> String? {
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
guard let separator = trimmed.firstIndex(of: "-") else { return nil }
let tail = String(trimmed[trimmed.index(after: separator)...])
return tail.isEmpty ? nil : tail
}
static func launchAgentUsesManagedCLI(programArguments: [String]) -> Bool {
var command = programArguments[...]
if command.count >= 3,
command[command.startIndex] == "/bin/sh",
command[command.index(after: command.startIndex)].hasSuffix("-env-wrapper.sh")
{
command = command.dropFirst(3)
} else if command.count >= 2,
command[command.startIndex].hasSuffix("-env-wrapper.sh")
{
command = command.dropFirst(2)
}
let managedRoot = URL(fileURLWithPath: CLIInstaller.managedExecutableLocation())
.deletingLastPathComponent()
.deletingLastPathComponent()
.standardizedFileURL.path + "/"
guard let executable = command.first else { return false }
let executablePath = URL(fileURLWithPath: executable).standardizedFileURL.path
let managedRuntimeRoot = managedRoot + "tools/node/"
if executablePath.hasPrefix(managedRoot), !executablePath.hasPrefix(managedRuntimeRoot) {
return true
}
guard command.count >= 2 else { return false }
let entrypoint = command[command.index(after: command.startIndex)]
return URL(fileURLWithPath: entrypoint).standardizedFileURL.path.hasPrefix(managedRoot)
}
static func didManagedGatewayRestart(previousPID: Int32?, currentPID: Int32?) -> Bool {
guard let currentPID else { return false }
guard let previousPID else { return true }
return currentPID != previousPID
}
}
private actor StatusBox {
private var value: String?
func set(_ value: String) {
self.value = value
}
func get() -> String? {
self.value
}
}