Compare commits
4 Commits
main
...
imlukef/on
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f2f9a05a3 | ||
|
|
fbbca10c83 | ||
|
|
09d972c1dd | ||
|
|
49c4ff67d4 |
@ -2,6 +2,13 @@ import Foundation
|
||||
|
||||
@MainActor
|
||||
enum CLIInstaller {
|
||||
struct PreflightStatus: Equatable {
|
||||
let needsCommandLineTools: Bool
|
||||
let message: String?
|
||||
|
||||
static let ready = PreflightStatus(needsCommandLineTools: false, message: nil)
|
||||
}
|
||||
|
||||
static func installedLocation() -> String? {
|
||||
self.installedLocation(
|
||||
searchPaths: CommandResolver.preferredPaths(),
|
||||
@ -34,6 +41,50 @@ enum CLIInstaller {
|
||||
self.installedLocation() != nil
|
||||
}
|
||||
|
||||
static func preflight() async -> PreflightStatus {
|
||||
let response = await ShellExecutor.runDetailed(
|
||||
command: ["/usr/bin/xcode-select", "-p"],
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 10)
|
||||
|
||||
guard response.success else {
|
||||
return PreflightStatus(
|
||||
needsCommandLineTools: true,
|
||||
message: """
|
||||
Apple Developer Tools are required before OpenClaw can install the CLI.
|
||||
Install them first, then come back and click “I've Installed It, Recheck”.
|
||||
""")
|
||||
}
|
||||
|
||||
return .ready
|
||||
}
|
||||
|
||||
static func requestCommandLineToolsInstall(
|
||||
statusHandler: @escaping @MainActor @Sendable (String) async -> Void
|
||||
) async {
|
||||
await statusHandler("Opening Apple developer tools installer…")
|
||||
let response = await ShellExecutor.runDetailed(
|
||||
command: ["/usr/bin/xcode-select", "--install"],
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 10)
|
||||
|
||||
let combined = [response.stdout, response.stderr]
|
||||
.joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
|
||||
if combined.contains("already installed") || combined.contains("softwareupdate") {
|
||||
await statusHandler(
|
||||
"Apple Developer Tools installer is already open or installed. Finish that step, then click “I've Installed It, Recheck”.")
|
||||
return
|
||||
}
|
||||
|
||||
await statusHandler(
|
||||
"Complete Apple's developer tools installer dialog, then click “I've Installed It, Recheck”.")
|
||||
}
|
||||
|
||||
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
|
||||
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||
let prefix = Self.installPrefix()
|
||||
|
||||
@ -6,6 +6,7 @@ let launchdLabel = "ai.openclaw.mac"
|
||||
let gatewayLaunchdLabel = "ai.openclaw.gateway"
|
||||
let onboardingVersionKey = "openclaw.onboardingVersion"
|
||||
let onboardingSeenKey = "openclaw.onboardingSeen"
|
||||
let onboardingSecurityAcknowledgedKey = "openclaw.onboardingSecurityAcknowledged"
|
||||
let currentOnboardingVersion = 7
|
||||
let pauseDefaultsKey = "openclaw.pauseEnabled"
|
||||
let iconAnimationsEnabledKey = "openclaw.iconAnimationsEnabled"
|
||||
|
||||
@ -15,7 +15,7 @@ struct CronSettings_Previews: PreviewProvider {
|
||||
createdAtMs: 0,
|
||||
updatedAtMs: 0,
|
||||
schedule: .every(everyMs: 86_400_000, anchorMs: nil),
|
||||
sessionTarget: .isolated,
|
||||
sessionTarget: .predefined(.isolated),
|
||||
wakeMode: .now,
|
||||
payload: .agentTurn(
|
||||
message: "Summarize inbox",
|
||||
@ -69,7 +69,7 @@ extension CronSettings {
|
||||
createdAtMs: 1_700_000_000_000,
|
||||
updatedAtMs: 1_700_000_100_000,
|
||||
schedule: .cron(expr: "0 8 * * *", tz: "UTC"),
|
||||
sessionTarget: .isolated,
|
||||
sessionTarget: .predefined(.isolated),
|
||||
wakeMode: .nextHeartbeat,
|
||||
payload: .agentTurn(
|
||||
message: "Summarize",
|
||||
|
||||
@ -90,7 +90,18 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
static func expectedGatewayVersionString() -> String? {
|
||||
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
self.expectedGatewayVersionString(
|
||||
bundleVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String,
|
||||
bundleIdentifier: Bundle.main.bundleIdentifier)
|
||||
}
|
||||
|
||||
static func expectedGatewayVersionString(bundleVersion: String?, bundleIdentifier: String?) -> String? {
|
||||
if let bundleIdentifier,
|
||||
bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).hasSuffix(".debug")
|
||||
{
|
||||
return nil
|
||||
}
|
||||
|
||||
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (trimmed?.isEmpty == false) ? trimmed : nil
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
@ -196,13 +197,9 @@ final class GatewayProcessManager {
|
||||
let instanceText = instance.map { self.describe(instance: $0) }
|
||||
let hasListener = instance != nil
|
||||
|
||||
let attemptAttach = {
|
||||
try await self.connection.requestRaw(method: .health, timeoutMs: 2000)
|
||||
}
|
||||
|
||||
for attempt in 0..<(hasListener ? 3 : 1) {
|
||||
do {
|
||||
let data = try await attemptAttach()
|
||||
let data = try await self.probeLocalGatewayHealth(timeoutMs: 2000)
|
||||
let snap = decodeHealthSnapshot(from: data)
|
||||
let details = self.describe(details: instanceText, port: port, snap: snap)
|
||||
self.existingGatewayDetails = details
|
||||
@ -337,7 +334,7 @@ final class GatewayProcessManager {
|
||||
while Date() < deadline {
|
||||
if !self.desiredActive { return }
|
||||
do {
|
||||
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||
_ = try await self.probeLocalGatewayHealth(timeoutMs: 1500)
|
||||
let instance = await PortGuardian.shared.describe(port: port)
|
||||
let details = instance.map { "pid \($0.pid)" }
|
||||
self.clearLastFailure()
|
||||
@ -380,7 +377,7 @@ final class GatewayProcessManager {
|
||||
while Date() < deadline {
|
||||
if !self.desiredActive { return false }
|
||||
do {
|
||||
_ = try await self.connection.requestRaw(method: .health, timeoutMs: 1500)
|
||||
_ = try await self.probeLocalGatewayHealth(timeoutMs: 1500)
|
||||
self.clearLastFailure()
|
||||
return true
|
||||
} catch {
|
||||
@ -413,6 +410,20 @@ final class GatewayProcessManager {
|
||||
if text.count <= limit { return text }
|
||||
return String(text.suffix(limit))
|
||||
}
|
||||
|
||||
private func probeLocalGatewayHealth(timeoutMs: Double) async throws -> Data {
|
||||
let config = GatewayEndpointStore.localConfig()
|
||||
let channel = GatewayChannelActor(
|
||||
url: config.url,
|
||||
token: config.token,
|
||||
password: config.password)
|
||||
defer {
|
||||
Task {
|
||||
await channel.shutdown()
|
||||
}
|
||||
}
|
||||
return try await channel.request(method: GatewayConnection.Method.health.rawValue, params: nil, timeoutMs: timeoutMs)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
@ -33,6 +33,7 @@ final class MacNodeModeCoordinator {
|
||||
var retryDelay: UInt64 = 1_000_000_000
|
||||
var lastCameraEnabled: Bool?
|
||||
var lastBrowserControlEnabled: Bool?
|
||||
var lastBlockedOnOnboarding = false
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
while !Task.isCancelled {
|
||||
@ -41,6 +42,22 @@ final class MacNodeModeCoordinator {
|
||||
continue
|
||||
}
|
||||
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
let onboardingComplete = Self.shouldConnectNodeMode(
|
||||
onboardingSeen: defaults.bool(forKey: onboardingSeenKey),
|
||||
onboardingVersion: defaults.integer(forKey: onboardingVersionKey),
|
||||
root: root)
|
||||
if !onboardingComplete {
|
||||
if !lastBlockedOnOnboarding {
|
||||
self.logger.info("mac node waiting for onboarding completion")
|
||||
lastBlockedOnOnboarding = true
|
||||
}
|
||||
await self.session.disconnect()
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
}
|
||||
lastBlockedOnOnboarding = false
|
||||
|
||||
let cameraEnabled = defaults.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||
if lastCameraEnabled == nil {
|
||||
lastCameraEnabled = cameraEnabled
|
||||
@ -116,6 +133,20 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
static func shouldConnectNodeMode(
|
||||
onboardingSeen: Bool,
|
||||
onboardingVersion: Int,
|
||||
root: [String: Any]
|
||||
) -> Bool {
|
||||
if onboardingSeen && onboardingVersion >= currentOnboardingVersion {
|
||||
return true
|
||||
}
|
||||
|
||||
// Preserve runtime connectivity for existing local installs when a newer
|
||||
// app build refreshes onboarding copy or flow.
|
||||
return OnboardingWizardModel.hasExistingLocalSetup(root: root)
|
||||
}
|
||||
|
||||
private func currentCaps() -> [String] {
|
||||
var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue]
|
||||
if OpenClawConfigFile.browserControlEnabled() {
|
||||
|
||||
@ -25,6 +25,7 @@ final class OnboardingController {
|
||||
if ProcessInfo.processInfo.isNixMode {
|
||||
// Nix mode is fully declarative; onboarding would suggest interactive setup that doesn't apply.
|
||||
UserDefaults.standard.set(true, forKey: "openclaw.onboardingSeen")
|
||||
UserDefaults.standard.set(true, forKey: onboardingSecurityAcknowledgedKey)
|
||||
UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey)
|
||||
AppStateStore.shared.onboardingSeen = true
|
||||
return
|
||||
@ -67,6 +68,8 @@ struct OnboardingView: View {
|
||||
@State var isRequesting = false
|
||||
@State var installingCLI = false
|
||||
@State var cliStatus: String?
|
||||
@State var cliPreflightStatus: String?
|
||||
@State var cliNeedsCommandLineTools = false
|
||||
@State var copied = false
|
||||
@State var monitoringPermissions = false
|
||||
@State var monitoringDiscovery = false
|
||||
@ -88,6 +91,7 @@ struct OnboardingView: View {
|
||||
@State var onboardingWizard = OnboardingWizardModel()
|
||||
@State var didLoadOnboardingSkills = false
|
||||
@State var localGatewayProbe: LocalGatewayProbe?
|
||||
@State var securityNoticeAcknowledged: Bool
|
||||
@Bindable var state: AppState
|
||||
var permissionMonitor: PermissionMonitor
|
||||
|
||||
@ -97,6 +101,7 @@ struct OnboardingView: View {
|
||||
let pageWidth: CGFloat = Self.windowWidth
|
||||
let contentHeight: CGFloat = 460
|
||||
let connectionPageIndex = 1
|
||||
let cliPageIndex = 6
|
||||
let wizardPageIndex = 3
|
||||
let onboardingChatPageIndex = 8
|
||||
|
||||
@ -113,7 +118,7 @@ struct OnboardingView: View {
|
||||
case .unconfigured:
|
||||
showOnboardingChat ? [0, 1, 8, 9] : [0, 1, 9]
|
||||
case .local:
|
||||
showOnboardingChat ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9]
|
||||
showOnboardingChat ? [0, 1, 6, 3, 5, 8, 9] : [0, 1, 6, 3, 5, 9]
|
||||
}
|
||||
}
|
||||
|
||||
@ -145,8 +150,25 @@ struct OnboardingView: View {
|
||||
self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete
|
||||
}
|
||||
|
||||
var isSecurityNoticeBlocking: Bool {
|
||||
self.activePageIndex == 0 && !self.securityNoticeAcknowledged
|
||||
}
|
||||
|
||||
var canAdvance: Bool {
|
||||
!self.isWizardBlocking
|
||||
if self.isSecurityNoticeBlocking {
|
||||
return false
|
||||
}
|
||||
if self.activePageIndex == self.cliPageIndex {
|
||||
return self.cliInstalled && !self.installingCLI
|
||||
}
|
||||
return !self.isWizardBlocking
|
||||
}
|
||||
|
||||
static func resolveSecurityNoticeAcknowledged(
|
||||
onboardingSeen: Bool,
|
||||
storedAcknowledgement: Bool) -> Bool
|
||||
{
|
||||
storedAcknowledgement || onboardingSeen
|
||||
}
|
||||
|
||||
var devLinkCommand: String {
|
||||
@ -171,6 +193,10 @@ struct OnboardingView: View {
|
||||
self.state = state
|
||||
self.permissionMonitor = permissionMonitor
|
||||
self._gatewayDiscovery = State(initialValue: discoveryModel)
|
||||
self._securityNoticeAcknowledged = State(
|
||||
initialValue: Self.resolveSecurityNoticeAcknowledged(
|
||||
onboardingSeen: state.onboardingSeen,
|
||||
storedAcknowledgement: UserDefaults.standard.bool(forKey: onboardingSecurityAcknowledgedKey)))
|
||||
self._onboardingChatModel = State(
|
||||
initialValue: OpenClawChatViewModel(
|
||||
sessionKey: "onboarding",
|
||||
|
||||
@ -45,7 +45,7 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func handleNext() {
|
||||
if self.isWizardBlocking { return }
|
||||
if !self.canAdvance { return }
|
||||
if self.currentPage < self.pageCount - 1 {
|
||||
withAnimation { self.currentPage += 1 }
|
||||
} else {
|
||||
@ -53,7 +53,13 @@ extension OnboardingView {
|
||||
}
|
||||
}
|
||||
|
||||
func setSecurityNoticeAcknowledged(_ acknowledged: Bool) {
|
||||
self.securityNoticeAcknowledged = acknowledged
|
||||
UserDefaults.standard.set(acknowledged, forKey: onboardingSecurityAcknowledgedKey)
|
||||
}
|
||||
|
||||
func finish() {
|
||||
UserDefaults.standard.set(true, forKey: onboardingSecurityAcknowledgedKey)
|
||||
UserDefaults.standard.set(true, forKey: "openclaw.onboardingSeen")
|
||||
UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey)
|
||||
OnboardingController.shared.close()
|
||||
|
||||
@ -4,7 +4,10 @@ import SwiftUI
|
||||
extension OnboardingView {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
GlowingOpenClawIcon(size: 130, glowIntensity: 0.28)
|
||||
Image(nsImage: NSApp.applicationIconImage)
|
||||
.resizable()
|
||||
.frame(width: 130, height: 130)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 130 * 0.22, style: .continuous))
|
||||
.offset(y: 10)
|
||||
.frame(height: 145)
|
||||
|
||||
@ -46,10 +49,6 @@ extension OnboardingView {
|
||||
self.currentPage = max(0, self.pageOrder.count - 1)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.onboardingWizard.isComplete) { _, newValue in
|
||||
guard newValue, self.activePageIndex == self.wizardPageIndex else { return }
|
||||
self.handleNext()
|
||||
}
|
||||
.onDisappear {
|
||||
self.stopPermissionMonitoring()
|
||||
self.stopDiscovery()
|
||||
@ -57,7 +56,7 @@ extension OnboardingView {
|
||||
}
|
||||
.task {
|
||||
await self.refreshPerms()
|
||||
self.refreshCLIStatus()
|
||||
await self.refreshCLIInstallerReadiness()
|
||||
await self.loadWorkspaceDefaults()
|
||||
await self.ensureDefaultWorkspace()
|
||||
self.refreshBootstrapStatus()
|
||||
@ -156,6 +155,16 @@ extension OnboardingView {
|
||||
.frame(width: self.pageWidth, alignment: .top)
|
||||
}
|
||||
|
||||
func onboardingFixedPage(@ViewBuilder _ content: () -> some View) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
content()
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.padding(.horizontal, 28)
|
||||
.frame(width: self.pageWidth, alignment: .top)
|
||||
}
|
||||
|
||||
func onboardingCard(
|
||||
spacing: CGFloat = 12,
|
||||
padding: CGFloat = 16,
|
||||
@ -166,10 +175,6 @@ extension OnboardingView {
|
||||
}
|
||||
.padding(padding)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color(NSColor.controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 3))
|
||||
}
|
||||
|
||||
func onboardingGlassCard(
|
||||
|
||||
@ -43,6 +43,11 @@ extension OnboardingView {
|
||||
self.updatePermissionMonitoring(for: pageIndex)
|
||||
self.updateDiscoveryMonitoring(for: pageIndex)
|
||||
self.maybeKickoffOnboardingChat(for: pageIndex)
|
||||
if pageIndex == self.cliPageIndex {
|
||||
Task { @MainActor in
|
||||
await self.refreshCLIInstallerReadiness()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopPermissionMonitoring() {
|
||||
@ -57,12 +62,20 @@ extension OnboardingView {
|
||||
|
||||
func installCLI() async {
|
||||
guard !self.installingCLI else { return }
|
||||
await self.refreshCLIInstallerReadiness()
|
||||
|
||||
if self.cliNeedsCommandLineTools {
|
||||
await self.requestCommandLineToolsInstall()
|
||||
return
|
||||
}
|
||||
|
||||
self.installingCLI = true
|
||||
defer { installingCLI = false }
|
||||
await CLIInstaller.install { message in
|
||||
self.cliStatus = message
|
||||
}
|
||||
self.refreshCLIStatus()
|
||||
await self.refreshCLIInstallerReadiness()
|
||||
}
|
||||
|
||||
func refreshCLIStatus() {
|
||||
@ -71,6 +84,29 @@ extension OnboardingView {
|
||||
self.cliInstalled = installLocation != nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func refreshCLIInstallerReadiness() async {
|
||||
self.refreshCLIStatus()
|
||||
|
||||
if self.cliInstalled {
|
||||
self.cliNeedsCommandLineTools = false
|
||||
self.cliPreflightStatus = nil
|
||||
return
|
||||
}
|
||||
|
||||
let preflight = await CLIInstaller.preflight()
|
||||
self.cliNeedsCommandLineTools = preflight.needsCommandLineTools
|
||||
self.cliPreflightStatus = preflight.message
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func requestCommandLineToolsInstall() async {
|
||||
await CLIInstaller.requestCommandLineToolsInstall { message in
|
||||
self.cliPreflightStatus = message
|
||||
}
|
||||
await self.refreshCLIInstallerReadiness()
|
||||
}
|
||||
|
||||
func refreshLocalGatewayProbe() async {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
let desc = await PortGuardian.shared.describe(port: port)
|
||||
|
||||
@ -33,7 +33,7 @@ extension OnboardingView {
|
||||
VStack(spacing: 22) {
|
||||
Text("Welcome to OpenClaw")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("OpenClaw is a powerful personal AI assistant that can connect to WhatsApp or Telegram.")
|
||||
Text("OpenClaw is a powerful personal AI assistant that connects to the apps you already use — WhatsApp, Telegram, Slack, and more.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@ -64,6 +64,36 @@ extension OnboardingView {
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color(NSColor.controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.06), radius: 8, y: 3))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.orange.opacity(0.06)))
|
||||
.frame(maxWidth: 520)
|
||||
|
||||
self.onboardingCard(spacing: 8, padding: 14) {
|
||||
if self.securityNoticeAcknowledged {
|
||||
Label("Security notice acknowledged on this Mac.", systemImage: "checkmark.shield.fill")
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(Color(nsColor: .systemGreen))
|
||||
} else {
|
||||
Toggle(
|
||||
isOn: Binding(
|
||||
get: { self.securityNoticeAcknowledged },
|
||||
set: { self.setSecurityNoticeAcknowledged($0) }))
|
||||
{
|
||||
Text("I understand the risks and want to continue.")
|
||||
.font(.callout.weight(.medium))
|
||||
}
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
Text("You only need to acknowledge this once on this Mac.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 520)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
@ -633,57 +663,92 @@ extension OnboardingView {
|
||||
self.onboardingPage {
|
||||
Text("Install the CLI")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Required for local mode: installs `openclaw` so launchd can run the gateway.")
|
||||
Text(
|
||||
self.cliNeedsCommandLineTools
|
||||
? "OpenClaw needs Apple Developer Tools first. Install those, then come back to install the CLI."
|
||||
: "Installs the OpenClaw command-line tool so the gateway can run in the background.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 520)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
self.onboardingCard(spacing: 10) {
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
Task { await self.installCLI() }
|
||||
} label: {
|
||||
let title = self.cliInstalled ? "Reinstall CLI" : "Install CLI"
|
||||
ZStack {
|
||||
Text(title)
|
||||
.opacity(self.installingCLI ? 0 : 1)
|
||||
if self.installingCLI {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
self.onboardingCard(spacing: 12) {
|
||||
Button {
|
||||
Task {
|
||||
if self.cliNeedsCommandLineTools {
|
||||
await self.requestCommandLineToolsInstall()
|
||||
} else {
|
||||
await self.installCLI()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
let title: String = if self.cliNeedsCommandLineTools {
|
||||
"Install Apple Developer Tools"
|
||||
} else if self.cliInstalled {
|
||||
"Reinstall CLI"
|
||||
} else {
|
||||
"Install CLI"
|
||||
}
|
||||
ZStack {
|
||||
Text(title)
|
||||
.opacity(self.installingCLI ? 0 : 1)
|
||||
if self.installingCLI {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.installingCLI)
|
||||
|
||||
if self.cliNeedsCommandLineTools {
|
||||
HStack(spacing: 10) {
|
||||
Button("I've Installed It, Recheck") {
|
||||
Task { await self.refreshCLIInstallerReadiness() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button("Open Software Update") {
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preferences.softwareupdate") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 120)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.installingCLI)
|
||||
|
||||
Button(self.copied ? "Copied" : "Copy install command") {
|
||||
self.copyToPasteboard(self.devLinkCommand)
|
||||
}
|
||||
.disabled(self.installingCLI)
|
||||
|
||||
if self.cliInstalled, let loc = self.cliInstallLocation {
|
||||
Label("Installed at \(loc)", systemImage: "checkmark.circle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.green)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
}
|
||||
|
||||
if let cliStatus {
|
||||
if self.cliInstalled, let loc = self.cliInstallLocation {
|
||||
Label("Installed at \(loc)", systemImage: "checkmark.circle.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
|
||||
if let cliPreflightStatus, self.cliNeedsCommandLineTools {
|
||||
Text(cliPreflightStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if let cliStatus {
|
||||
Text(cliStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
||||
Text(
|
||||
"""
|
||||
Installs a user-space Node 22+ runtime and the CLI (no Homebrew).
|
||||
Rerun anytime to reinstall or update.
|
||||
""")
|
||||
Text("Installs a user-space Node 22+ runtime (no Homebrew required).")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Text("Prefer to install manually?")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Button(self.copied ? "Copied" : "Copy install command") {
|
||||
self.copyToPasteboard(self.devLinkCommand)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.installingCLI)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,6 +59,7 @@ extension OnboardingView {
|
||||
_ = view.connectionPage()
|
||||
|
||||
view.currentPage = 0
|
||||
view.setSecurityNoticeAcknowledged(true)
|
||||
view.handleNext()
|
||||
view.handleBack()
|
||||
|
||||
|
||||
@ -4,11 +4,11 @@ import SwiftUI
|
||||
|
||||
extension OnboardingView {
|
||||
func wizardPage() -> some View {
|
||||
self.onboardingPage {
|
||||
self.onboardingFixedPage {
|
||||
VStack(spacing: 16) {
|
||||
Text("Setup Wizard")
|
||||
Text("Configure OpenClaw")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Follow the guided setup from the Gateway. This keeps onboarding in sync with the CLI.")
|
||||
Text("Follow the steps below to configure your AI provider and gateway.")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@ -193,6 +193,10 @@ final class OnboardingWizardModel {
|
||||
|
||||
private func shouldSkipWizard() -> Bool {
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
return Self.shouldSkipWizard(root: root)
|
||||
}
|
||||
|
||||
static func hasExistingLocalSetup(root: [String: Any]) -> Bool {
|
||||
if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty {
|
||||
return true
|
||||
}
|
||||
@ -217,6 +221,10 @@ final class OnboardingWizardModel {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func shouldSkipWizard(root: [String: Any]) -> Bool {
|
||||
Self.hasExistingLocalSetup(root: root)
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingWizardStepView: View {
|
||||
@ -254,17 +262,19 @@ struct OnboardingWizardStepView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if wizardStepType(self.step) == "select" {
|
||||
self.selectStepLayout
|
||||
} else {
|
||||
self.standardStepLayout
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var standardStepLayout: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let title = step.title, !title.isEmpty {
|
||||
Text(title)
|
||||
.font(.title2.weight(.semibold))
|
||||
}
|
||||
if let message = step.message, !message.isEmpty {
|
||||
Text(message)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
self.stepHeader
|
||||
|
||||
switch wizardStepType(self.step) {
|
||||
case "note":
|
||||
@ -274,8 +284,6 @@ struct OnboardingWizardStepView: View {
|
||||
case "confirm":
|
||||
Toggle("", isOn: self.$confirmValue)
|
||||
.toggleStyle(.switch)
|
||||
case "select":
|
||||
self.selectOptions
|
||||
case "multiselect":
|
||||
self.multiselectOptions
|
||||
case "progress":
|
||||
@ -288,14 +296,63 @@ struct OnboardingWizardStepView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button(action: self.submit) {
|
||||
Text(wizardStepType(self.step) == "action" ? "Run" : "Continue")
|
||||
.frame(minWidth: 120)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isSubmitting || self.isBlocked)
|
||||
self.primaryActionButton
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var selectStepLayout: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
self.stepHeader
|
||||
|
||||
ScrollView {
|
||||
self.selectOptions
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.frame(minHeight: 220, maxHeight: 320)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Selected: \(self.selectedOptionLabel)")
|
||||
.font(.subheadline.weight(.medium))
|
||||
if let hint = self.selectedOptionHint {
|
||||
Text(hint)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
self.primaryActionButton
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var stepHeader: some View {
|
||||
if let title = step.title, !title.isEmpty {
|
||||
Text(title)
|
||||
.font(.title2.weight(.semibold))
|
||||
}
|
||||
if let message = step.message, !message.isEmpty {
|
||||
Text(message)
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var primaryActionButton: some View {
|
||||
Button(action: self.submit) {
|
||||
Text(wizardStepType(self.step) == "action" ? "Run" : "Continue")
|
||||
.frame(minWidth: 120)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.isSubmitting || self.isBlocked)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@ -332,11 +389,12 @@ struct OnboardingWizardStepView: View {
|
||||
Button {
|
||||
self.selectedIndex = item.index
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.option.label)
|
||||
.font(.body.weight(self.selectedIndex == item.index ? .semibold : .regular))
|
||||
.foregroundStyle(.primary)
|
||||
if let hint = item.option.hint, !hint.isEmpty {
|
||||
Text(hint)
|
||||
@ -344,7 +402,10 @@ struct OnboardingWizardStepView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@ -381,6 +442,22 @@ struct OnboardingWizardStepView: View {
|
||||
return false
|
||||
}
|
||||
|
||||
private var selectedOptionLabel: String {
|
||||
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
||||
return "None"
|
||||
}
|
||||
return self.optionItems[self.selectedIndex].option.label
|
||||
}
|
||||
|
||||
private var selectedOptionHint: String? {
|
||||
guard self.optionItems.indices.contains(self.selectedIndex) else {
|
||||
return nil
|
||||
}
|
||||
let hint = self.optionItems[self.selectedIndex].option.hint?.trimmingCharacters(
|
||||
in: .whitespacesAndNewlines)
|
||||
return hint?.isEmpty == false ? hint : nil
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
switch wizardStepType(self.step) {
|
||||
case "note", "progress":
|
||||
|
||||
@ -27,6 +27,50 @@ struct OnboardingViewSmokeTests {
|
||||
#expect(!order.contains(8))
|
||||
}
|
||||
|
||||
@Test func `fresh installs require security acknowledgement before advancing`() {
|
||||
let defaults = UserDefaults.standard
|
||||
let previous = defaults.object(forKey: onboardingSecurityAcknowledgedKey)
|
||||
defaults.removeObject(forKey: onboardingSecurityAcknowledgedKey)
|
||||
defer {
|
||||
if let previous {
|
||||
defaults.set(previous, forKey: onboardingSecurityAcknowledgedKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: onboardingSecurityAcknowledgedKey)
|
||||
}
|
||||
}
|
||||
|
||||
let freshState = AppState(preview: true)
|
||||
freshState.onboardingSeen = false
|
||||
let freshView = OnboardingView(
|
||||
state: freshState,
|
||||
permissionMonitor: PermissionMonitor.shared,
|
||||
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
|
||||
|
||||
#expect(freshView.isSecurityNoticeBlocking)
|
||||
#expect(!freshView.canAdvance)
|
||||
|
||||
defaults.set(true, forKey: onboardingSecurityAcknowledgedKey)
|
||||
|
||||
let acknowledgedState = AppState(preview: true)
|
||||
acknowledgedState.onboardingSeen = false
|
||||
let acknowledgedView = OnboardingView(
|
||||
state: acknowledgedState,
|
||||
permissionMonitor: PermissionMonitor.shared,
|
||||
discoveryModel: GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName))
|
||||
|
||||
#expect(!acknowledgedView.isSecurityNoticeBlocking)
|
||||
#expect(acknowledgedView.canAdvance)
|
||||
}
|
||||
|
||||
@Test func `existing onboarded users keep their acknowledgement`() {
|
||||
#expect(OnboardingView.resolveSecurityNoticeAcknowledged(
|
||||
onboardingSeen: true,
|
||||
storedAcknowledgement: false))
|
||||
#expect(!OnboardingView.resolveSecurityNoticeAcknowledged(
|
||||
onboardingSeen: false,
|
||||
storedAcknowledgement: false))
|
||||
}
|
||||
|
||||
@Test func `select remote gateway clears stale ssh target when endpoint unresolved`() async {
|
||||
let override = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-config-\(UUID().uuidString)")
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized)
|
||||
@MainActor
|
||||
struct OnboardingWizardModelTests {
|
||||
@Test func `skip wizard for legacy gateway auth config`() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"auth": [
|
||||
"token": "legacy-token",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
#expect(OnboardingWizardModel.shouldSkipWizard(root: root))
|
||||
}
|
||||
|
||||
@Test func `do not skip wizard for empty config`() {
|
||||
#expect(OnboardingWizardModel.shouldSkipWizard(root: [:]) == false)
|
||||
}
|
||||
|
||||
@Test func `node mode keeps connecting for configured installs after onboarding refresh`() {
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"auth": [
|
||||
"token": "legacy-token",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
#expect(
|
||||
MacNodeModeCoordinator.shouldConnectNodeMode(
|
||||
onboardingSeen: true,
|
||||
onboardingVersion: currentOnboardingVersion - 1,
|
||||
root: root))
|
||||
#expect(
|
||||
MacNodeModeCoordinator.shouldConnectNodeMode(
|
||||
onboardingSeen: false,
|
||||
onboardingVersion: 0,
|
||||
root: root))
|
||||
}
|
||||
|
||||
@Test func `node mode blocks truly unconfigured installs until onboarding is current`() {
|
||||
#expect(
|
||||
MacNodeModeCoordinator.shouldConnectNodeMode(
|
||||
onboardingSeen: false,
|
||||
onboardingVersion: 0,
|
||||
root: [:]) == false)
|
||||
#expect(
|
||||
MacNodeModeCoordinator.shouldConnectNodeMode(
|
||||
onboardingSeen: true,
|
||||
onboardingVersion: currentOnboardingVersion,
|
||||
root: [:]))
|
||||
}
|
||||
}
|
||||
@ -28,7 +28,7 @@ export async function promptAuthChoiceGrouped(params: {
|
||||
];
|
||||
|
||||
const providerSelection = (await params.prompter.select({
|
||||
message: "Model/auth provider",
|
||||
message: "Choose how you want to connect.",
|
||||
options: providerOptions,
|
||||
})) as string;
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user