From 45c34558c4a7c2a6dd15c68dc9f658e8be3d762b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 05:32:29 +0000 Subject: [PATCH] feat(mac): add simplified Easy Setup wizard for first-time users Adds a streamlined 4-step onboarding flow for non-technical users: - Welcome page with feature overview - One-click OAuth sign-in with auto-clipboard detection - Simplified permissions page (core permissions only) - Auto-setup of gateway, CLI, and workspace The Easy Setup is now shown by default on first launch. Power users can click "Advanced Setup" to access the full onboarding wizard with gateway mode selection, remote configuration, etc. This makes the DMG install experience much simpler for laymen who just want to download and go. --- .../Sources/Clawdbot/EasySetupView.swift | 678 ++++++++++++++++++ apps/macos/Sources/Clawdbot/MenuBar.swift | 3 +- 2 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 apps/macos/Sources/Clawdbot/EasySetupView.swift diff --git a/apps/macos/Sources/Clawdbot/EasySetupView.swift b/apps/macos/Sources/Clawdbot/EasySetupView.swift new file mode 100644 index 000000000..3516adc09 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/EasySetupView.swift @@ -0,0 +1,678 @@ +import AppKit +import ClawdbotIPC +import Combine +import Observation +import SwiftUI + +/// A simplified onboarding flow for first-time users. +/// Four simple steps: Welcome → Sign In → Permissions → Done +/// All technical details (gateway, CLI, workspace) are handled automatically. + +@MainActor +final class EasySetupController { + static let shared = EasySetupController() + private var window: NSWindow? + + func show() { + if ProcessInfo.processInfo.isNixMode { + UserDefaults.standard.set(true, forKey: "clawdbot.onboardingSeen") + UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) + AppStateStore.shared.onboardingSeen = true + return + } + if let window { + DockIconManager.shared.temporarilyShowDock() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + let hosting = NSHostingController(rootView: EasySetupView()) + let window = NSWindow(contentViewController: hosting) + window.title = "Welcome to Clawdbot" + window.setContentSize(NSSize(width: EasySetupView.windowWidth, height: EasySetupView.windowHeight)) + window.styleMask = [.titled, .closable, .fullSizeContentView] + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.isMovableByWindowBackground = true + window.center() + DockIconManager.shared.temporarilyShowDock() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + self.window = window + } + + func close() { + self.window?.close() + self.window = nil + } + + func switchToAdvanced() { + self.close() + OnboardingController.shared.show() + } +} + +struct EasySetupView: View { + @State private var currentPage = 0 + @State private var isSettingUp = false + @State private var setupStatus: String = "" + @State private var setupComplete = false + @State private var setupError: String? + + // OAuth state + @State private var oauthPKCE: AnthropicOAuth.PKCE? + @State private var oauthCode: String = "" + @State private var oauthBusy = false + @State private var oauthConnected = false + @State private var oauthVerified = false + @State private var lastPasteboardCount = NSPasteboard.general.changeCount + + @Bindable var state: AppState + var permissionMonitor: PermissionMonitor + + static let windowWidth: CGFloat = 540 + static let windowHeight: CGFloat = 620 + static let clipboardPoll: AnyPublisher = { + if ProcessInfo.processInfo.isRunningTests { + return Empty(completeImmediately: false).eraseToAnyPublisher() + } + return Timer.publish(every: 0.5, on: .main, in: .common) + .autoconnect() + .eraseToAnyPublisher() + }() + + // Core permissions to show (subset of all capabilities for simplified view) + private static let corePermissions: [Capability] = [ + .accessibility, + .screenRecording, + .microphone, + .notifications, + ] + + init( + state: AppState = AppStateStore.shared, + permissionMonitor: PermissionMonitor = .shared + ) { + self.state = state + self.permissionMonitor = permissionMonitor + } + + private let pageCount = 4 + + var body: some View { + VStack(spacing: 0) { + GlowingClawdbotIcon(size: 100, glowIntensity: 0.25) + .frame(height: 130) + + GeometryReader { _ in + HStack(spacing: 0) { + self.welcomePage.frame(width: Self.windowWidth) + self.signInPage.frame(width: Self.windowWidth) + self.permissionsPage.frame(width: Self.windowWidth) + self.donePage.frame(width: Self.windowWidth) + } + .offset(x: CGFloat(-self.currentPage) * Self.windowWidth) + .animation(.spring(response: 0.45, dampingFraction: 0.85), value: self.currentPage) + } + + Spacer(minLength: 0) + self.navigationBar + } + .frame(width: Self.windowWidth, height: Self.windowHeight) + .background(Color(NSColor.windowBackgroundColor)) + .task { + await self.checkExistingAuth() + await self.permissionMonitor.refreshNow() + } + } + + // MARK: - Pages + + private var welcomePage: some View { + self.pageContainer { + VStack(spacing: 20) { + Text("Welcome to Clawdbot") + .font(.largeTitle.weight(.semibold)) + + Text("Your personal AI assistant for Mac") + .font(.title3) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 16) { + self.featureItem( + icon: "message.fill", + title: "Chat anywhere", + description: "Message your AI via WhatsApp, Telegram, or right from the menu bar" + ) + self.featureItem( + icon: "waveform", + title: "Voice commands", + description: "Talk to your AI hands-free with Voice Wake" + ) + self.featureItem( + icon: "sparkles", + title: "Mac automation", + description: "Let AI help with tasks, files, and apps on your Mac" + ) + } + .padding(.top, 8) + + Text("Setup takes about 2 minutes") + .font(.callout) + .foregroundStyle(.tertiary) + .padding(.top, 12) + + Button("Advanced Setup") { + EasySetupController.shared.switchToAdvanced() + } + .buttonStyle(.link) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + + private var signInPage: some View { + self.pageContainer { + VStack(spacing: 20) { + Text("Sign in with Claude") + .font(.largeTitle.weight(.semibold)) + + Text("Connect your Anthropic account to power your AI assistant") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 400) + + VStack(spacing: 16) { + // Status indicator + HStack(spacing: 10) { + Circle() + .fill(self.oauthVerified ? Color.green : (self.oauthConnected ? Color.orange : Color.gray)) + .frame(width: 12, height: 12) + Text(self.oauthStatusText) + .font(.headline) + Spacer() + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor)) + ) + + if !self.oauthConnected { + Button { + self.startOAuth() + } label: { + HStack { + if self.oauthBusy { + ProgressView() + .controlSize(.small) + .padding(.trailing, 4) + } + Text(self.oauthBusy ? "Opening browser..." : "Sign in with Anthropic") + } + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .disabled(self.oauthBusy) + + if self.oauthPKCE != nil { + VStack(spacing: 12) { + Text("Paste the code from your browser:") + .font(.callout) + .foregroundStyle(.secondary) + + HStack { + TextField("code#state", text: self.$oauthCode) + .textFieldStyle(.roundedBorder) + + Button("Connect") { + Task { await self.finishOAuth() } + } + .disabled(self.oauthCode.isEmpty || self.oauthBusy) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor).opacity(0.5)) + ) + .onReceive(Self.clipboardPoll) { _ in + self.checkClipboard() + } + } + } else if !self.oauthVerified { + Button("Verify Connection") { + Task { await self.verifyOAuth() } + } + .buttonStyle(.bordered) + .disabled(self.oauthBusy) + } + } + .padding(.top, 8) + + Spacer(minLength: 0) + + Text("Your credentials are stored securely on your Mac") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + + private var permissionsPage: some View { + self.pageContainer { + VStack(spacing: 20) { + Text("Mac Permissions") + .font(.largeTitle.weight(.semibold)) + + Text("Grant access so Clawdbot can help you with tasks on your Mac") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 400) + + VStack(spacing: 12) { + ForEach(Self.corePermissions, id: \.self) { cap in + self.permissionRow(cap) + } + } + .padding() + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color(NSColor.controlBackgroundColor)) + ) + + Button("Refresh Permissions") { + Task { await self.permissionMonitor.refreshNow() } + } + .buttonStyle(.link) + + Text("You can change these anytime in System Settings") + .font(.caption) + .foregroundStyle(.tertiary) + } + } + } + + private var donePage: some View { + self.pageContainer { + VStack(spacing: 20) { + if self.setupComplete { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 60)) + .foregroundStyle(.green) + + Text("You're all set!") + .font(.largeTitle.weight(.semibold)) + + Text("Clawdbot is ready to help. Look for the icon in your menu bar.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 400) + + VStack(alignment: .leading, spacing: 16) { + self.nextStepItem( + icon: "bubble.left.and.bubble.right", + title: "Open the menu bar", + description: "Click the Clawdbot icon to start chatting" + ) + self.nextStepItem( + icon: "link", + title: "Connect messaging apps", + description: "Link WhatsApp or Telegram in Settings → Channels" + ) + self.nextStepItem( + icon: "waveform.circle", + title: "Try Voice Wake", + description: "Enable hands-free commands in Settings" + ) + } + .padding(.top, 8) + + Toggle("Launch Clawdbot at login", isOn: self.$state.launchAtLogin) + .onChange(of: self.state.launchAtLogin) { _, newValue in + AppStateStore.updateLaunchAtLogin(enabled: newValue) + } + .padding(.top, 8) + } else if self.isSettingUp { + ProgressView() + .controlSize(.large) + .padding(.bottom, 16) + + Text("Setting up Clawdbot...") + .font(.title2.weight(.semibold)) + + Text(self.setupStatus) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } else if let error = self.setupError { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 50)) + .foregroundStyle(.orange) + + Text("Setup encountered an issue") + .font(.title2.weight(.semibold)) + + Text(error) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 400) + + Button("Try Again") { + Task { await self.performSetup() } + } + .buttonStyle(.borderedProminent) + } else { + Text("Almost there!") + .font(.largeTitle.weight(.semibold)) + + Text("Click Finish to complete the setup") + .font(.body) + .foregroundStyle(.secondary) + } + } + } + } + + // MARK: - Navigation + + private var navigationBar: some View { + HStack(spacing: 20) { + // Back button + if self.currentPage > 0 && !self.isSettingUp { + Button { + withAnimation { self.currentPage -= 1 } + } label: { + Label("Back", systemImage: "chevron.left") + .labelStyle(.iconOnly) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } else { + Spacer().frame(width: 40) + } + + Spacer() + + // Page dots + HStack(spacing: 8) { + ForEach(0.. some View) -> some View { + ScrollView { + VStack(spacing: 16) { + content() + Spacer(minLength: 0) + } + .padding(.horizontal, 32) + .padding(.top, 8) + } + } + + private func featureItem(icon: String, title: String, description: String) -> some View { + HStack(alignment: .top, spacing: 14) { + Image(systemName: icon) + .font(.title2) + .foregroundStyle(Color.accentColor) + .frame(width: 32) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline) + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } + + private func nextStepItem(icon: String, title: String, description: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: icon) + .font(.title3) + .foregroundStyle(Color.accentColor) + .frame(width: 26) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.callout.weight(.semibold)) + Text(description) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + } + + private func permissionRow(_ cap: Capability) -> some View { + let granted = self.permissionMonitor.status[cap] ?? false + return HStack(spacing: 12) { + Image(systemName: granted ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundStyle(granted ? .green : .secondary) + + VStack(alignment: .leading, spacing: 2) { + Text(self.permissionTitle(for: cap)) + .font(.callout.weight(.medium)) + Text(self.permissionDescription(for: cap)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + if !granted { + Button("Enable") { + Task { await self.requestPermission(cap) } + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.vertical, 4) + } + + private func permissionTitle(for cap: Capability) -> String { + switch cap { + case .appleScript: "Automation" + case .notifications: "Notifications" + case .accessibility: "Accessibility" + case .screenRecording: "Screen Recording" + case .microphone: "Microphone" + case .speechRecognition: "Speech Recognition" + case .camera: "Camera" + case .location: "Location" + } + } + + private func permissionDescription(for cap: Capability) -> String { + switch cap { + case .appleScript: "Control other apps for automation" + case .notifications: "Show alerts for AI activity" + case .accessibility: "Control apps and type text" + case .screenRecording: "See what's on your screen" + case .microphone: "Listen for voice commands" + case .speechRecognition: "Transcribe voice on-device" + case .camera: "Take photos and video" + case .location: "Know your location" + } + } + + private var oauthStatusText: String { + if self.oauthVerified { return "Connected to Claude" } + if self.oauthConnected { return "Verifying connection..." } + return "Not connected" + } + + // MARK: - OAuth + + private func checkExistingAuth() async { + let status = ClawdbotOAuthStore.anthropicOAuthStatus() + if status == .valid { + self.oauthConnected = true + self.oauthVerified = true + } + } + + private func startOAuth() { + self.oauthBusy = true + Task { + defer { self.oauthBusy = false } + do { + let pkce = try await AnthropicOAuth.startOAuth() + self.oauthPKCE = pkce + } catch { + self.oauthPKCE = nil + } + } + } + + private func finishOAuth() async { + guard let pkce = self.oauthPKCE else { return } + self.oauthBusy = true + defer { self.oauthBusy = false } + + do { + try await AnthropicOAuth.finishOAuth(code: self.oauthCode, pkce: pkce) + self.oauthConnected = true + self.oauthPKCE = nil + self.oauthCode = "" + await self.verifyOAuth() + } catch { + // Keep PKCE for retry + } + } + + private func verifyOAuth() async { + self.oauthBusy = true + defer { self.oauthBusy = false } + + let status = ClawdbotOAuthStore.anthropicOAuthStatus() + self.oauthVerified = status == .valid + } + + private func checkClipboard() { + let count = NSPasteboard.general.changeCount + guard count != self.lastPasteboardCount else { return } + self.lastPasteboardCount = count + + guard let content = NSPasteboard.general.string(forType: .string) else { return } + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + + // Check if it looks like an OAuth code#state + if trimmed.contains("#"), trimmed.count > 20, trimmed.count < 500 { + self.oauthCode = trimmed + // Auto-connect if code detected + Task { await self.finishOAuth() } + } + } + + // MARK: - Permissions + + private func requestPermission(_ cap: Capability) async { + _ = await PermissionManager.ensure([cap], interactive: true) + try? await Task.sleep(for: .milliseconds(500)) + await self.permissionMonitor.refreshNow() + } + + // MARK: - Setup + + private func performSetup() async { + self.isSettingUp = true + self.setupError = nil + + // Step 1: Configure local mode + self.setupStatus = "Configuring local gateway..." + self.state.connectionMode = .local + GatewayProcessManager.shared.setActive(true) + + // Step 2: Install CLI (in background, non-blocking) + self.setupStatus = "Installing command-line tools..." + await self.installCLIQuietly() + + // Step 3: Wait for gateway + self.setupStatus = "Starting AI gateway..." + let gatewayReady = await GatewayProcessManager.shared.waitForGatewayReady(timeout: 30) + if !gatewayReady { + self.setupError = "Could not start the AI gateway. Try restarting the app." + self.isSettingUp = false + return + } + + // Step 4: Create workspace + self.setupStatus = "Creating workspace..." + await self.createDefaultWorkspace() + + // Step 5: Mark onboarding complete + self.setupStatus = "Finishing up..." + UserDefaults.standard.set(true, forKey: "clawdbot.onboardingSeen") + UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) + AppStateStore.shared.onboardingSeen = true + + self.isSettingUp = false + self.setupComplete = true + } + + private func installCLIQuietly() async { + // Run CLI install silently - don't block setup on it + await CLIInstaller.install { _ in } + } + + private func createDefaultWorkspace() async { + let workspaceURL = ClawdbotConfigFile.defaultWorkspaceURL() + _ = AgentWorkspace.bootstrapWorkspace(at: workspaceURL, options: .createIfMissing) + } + + private func finishSetup() { + EasySetupController.shared.close() + } +} diff --git a/apps/macos/Sources/Clawdbot/MenuBar.swift b/apps/macos/Sources/Clawdbot/MenuBar.swift index 26a467e91..6ef810f8d 100644 --- a/apps/macos/Sources/Clawdbot/MenuBar.swift +++ b/apps/macos/Sources/Clawdbot/MenuBar.swift @@ -322,7 +322,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let shouldShow = seenVersion < currentOnboardingVersion || !AppStateStore.shared.onboardingSeen guard shouldShow else { return } DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - OnboardingController.shared.show() + // Use simplified Easy Setup for first-time users by default + EasySetupController.shared.show() } }