mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 13:31:16 +00:00
* feat(mac): playful hard-hat working mascot during setup and updates The mascot gets a new .working mood: it dons a safety hard hat, hammers away with impact sparks while setup work is in flight, and pauses every few seconds to wipe its brow. Used on the onboarding "Getting things ready" page and the post-update window instead of the generic thinking mood. Lives in shared OpenClawKit so iOS can adopt it too. * chore(i18n): refresh native inventory line for mascot view
893 lines
34 KiB
Swift
893 lines
34 KiB
Swift
import AppKit
|
||
import Foundation
|
||
import Observation
|
||
import OpenClawChatUI
|
||
import OpenClawKit
|
||
import SwiftUI
|
||
|
||
struct PostAppUpdateReceipt: Codable, Equatable {
|
||
private enum CodingKeys: String, CodingKey {
|
||
case fromVersion
|
||
case toVersion
|
||
case recordedAt
|
||
case gatewayUpdateIncomplete
|
||
case notificationAttempts
|
||
case notificationInFlight
|
||
}
|
||
|
||
let fromVersion: String
|
||
let toVersion: String
|
||
let recordedAt: Date
|
||
let gatewayUpdateIncomplete: Bool
|
||
let notificationAttempts: Int
|
||
let notificationInFlight: Bool
|
||
|
||
init(
|
||
fromVersion: String,
|
||
toVersion: String,
|
||
recordedAt: Date,
|
||
gatewayUpdateIncomplete: Bool = false,
|
||
notificationAttempts: Int = 0,
|
||
notificationInFlight: Bool = false)
|
||
{
|
||
self.fromVersion = fromVersion
|
||
self.toVersion = toVersion
|
||
self.recordedAt = recordedAt
|
||
self.gatewayUpdateIncomplete = gatewayUpdateIncomplete
|
||
self.notificationAttempts = notificationAttempts
|
||
self.notificationInFlight = notificationInFlight
|
||
}
|
||
|
||
init(from decoder: Decoder) throws {
|
||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||
self.fromVersion = try container.decode(String.self, forKey: .fromVersion)
|
||
self.toVersion = try container.decode(String.self, forKey: .toVersion)
|
||
self.recordedAt = try container.decode(Date.self, forKey: .recordedAt)
|
||
self.gatewayUpdateIncomplete = try container.decodeIfPresent(
|
||
Bool.self,
|
||
forKey: .gatewayUpdateIncomplete) ?? false
|
||
self.notificationAttempts = try container.decodeIfPresent(
|
||
Int.self,
|
||
forKey: .notificationAttempts) ?? 0
|
||
self.notificationInFlight = try container.decodeIfPresent(
|
||
Bool.self,
|
||
forKey: .notificationInFlight) ?? false
|
||
}
|
||
}
|
||
|
||
enum PostAppUpdateReceiptStore {
|
||
static let notificationRetryLimit = 2
|
||
|
||
static func record(
|
||
fromVersion: String,
|
||
toVersion: String,
|
||
defaults: UserDefaults = .standard,
|
||
now: Date = Date())
|
||
{
|
||
let from = fromVersion.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let to = toVersion.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !from.isEmpty, !to.isEmpty, from != to else { return }
|
||
let receipt = PostAppUpdateReceipt(fromVersion: from, toVersion: to, recordedAt: now)
|
||
self.persist(receipt, defaults: defaults)
|
||
}
|
||
|
||
static func pending(
|
||
currentVersion: String?,
|
||
defaults: UserDefaults = .standard) -> PostAppUpdateReceipt?
|
||
{
|
||
guard let currentVersion = normalized(currentVersion),
|
||
let data = defaults.data(forKey: postAppUpdateReceiptKey),
|
||
let receipt = try? JSONDecoder().decode(PostAppUpdateReceipt.self, from: data),
|
||
normalized(receipt.toVersion) == currentVersion
|
||
else { return nil }
|
||
return receipt
|
||
}
|
||
|
||
static func pendingForLaunch(
|
||
currentVersion: String?,
|
||
onboardingSeen: Bool,
|
||
allowsUpdateWorkflow: Bool = true,
|
||
defaults: UserDefaults = .standard,
|
||
now: Date = Date()) -> PostAppUpdateReceipt?
|
||
{
|
||
guard let currentVersion = normalized(currentVersion) else { return nil }
|
||
let previousVersion = self.normalized(defaults.string(forKey: lastLaunchedAppVersionKey))
|
||
let receipt: PostAppUpdateReceipt?
|
||
if !onboardingSeen || !allowsUpdateWorkflow {
|
||
// A receipt can arrive before first-run onboarding. Consume it here so
|
||
// completing onboarding cannot replay old post-update work later.
|
||
self.clear(defaults: defaults)
|
||
receipt = nil
|
||
} else if let pending = self.pending(currentVersion: currentVersion, defaults: defaults) {
|
||
receipt = pending
|
||
} else if previousVersion != currentVersion {
|
||
// The first recorder-capable build has no prior launch marker. An
|
||
// onboarded install is therefore an upgrade; fresh installs were gated above.
|
||
let bootstrap = PostAppUpdateReceipt(
|
||
fromVersion: previousVersion ?? "unknown",
|
||
toVersion: currentVersion,
|
||
recordedAt: now)
|
||
self.persist(bootstrap, defaults: defaults)
|
||
receipt = bootstrap
|
||
} else {
|
||
receipt = nil
|
||
}
|
||
defaults.set(currentVersion, forKey: lastLaunchedAppVersionKey)
|
||
return receipt
|
||
}
|
||
|
||
static func clear(defaults: UserDefaults = .standard) {
|
||
defaults.removeObject(forKey: postAppUpdateReceiptKey)
|
||
}
|
||
|
||
@discardableResult
|
||
static func setGatewayUpdateIncomplete(
|
||
_ incomplete: Bool,
|
||
receipt: PostAppUpdateReceipt,
|
||
defaults: UserDefaults = .standard) -> PostAppUpdateReceipt
|
||
{
|
||
let updated = PostAppUpdateReceipt(
|
||
fromVersion: receipt.fromVersion,
|
||
toVersion: receipt.toVersion,
|
||
recordedAt: receipt.recordedAt,
|
||
gatewayUpdateIncomplete: incomplete,
|
||
notificationAttempts: receipt.notificationAttempts,
|
||
notificationInFlight: receipt.notificationInFlight)
|
||
self.persist(updated, defaults: defaults)
|
||
return updated
|
||
}
|
||
|
||
@discardableResult
|
||
static func recordNotificationFailure(
|
||
receipt: PostAppUpdateReceipt,
|
||
defaults: UserDefaults = .standard) -> PostAppUpdateReceipt
|
||
{
|
||
// One later-launch retry handles restart races. The bound prevents
|
||
// permanent auth/schema errors from reopening this window forever.
|
||
let updated = PostAppUpdateReceipt(
|
||
fromVersion: receipt.fromVersion,
|
||
toVersion: receipt.toVersion,
|
||
recordedAt: receipt.recordedAt,
|
||
gatewayUpdateIncomplete: receipt.gatewayUpdateIncomplete,
|
||
notificationAttempts: min(receipt.notificationAttempts + 1, self.notificationRetryLimit),
|
||
notificationInFlight: receipt.notificationInFlight)
|
||
self.persist(updated, defaults: defaults)
|
||
return updated
|
||
}
|
||
|
||
@discardableResult
|
||
static func setNotificationInFlight(
|
||
_ inFlight: Bool,
|
||
receipt: PostAppUpdateReceipt,
|
||
defaults: UserDefaults = .standard) -> PostAppUpdateReceipt
|
||
{
|
||
let updated = PostAppUpdateReceipt(
|
||
fromVersion: receipt.fromVersion,
|
||
toVersion: receipt.toVersion,
|
||
recordedAt: receipt.recordedAt,
|
||
gatewayUpdateIncomplete: receipt.gatewayUpdateIncomplete,
|
||
notificationAttempts: receipt.notificationAttempts,
|
||
notificationInFlight: inFlight)
|
||
self.persist(updated, defaults: defaults)
|
||
// Cross the persistence boundary before the Gateway request. A crash
|
||
// after enqueue must not replay this one-time welcome on next launch.
|
||
defaults.synchronize()
|
||
return updated
|
||
}
|
||
|
||
private static func persist(_ receipt: PostAppUpdateReceipt, defaults: UserDefaults) {
|
||
guard let data = try? JSONEncoder().encode(receipt) else { return }
|
||
defaults.set(data, forKey: postAppUpdateReceiptKey)
|
||
}
|
||
|
||
private static func normalized(_ value: String?) -> String? {
|
||
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return trimmed?.isEmpty == false ? trimmed : nil
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
@Observable
|
||
final class PostUpdateModel {
|
||
enum Phase: Equatable {
|
||
case checking
|
||
case updating
|
||
case verifying
|
||
case notifying
|
||
case complete
|
||
case failed
|
||
}
|
||
|
||
var phase: Phase = .checking
|
||
var title = String(localized: "Finishing your OpenClaw update")
|
||
var message = String(localized: "Checking the Mac app and Gateway…")
|
||
var details: String?
|
||
|
||
var isWorking: Bool {
|
||
switch self.phase {
|
||
case .checking, .updating, .verifying, .notifying: true
|
||
case .complete, .failed: false
|
||
}
|
||
}
|
||
|
||
var mood: OpenClawMascotMood {
|
||
switch self.phase {
|
||
case .checking, .updating, .verifying, .notifying: .working
|
||
case .complete: .celebrating
|
||
case .failed: .sad
|
||
}
|
||
}
|
||
}
|
||
|
||
enum PostUpdateGatewayAction: Equatable {
|
||
case none
|
||
case ownershipFailure
|
||
case repair
|
||
case update
|
||
case install
|
||
}
|
||
|
||
struct PostUpdateSessionsResponse: Decodable {
|
||
let sessions: [PostUpdateSession]
|
||
let nextOffset: Int?
|
||
}
|
||
|
||
struct PostUpdateSession: Decodable {
|
||
let key: String
|
||
let kind: String
|
||
let lastChannel: String?
|
||
let lastInteractionAt: Double?
|
||
let spawnedBy: String?
|
||
let parentSessionKey: String?
|
||
}
|
||
|
||
enum PostUpdateNotificationOutcome: Equatable {
|
||
case delivered
|
||
case noEligibleSession
|
||
case deliveryUnconfirmed
|
||
case skippedUnsupportedGateway
|
||
case skippedWhilePaused
|
||
case retryLater
|
||
|
||
var consumesReceipt: Bool {
|
||
self != .retryLater
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
final class PostUpdateController: NSObject, NSWindowDelegate {
|
||
static let shared = PostUpdateController()
|
||
static let updateGuideURL = URL(string: "https://docs.openclaw.ai/install/updating")!
|
||
static let discordURL = URL(string: "https://discord.gg/clawd")!
|
||
|
||
private let model = PostUpdateModel()
|
||
private var receipt: PostAppUpdateReceipt?
|
||
private var window: NSWindow?
|
||
private var task: Task<Void, Never>?
|
||
|
||
@discardableResult
|
||
func startIfNeeded() -> Bool {
|
||
guard let receipt = PostAppUpdateReceiptStore.pendingForLaunch(
|
||
currentVersion: GatewayEnvironment.appVersionString(),
|
||
onboardingSeen: AppStateStore.shared.onboardingSeen,
|
||
allowsUpdateWorkflow: !CLIInstallBuild.isDebug)
|
||
else { return false }
|
||
self.receipt = receipt
|
||
self.run()
|
||
return true
|
||
}
|
||
|
||
func retry() {
|
||
guard self.receipt != nil, !self.model.isWorking else { return }
|
||
self.run()
|
||
}
|
||
|
||
func close() {
|
||
self.window?.close()
|
||
}
|
||
|
||
func openUpdateGuide() {
|
||
NSWorkspace.shared.open(Self.updateGuideURL)
|
||
}
|
||
|
||
func openDiscord() {
|
||
NSWorkspace.shared.open(Self.discordURL)
|
||
}
|
||
|
||
func windowWillClose(_ notification: Notification) {
|
||
guard let closing = notification.object as? NSWindow, closing === window else { return }
|
||
self.window = nil
|
||
}
|
||
|
||
private func show() {
|
||
if let window {
|
||
DockIconManager.shared.temporarilyShowDock()
|
||
window.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
return
|
||
}
|
||
let hosting = NSHostingController(rootView: PostUpdateView(model: model))
|
||
let window = NSWindow(contentViewController: hosting)
|
||
window.title = String(localized: "OpenClaw updated")
|
||
window.setContentSize(NSSize(width: 560, height: 600))
|
||
window.styleMask = OnboardingController.windowStyleMask
|
||
window.contentMinSize = NSSize(width: 560, height: 600)
|
||
window.contentMaxSize = NSSize(width: 560, height: 760)
|
||
window.titlebarAppearsTransparent = true
|
||
window.titleVisibility = .hidden
|
||
window.isMovableByWindowBackground = true
|
||
window.delegate = self
|
||
window.center()
|
||
DockIconManager.shared.temporarilyShowDock()
|
||
window.makeKeyAndOrderFront(nil)
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
self.window = window
|
||
}
|
||
|
||
private func run() {
|
||
guard let receipt, task == nil else { return }
|
||
self.model.phase = .checking
|
||
self.model.title = String(localized: "Finishing your OpenClaw update")
|
||
self.model.message = String(localized: "Checking the Mac app and Gateway…")
|
||
self.model.details = nil
|
||
self.window?.standardWindowButton(.closeButton)?.isEnabled = false
|
||
self.task = Task { @MainActor [weak self] in
|
||
guard let self else { return }
|
||
await self.finishUpdate(receipt: receipt)
|
||
self.window?.standardWindowButton(.closeButton)?.isEnabled = true
|
||
self.task = nil
|
||
}
|
||
}
|
||
|
||
private func finishUpdate(receipt: PostAppUpdateReceipt) async {
|
||
let connectionMode = AppStateStore.shared.connectionMode
|
||
guard CLIInstallPrompter.shouldManageCLI(connectionMode: connectionMode) else {
|
||
self.finishSilently()
|
||
return
|
||
}
|
||
|
||
// Resume a definitely uncommitted notification before the Gateway action
|
||
// gate; a ready runtime must not discard it as an app-only receipt.
|
||
if Self.isNotificationOnlyRetry(receipt) {
|
||
await self.finishNotification(receipt: receipt, connectionMode: connectionMode)
|
||
return
|
||
}
|
||
if receipt.notificationInFlight && !receipt.gatewayUpdateIncomplete {
|
||
self.finishNotification(
|
||
outcome: .deliveryUnconfirmed,
|
||
receipt: receipt,
|
||
connectionMode: connectionMode)
|
||
return
|
||
}
|
||
|
||
let managedStatus = await CLIInstaller.managedStatus()
|
||
let runtimeProgramArguments: [String]
|
||
switch connectionMode {
|
||
case .local:
|
||
guard let programArguments = GatewayLaunchAgentManager.launchdProgramArguments() else {
|
||
self.finishAfterOwnershipCheckFailure(
|
||
connectionMode: connectionMode,
|
||
receipt: receipt)
|
||
return
|
||
}
|
||
runtimeProgramArguments = programArguments
|
||
case .remote:
|
||
guard let programArguments = NodeServiceManager.launchdProgramArguments() else {
|
||
self.finishAfterOwnershipCheckFailure(
|
||
connectionMode: connectionMode,
|
||
receipt: receipt)
|
||
return
|
||
}
|
||
runtimeProgramArguments = programArguments
|
||
case .unconfigured:
|
||
runtimeProgramArguments = []
|
||
}
|
||
let ownsManagedRuntime = Self.ownsManagedRuntime(
|
||
connectionMode: connectionMode,
|
||
programArguments: runtimeProgramArguments,
|
||
gatewayUpdateChannel: OpenClawConfigFile.gatewayUpdateChannel(),
|
||
installPolicy: CLIInstallPolicy.storedPolicy(),
|
||
launchAgentWriteDisabled: GatewayLaunchAgentManager.isLaunchAgentWriteDisabled())
|
||
let restartGateway = connectionMode == .local && !AppStateStore.shared.isPaused
|
||
|
||
// App-only relaunches stay invisible. The window belongs only to
|
||
// confirmed managed Gateway work and its recovery path.
|
||
switch Self.gatewayAction(
|
||
status: managedStatus,
|
||
ownsManagedRuntime: ownsManagedRuntime,
|
||
gatewayUpdateIncomplete: receipt.gatewayUpdateIncomplete)
|
||
{
|
||
case .none:
|
||
self.finishSilently()
|
||
return
|
||
case .ownershipFailure:
|
||
self.finishAfterOwnershipCheckFailure(
|
||
connectionMode: connectionMode,
|
||
receipt: receipt)
|
||
return
|
||
case .repair:
|
||
self.model.phase = .updating
|
||
self.show()
|
||
let outcome = await CLIInstaller.updateManaged(
|
||
targetVersion: receipt.toVersion,
|
||
restartGateway: restartGateway,
|
||
repair: true)
|
||
{ [weak self] message in
|
||
self?.model.message = message
|
||
}
|
||
guard self.consume(outcome) else { return }
|
||
case .update:
|
||
self.setGatewayUpdateIncomplete(true, receipt: receipt)
|
||
self.model.phase = .updating
|
||
self.show()
|
||
let outcome = await CLIInstaller.updateManaged(
|
||
targetVersion: receipt.toVersion,
|
||
restartGateway: restartGateway)
|
||
{ [weak self] message in
|
||
self?.model.message = message
|
||
}
|
||
guard self.consume(outcome) else { return }
|
||
case .install:
|
||
self.setGatewayUpdateIncomplete(true, receipt: receipt)
|
||
self.model.phase = .updating
|
||
self.show()
|
||
let installed = await CLIInstaller.install(target: .exact(receipt.toVersion)) { [weak self] message in
|
||
self?.model.message = message
|
||
}
|
||
guard installed else {
|
||
self.fail(
|
||
message: String(localized: "Gateway recovery failed."),
|
||
details: String(localized: "The managed OpenClaw runtime could not be reinstalled."))
|
||
return
|
||
}
|
||
}
|
||
|
||
self.model.phase = .verifying
|
||
self.model.message = connectionMode == .local
|
||
? String(localized: "Restarting and verifying the Gateway…")
|
||
: String(localized: "Verifying the Mac node runtime…")
|
||
guard await self.verifyRuntime(connectionMode: connectionMode) else { return }
|
||
// Verification owns the persistence boundary: clearing earlier would
|
||
// turn a failed health check into a silent app-only completion on retry.
|
||
self.setGatewayUpdateIncomplete(false, receipt: receipt)
|
||
|
||
await self.finishNotification(receipt: receipt, connectionMode: connectionMode)
|
||
}
|
||
|
||
private func finishNotification(
|
||
receipt: PostAppUpdateReceipt,
|
||
connectionMode: AppState.ConnectionMode) async
|
||
{
|
||
self.model.phase = .notifying
|
||
self.model.message = String(localized: "Letting your agent know you’re back…")
|
||
let notification = connectionMode == .local && AppStateStore.shared.isPaused
|
||
? PostUpdateNotificationOutcome.skippedWhilePaused
|
||
: await self.notifyMostRecentSession(
|
||
version: receipt.toVersion,
|
||
connectionMode: connectionMode,
|
||
receipt: receipt)
|
||
|
||
self.finishNotification(
|
||
outcome: notification,
|
||
receipt: receipt,
|
||
connectionMode: connectionMode)
|
||
}
|
||
|
||
private func finishNotification(
|
||
outcome notification: PostUpdateNotificationOutcome,
|
||
receipt: PostAppUpdateReceipt,
|
||
connectionMode: AppState.ConnectionMode)
|
||
{
|
||
let notificationRetryScheduled: Bool
|
||
if notification == .retryLater {
|
||
let updated = PostAppUpdateReceiptStore.recordNotificationFailure(
|
||
receipt: self.receipt ?? receipt)
|
||
self.receipt = updated
|
||
notificationRetryScheduled = updated.notificationAttempts < PostAppUpdateReceiptStore.notificationRetryLimit
|
||
} else {
|
||
notificationRetryScheduled = false
|
||
}
|
||
if notification.consumesReceipt || !notificationRetryScheduled {
|
||
PostAppUpdateReceiptStore.clear()
|
||
}
|
||
self.model.phase = .complete
|
||
self.model.title = String(localized: "Welcome back")
|
||
self.model.message = connectionMode == .local
|
||
? String(localized: "OpenClaw \(receipt.toVersion) and its Gateway are ready.")
|
||
: String(localized: "OpenClaw \(receipt.toVersion) and its Mac node runtime are ready.")
|
||
self.model.details = switch (notification, notificationRetryScheduled) {
|
||
case (.retryLater, true):
|
||
String(localized: "Your agent could not be notified yet. OpenClaw will retry after the next app launch.")
|
||
case (.retryLater, false):
|
||
if connectionMode == .local {
|
||
String(
|
||
localized: """
|
||
OpenClaw could not notify your agent automatically. \
|
||
The app and Gateway update are complete.
|
||
""")
|
||
} else {
|
||
String(
|
||
localized: """
|
||
OpenClaw could not notify your agent automatically. \
|
||
The app and Mac node update are complete.
|
||
""")
|
||
}
|
||
case (.deliveryUnconfirmed, _):
|
||
String(
|
||
localized: """
|
||
OpenClaw could not confirm the agent notification. \
|
||
It will not retry, to avoid a duplicate welcome.
|
||
""")
|
||
case (.skippedUnsupportedGateway, _):
|
||
String(
|
||
localized: "The remote Gateway is older than this Mac app, so OpenClaw skipped the agent notification.")
|
||
case (.skippedWhilePaused, _):
|
||
String(localized: "The Gateway remains paused, so OpenClaw did not wake your agent.")
|
||
default:
|
||
nil
|
||
}
|
||
}
|
||
|
||
private func consume(_ outcome: ManagedCLIUpdateOutcome) -> Bool {
|
||
switch outcome {
|
||
case .success:
|
||
return true
|
||
case let .failure(message, details):
|
||
self.fail(message: message, details: details)
|
||
return false
|
||
}
|
||
}
|
||
|
||
private func setGatewayUpdateIncomplete(
|
||
_ incomplete: Bool,
|
||
receipt: PostAppUpdateReceipt)
|
||
{
|
||
self.receipt = PostAppUpdateReceiptStore.setGatewayUpdateIncomplete(
|
||
incomplete,
|
||
receipt: receipt)
|
||
}
|
||
|
||
private func verifyRuntime(connectionMode: AppState.ConnectionMode) async -> Bool {
|
||
guard case .ready = await CLIInstaller.managedStatus() else {
|
||
self.fail(
|
||
message: String(localized: "Gateway verification failed."),
|
||
details: String(localized: "The managed runtime does not match the updated Mac app."))
|
||
return false
|
||
}
|
||
if connectionMode == .remote {
|
||
if let error = await NodeServiceManager.restart() {
|
||
self.fail(
|
||
message: String(localized: "The Mac node did not restart."),
|
||
details: error)
|
||
return false
|
||
}
|
||
guard await NodeServiceManager.waitUntilRunning() else {
|
||
self.fail(
|
||
message: String(localized: "The Mac node did not become ready."),
|
||
details: String(localized: "The node service restarted but did not remain running."))
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
await GatewayConnection.shared.shutdown()
|
||
let activation = await CLIInstaller.activateLocalGateway()
|
||
switch activation {
|
||
case .failed:
|
||
self.fail(
|
||
message: String(localized: "The Gateway did not start."),
|
||
details: String(localized: "The update is installed, but Gateway health did not become ready."))
|
||
return false
|
||
case .deferred:
|
||
// A paused Gateway stays paused. On-disk version verification above
|
||
// is sufficient; reconnecting would violate the user's pause state.
|
||
return true
|
||
case .ready:
|
||
break
|
||
}
|
||
await ControlChannel.shared.refreshEndpoint(reason: "post-app-update")
|
||
guard ControlChannel.shared.state == .connected else {
|
||
self.fail(
|
||
message: String(localized: "The Gateway could not reconnect."),
|
||
details: String(
|
||
localized: "OpenClaw installed the update but could not verify the Gateway connection."))
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
private func notifyMostRecentSession(
|
||
version: String,
|
||
connectionMode: AppState.ConnectionMode,
|
||
receipt: PostAppUpdateReceipt) async -> PostUpdateNotificationOutcome
|
||
{
|
||
guard let serverLease = await GatewayConnection.shared.captureServerLease() else {
|
||
return .retryLater
|
||
}
|
||
if connectionMode == .remote {
|
||
let gatewayVersion = await GatewayConnection.shared.cachedGatewayVersion(
|
||
ifCurrentServerLease: serverLease)
|
||
if let blocked = Self.remoteNotificationBlocker(
|
||
gatewayVersion: gatewayVersion,
|
||
appVersion: version)
|
||
{
|
||
return blocked
|
||
}
|
||
}
|
||
|
||
let session: PostUpdateSession
|
||
do {
|
||
guard let selected = try await Self.preferredNotificationSession(loadPage: { offset in
|
||
var params: [String: OpenClawKit.AnyCodable] = [
|
||
"limit": OpenClawKit.AnyCodable(100),
|
||
"includeGlobal": OpenClawKit.AnyCodable(false),
|
||
"includeUnknown": OpenClawKit.AnyCodable(false),
|
||
"configuredAgentsOnly": OpenClawKit.AnyCodable(true),
|
||
"requireLastInteraction": OpenClawKit.AnyCodable(true),
|
||
"sortBy": OpenClawKit.AnyCodable("lastInteractionAt"),
|
||
]
|
||
if offset > 0 {
|
||
params["offset"] = OpenClawKit.AnyCodable(offset)
|
||
}
|
||
let data = try await GatewayConnection.shared.request(
|
||
method: "sessions.list",
|
||
params: params,
|
||
ifCurrentServerLease: serverLease)
|
||
return try JSONDecoder().decode(PostUpdateSessionsResponse.self, from: data)
|
||
}) else { return .noEligibleSession }
|
||
session = selected
|
||
} catch {
|
||
// Read-only discovery is safe to retry after the next app launch.
|
||
return .retryLater
|
||
}
|
||
|
||
let text =
|
||
"OpenClaw updated to \(version). Briefly welcome the user back and say you are updated, " +
|
||
"then continue normally."
|
||
self.receipt = PostAppUpdateReceiptStore.setNotificationInFlight(
|
||
true,
|
||
receipt: self.receipt ?? receipt)
|
||
do {
|
||
_ = try await GatewayConnection.shared.request(
|
||
method: "system-event",
|
||
params: [
|
||
"text": OpenClawKit.AnyCodable(text),
|
||
"sessionKey": OpenClawKit.AnyCodable(session.key),
|
||
"wake": OpenClawKit.AnyCodable(true),
|
||
],
|
||
ifCurrentServerLease: serverLease)
|
||
return .delivered
|
||
} catch {
|
||
let outcome = Self.notificationSendFailureOutcome(error)
|
||
if outcome == .retryLater {
|
||
self.receipt = PostAppUpdateReceiptStore.setNotificationInFlight(
|
||
false,
|
||
receipt: self.receipt ?? receipt)
|
||
}
|
||
return outcome
|
||
}
|
||
}
|
||
|
||
static func supportsPostUpdateNotification(
|
||
gatewayVersion: String?,
|
||
appVersion: String) -> Bool
|
||
{
|
||
guard Semver.parse(gatewayVersion) != nil,
|
||
Semver.parse(appVersion) != nil,
|
||
let gatewayVersion
|
||
else { return false }
|
||
return !CLIInstallPrompter.isManagedUpgrade(
|
||
found: gatewayVersion,
|
||
required: appVersion)
|
||
}
|
||
|
||
static func isNotificationOnlyRetry(_ receipt: PostAppUpdateReceipt) -> Bool {
|
||
receipt.notificationAttempts > 0 &&
|
||
!receipt.notificationInFlight &&
|
||
!receipt.gatewayUpdateIncomplete
|
||
}
|
||
|
||
static func gatewayAction(
|
||
status: CLIInstaller.Status,
|
||
ownsManagedRuntime: Bool,
|
||
gatewayUpdateIncomplete: Bool) -> PostUpdateGatewayAction
|
||
{
|
||
if gatewayUpdateIncomplete, !ownsManagedRuntime {
|
||
return .ownershipFailure
|
||
}
|
||
guard ownsManagedRuntime else { return .none }
|
||
return switch status {
|
||
case .ready:
|
||
gatewayUpdateIncomplete ? .repair : .none
|
||
case let .incompatible(_, found, required):
|
||
CLIInstallPrompter.isManagedUpgrade(found: found, required: required) ? .update : .none
|
||
case .missing, .unusable:
|
||
.install
|
||
}
|
||
}
|
||
|
||
static func shouldPresentOwnershipFailure(
|
||
connectionMode: AppState.ConnectionMode,
|
||
gatewayUpdateIncomplete: Bool) -> Bool
|
||
{
|
||
gatewayUpdateIncomplete && connectionMode != .unconfigured
|
||
}
|
||
|
||
static func remoteNotificationBlocker(
|
||
gatewayVersion: String?,
|
||
appVersion: String) -> PostUpdateNotificationOutcome?
|
||
{
|
||
guard let gatewayVersion else { return .retryLater }
|
||
return Self.supportsPostUpdateNotification(
|
||
gatewayVersion: gatewayVersion,
|
||
appVersion: appVersion) ? nil : .skippedUnsupportedGateway
|
||
}
|
||
|
||
static func ownsManagedRuntime(
|
||
connectionMode: AppState.ConnectionMode,
|
||
programArguments: [String],
|
||
gatewayUpdateChannel: String?,
|
||
installPolicy: String?,
|
||
launchAgentWriteDisabled: Bool) -> Bool
|
||
{
|
||
CLIInstallPrompter.managedRepairGatesOpen(
|
||
launchAgentUsesManagedCLI: CLIInstallPrompter.launchAgentUsesManagedCLI(
|
||
programArguments: programArguments),
|
||
gatewayUpdateChannel: gatewayUpdateChannel,
|
||
installPolicy: installPolicy,
|
||
// This debug gate owns only the local Gateway LaunchAgent. Remote
|
||
// mode proves ownership from the node service command instead.
|
||
launchAgentWriteDisabled: connectionMode == .local && launchAgentWriteDisabled)
|
||
}
|
||
|
||
static func notificationSendFailureOutcome(_ error: Error) -> PostUpdateNotificationOutcome {
|
||
if error is OpenClawChatTransportSendError || error is GatewayResponseError {
|
||
return .retryLater
|
||
}
|
||
// The request may have committed before another transport failure was
|
||
// observed. Consuming the attempt avoids a duplicate welcome.
|
||
return .deliveryUnconfirmed
|
||
}
|
||
|
||
static func preferredNotificationSession(
|
||
loadPage: (Int) async throws -> PostUpdateSessionsResponse) async throws -> PostUpdateSession?
|
||
{
|
||
var offset = 0
|
||
while true {
|
||
let page = try await loadPage(offset)
|
||
if let session = page.sessions.first(where: { session in
|
||
// External direct sessions may belong to other people. Only
|
||
// wake the internal operator surface after an app update.
|
||
session.kind == "direct" &&
|
||
session.lastChannel?.lowercased() == "webchat" &&
|
||
session.lastInteractionAt != nil &&
|
||
session.spawnedBy == nil &&
|
||
session.parentSessionKey == nil
|
||
}) {
|
||
return session
|
||
}
|
||
guard let nextOffset = page.nextOffset, nextOffset > offset else { return nil }
|
||
offset = nextOffset
|
||
}
|
||
}
|
||
|
||
private func finishSilently() {
|
||
PostAppUpdateReceiptStore.clear()
|
||
self.receipt = nil
|
||
self.close()
|
||
}
|
||
|
||
private func finishAfterOwnershipCheckFailure(
|
||
connectionMode: AppState.ConnectionMode,
|
||
receipt: PostAppUpdateReceipt)
|
||
{
|
||
guard Self.shouldPresentOwnershipFailure(
|
||
connectionMode: connectionMode,
|
||
gatewayUpdateIncomplete: receipt.gatewayUpdateIncomplete)
|
||
else {
|
||
self.finishSilently()
|
||
return
|
||
}
|
||
|
||
// An incomplete receipt proves app-owned Gateway work already began.
|
||
// Keep it retryable when the service record is temporarily unreadable.
|
||
self.show()
|
||
switch connectionMode {
|
||
case .local:
|
||
self.fail(
|
||
message: String(localized: "The Gateway could not be checked."),
|
||
details: String(
|
||
localized: """
|
||
OpenClaw could not read the Gateway service ownership record. \
|
||
Retry after checking the Gateway LaunchAgent.
|
||
"""))
|
||
case .remote:
|
||
self.fail(
|
||
message: String(localized: "The Mac node could not be checked."),
|
||
details: String(
|
||
localized: """
|
||
OpenClaw could not read the node service ownership record. \
|
||
Retry after checking the node LaunchAgent.
|
||
"""))
|
||
case .unconfigured:
|
||
self.finishSilently()
|
||
}
|
||
}
|
||
|
||
private func fail(message: String, details: String?) {
|
||
self.model.phase = .failed
|
||
self.model.title = String(localized: "Gateway update needs help")
|
||
self.model.message = message
|
||
self.model.details = details
|
||
}
|
||
}
|
||
|
||
private struct PostUpdateView: View {
|
||
@Bindable var model: PostUpdateModel
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
GlowingOpenClawIcon(size: 150, mood: self.model.mood)
|
||
.frame(height: 205)
|
||
|
||
VStack(spacing: 18) {
|
||
Text(self.model.title)
|
||
.font(.system(size: 25, weight: .semibold))
|
||
.multilineTextAlignment(.center)
|
||
Text(self.model.message)
|
||
.font(.body)
|
||
.foregroundStyle(.secondary)
|
||
.multilineTextAlignment(.center)
|
||
.frame(maxWidth: 430)
|
||
|
||
if self.model.isWorking {
|
||
ProgressView()
|
||
.controlSize(.large)
|
||
.padding(.top, 6)
|
||
}
|
||
|
||
if let details = self.model.details {
|
||
ScrollView {
|
||
Text(details)
|
||
.font(.system(.caption, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.frame(maxHeight: 120)
|
||
.padding(12)
|
||
.background(.quaternary, in: RoundedRectangle(cornerRadius: 10))
|
||
}
|
||
|
||
Spacer(minLength: 0)
|
||
self.actions
|
||
}
|
||
.padding(.horizontal, 42)
|
||
.padding(.bottom, 34)
|
||
}
|
||
.frame(minWidth: 560, minHeight: 600)
|
||
.background(Color(NSColor.windowBackgroundColor))
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var actions: some View {
|
||
switch self.model.phase {
|
||
case .failed:
|
||
HStack {
|
||
Button("Update guide") { PostUpdateController.shared.openUpdateGuide() }
|
||
Button("Ask Discord") { PostUpdateController.shared.openDiscord() }
|
||
Spacer()
|
||
Button("Retry") { PostUpdateController.shared.retry() }
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
case .complete:
|
||
HStack {
|
||
Spacer()
|
||
Button("Continue") { PostUpdateController.shared.close() }
|
||
.buttonStyle(.borderedProminent)
|
||
}
|
||
case .checking, .updating, .verifying, .notifying:
|
||
EmptyView()
|
||
}
|
||
}
|
||
}
|