import AppKit import CryptoKit import Observation import OpenClawDiscovery import SwiftUI enum UIStrings { static let welcomeTitle = "Welcome to OpenClaw" } enum RemoteOnboardingProbeState: Equatable { case idle case checking case ok(RemoteGatewayProbeSuccess) case failed(String) } enum OnboardingCrestodianResumeStore { struct ActivationOwner: Equatable { let id: String let routeFingerprint: String } enum PendingState: Equatable { case none case activating(deadline: Date) case verified(deadline: Date) case activationExpired case completed } private enum RecordPhase: String { case activating case verified case completed } private struct Record { let phase: RecordPhase let startedAt: Date? let deadline: Date? let activationOwner: ActivationOwner? } private static let recordVersion = 4 /// v2 receipts had no auth binding, so a completed marker could otherwise /// attach to replacement credentials on the same endpoint. private static let unsafeOwnerlessRecordVersion = 2 /// v3 persisted a plain SHA-256 credential verifier. Never retain or /// migrate it; a fresh activation is safer than carrying sensitive bytes. private static let unsafeCredentialFingerprintRecordVersion = 3 private static let legacyRecordVersion = 1 private static let activationDeadlineSafetySeconds: TimeInterval = 5 static let maximumActivationTimeoutMs: Double = 480_000 /// Legacy string markers do not say whether activation returned. Waiting /// one full maximum request window is the only safe migration. static let legacyActivationLeaseSeconds: TimeInterval = maximumActivationTimeoutMs / 1000 + activationDeadlineSafetySeconds @MainActor static func selectedRouteIdentity( state: AppState = AppStateStore.shared, preferredGatewayID: String? = GatewayDiscoveryPreferences.preferredStableID()) -> String? { let defaultRemotePort = GatewayEnvironment.gatewayPort() let sshRemotePort: Int = if state.connectionMode == .remote, state.remoteTransport == .ssh { RemotePortTunnel.resolveRemotePortOverride( defaultRemotePort: defaultRemotePort, for: CommandResolver.parseSSHTarget(state.remoteTarget)?.host ?? "") ?? defaultRemotePort } else { defaultRemotePort } return self.routeIdentity( connectionMode: state.connectionMode, preferredGatewayID: preferredGatewayID, remoteTransport: state.remoteTransport, remoteURL: state.remoteUrl, remoteTarget: state.remoteTarget, localStateDir: OpenClawConfigFile.stateDirURL(), sshRemotePort: sshRemotePort) } static func routeIdentity( connectionMode: AppState.ConnectionMode, preferredGatewayID: String?, remoteTransport: AppState.RemoteTransport, remoteURL: String, remoteTarget: String, localStateDir: URL = OpenClawConfigFile.stateDirURL(), sshRemotePort: Int = GatewayEnvironment.gatewayPort()) -> String? { switch connectionMode { case .unconfigured: return nil case .local: let stateDir = localStateDir.resolvingSymlinksInPath().standardizedFileURL.path let defaultStateDir = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".openclaw", isDirectory: true) .resolvingSymlinksInPath() .standardizedFileURL.path if stateDir == defaultStateDir { return "local" } return "local:\(self.nonSecretFingerprint(stateDir))" case .remote: if let gatewayID = normalized(preferredGatewayID) { return "remote:id:\(gatewayID)" } let endpoint = switch remoteTransport { case .direct: self.nonSecretFingerprint(self.directEndpointIdentity(remoteURL)) case .ssh: self.nonSecretFingerprint("\(remoteTarget):gateway-port:\(sshRemotePort)") } return "remote:\(remoteTransport.rawValue):\(endpoint)" } } static func isPending( for routeIdentity: String?, defaults: UserDefaults = .standard, now: Date = Date()) -> Bool { self.pendingState(for: routeIdentity, defaults: defaults, now: now) != .none } @discardableResult static func markPending( routeIdentity: String?, activationOwner: ActivationOwner? = nil, activationTimeoutMs: Double = OnboardingCrestodianResumeStore.maximumActivationTimeoutMs, defaults: UserDefaults = .standard, now: Date = Date()) -> Date? { guard let routeIdentity = normalized(routeIdentity) else { return nil } let duration = max(0, activationTimeoutMs / 1000) + self.activationDeadlineSafetySeconds let deadline = now.addingTimeInterval(duration) var records = self.loadRecords(defaults: defaults, now: now) records[routeIdentity] = Record( phase: .activating, startedAt: now, deadline: deadline, activationOwner: activationOwner) self.writeRecords(records, defaults: defaults) return deadline } static func restorePending( routeIdentity: String, activationOwner: ActivationOwner? = nil, deadline: Date, defaults: UserDefaults = .standard, now: Date = Date()) { guard let routeIdentity = normalized(routeIdentity) else { return } var records = self.loadRecords(defaults: defaults, now: now) records[routeIdentity] = Record( phase: .activating, startedAt: now, deadline: deadline, activationOwner: activationOwner) self.writeRecords(records, defaults: defaults) } static func markVerified( ifOwnedBy routeIdentity: String?, activationOwner: ActivationOwner? = nil, defaults: UserDefaults = .standard, now: Date = Date()) { guard let routeIdentity = normalized(routeIdentity) else { return } var records = self.loadRecords(defaults: defaults, now: now) guard let record = records[routeIdentity], ownerMatches(record, activationOwner: activationOwner) else { return } records[routeIdentity] = Record( phase: .verified, startedAt: record.startedAt, deadline: record.deadline ?? now.addingTimeInterval(self.legacyActivationLeaseSeconds), activationOwner: record.activationOwner) self.writeRecords(records, defaults: defaults) } @discardableResult static func markCompleted( ifOwnedBy routeIdentity: String?, activationOwner: ActivationOwner? = nil, defaults: UserDefaults = .standard, now: Date = Date()) -> Bool { guard let routeIdentity = normalized(routeIdentity) else { return false } var records = self.loadRecords(defaults: defaults, now: now) guard let record = records[routeIdentity], ownerMatches(record, activationOwner: activationOwner) else { return false } records[routeIdentity] = Record( phase: .completed, startedAt: record.startedAt, deadline: record.deadline, activationOwner: record.activationOwner) self.writeRecords(records, defaults: defaults) return true } static func activationOwner( for routeIdentity: String?, defaults: UserDefaults = .standard, now: Date = Date()) -> ActivationOwner? { guard let routeIdentity = normalized(routeIdentity) else { return nil } return self.loadRecords(defaults: defaults, now: now)[routeIdentity]?.activationOwner } static func isOwned( by activationOwner: ActivationOwner, for routeIdentity: String?, defaults: UserDefaults = .standard, now: Date = Date()) -> Bool { guard let routeIdentity = normalized(routeIdentity), let record = loadRecords(defaults: defaults, now: now)[routeIdentity] else { return false } return record.activationOwner == activationOwner } static func pendingState( for routeIdentity: String?, defaults: UserDefaults = .standard, now: Date = Date()) -> PendingState { guard let routeIdentity = normalized(routeIdentity), let record = loadRecords(defaults: defaults, now: now)[routeIdentity] else { return .none } switch record.phase { case .completed: return .completed case .activating, .verified: guard let deadline = record.deadline else { return .activationExpired } guard now < deadline else { return .activationExpired } return record.phase == .activating ? .activating(deadline: deadline) : .verified(deadline: deadline) } } @discardableResult static func clear( ifOwnedBy routeIdentity: String, activationOwner: ActivationOwner? = nil, defaults: UserDefaults = .standard) -> Bool { guard let routeIdentity = normalized(routeIdentity) else { return false } var records = self.loadRecords(defaults: defaults) guard let record = records[routeIdentity], ownerMatches(record, activationOwner: activationOwner) else { return false } records.removeValue(forKey: routeIdentity) self.writeRecords(records, defaults: defaults) return true } static func clear(defaults: UserDefaults = .standard) { defaults.removeObject(forKey: onboardingCrestodianPendingKey) } private static func loadRecords( defaults: UserDefaults, now: Date = Date()) -> [String: Record] { guard let stored = defaults.object(forKey: onboardingCrestodianPendingKey) else { return [:] } if let legacyRoute = normalized(stored as? String) { let records = [legacyRoute: conservativeLegacyRecord(now: now)] self.writeRecords(records, defaults: defaults) return records } guard let container = stored as? [String: Any] else { self.clear(defaults: defaults) return [:] } let version = (container["version"] as? NSNumber)?.intValue if version == self.legacyRecordVersion, let routeIdentity = normalized(container["routeIdentity"] as? String) { let record = self.decodeLegacyRecord(container, now: now) let records = [routeIdentity: record] self.writeRecords(records, defaults: defaults) return records } if version == self.unsafeOwnerlessRecordVersion || version == self.unsafeCredentialFingerprintRecordVersion { guard let storedRecords = container["records"] as? [String: Any] else { self.clear(defaults: defaults) return [:] } // Strip the unsafe/absent auth owner immediately, but retain active // deadlines so a possibly running activation cannot overlap a new one. let records: [String: Record] = storedRecords.reduce(into: [:]) { result, entry in guard let routeIdentity = normalized(entry.key), let payload = entry.value as? [String: Any], let record = decodeRecord(payload), record.phase != .completed else { return } result[routeIdentity] = Record( phase: record.phase, startedAt: record.startedAt, deadline: record.deadline, activationOwner: nil) } self.writeRecords(records, defaults: defaults) return records } guard version == self.recordVersion, let storedRecords = container["records"] as? [String: Any] else { self.clear(defaults: defaults) return [:] } return storedRecords.reduce(into: [:]) { result, entry in guard let routeIdentity = normalized(entry.key), let payload = entry.value as? [String: Any], let record = decodeRecord(payload) else { return } result[routeIdentity] = record } } private static func decodeLegacyRecord(_ payload: [String: Any], now: Date) -> Record { guard let phaseRaw = payload["phase"] as? String, let phase = RecordPhase(rawValue: phaseRaw) else { return self.conservativeLegacyRecord(now: now) } let startedAt = self.date(payload["startedAt"]) let deadline = self.date(payload["deadlineAt"]) switch phase { case .activating: return Record( phase: .activating, startedAt: startedAt ?? now, deadline: deadline ?? now.addingTimeInterval(self.legacyActivationLeaseSeconds), activationOwner: nil) case .verified, .completed: // v1 `verified` could be written by an early read-only probe and // carried no deadline, so migration must restore a full lease. return Record( phase: .verified, startedAt: startedAt ?? now, deadline: deadline ?? now.addingTimeInterval(self.legacyActivationLeaseSeconds), activationOwner: nil) } } private static func conservativeLegacyRecord(now: Date) -> Record { Record( phase: .activating, startedAt: now, deadline: now.addingTimeInterval(self.legacyActivationLeaseSeconds), activationOwner: nil) } private static func decodeRecord(_ payload: [String: Any]) -> Record? { guard let phaseRaw = payload["phase"] as? String, let phase = RecordPhase(rawValue: phaseRaw) else { return nil } let activationID = self.normalized(payload["activationId"] as? String) let routeFingerprint = self.normalized(payload["routeFingerprint"] as? String) let activationOwner: ActivationOwner? = if let activationID, let routeFingerprint { ActivationOwner(id: activationID, routeFingerprint: routeFingerprint) } else { nil } return Record( phase: phase, startedAt: self.date(payload["startedAt"]), deadline: self.date(payload["deadlineAt"]), activationOwner: activationOwner) } private static func writeRecords(_ records: [String: Record], defaults: UserDefaults) { guard !records.isEmpty else { self.clear(defaults: defaults) return } let payload = records.mapValues { record -> [String: Any] in var value: [String: Any] = ["phase": record.phase.rawValue] if let startedAt = record.startedAt { value["startedAt"] = startedAt.timeIntervalSince1970 } if let deadline = record.deadline { value["deadlineAt"] = deadline.timeIntervalSince1970 } if let activationOwner = record.activationOwner { value["activationId"] = activationOwner.id value["routeFingerprint"] = activationOwner.routeFingerprint } return value } defaults.set( ["version": self.recordVersion, "records": payload], forKey: onboardingCrestodianPendingKey) } private static func date(_ value: Any?) -> Date? { guard let interval = (value as? NSNumber)?.doubleValue else { return nil } return Date(timeIntervalSince1970: interval) } private static func normalized(_ value: String?) -> String? { let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed?.isEmpty == false ? trimmed : nil } private static func ownerMatches( _ record: Record, activationOwner: ActivationOwner?) -> Bool { // A missing owner names legacy ownerless records; it is not a wildcard. // Otherwise stale UI paths can verify, complete, or clear a newer activation. record.activationOwner == activationOwner } private static func nonSecretFingerprint(_ value: String) -> String { let raw = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !raw.isEmpty else { return "" } let digest = SHA256.hash(data: Data(raw.utf8)) return digest.map { String(format: "%02x", $0) }.joined() } private static func directEndpointIdentity(_ value: String) -> String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) let normalized = GatewayRemoteConfig.normalizeGatewayUrlString(trimmed) ?? trimmed guard var components = URLComponents(string: normalized) else { return normalized } // Auth can rotate while an activation is still committing. The durable // lease follows the endpoint, while route-bound RPCs separately guard auth. components.user = nil components.password = nil components.queryItems = components.queryItems?.filter { queryItem in !self.isSensitiveQueryItemName(queryItem.name) } if components.queryItems?.isEmpty == true { components.query = nil } components.fragment = nil return components.string ?? normalized } private static func isSensitiveQueryItemName(_ value: String) -> Bool { let normalized = value .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() .replacingOccurrences(of: "-", with: "_") return [ "access_token", "api_key", "apikey", "app_secret", "auth", "auth_token", "authorization", "client_secret", "code", "credential", "hook_token", "id_token", "jwt", "key", "pass", "passwd", "password", "private_key", "refresh_token", "secret", "session", "signature", "token", "x_amz_security_token", "x_amz_signature", ].contains(normalized) } } @MainActor final class OnboardingController: NSObject, NSWindowDelegate { static let shared = OnboardingController() static let windowStyleMask: NSWindow.StyleMask = [.titled, .closable, .resizable, .fullSizeContentView] private var window: NSWindow? /// Human description of work in flight ("Installing the Gateway…"). /// While set, closing the window asks for confirmation instead of quitting /// setup mid-operation. var busyReason: String? static func markComplete() { UserDefaults.standard.set(true, forKey: onboardingSeenKey) UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) AppStateStore.shared.onboardingSeen = true } func show() { if ProcessInfo.processInfo.isNixMode { // Nix mode is fully declarative; onboarding would suggest interactive setup that doesn't apply. Self.markComplete() return } if let window { DockIconManager.shared.temporarilyShowDock() window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) return } let hosting = NSHostingController(rootView: OnboardingView()) let window = NSWindow(contentViewController: hosting) window.title = UIStrings.welcomeTitle window.setContentSize(NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight)) window.styleMask = Self.windowStyleMask // Keep the focused dialog width while letting taller displays give setup more breathing room. window.contentMinSize = NSSize(width: OnboardingView.windowWidth, height: OnboardingView.windowHeight) window.contentMaxSize = NSSize(width: OnboardingView.windowWidth, height: .greatestFiniteMagnitude) 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 } func close() { self.busyReason = nil self.window?.close() self.window = nil } func setWindowCloseEnabled(_ enabled: Bool) { self.window?.standardWindowButton(.closeButton)?.isEnabled = enabled } func restart() { self.close() self.show() } func windowShouldClose(_: NSWindow) -> Bool { guard let busyReason else { return true } let alert = NSAlert() alert.messageText = "Setup is still working" alert.informativeText = "\(busyReason)\n\nYou can keep this window open until it finishes, " + "or quit setup and pick it up again later from the menu bar." alert.alertStyle = .warning alert.addButton(withTitle: "Continue Setup") alert.addButton(withTitle: "Quit Setup") let response = alert.runModal() return response == .alertSecondButtonReturn } func windowWillClose(_ notification: Notification) { guard let closing = notification.object as? NSWindow, closing === window else { return } self.busyReason = nil self.window = nil } } struct OnboardingView: View { enum CLIInstallPhase { case idle case installing case startingService } @State var currentPage = 0 @State var isRequesting = false @State var installingCLI = false @State var cliInstallPhase: CLIInstallPhase = .idle @State var cliStatus: String? @State var monitoringPermissions = false @State var monitoringDiscovery = false @State var cliExecutableReady = false @State var cliInstalled = false @State var cliStatusKnown = false @State var onboardingVisible = false @State var cliInstallLocation: String? @State var showAdvancedConnection = false @State var showRemoteChoices = false @State var preferredGatewayID: String? @State var remoteProbeState: RemoteOnboardingProbeState = .idle @State var remoteAuthIssue: RemoteGatewayAuthIssue? @State var suppressRemoteProbeReset = false @State var gatewayDiscovery: GatewayDiscoveryModel @State var onboardingSkillsModel = SkillsSettingsModel() @State var crestodianState = OnboardingCrestodianChatState() @State var aiSetup = OnboardingAISetupModel() @State var configuredGatewayProbe = OnboardingConfiguredGatewayProbe() @State var didLoadOnboardingSkills = false @State var localGatewayProbe: LocalGatewayProbe? @State var defaultsToLocalGateway: Bool @Bindable var state: AppState var permissionMonitor: PermissionMonitor let crestodianDefaults: UserDefaults let aiSetupRouteIdentityProvider: @MainActor () -> String? let gatewaySelectionPersister: @MainActor () -> Bool static let windowWidth: CGFloat = 630 static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content let pageWidth: CGFloat = Self.windowWidth let connectionPageIndex = 1 let cliPageIndex = 2 let aiPageIndex = 3 let onboardingChatPageIndex = 8 let readyPageIndex = 9 let permissionsPageIndex = 5 var heroFrameHeight: CGFloat { 145 } var heroSize: CGFloat { 130 } /// The baseline fits every setup control. Taller windows donate all extra room /// to the active page instead of leaving the content pinned to a fixed canvas. func contentHeight(for windowHeight: CGFloat) -> CGFloat { Self.contentHeight(for: windowHeight, usesCompactHero: self.usesCompactHero) } static func contentHeight(for windowHeight: CGFloat, usesCompactHero: Bool) -> CGFloat { let availableHeight = max(Self.windowHeight, windowHeight) let heroHeight: CGFloat = usesCompactHero ? 78 : 145 return availableHeight - heroHeight - 72 } static func pageOrder( for mode: AppState.ConnectionMode, requiresCLIInstall: Bool) -> [Int] { switch mode { case .remote: // Remote setup doesn't need local gateway/CLI/workspace setup pages, // but the AI check runs against the remote gateway so a broken // remote model surfaces here, not in the first chat. return [0, 1, 3, 5, 9] case .unconfigured: return [0, 1, 9] case .local: let setupPages = requiresCLIInstall ? [0, 1, 2, 3, 5] : [0, 1, 3, 5] return setupPages + [9] } } var selectedConnectionMode: AppState.ConnectionMode { if self.isConnectionSelectionBlocking { return .local } return self.state.connectionMode } var isConnectionSelectionBlocking: Bool { self.defaultsToLocalGateway && self.state.connectionMode == .unconfigured } var pageOrder: [Int] { Self.pageOrder( for: self.state.connectionMode, requiresCLIInstall: self.state.connectionMode == .local && !self.cliInstalled) } var pageCount: Int { self.pageOrder.count } var activePageIndex: Int { self.activePageIndex(for: self.currentPage) } var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } var isCLIBlocking: Bool { self.activePageIndex == self.cliPageIndex && !self.cliInstalled } /// Onboarding must not finish without working inference: the AI page /// blocks Next until a candidate passed its live test (config is authored /// server-side on that success). "Configure later" on the connection page /// remains the explicit skip path. var isAISetupBlocking: Bool { Self.shouldBlockAISetup( currentPage: self.currentPage, pageOrder: self.pageOrder, aiPageIndex: self.aiPageIndex, connectionMode: self.state.connectionMode, connected: self.aiSetup.connected) } static func shouldBlockAISetup( currentPage: Int, pageOrder: [Int], aiPageIndex: Int, connectionMode: AppState.ConnectionMode, connected: Bool) -> Bool { guard connectionMode != .unconfigured, !connected, let aiPageCursor = pageOrder.firstIndex(of: aiPageIndex) else { return false } return currentPage >= aiPageCursor } var canAdvance: Bool { !self.isCLIBlocking && !self.isAISetupBlocking } struct LocalGatewayProbe: Equatable { let port: Int let pid: Int32 let command: String let expected: Bool } init( state: AppState = AppStateStore.shared, permissionMonitor: PermissionMonitor = .shared, discoveryModel: GatewayDiscoveryModel = GatewayDiscoveryModel( localDisplayName: InstanceIdentity.displayName, filterLocalGateways: false), aiSetupGateway: GatewayConnection = .shared, crestodianDefaults: UserDefaults = .standard, aiSetupRouteIdentityProvider: (@MainActor () -> String?)? = nil, configuredGatewayProbeTimeoutMs: Double = 15000, gatewaySelectionPersister: (@MainActor () -> Bool)? = nil) { self.state = state self.permissionMonitor = permissionMonitor self.crestodianDefaults = crestodianDefaults let routeIdentityProvider = aiSetupRouteIdentityProvider ?? { OnboardingCrestodianResumeStore.selectedRouteIdentity(state: state) } self.aiSetupRouteIdentityProvider = routeIdentityProvider self.gatewaySelectionPersister = gatewaySelectionPersister ?? { state.syncGatewayConfigNow() } _defaultsToLocalGateway = State( initialValue: !state.onboardingSeen && state.connectionMode == .unconfigured) _gatewayDiscovery = State(initialValue: discoveryModel) _aiSetup = State(initialValue: OnboardingAISetupModel( gateway: aiSetupGateway, defaults: crestodianDefaults, routeIdentityProvider: routeIdentityProvider)) _configuredGatewayProbe = State( initialValue: OnboardingConfiguredGatewayProbe( gateway: aiSetupGateway, timeoutMs: configuredGatewayProbeTimeoutMs)) } }