Compare commits

...

4 Commits

17 changed files with 502 additions and 81 deletions

View File

@ -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()

View File

@ -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"

View File

@ -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",

View File

@ -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
}

View File

@ -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

View File

@ -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() {

View File

@ -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",

View File

@ -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()

View File

@ -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(

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -59,6 +59,7 @@ extension OnboardingView {
_ = view.connectionPage()
view.currentPage = 0
view.setSecurityNoticeAcknowledged(true)
view.handleNext()
view.handleBack()

View File

@ -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)

View File

@ -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":

View File

@ -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)")

View File

@ -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: [:]))
}
}

View File

@ -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;