Files
clawdbot/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift
2026-01-19 10:08:29 +00:00

806 lines
36 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 gateway nearby, 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 gateways…")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 4)
} else {
VStack(alignment: .leading, spacing: 6) {
Text("Nearby gateways")
.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: "Dont 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 "Gateway pairing only"
}
func isSelectedGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool {
guard self.state.connectionMode == .remote else { return false }
let preferred = self.preferredGatewayID ?? GatewayDiscoveryPreferences.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 dont 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 cant 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 assistants behavior. " +
"For backup, make the workspace a private git repo so your agents " +
"“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 youre 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 → Channels to link channels and monitor status.",
systemImage: "link",
buttonTitle: "Open Settings → Channels")
{
self.openSettings(tab: .channels)
}
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",
buttonTitle: "Open Settings → Skills")
{
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("Couldnt 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)
}
}
}
}