import AppKit import ClawdbotChatUI import ClawdbotDiscovery import ClawdbotIPC import SwiftUI extension OnboardingView { @ViewBuilder func pageView(for pageIndex: Int) -> some View { switch pageIndex { case 0: self.welcomePage() case 1: self.connectionPage() case 2: self.anthropicAuthPage() case 3: self.wizardPage() case 5: self.permissionsPage() case 6: self.cliPage() case 8: self.onboardingChatPage() case 9: self.readyPage() default: EmptyView() } } func welcomePage() -> some View { self.onboardingPage { VStack(spacing: 22) { Text("Welcome to Clawdbot") .font(.largeTitle.weight(.semibold)) Text("Clawdbot is a powerful personal AI assistant that can connect to WhatsApp or Telegram.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .lineLimit(2) .frame(maxWidth: 560) .fixedSize(horizontal: false, vertical: true) self.onboardingCard(spacing: 10, padding: 14) { HStack(alignment: .top, spacing: 12) { Image(systemName: "exclamationmark.triangle.fill") .font(.title3.weight(.semibold)) .foregroundStyle(Color(nsColor: .systemOrange)) .frame(width: 22) .padding(.top, 1) VStack(alignment: .leading, spacing: 6) { Text("Security notice") .font(.headline) Text( "The connected AI agent (e.g. Claude) can trigger powerful actions on your Mac, " + "including running commands, reading/writing files, and capturing screenshots — " + "depending on the permissions you grant.\n\n" + "Only enable Clawdbot if you understand the risks and trust the prompts and " + "integrations you use.") .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } } .frame(maxWidth: 520) } .padding(.top, 16) } } func connectionPage() -> some View { self.onboardingPage { Text("Choose your Gateway") .font(.largeTitle.weight(.semibold)) Text( "Clawdbot uses a single Gateway that stays running. Pick this Mac, " + "connect to a discovered bridge nearby for pairing, or configure later.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .lineLimit(2) .frame(maxWidth: 520) .fixedSize(horizontal: false, vertical: true) self.onboardingCard(spacing: 12, padding: 14) { VStack(alignment: .leading, spacing: 10) { let localSubtitle: String = { guard let probe = self.localGatewayProbe else { return "Gateway starts automatically on this Mac." } let base = probe.expected ? "Existing gateway detected" : "Port \(probe.port) already in use" let command = probe.command.isEmpty ? "" : " (\(probe.command) pid \(probe.pid))" return "\(base)\(command). Will attach." }() self.connectionChoiceButton( title: "This Mac", subtitle: localSubtitle, selected: self.state.connectionMode == .local) { self.selectLocalGateway() } Divider().padding(.vertical, 4) HStack(spacing: 8) { Image(systemName: "dot.radiowaves.left.and.right") .font(.caption) .foregroundStyle(.secondary) Text(self.gatewayDiscovery.statusText) .font(.caption) .foregroundStyle(.secondary) if self.gatewayDiscovery.gateways.isEmpty { ProgressView().controlSize(.small) Button("Refresh") { self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0) } .buttonStyle(.link) .help("Retry Tailscale discovery (DNS-SD).") } Spacer(minLength: 0) } if self.gatewayDiscovery.gateways.isEmpty { Text("Searching for nearby bridges…") .font(.caption) .foregroundStyle(.secondary) .padding(.leading, 4) } else { VStack(alignment: .leading, spacing: 6) { Text("Nearby bridges (pairing only)") .font(.caption) .foregroundStyle(.secondary) .padding(.leading, 4) ForEach(self.gatewayDiscovery.gateways.prefix(6)) { gateway in self.connectionChoiceButton( title: gateway.displayName, subtitle: self.gatewaySubtitle(for: gateway), selected: self.isSelectedGateway(gateway)) { self.selectRemoteGateway(gateway) } } } .padding(8) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(Color(NSColor.controlBackgroundColor))) } self.connectionChoiceButton( title: "Configure later", subtitle: "Don’t start the Gateway yet.", selected: self.state.connectionMode == .unconfigured) { self.selectUnconfiguredGateway() } Button(self.showAdvancedConnection ? "Hide Advanced" : "Advanced…") { withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { self.showAdvancedConnection.toggle() } if self.showAdvancedConnection, self.state.connectionMode != .remote { self.state.connectionMode = .remote } } .buttonStyle(.link) if self.showAdvancedConnection { let labelWidth: CGFloat = 110 let fieldWidth: CGFloat = 320 VStack(alignment: .leading, spacing: 10) { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) { GridRow { Text("SSH target") .font(.callout.weight(.semibold)) .frame(width: labelWidth, alignment: .leading) TextField("user@host[:port]", text: self.$state.remoteTarget) .textFieldStyle(.roundedBorder) .frame(width: fieldWidth) } GridRow { Text("Identity file") .font(.callout.weight(.semibold)) .frame(width: labelWidth, alignment: .leading) TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity) .textFieldStyle(.roundedBorder) .frame(width: fieldWidth) } GridRow { Text("Project root") .font(.callout.weight(.semibold)) .frame(width: labelWidth, alignment: .leading) TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot) .textFieldStyle(.roundedBorder) .frame(width: fieldWidth) } GridRow { Text("CLI path") .font(.callout.weight(.semibold)) .frame(width: labelWidth, alignment: .leading) TextField( "/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath) .textFieldStyle(.roundedBorder) .frame(width: fieldWidth) } } Text("Tip: keep Tailscale enabled so your gateway stays reachable.") .font(.footnote) .foregroundStyle(.secondary) .lineLimit(1) } .transition(.opacity.combined(with: .move(edge: .top))) } } } } } func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { if let host = gateway.tailnetDns ?? gateway.lanHost { let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : "" return "\(host)\(portSuffix)" } return "Bridge pairing only" } func isSelectedGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool { guard self.state.connectionMode == .remote else { return false } let preferred = self.preferredGatewayID ?? BridgeDiscoveryPreferences.preferredStableID() return preferred == gateway.stableID } func connectionChoiceButton( title: String, subtitle: String?, selected: Bool, action: @escaping () -> Void) -> some View { Button { withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) { action() } } label: { HStack(alignment: .center, spacing: 10) { VStack(alignment: .leading, spacing: 2) { Text(title) .font(.callout.weight(.semibold)) .lineLimit(1) .truncationMode(.tail) if let subtitle { Text(subtitle) .font(.caption.monospaced()) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) } } Spacer(minLength: 0) if selected { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Color.accentColor) } else { Image(systemName: "arrow.right.circle") .foregroundStyle(.secondary) } } .padding(.horizontal, 10) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(selected ? Color.accentColor.opacity(0.12) : Color.clear)) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .strokeBorder( selected ? Color.accentColor.opacity(0.45) : Color.clear, lineWidth: 1)) } .buttonStyle(.plain) } func anthropicAuthPage() -> some View { self.onboardingPage { Text("Connect Claude") .font(.largeTitle.weight(.semibold)) Text("Give your model the token it needs!") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 540) .fixedSize(horizontal: false, vertical: true) Text("Clawdbot supports any model — we strongly recommend Opus 4.5 for the best experience.") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 540) .fixedSize(horizontal: false, vertical: true) self.onboardingCard(spacing: 12, padding: 16) { HStack(alignment: .center, spacing: 10) { Circle() .fill(self.anthropicAuthVerified ? Color.green : Color.orange) .frame(width: 10, height: 10) Text( self.anthropicAuthConnected ? (self.anthropicAuthVerified ? "Claude connected (OAuth) — verified" : "Claude connected (OAuth)") : "Not connected yet") .font(.headline) Spacer() } if self.anthropicAuthConnected, self.anthropicAuthVerifying { Text("Verifying OAuth…") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } else if !self.anthropicAuthConnected { Text(self.anthropicAuthDetectedStatus.shortDescription) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } else if self.anthropicAuthVerified, let date = self.anthropicAuthVerifiedAt { Text("Detected working OAuth (\(date.formatted(date: .abbreviated, time: .shortened))).") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Text( "This lets Clawdbot use Claude immediately. Credentials are stored at " + "`~/.clawdbot/credentials/oauth.json` (owner-only).") .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 12) { Text(ClawdbotOAuthStore.oauthURL().path) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) Spacer() Button("Reveal") { NSWorkspace.shared.activateFileViewerSelecting([ClawdbotOAuthStore.oauthURL()]) } .buttonStyle(.bordered) Button("Refresh") { self.refreshAnthropicOAuthStatus() } .buttonStyle(.bordered) } Divider().padding(.vertical, 2) HStack(spacing: 12) { if !self.anthropicAuthVerified { if self.anthropicAuthConnected { Button("Verify") { Task { await self.verifyAnthropicOAuthIfNeeded(force: true) } } .buttonStyle(.borderedProminent) .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying) if self.anthropicAuthVerificationFailed { Button("Re-auth (OAuth)") { self.startAnthropicOAuth() } .buttonStyle(.bordered) .disabled(self.anthropicAuthBusy || self.anthropicAuthVerifying) } } else { Button { self.startAnthropicOAuth() } label: { if self.anthropicAuthBusy { ProgressView() } else { Text("Open Claude sign-in (OAuth)") } } .buttonStyle(.borderedProminent) .disabled(self.anthropicAuthBusy) } } } if !self.anthropicAuthVerified, self.anthropicAuthPKCE != nil { VStack(alignment: .leading, spacing: 8) { Text("Paste the `code#state` value") .font(.headline) TextField("code#state", text: self.$anthropicAuthCode) .textFieldStyle(.roundedBorder) Toggle("Auto-detect from clipboard", isOn: self.$anthropicAuthAutoDetectClipboard) .font(.caption) .foregroundStyle(.secondary) .disabled(self.anthropicAuthBusy) Toggle("Auto-connect when detected", isOn: self.$anthropicAuthAutoConnectClipboard) .font(.caption) .foregroundStyle(.secondary) .disabled(self.anthropicAuthBusy) Button("Connect") { Task { await self.finishAnthropicOAuth() } } .buttonStyle(.bordered) .disabled( self.anthropicAuthBusy || self.anthropicAuthCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) } .onReceive(Self.clipboardPoll) { _ in self.pollAnthropicClipboardIfNeeded() } } self.onboardingCard(spacing: 8, padding: 12) { Text("API key (advanced)") .font(.headline) Text( "You can also use an Anthropic API key, but this UI is instructions-only for now " + "(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).") .font(.subheadline) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } .shadow(color: .clear, radius: 0) .background(Color.clear) if let status = self.anthropicAuthStatus { Text(status) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } } .task { await self.verifyAnthropicOAuthIfNeeded() } } func permissionsPage() -> some View { self.onboardingPage { Text("Grant permissions") .font(.largeTitle.weight(.semibold)) Text("These macOS permissions let Clawdbot automate apps and capture context on this Mac.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 520) .fixedSize(horizontal: false, vertical: true) self.onboardingCard(spacing: 8, padding: 12) { ForEach(Capability.allCases, id: \.self) { cap in PermissionRow( capability: cap, status: self.permissionMonitor.status[cap] ?? false, compact: true) { Task { await self.request(cap) } } } HStack(spacing: 12) { Button { Task { await self.refreshPerms() } } label: { Label("Refresh", systemImage: "arrow.clockwise") } .buttonStyle(.bordered) .controlSize(.small) .help("Refresh status") if self.isRequesting { ProgressView() .controlSize(.small) } } .padding(.top, 4) } } } func cliPage() -> some View { self.onboardingPage { Text("Install the CLI") .font(.largeTitle.weight(.semibold)) Text("Required for local mode: installs `clawdbot` so launchd can run the gateway.") .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) } } .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) } } 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. """) .font(.footnote) .foregroundStyle(.secondary) } } } } func workspacePage() -> some View { self.onboardingPage { Text("Agent workspace") .font(.largeTitle.weight(.semibold)) Text( "Clawdbot runs the agent from a dedicated workspace so it can load `AGENTS.md` " + "and write files there without mixing into your other projects.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 560) .fixedSize(horizontal: false, vertical: true) self.onboardingCard(spacing: 10) { if self.state.connectionMode == .remote { Text("Remote gateway detected") .font(.headline) Text( "Create the workspace on the remote host (SSH in first). " + "The macOS app can’t write files on your gateway over SSH yet.") .font(.subheadline) .foregroundStyle(.secondary) Button(self.copied ? "Copied" : "Copy setup command") { self.copyToPasteboard(self.workspaceBootstrapCommand) } .buttonStyle(.bordered) } else { VStack(alignment: .leading, spacing: 8) { Text("Workspace folder") .font(.headline) TextField( AgentWorkspace.displayPath(for: ClawdbotConfigFile.defaultWorkspaceURL()), text: self.$workspacePath) .textFieldStyle(.roundedBorder) HStack(spacing: 12) { Button { Task { await self.applyWorkspace() } } label: { if self.workspaceApplying { ProgressView() } else { Text("Create workspace") } } .buttonStyle(.borderedProminent) .disabled(self.workspaceApplying) Button("Open folder") { let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) NSWorkspace.shared.open(url) } .buttonStyle(.bordered) .disabled(self.workspaceApplying) Button("Save in config") { Task { let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) let saved = await self.saveAgentWorkspace(AgentWorkspace.displayPath(for: url)) if saved { self.workspaceStatus = "Saved to ~/.clawdbot/clawdbot.json (agents.defaults.workspace)" } } } .buttonStyle(.bordered) .disabled(self.workspaceApplying) } } if let workspaceStatus { Text(workspaceStatus) .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } else { Text( "Tip: edit AGENTS.md in this folder to shape the assistant’s behavior. " + "For backup, make the workspace a private git repo so your agent’s " + "“memory” is versioned.") .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) } } } } } func onboardingChatPage() -> some View { VStack(spacing: 16) { Text("Meet your agent") .font(.largeTitle.weight(.semibold)) Text( "This is a dedicated onboarding chat. Your agent will introduce itself, " + "learn who you are, and help you connect WhatsApp or Telegram if you want.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 520) .fixedSize(horizontal: false, vertical: true) self.onboardingGlassCard(padding: 8) { ClawdbotChatView(viewModel: self.onboardingChatModel, style: .onboarding) .frame(maxHeight: .infinity) } .frame(maxHeight: .infinity) } .padding(.horizontal, 28) .frame(width: self.pageWidth, height: self.contentHeight, alignment: .top) } func readyPage() -> some View { self.onboardingPage { Text("All set") .font(.largeTitle.weight(.semibold)) self.onboardingCard { if self.state.connectionMode == .unconfigured { self.featureRow( title: "Configure later", subtitle: "Pick Local or Remote in Settings → General whenever you’re ready.", systemImage: "gearshape") Divider() .padding(.vertical, 6) } if self.state.connectionMode == .remote { self.featureRow( title: "Remote gateway checklist", subtitle: """ On your gateway host: install/update the `clawdbot` package and make sure credentials exist (typically `~/.clawdbot/credentials/oauth.json`). Then connect again if needed. """, systemImage: "network") Divider() .padding(.vertical, 6) } self.featureRow( title: "Open the menu bar panel", subtitle: "Click the Clawdbot menu bar icon for quick chat and status.", systemImage: "bubble.left.and.bubble.right") self.featureActionRow( title: "Connect WhatsApp or Telegram", subtitle: "Open Settings → Connections to link providers and monitor status.", systemImage: "link") { self.openSettings(tab: .connections) } self.featureRow( title: "Try Voice Wake", subtitle: "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.", systemImage: "waveform.circle") self.featureRow( title: "Use the panel + Canvas", subtitle: "Open the menu bar panel for quick chat; the agent can show previews " + "and richer visuals in Canvas.", systemImage: "rectangle.inset.filled.and.person.filled") self.featureActionRow( title: "Give your agent more powers", subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.", systemImage: "sparkles") { self.openSettings(tab: .skills) } self.skillsOverview Toggle("Launch at login", isOn: self.$state.launchAtLogin) .onChange(of: self.state.launchAtLogin) { _, newValue in AppStateStore.updateLaunchAtLogin(enabled: newValue) } } } .task { await self.maybeLoadOnboardingSkills() } } private func maybeLoadOnboardingSkills() async { guard !self.didLoadOnboardingSkills else { return } self.didLoadOnboardingSkills = true await self.onboardingSkillsModel.refresh() } private var skillsOverview: some View { VStack(alignment: .leading, spacing: 8) { Divider() .padding(.vertical, 6) HStack(spacing: 10) { Text("Skills included") .font(.headline) Spacer(minLength: 0) if self.onboardingSkillsModel.isLoading { ProgressView() .controlSize(.small) } else { Button("Refresh") { Task { await self.onboardingSkillsModel.refresh() } } .buttonStyle(.link) } } if let error = self.onboardingSkillsModel.error { VStack(alignment: .leading, spacing: 4) { Text("Couldn’t load skills from the Gateway.") .font(.footnote.weight(.semibold)) .foregroundStyle(.orange) Text( "Make sure the Gateway is running and connected, " + "then hit Refresh (or open Settings → Skills).") .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Text("Details: \(error)") .font(.caption.monospaced()) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } } else if self.onboardingSkillsModel.skills.isEmpty { Text("No skills reported yet.") .font(.footnote) .foregroundStyle(.secondary) } else { ScrollView { LazyVStack(alignment: .leading, spacing: 10) { ForEach(self.onboardingSkillsModel.skills) { skill in HStack(alignment: .top, spacing: 10) { Text(skill.emoji ?? "✨") .font(.callout) .frame(width: 22, alignment: .leading) VStack(alignment: .leading, spacing: 2) { Text(skill.name) .font(.callout.weight(.semibold)) Text(skill.description) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) } } } .padding(10) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color(NSColor.windowBackgroundColor))) } .frame(maxHeight: 160) } } } }