Improve macOS onboarding UX and gateway setup

This commit is contained in:
ImLukeF
2026-03-15 22:55:59 +11:00
parent ab1da26f4d
commit 49c4ff67d4
13 changed files with 338 additions and 99 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

@@ -14,6 +14,10 @@ enum CronCustomSessionTarget: Codable, Equatable {
case predefined(CronSessionTarget)
case session(id: String)
static let main: CronCustomSessionTarget = .predefined(.main)
static let isolated: CronCustomSessionTarget = .predefined(.isolated)
static let current: CronCustomSessionTarget = .predefined(.current)
var rawValue: String {
switch self {
case .predefined(let target):

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,20 @@ final class MacNodeModeCoordinator {
continue
}
let onboardingComplete = Self.shouldConnectNodeMode(
onboardingSeen: defaults.bool(forKey: onboardingSeenKey),
onboardingVersion: defaults.integer(forKey: onboardingVersionKey))
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 +131,10 @@ final class MacNodeModeCoordinator {
}
}
static func shouldConnectNodeMode(onboardingSeen: Bool, onboardingVersion: Int) -> Bool {
onboardingSeen && onboardingVersion >= currentOnboardingVersion
}
private func currentCaps() -> [String] {
var caps: [String] = [OpenClawCapability.canvas.rawValue, OpenClawCapability.screen.rawValue]
if OpenClawConfigFile.browserControlEnabled() {

View File

@@ -67,6 +67,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
@@ -97,6 +99,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 +116,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]
}
}
@@ -146,7 +149,10 @@ struct OnboardingView: View {
}
var canAdvance: Bool {
!self.isWizardBlocking
if self.activePageIndex == self.cliPageIndex {
return self.cliInstalled && !self.installingCLI
}
return !self.isWizardBlocking
}
var devLinkCommand: String {

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,13 @@ 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)
}
.padding(.top, 16)
@@ -633,57 +640,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

@@ -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,28 +193,13 @@ final class OnboardingWizardModel {
private func shouldSkipWizard() -> Bool {
let root = OpenClawConfigFile.loadDict()
return Self.shouldSkipWizard(root: root)
}
static func shouldSkipWizard(root: [String: Any]) -> Bool {
if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty {
return true
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any]
{
if let mode = auth["mode"] as? String,
!mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let token = auth["token"] as? String,
!token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
if let password = auth["password"] as? String,
!password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
return true
}
}
return false
}
}
@@ -254,17 +239,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 +261,6 @@ struct OnboardingWizardStepView: View {
case "confirm":
Toggle("", isOn: self.$confirmValue)
.toggleStyle(.switch)
case "select":
self.selectOptions
case "multiselect":
self.multiselectOptions
case "progress":
@@ -288,14 +273,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 +366,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 +379,10 @@ struct OnboardingWizardStepView: View {
.foregroundStyle(.secondary)
}
}
Spacer(minLength: 0)
}
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@@ -381,6 +419,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

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