Files
openclaw/apps/macos/Sources/OpenClaw/PostUpdate.swift
Peter Steinberger e4f4b93b10 feat(mac): playful hard-hat working mascot during setup and updates (#108735)
* 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
2026-07-16 02:45:19 -07:00

893 lines
34 KiB
Swift
Raw 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 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 youre 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()
}
}
}