mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 13:41:37 +00:00
765 lines
29 KiB
Swift
765 lines
29 KiB
Swift
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))
|
|
}
|
|
}
|