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