From f44014ff00b73a581c9fd93fc8cea0e5ea6c1c79 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 24 Dec 2025 19:29:27 +0100 Subject: [PATCH] refactor: split onboarding view --- apps/macos/Sources/Clawdis/Onboarding.swift | 1455 +---------------- .../Clawdis/OnboardingView+Actions.swift | 136 ++ .../Sources/Clawdis/OnboardingView+Chat.swift | 26 + .../Clawdis/OnboardingView+Layout.swift | 200 +++ .../Clawdis/OnboardingView+Monitoring.swift | 135 ++ .../Clawdis/OnboardingView+Pages.swift | 676 ++++++++ .../Clawdis/OnboardingView+Testing.swift | 83 + .../Clawdis/OnboardingView+Workspace.swift | 69 + .../Sources/Clawdis/OnboardingWidgets.swift | 65 + 9 files changed, 1436 insertions(+), 1409 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/OnboardingView+Actions.swift create mode 100644 apps/macos/Sources/Clawdis/OnboardingView+Chat.swift create mode 100644 apps/macos/Sources/Clawdis/OnboardingView+Layout.swift create mode 100644 apps/macos/Sources/Clawdis/OnboardingView+Monitoring.swift create mode 100644 apps/macos/Sources/Clawdis/OnboardingView+Pages.swift create mode 100644 apps/macos/Sources/Clawdis/OnboardingView+Testing.swift create mode 100644 apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift create mode 100644 apps/macos/Sources/Clawdis/OnboardingWidgets.swift diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 0cc9e3eb8..06b89b678 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -42,49 +42,48 @@ final class OnboardingController { } } -// swiftlint:disable:next type_body_length struct OnboardingView: View { - @Environment(\.openSettings) private var openSettings - @State private var currentPage = 0 - @State private var isRequesting = false - @State private var installingCLI = false - @State private var cliStatus: String? - @State private var copied = false - @State private var monitoringPermissions = false - @State private var monitoringDiscovery = false - @State private var cliInstalled = false - @State private var cliInstallLocation: String? - @State private var workspacePath: String = "" - @State private var workspaceStatus: String? - @State private var workspaceApplying = false - @State private var anthropicAuthPKCE: AnthropicOAuth.PKCE? - @State private var anthropicAuthCode: String = "" - @State private var anthropicAuthStatus: String? - @State private var anthropicAuthBusy = false - @State private var anthropicAuthConnected = false - @State private var anthropicAuthDetectedStatus: ClawdisOAuthStore.AnthropicOAuthStatus = .missingFile - @State private var anthropicAuthAutoDetectClipboard = true - @State private var anthropicAuthAutoConnectClipboard = true - @State private var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount - @State private var monitoringAuth = false - @State private var authMonitorTask: Task? - @State private var needsBootstrap = false - @State private var didAutoKickoff = false - @State private var showAdvancedConnection = false - @State private var preferredGatewayID: String? - @State private var gatewayDiscovery: GatewayDiscoveryModel - @State private var onboardingChatModel: ClawdisChatViewModel - @State private var localGatewayProbe: LocalGatewayProbe? - @Bindable private var state: AppState - private var permissionMonitor: PermissionMonitor + @Environment(\.openSettings) var openSettings + @State var currentPage = 0 + @State var isRequesting = false + @State var installingCLI = false + @State var cliStatus: String? + @State var copied = false + @State var monitoringPermissions = false + @State var monitoringDiscovery = false + @State var cliInstalled = false + @State var cliInstallLocation: String? + @State var workspacePath: String = "" + @State var workspaceStatus: String? + @State var workspaceApplying = false + @State var anthropicAuthPKCE: AnthropicOAuth.PKCE? + @State var anthropicAuthCode: String = "" + @State var anthropicAuthStatus: String? + @State var anthropicAuthBusy = false + @State var anthropicAuthConnected = false + @State var anthropicAuthDetectedStatus: ClawdisOAuthStore.AnthropicOAuthStatus = .missingFile + @State var anthropicAuthAutoDetectClipboard = true + @State var anthropicAuthAutoConnectClipboard = true + @State var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount + @State var monitoringAuth = false + @State var authMonitorTask: Task? + @State var needsBootstrap = false + @State var didAutoKickoff = false + @State var showAdvancedConnection = false + @State var preferredGatewayID: String? + @State var gatewayDiscovery: GatewayDiscoveryModel + @State var onboardingChatModel: ClawdisChatViewModel + @State var localGatewayProbe: LocalGatewayProbe? + @Bindable var state: AppState + var permissionMonitor: PermissionMonitor - private let pageWidth: CGFloat = 630 - private let contentHeight: CGFloat = 420 - private let connectionPageIndex = 1 - private let anthropicAuthPageIndex = 2 - private let onboardingChatPageIndex = 8 + let pageWidth: CGFloat = 630 + let contentHeight: CGFloat = 420 + let connectionPageIndex = 1 + let anthropicAuthPageIndex = 2 + let onboardingChatPageIndex = 8 - private static let clipboardPoll: AnyPublisher = { + static let clipboardPoll: AnyPublisher = { if ProcessInfo.processInfo.isRunningTests { return Empty(completeImmediately: false).eraseToAnyPublisher() } @@ -93,7 +92,7 @@ struct OnboardingView: View { .eraseToAnyPublisher() }() - private let permissionsPageIndex = 5 + let permissionsPageIndex = 5 static func pageOrder( for mode: AppState.ConnectionMode, needsBootstrap: Bool) -> [Int] @@ -110,22 +109,22 @@ struct OnboardingView: View { } } - private var pageOrder: [Int] { + var pageOrder: [Int] { Self.pageOrder(for: self.state.connectionMode, needsBootstrap: self.needsBootstrap) } - private var pageCount: Int { self.pageOrder.count } - private var activePageIndex: Int { + var pageCount: Int { self.pageOrder.count } + var activePageIndex: Int { self.activePageIndex(for: self.currentPage) } - private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } - private var devLinkCommand: String { + var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } + var devLinkCommand: String { let bundlePath = Bundle.main.bundlePath return "ln -sf '\(bundlePath)/Contents/Resources/Relay/clawdis' /usr/local/bin/clawdis" } - private struct LocalGatewayProbe: Equatable { + struct LocalGatewayProbe: Equatable { let port: Int let pid: Int32 let command: String @@ -145,1366 +144,4 @@ struct OnboardingView: View { sessionKey: "onboarding", transport: MacGatewayChatTransport())) } - - var body: some View { - VStack(spacing: 0) { - GlowingClawdisIcon(size: 130, glowIntensity: 0.28) - .offset(y: 10) - .frame(height: 145) - - GeometryReader { _ in - HStack(spacing: 0) { - ForEach(self.pageOrder, id: \.self) { pageIndex in - self.pageView(for: pageIndex) - .frame(width: self.pageWidth) - } - } - .offset(x: CGFloat(-self.currentPage) * self.pageWidth) - .animation( - .interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25), - value: self.currentPage) - .frame(height: self.contentHeight, alignment: .top) - .clipped() - } - .frame(height: self.contentHeight) - - Spacer(minLength: 0) - self.navigationBar - } - .frame(width: self.pageWidth, height: 644) - .background(Color(NSColor.windowBackgroundColor)) - .onAppear { - self.currentPage = 0 - self.updateMonitoring(for: 0) - } - .onChange(of: self.currentPage) { _, newValue in - self.updateMonitoring(for: self.activePageIndex(for: newValue)) - } - .onChange(of: self.state.connectionMode) { _, _ in - let oldActive = self.activePageIndex - self.reconcilePageForModeChange(previousActivePageIndex: oldActive) - self.updateDiscoveryMonitoring(for: self.activePageIndex) - } - .onChange(of: self.needsBootstrap) { _, _ in - if self.currentPage >= self.pageOrder.count { - self.currentPage = max(0, self.pageOrder.count - 1) - } - } - .onDisappear { - self.stopPermissionMonitoring() - self.stopDiscovery() - self.stopAuthMonitoring() - } - .task { - await self.refreshPerms() - self.refreshCLIStatus() - self.loadWorkspaceDefaults() - self.ensureDefaultWorkspace() - self.refreshAnthropicOAuthStatus() - self.refreshBootstrapStatus() - self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID() - } - } - - private func activePageIndex(for pageCursor: Int) -> Int { - guard !self.pageOrder.isEmpty else { return 0 } - let clamped = min(max(0, pageCursor), self.pageOrder.count - 1) - return self.pageOrder[clamped] - } - - private func reconcilePageForModeChange(previousActivePageIndex: Int) { - if let exact = self.pageOrder.firstIndex(of: previousActivePageIndex) { - withAnimation { self.currentPage = exact } - return - } - if let next = self.pageOrder.firstIndex(where: { $0 > previousActivePageIndex }) { - withAnimation { self.currentPage = next } - return - } - withAnimation { self.currentPage = max(0, self.pageOrder.count - 1) } - } - - @ViewBuilder - private func pageView(for pageIndex: Int) -> some View { - switch pageIndex { - case 0: - self.welcomePage() - case 1: - self.connectionPage() - case 2: - self.anthropicAuthPage() - case 5: - self.permissionsPage() - case 6: - self.cliPage() - case 8: - self.onboardingChatPage() - case 9: - self.readyPage() - default: - EmptyView() - } - } - - private func welcomePage() -> some View { - self.onboardingPage { - VStack(spacing: 22) { - Text("Welcome to Clawdis") - .font(.largeTitle.weight(.semibold)) - Text("Clawdis 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 Clawdis 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) - } - } - - private func connectionPage() -> some View { - self.onboardingPage { - Text("Choose your Gateway") - .font(.largeTitle.weight(.semibold)) - Text( - "Clawdis 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) - } - 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/clawdis", text: self.$state.remoteProjectRoot) - .textFieldStyle(.roundedBorder) - .frame(width: fieldWidth) - } - GridRow { - Text("CLI path") - .font(.callout.weight(.semibold)) - .frame(width: labelWidth, alignment: .leading) - TextField("/Applications/Clawdis.app/.../clawdis", 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))) - } - } - } - } - } - - private func selectLocalGateway() { - self.state.connectionMode = .local - self.preferredGatewayID = nil - self.showAdvancedConnection = false - BridgeDiscoveryPreferences.setPreferredStableID(nil) - } - - private func selectUnconfiguredGateway() { - self.state.connectionMode = .unconfigured - self.preferredGatewayID = nil - self.showAdvancedConnection = false - BridgeDiscoveryPreferences.setPreferredStableID(nil) - } - - private func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { - self.preferredGatewayID = gateway.stableID - BridgeDiscoveryPreferences.setPreferredStableID(gateway.stableID) - - if let host = gateway.tailnetDns ?? gateway.lanHost { - let user = NSUserName() - self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget( - user: user, - host: host, - port: gateway.sshPort) - } - self.state.remoteCliPath = gateway.cliPath ?? "" - - self.state.connectionMode = .remote - MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID) - } - - private 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" - } - - private func isSelectedGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool { - guard self.state.connectionMode == .remote else { return false } - let preferred = self.preferredGatewayID ?? BridgeDiscoveryPreferences.preferredStableID() - return preferred == gateway.stableID - } - - private 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) - } - - private 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("Clawdis 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.anthropicAuthConnected ? Color.green : Color.orange) - .frame(width: 10, height: 10) - Text(self.anthropicAuthConnected ? "Claude connected (OAuth)" : "Not connected yet") - .font(.headline) - Spacer() - } - - if !self.anthropicAuthConnected { - Text(self.anthropicAuthDetectedStatus.shortDescription) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Text( - "This lets Clawdis use Claude immediately. Credentials are stored at " + - "`~/.clawdis/credentials/oauth.json` (owner-only). You can redo this anytime.") - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - HStack(spacing: 12) { - Text(ClawdisOAuthStore.oauthURL().path) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - - Spacer() - - Button("Reveal") { - NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()]) - } - .buttonStyle(.bordered) - - Button("Refresh") { - self.refreshAnthropicOAuthStatus() - } - .buttonStyle(.bordered) - } - - Divider().padding(.vertical, 2) - - HStack(spacing: 12) { - Button { - self.startAnthropicOAuth() - } label: { - if self.anthropicAuthBusy { - ProgressView() - } else { - Text("Open Claude sign-in (OAuth)") - } - } - .buttonStyle(.borderedProminent) - .disabled(self.anthropicAuthBusy) - } - - if 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) - } - } - } - } - - private func startAnthropicOAuth() { - guard !self.anthropicAuthBusy else { return } - self.anthropicAuthBusy = true - defer { self.anthropicAuthBusy = false } - - do { - let pkce = try AnthropicOAuth.generatePKCE() - self.anthropicAuthPKCE = pkce - let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce) - NSWorkspace.shared.open(url) - self.anthropicAuthStatus = "Browser opened. After approving, paste the `code#state` value here." - } catch { - self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)" - } - } - - @MainActor - private func finishAnthropicOAuth() async { - guard !self.anthropicAuthBusy else { return } - guard let pkce = self.anthropicAuthPKCE else { return } - self.anthropicAuthBusy = true - defer { self.anthropicAuthBusy = false } - - guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else { - self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state." - return - } - - do { - let creds = try await AnthropicOAuth.exchangeCode( - code: parsed.code, - state: parsed.state, - verifier: pkce.verifier) - try ClawdisOAuthStore.saveAnthropicOAuth(creds) - self.refreshAnthropicOAuthStatus() - self.anthropicAuthStatus = "Connected. Clawdis can now use Claude." - } catch { - self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)" - } - } - - private func pollAnthropicClipboardIfNeeded() { - guard self.currentPage == self.anthropicAuthPageIndex else { return } - guard self.anthropicAuthPKCE != nil else { return } - guard !self.anthropicAuthBusy else { return } - guard self.anthropicAuthAutoDetectClipboard else { return } - - let pb = NSPasteboard.general - let changeCount = pb.changeCount - guard changeCount != self.anthropicAuthLastPasteboardChangeCount else { return } - self.anthropicAuthLastPasteboardChangeCount = changeCount - - guard let raw = pb.string(forType: .string), !raw.isEmpty else { return } - guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return } - guard let pkce = self.anthropicAuthPKCE, parsed.state == pkce.verifier else { return } - - let next = "\(parsed.code)#\(parsed.state)" - if self.anthropicAuthCode != next { - self.anthropicAuthCode = next - self.anthropicAuthStatus = "Detected `code#state` from clipboard." - } - - guard self.anthropicAuthAutoConnectClipboard else { return } - Task { await self.finishAnthropicOAuth() } - } - - private func refreshAnthropicOAuthStatus() { - _ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded() - let status = ClawdisOAuthStore.anthropicOAuthStatus() - self.anthropicAuthDetectedStatus = status - self.anthropicAuthConnected = status.isConnected - } - - private func permissionsPage() -> some View { - self.onboardingPage { - Text("Grant permissions") - .font(.largeTitle.weight(.semibold)) - Text("These macOS permissions let Clawdis 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) - } - } - } - - private func cliPage() -> some View { - self.onboardingPage { - Text("Install the helper CLI") - .font(.largeTitle.weight(.semibold)) - Text("Optional, but recommended: link `clawdis` so scripts can reach the local 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 dev link") { - 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( - """ - We install into /usr/local/bin and /opt/homebrew/bin. - Rerun anytime if you move the build output. - """) - .font(.footnote) - .foregroundStyle(.secondary) - } - } - } - } - - private func workspacePage() -> some View { - self.onboardingPage { - Text("Agent workspace") - .font(.largeTitle.weight(.semibold)) - Text( - "Clawdis 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: ClawdisConfigFile.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") { - let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) - ClawdisConfigFile.setAgentWorkspace(AgentWorkspace.displayPath(for: url)) - self.workspaceStatus = "Saved to ~/.clawdis/clawdis.json (agent.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) - } - } - } - } - } - - private 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.onboardingCard(padding: 8) { - ClawdisChatView(viewModel: self.onboardingChatModel, style: .onboarding) - .frame(maxHeight: .infinity) - } - .frame(maxHeight: .infinity) - } - .padding(.horizontal, 28) - .frame(width: self.pageWidth, height: self.contentHeight, alignment: .top) - } - - private 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 `clawdis` package and make sure credentials exist - (typically `~/.clawdis/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 Clawdis 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) - } - Toggle("Launch at login", isOn: self.$state.launchAtLogin) - .onChange(of: self.state.launchAtLogin) { _, newValue in - AppStateStore.updateLaunchAtLogin(enabled: newValue) - } - } - } - } - - private func openSettings(tab: SettingsTab) { - SettingsTabRouter.request(tab) - self.openSettings() - NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab) - } - - private var navigationBar: some View { - HStack(spacing: 20) { - ZStack(alignment: .leading) { - Button(action: {}, label: { - Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly) - }) - .buttonStyle(.plain) - .opacity(0) - .disabled(true) - - if self.currentPage > 0 { - Button(action: self.handleBack, label: { - Label("Back", systemImage: "chevron.left") - .labelStyle(.iconOnly) - }) - .buttonStyle(.plain) - .foregroundColor(.secondary) - .opacity(0.8) - .transition(.opacity.combined(with: .scale(scale: 0.9))) - } - } - .frame(minWidth: 80, alignment: .leading) - - Spacer() - - HStack(spacing: 8) { - ForEach(0.. some View) -> some View { - VStack(spacing: 16) { - content() - Spacer() - } - .padding(.horizontal, 28) - .frame(width: self.pageWidth, alignment: .top) - } - - private func onboardingCard( - spacing: CGFloat = 12, - padding: CGFloat = 16, - @ViewBuilder _ content: () -> some View) -> some View - { - VStack(alignment: .leading, spacing: spacing) { - content() - } - .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)) - } - - private func featureRow(title: String, subtitle: String, systemImage: String) -> some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: systemImage) - .font(.title3.weight(.semibold)) - .foregroundStyle(Color.accentColor) - .frame(width: 26) - VStack(alignment: .leading, spacing: 4) { - Text(title).font(.headline) - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - } - - private func featureActionRow( - title: String, - subtitle: String, - systemImage: String, - action: @escaping () -> Void) -> some View - { - HStack(alignment: .top, spacing: 12) { - Image(systemName: systemImage) - .font(.title3.weight(.semibold)) - .foregroundStyle(Color.accentColor) - .frame(width: 26) - VStack(alignment: .leading, spacing: 4) { - Text(title).font(.headline) - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - Button("Open Settings → Skills", action: action) - .buttonStyle(.link) - .padding(.top, 2) - } - Spacer(minLength: 0) - } - .padding(.vertical, 4) - } - - private func handleBack() { - withAnimation { - self.currentPage = max(0, self.currentPage - 1) - } - } - - private func handleNext() { - if self.currentPage < self.pageCount - 1 { - withAnimation { self.currentPage += 1 } - } else { - self.finish() - } - } - - private func finish() { - UserDefaults.standard.set(true, forKey: "clawdis.onboardingSeen") - UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) - OnboardingController.shared.close() - } - - @MainActor - private func refreshPerms() async { - await self.permissionMonitor.refreshNow() - } - - @MainActor - private func request(_ cap: Capability) async { - guard !self.isRequesting else { return } - self.isRequesting = true - defer { isRequesting = false } - _ = await PermissionManager.ensure([cap], interactive: true) - await self.refreshPerms() - } - - private func updatePermissionMonitoring(for pageIndex: Int) { - let shouldMonitor = pageIndex == self.permissionsPageIndex - if shouldMonitor, !self.monitoringPermissions { - self.monitoringPermissions = true - PermissionMonitor.shared.register() - } else if !shouldMonitor, self.monitoringPermissions { - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() - } - } - - private func updateDiscoveryMonitoring(for pageIndex: Int) { - let isConnectionPage = pageIndex == self.connectionPageIndex - let shouldMonitor = isConnectionPage - if shouldMonitor, !self.monitoringDiscovery { - self.monitoringDiscovery = true - Task { @MainActor in - try? await Task.sleep(nanoseconds: 550_000_000) - guard self.monitoringDiscovery else { return } - self.gatewayDiscovery.start() - await self.refreshLocalGatewayProbe() - } - } else if !shouldMonitor, self.monitoringDiscovery { - self.monitoringDiscovery = false - self.gatewayDiscovery.stop() - } - } - - private func updateMonitoring(for pageIndex: Int) { - self.updatePermissionMonitoring(for: pageIndex) - self.updateDiscoveryMonitoring(for: pageIndex) - self.updateAuthMonitoring(for: pageIndex) - self.maybeKickoffOnboardingChat(for: pageIndex) - } - - private func stopPermissionMonitoring() { - guard self.monitoringPermissions else { return } - self.monitoringPermissions = false - PermissionMonitor.shared.unregister() - } - - private func stopDiscovery() { - guard self.monitoringDiscovery else { return } - self.monitoringDiscovery = false - self.gatewayDiscovery.stop() - } - - private func updateAuthMonitoring(for pageIndex: Int) { - let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local - if shouldMonitor, !self.monitoringAuth { - self.monitoringAuth = true - self.startAuthMonitoring() - } else if !shouldMonitor, self.monitoringAuth { - self.stopAuthMonitoring() - } - } - - private func startAuthMonitoring() { - self.refreshAnthropicOAuthStatus() - self.authMonitorTask?.cancel() - self.authMonitorTask = Task { - while !Task.isCancelled { - await MainActor.run { self.refreshAnthropicOAuthStatus() } - try? await Task.sleep(nanoseconds: 1_000_000_000) - } - } - } - - private func stopAuthMonitoring() { - self.monitoringAuth = false - self.authMonitorTask?.cancel() - self.authMonitorTask = nil - } - - private func installCLI() async { - guard !self.installingCLI else { return } - self.installingCLI = true - defer { installingCLI = false } - await CLIInstaller.install { message in - await MainActor.run { self.cliStatus = message } - } - self.refreshCLIStatus() - } - - private func refreshCLIStatus() { - let installLocation = CLIInstaller.installedLocation() - self.cliInstallLocation = installLocation - self.cliInstalled = installLocation != nil - } - - private func refreshLocalGatewayProbe() async { - let port = GatewayEnvironment.gatewayPort() - let desc = await PortGuardian.shared.describe(port: port) - await MainActor.run { - guard let desc else { - self.localGatewayProbe = nil - return - } - let command = desc.command.trimmingCharacters(in: .whitespacesAndNewlines) - let expectedTokens = ["node", "clawdis", "tsx", "pnpm", "bun"] - let lower = command.lowercased() - let expected = expectedTokens.contains { lower.contains($0) } - self.localGatewayProbe = LocalGatewayProbe( - port: port, - pid: desc.pid, - command: command, - expected: expected) - } - } - - private func copyToPasteboard(_ text: String) { - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(text, forType: .string) - self.copied = true - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false } - } - - private func loadWorkspaceDefaults() { - guard self.workspacePath.isEmpty else { return } - let configured = ClawdisConfigFile.agentWorkspace() - let url = AgentWorkspace.resolveWorkspaceURL(from: configured) - self.workspacePath = AgentWorkspace.displayPath(for: url) - self.refreshBootstrapStatus() - } - - private func ensureDefaultWorkspace() { - guard self.state.connectionMode == .local else { return } - let configured = ClawdisConfigFile.agentWorkspace() - let url = AgentWorkspace.resolveWorkspaceURL(from: configured) - switch AgentWorkspace.bootstrapSafety(for: url) { - case .safe: - do { - _ = try AgentWorkspace.bootstrap(workspaceURL: url) - if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - ClawdisConfigFile.setAgentWorkspace(AgentWorkspace.displayPath(for: url)) - } - } catch { - self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" - } - case let .unsafe(reason): - self.workspaceStatus = "Workspace not touched: \(reason)" - } - self.refreshBootstrapStatus() - } - - private func refreshBootstrapStatus() { - let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) - self.needsBootstrap = AgentWorkspace.needsBootstrap(workspaceURL: url) - if self.needsBootstrap { - self.didAutoKickoff = false - } - } - - private var workspaceBootstrapCommand: String { - let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines) - return """ - mkdir -p ~/.clawdis/workspace - cat > ~/.clawdis/workspace/AGENTS.md <<'EOF' - \(template) - EOF - """ - } - - private func applyWorkspace() async { - guard !self.workspaceApplying else { return } - self.workspaceApplying = true - defer { self.workspaceApplying = false } - - do { - let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) - if case let .unsafe(reason) = AgentWorkspace.bootstrapSafety(for: url) { - self.workspaceStatus = "Workspace not created: \(reason)" - return - } - _ = try AgentWorkspace.bootstrap(workspaceURL: url) - self.workspacePath = AgentWorkspace.displayPath(for: url) - self.workspaceStatus = "Workspace ready at \(self.workspacePath)" - self.refreshBootstrapStatus() - } catch { - self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" - } - } - - private func maybeKickoffOnboardingChat(for pageIndex: Int) { - guard pageIndex == self.onboardingChatPageIndex else { return } - guard self.needsBootstrap else { return } - guard !self.didAutoKickoff else { return } - self.didAutoKickoff = true - - Task { @MainActor in - for _ in 0..<20 { - if !self.onboardingChatModel.isLoading { break } - try? await Task.sleep(nanoseconds: 200_000_000) - } - guard self.onboardingChatModel.messages.isEmpty else { return } - let kickoff = - "Hi! I just installed Clawdis and you’re my brand‑new agent. " + - "Please start the first‑run ritual from BOOTSTRAP.md, ask one question at a time, " + - "and before we talk about WhatsApp/Telegram, visit soul.md with me to craft SOUL.md: " + - "ask what matters to me and how you should be. Then guide me through choosing " + - "how we should talk (web‑only, WhatsApp, or Telegram)." - self.onboardingChatModel.input = kickoff - self.onboardingChatModel.send() - } - } } - -private struct GlowingClawdisIcon: View { - @Environment(\.scenePhase) private var scenePhase - - let size: CGFloat - let glowIntensity: Double - let enableFloating: Bool - - @State private var breathe = false - - init(size: CGFloat = 148, glowIntensity: Double = 0.35, enableFloating: Bool = true) { - self.size = size - self.glowIntensity = glowIntensity - self.enableFloating = enableFloating - } - - var body: some View { - let glowBlurRadius: CGFloat = 18 - let glowCanvasSize: CGFloat = self.size + 56 - ZStack { - Circle() - .fill( - LinearGradient( - colors: [ - Color.accentColor.opacity(self.glowIntensity), - Color.blue.opacity(self.glowIntensity * 0.6), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing)) - .frame(width: glowCanvasSize, height: glowCanvasSize) - .padding(glowBlurRadius) - .blur(radius: glowBlurRadius) - .scaleEffect(self.breathe ? 1.08 : 0.96) - .opacity(0.84) - - Image(nsImage: NSApp.applicationIconImage) - .resizable() - .frame(width: self.size, height: self.size) - .clipShape(RoundedRectangle(cornerRadius: self.size * 0.22, style: .continuous)) - .shadow(color: .black.opacity(0.18), radius: 14, y: 6) - .scaleEffect(self.breathe ? 1.02 : 1.0) - } - .frame( - width: glowCanvasSize + (glowBlurRadius * 2), - height: glowCanvasSize + (glowBlurRadius * 2)) - .onAppear { self.updateBreatheAnimation() } - .onDisappear { self.breathe = false } - .onChange(of: self.scenePhase) { _, _ in - self.updateBreatheAnimation() - } - } - - private func updateBreatheAnimation() { - guard self.enableFloating, self.scenePhase == .active else { - self.breathe = false - return - } - guard !self.breathe else { return } - withAnimation(Animation.easeInOut(duration: 3.6).repeatForever(autoreverses: true)) { - self.breathe = true - } - } -} - -#if DEBUG -@MainActor -extension OnboardingView { - static func exerciseForTesting() { - let state = AppState(preview: true) - let discovery = GatewayDiscoveryModel() - discovery.statusText = "Searching..." - let gateway = GatewayDiscoveryModel.DiscoveredGateway( - displayName: "Test Bridge", - lanHost: "bridge.local", - tailnetDns: "bridge.ts.net", - sshPort: 2222, - cliPath: "/usr/local/bin/clawdis", - stableID: "bridge-1", - debugID: "bridge-1", - isLocal: false) - discovery.gateways = [gateway] - - var view = OnboardingView( - state: state, - permissionMonitor: PermissionMonitor.shared, - discoveryModel: discovery) - view.needsBootstrap = true - view.localGatewayProbe = LocalGatewayProbe( - port: 18789, - pid: 123, - command: "clawdis-gateway", - expected: true) - view.showAdvancedConnection = true - view.preferredGatewayID = gateway.stableID - view.cliInstalled = true - view.cliInstallLocation = "/usr/local/bin/clawdis" - view.cliStatus = "Installed" - view.workspacePath = "/tmp/clawdis" - view.workspaceStatus = "Saved workspace" - view.anthropicAuthPKCE = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge") - view.anthropicAuthCode = "code#state" - view.anthropicAuthStatus = "Connected" - view.anthropicAuthDetectedStatus = .connected(expiresAtMs: 1_700_000_000_000) - view.anthropicAuthConnected = true - view.anthropicAuthAutoDetectClipboard = false - view.anthropicAuthAutoConnectClipboard = false - - view.state.connectionMode = .local - _ = view.welcomePage() - _ = view.connectionPage() - _ = view.anthropicAuthPage() - _ = view.permissionsPage() - _ = view.cliPage() - _ = view.workspacePage() - _ = view.onboardingChatPage() - _ = view.readyPage() - - view.selectLocalGateway() - view.selectRemoteGateway(gateway) - view.selectUnconfiguredGateway() - - view.state.connectionMode = .remote - _ = view.connectionPage() - _ = view.workspacePage() - - view.state.connectionMode = .unconfigured - _ = view.connectionPage() - - view.currentPage = 0 - view.handleNext() - view.handleBack() - - _ = view.onboardingPage { Text("Test") } - _ = view.onboardingCard { Text("Card") } - _ = view.featureRow(title: "Feature", subtitle: "Subtitle", systemImage: "sparkles") - _ = view.featureActionRow( - title: "Action", - subtitle: "Action subtitle", - systemImage: "gearshape", - action: {}) - _ = view.gatewaySubtitle(for: gateway) - _ = view.isSelectedGateway(gateway) - } -} -#endif diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Actions.swift b/apps/macos/Sources/Clawdis/OnboardingView+Actions.swift new file mode 100644 index 000000000..bba52c330 --- /dev/null +++ b/apps/macos/Sources/Clawdis/OnboardingView+Actions.swift @@ -0,0 +1,136 @@ +import AppKit +import ClawdisIPC +import SwiftUI + +extension OnboardingView { + func selectLocalGateway() { + self.state.connectionMode = .local + self.preferredGatewayID = nil + self.showAdvancedConnection = false + BridgeDiscoveryPreferences.setPreferredStableID(nil) + } + + func selectUnconfiguredGateway() { + self.state.connectionMode = .unconfigured + self.preferredGatewayID = nil + self.showAdvancedConnection = false + BridgeDiscoveryPreferences.setPreferredStableID(nil) + } + + func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { + self.preferredGatewayID = gateway.stableID + BridgeDiscoveryPreferences.setPreferredStableID(gateway.stableID) + + if let host = gateway.tailnetDns ?? gateway.lanHost { + let user = NSUserName() + self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget( + user: user, + host: host, + port: gateway.sshPort) + } + self.state.remoteCliPath = gateway.cliPath ?? "" + + self.state.connectionMode = .remote + MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID) + } + + func openSettings(tab: SettingsTab) { + SettingsTabRouter.request(tab) + self.openSettings() + NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab) + } + + func handleBack() { + withAnimation { + self.currentPage = max(0, self.currentPage - 1) + } + } + + func handleNext() { + if self.currentPage < self.pageCount - 1 { + withAnimation { self.currentPage += 1 } + } else { + self.finish() + } + } + + func finish() { + UserDefaults.standard.set(true, forKey: "clawdis.onboardingSeen") + UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) + OnboardingController.shared.close() + } + + func copyToPasteboard(_ text: String) { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(text, forType: .string) + self.copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false } + } + + func startAnthropicOAuth() { + guard !self.anthropicAuthBusy else { return } + self.anthropicAuthBusy = true + defer { self.anthropicAuthBusy = false } + + do { + let pkce = try AnthropicOAuth.generatePKCE() + self.anthropicAuthPKCE = pkce + let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce) + NSWorkspace.shared.open(url) + self.anthropicAuthStatus = "Browser opened. After approving, paste the `code#state` value here." + } catch { + self.anthropicAuthStatus = "Failed to start OAuth: \(error.localizedDescription)" + } + } + + @MainActor + func finishAnthropicOAuth() async { + guard !self.anthropicAuthBusy else { return } + guard let pkce = self.anthropicAuthPKCE else { return } + self.anthropicAuthBusy = true + defer { self.anthropicAuthBusy = false } + + guard let parsed = AnthropicOAuthCodeState.parse(from: self.anthropicAuthCode) else { + self.anthropicAuthStatus = "OAuth failed: missing or invalid code/state." + return + } + + do { + let creds = try await AnthropicOAuth.exchangeCode( + code: parsed.code, + state: parsed.state, + verifier: pkce.verifier) + try ClawdisOAuthStore.saveAnthropicOAuth(creds) + self.refreshAnthropicOAuthStatus() + self.anthropicAuthStatus = "Connected. Clawdis can now use Claude." + } catch { + self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)" + } + } + + func pollAnthropicClipboardIfNeeded() { + guard self.currentPage == self.anthropicAuthPageIndex else { return } + guard self.anthropicAuthPKCE != nil else { return } + guard !self.anthropicAuthBusy else { return } + guard self.anthropicAuthAutoDetectClipboard else { return } + + let pb = NSPasteboard.general + let changeCount = pb.changeCount + guard changeCount != self.anthropicAuthLastPasteboardChangeCount else { return } + self.anthropicAuthLastPasteboardChangeCount = changeCount + + guard let raw = pb.string(forType: .string), !raw.isEmpty else { return } + guard let parsed = AnthropicOAuthCodeState.parse(from: raw) else { return } + guard let pkce = self.anthropicAuthPKCE, parsed.state == pkce.verifier else { return } + + let next = "\(parsed.code)#\(parsed.state)" + if self.anthropicAuthCode != next { + self.anthropicAuthCode = next + self.anthropicAuthStatus = "Detected `code#state` from clipboard." + } + + guard self.anthropicAuthAutoConnectClipboard else { return } + Task { await self.finishAnthropicOAuth() } + } +} diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Chat.swift b/apps/macos/Sources/Clawdis/OnboardingView+Chat.swift new file mode 100644 index 000000000..a9108b12a --- /dev/null +++ b/apps/macos/Sources/Clawdis/OnboardingView+Chat.swift @@ -0,0 +1,26 @@ +import Foundation + +extension OnboardingView { + func maybeKickoffOnboardingChat(for pageIndex: Int) { + guard pageIndex == self.onboardingChatPageIndex else { return } + guard self.needsBootstrap else { return } + guard !self.didAutoKickoff else { return } + self.didAutoKickoff = true + + Task { @MainActor in + for _ in 0..<20 { + if !self.onboardingChatModel.isLoading { break } + try? await Task.sleep(nanoseconds: 200_000_000) + } + guard self.onboardingChatModel.messages.isEmpty else { return } + let kickoff = + "Hi! I just installed Clawdis and you’re my brand‑new agent. " + + "Please start the first‑run ritual from BOOTSTRAP.md, ask one question at a time, " + + "and before we talk about WhatsApp/Telegram, visit soul.md with me to craft SOUL.md: " + + "ask what matters to me and how you should be. Then guide me through choosing " + + "how we should talk (web‑only, WhatsApp, or Telegram)." + self.onboardingChatModel.input = kickoff + self.onboardingChatModel.send() + } + } +} diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Layout.swift b/apps/macos/Sources/Clawdis/OnboardingView+Layout.swift new file mode 100644 index 000000000..31ac00dd2 --- /dev/null +++ b/apps/macos/Sources/Clawdis/OnboardingView+Layout.swift @@ -0,0 +1,200 @@ +import AppKit +import SwiftUI + +extension OnboardingView { + var body: some View { + VStack(spacing: 0) { + GlowingClawdisIcon(size: 130, glowIntensity: 0.28) + .offset(y: 10) + .frame(height: 145) + + GeometryReader { _ in + HStack(spacing: 0) { + ForEach(self.pageOrder, id: \.self) { pageIndex in + self.pageView(for: pageIndex) + .frame(width: self.pageWidth) + } + } + .offset(x: CGFloat(-self.currentPage) * self.pageWidth) + .animation( + .interactiveSpring(response: 0.5, dampingFraction: 0.86, blendDuration: 0.25), + value: self.currentPage) + .frame(height: self.contentHeight, alignment: .top) + .clipped() + } + .frame(height: self.contentHeight) + + Spacer(minLength: 0) + self.navigationBar + } + .frame(width: self.pageWidth, height: 644) + .background(Color(NSColor.windowBackgroundColor)) + .onAppear { + self.currentPage = 0 + self.updateMonitoring(for: 0) + } + .onChange(of: self.currentPage) { _, newValue in + self.updateMonitoring(for: self.activePageIndex(for: newValue)) + } + .onChange(of: self.state.connectionMode) { _, _ in + let oldActive = self.activePageIndex + self.reconcilePageForModeChange(previousActivePageIndex: oldActive) + self.updateDiscoveryMonitoring(for: self.activePageIndex) + } + .onChange(of: self.needsBootstrap) { _, _ in + if self.currentPage >= self.pageOrder.count { + self.currentPage = max(0, self.pageOrder.count - 1) + } + } + .onDisappear { + self.stopPermissionMonitoring() + self.stopDiscovery() + self.stopAuthMonitoring() + } + .task { + await self.refreshPerms() + self.refreshCLIStatus() + self.loadWorkspaceDefaults() + self.ensureDefaultWorkspace() + self.refreshAnthropicOAuthStatus() + self.refreshBootstrapStatus() + self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID() + } + } + + func activePageIndex(for pageCursor: Int) -> Int { + guard !self.pageOrder.isEmpty else { return 0 } + let clamped = min(max(0, pageCursor), self.pageOrder.count - 1) + return self.pageOrder[clamped] + } + + func reconcilePageForModeChange(previousActivePageIndex: Int) { + if let exact = self.pageOrder.firstIndex(of: previousActivePageIndex) { + withAnimation { self.currentPage = exact } + return + } + if let next = self.pageOrder.firstIndex(where: { $0 > previousActivePageIndex }) { + withAnimation { self.currentPage = next } + return + } + withAnimation { self.currentPage = max(0, self.pageOrder.count - 1) } + } + + var navigationBar: some View { + HStack(spacing: 20) { + ZStack(alignment: .leading) { + Button(action: {}, label: { + Label("Back", systemImage: "chevron.left").labelStyle(.iconOnly) + }) + .buttonStyle(.plain) + .opacity(0) + .disabled(true) + + if self.currentPage > 0 { + Button(action: self.handleBack, label: { + Label("Back", systemImage: "chevron.left") + .labelStyle(.iconOnly) + }) + .buttonStyle(.plain) + .foregroundColor(.secondary) + .opacity(0.8) + .transition(.opacity.combined(with: .scale(scale: 0.9))) + } + } + .frame(minWidth: 80, alignment: .leading) + + Spacer() + + HStack(spacing: 8) { + ForEach(0.. some View) -> some View { + VStack(spacing: 16) { + content() + Spacer() + } + .padding(.horizontal, 28) + .frame(width: self.pageWidth, alignment: .top) + } + + func onboardingCard( + spacing: CGFloat = 12, + padding: CGFloat = 16, + @ViewBuilder _ content: () -> some View) -> some View + { + VStack(alignment: .leading, spacing: spacing) { + content() + } + .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 featureRow(title: String, subtitle: String, systemImage: String) -> some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 26) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline) + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } + + func featureActionRow( + title: String, + subtitle: String, + systemImage: String, + action: @escaping () -> Void) -> some View + { + HStack(alignment: .top, spacing: 12) { + Image(systemName: systemImage) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 26) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.headline) + Text(subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + Button("Open Settings → Skills", action: action) + .buttonStyle(.link) + .padding(.top, 2) + } + Spacer(minLength: 0) + } + .padding(.vertical, 4) + } +} diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Monitoring.swift b/apps/macos/Sources/Clawdis/OnboardingView+Monitoring.swift new file mode 100644 index 000000000..10e87acd5 --- /dev/null +++ b/apps/macos/Sources/Clawdis/OnboardingView+Monitoring.swift @@ -0,0 +1,135 @@ +import ClawdisIPC +import Foundation + +extension OnboardingView { + @MainActor + func refreshPerms() async { + await self.permissionMonitor.refreshNow() + } + + @MainActor + func request(_ cap: Capability) async { + guard !self.isRequesting else { return } + self.isRequesting = true + defer { isRequesting = false } + _ = await PermissionManager.ensure([cap], interactive: true) + await self.refreshPerms() + } + + func updatePermissionMonitoring(for pageIndex: Int) { + let shouldMonitor = pageIndex == self.permissionsPageIndex + if shouldMonitor, !self.monitoringPermissions { + self.monitoringPermissions = true + PermissionMonitor.shared.register() + } else if !shouldMonitor, self.monitoringPermissions { + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() + } + } + + func updateDiscoveryMonitoring(for pageIndex: Int) { + let isConnectionPage = pageIndex == self.connectionPageIndex + let shouldMonitor = isConnectionPage + if shouldMonitor, !self.monitoringDiscovery { + self.monitoringDiscovery = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 550_000_000) + guard self.monitoringDiscovery else { return } + self.gatewayDiscovery.start() + await self.refreshLocalGatewayProbe() + } + } else if !shouldMonitor, self.monitoringDiscovery { + self.monitoringDiscovery = false + self.gatewayDiscovery.stop() + } + } + + func updateMonitoring(for pageIndex: Int) { + self.updatePermissionMonitoring(for: pageIndex) + self.updateDiscoveryMonitoring(for: pageIndex) + self.updateAuthMonitoring(for: pageIndex) + self.maybeKickoffOnboardingChat(for: pageIndex) + } + + func stopPermissionMonitoring() { + guard self.monitoringPermissions else { return } + self.monitoringPermissions = false + PermissionMonitor.shared.unregister() + } + + func stopDiscovery() { + guard self.monitoringDiscovery else { return } + self.monitoringDiscovery = false + self.gatewayDiscovery.stop() + } + + func updateAuthMonitoring(for pageIndex: Int) { + let shouldMonitor = pageIndex == self.anthropicAuthPageIndex && self.state.connectionMode == .local + if shouldMonitor, !self.monitoringAuth { + self.monitoringAuth = true + self.startAuthMonitoring() + } else if !shouldMonitor, self.monitoringAuth { + self.stopAuthMonitoring() + } + } + + func startAuthMonitoring() { + self.refreshAnthropicOAuthStatus() + self.authMonitorTask?.cancel() + self.authMonitorTask = Task { + while !Task.isCancelled { + await MainActor.run { self.refreshAnthropicOAuthStatus() } + try? await Task.sleep(nanoseconds: 1_000_000_000) + } + } + } + + func stopAuthMonitoring() { + self.monitoringAuth = false + self.authMonitorTask?.cancel() + self.authMonitorTask = nil + } + + func installCLI() async { + guard !self.installingCLI else { return } + self.installingCLI = true + defer { installingCLI = false } + await CLIInstaller.install { message in + await MainActor.run { self.cliStatus = message } + } + self.refreshCLIStatus() + } + + func refreshCLIStatus() { + let installLocation = CLIInstaller.installedLocation() + self.cliInstallLocation = installLocation + self.cliInstalled = installLocation != nil + } + + func refreshLocalGatewayProbe() async { + let port = GatewayEnvironment.gatewayPort() + let desc = await PortGuardian.shared.describe(port: port) + await MainActor.run { + guard let desc else { + self.localGatewayProbe = nil + return + } + let command = desc.command.trimmingCharacters(in: .whitespacesAndNewlines) + let expectedTokens = ["node", "clawdis", "tsx", "pnpm", "bun"] + let lower = command.lowercased() + let expected = expectedTokens.contains { lower.contains($0) } + self.localGatewayProbe = LocalGatewayProbe( + port: port, + pid: desc.pid, + command: command, + expected: expected) + } + } + + func refreshAnthropicOAuthStatus() { + _ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded() + let status = ClawdisOAuthStore.anthropicOAuthStatus() + self.anthropicAuthDetectedStatus = status + self.anthropicAuthConnected = status.isConnected + } +} diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift b/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift new file mode 100644 index 000000000..2bcf09b24 --- /dev/null +++ b/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift @@ -0,0 +1,676 @@ +import AppKit +import ClawdisChatUI +import ClawdisIPC +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 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 Clawdis") + .font(.largeTitle.weight(.semibold)) + Text("Clawdis 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 Clawdis 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( + "Clawdis 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) + } + 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/clawdis", text: self.$state.remoteProjectRoot) + .textFieldStyle(.roundedBorder) + .frame(width: fieldWidth) + } + GridRow { + Text("CLI path") + .font(.callout.weight(.semibold)) + .frame(width: labelWidth, alignment: .leading) + TextField("/Applications/Clawdis.app/.../clawdis", 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("Clawdis 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.anthropicAuthConnected ? Color.green : Color.orange) + .frame(width: 10, height: 10) + Text(self.anthropicAuthConnected ? "Claude connected (OAuth)" : "Not connected yet") + .font(.headline) + Spacer() + } + + if !self.anthropicAuthConnected { + Text(self.anthropicAuthDetectedStatus.shortDescription) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Text( + "This lets Clawdis use Claude immediately. Credentials are stored at " + + "`~/.clawdis/credentials/oauth.json` (owner-only). You can redo this anytime.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: 12) { + Text(ClawdisOAuthStore.oauthURL().path) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + Button("Reveal") { + NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()]) + } + .buttonStyle(.bordered) + + Button("Refresh") { + self.refreshAnthropicOAuthStatus() + } + .buttonStyle(.bordered) + } + + Divider().padding(.vertical, 2) + + HStack(spacing: 12) { + Button { + self.startAnthropicOAuth() + } label: { + if self.anthropicAuthBusy { + ProgressView() + } else { + Text("Open Claude sign-in (OAuth)") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.anthropicAuthBusy) + } + + if 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) + } + } + } + } + + func permissionsPage() -> some View { + self.onboardingPage { + Text("Grant permissions") + .font(.largeTitle.weight(.semibold)) + Text("These macOS permissions let Clawdis 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 helper CLI") + .font(.largeTitle.weight(.semibold)) + Text("Optional, but recommended: link `clawdis` so scripts can reach the local 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 dev link") { + 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( + """ + We install into /usr/local/bin and /opt/homebrew/bin. + Rerun anytime if you move the build output. + """) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + } + } + + func workspacePage() -> some View { + self.onboardingPage { + Text("Agent workspace") + .font(.largeTitle.weight(.semibold)) + Text( + "Clawdis 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: ClawdisConfigFile.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") { + let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) + ClawdisConfigFile.setAgentWorkspace(AgentWorkspace.displayPath(for: url)) + self.workspaceStatus = "Saved to ~/.clawdis/clawdis.json (agent.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.onboardingCard(padding: 8) { + ClawdisChatView(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 `clawdis` package and make sure credentials exist + (typically `~/.clawdis/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 Clawdis 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) + } + Toggle("Launch at login", isOn: self.$state.launchAtLogin) + .onChange(of: self.state.launchAtLogin) { _, newValue in + AppStateStore.updateLaunchAtLogin(enabled: newValue) + } + } + } + } +} diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Testing.swift b/apps/macos/Sources/Clawdis/OnboardingView+Testing.swift new file mode 100644 index 000000000..0e2054e2c --- /dev/null +++ b/apps/macos/Sources/Clawdis/OnboardingView+Testing.swift @@ -0,0 +1,83 @@ +import SwiftUI + +#if DEBUG +@MainActor +extension OnboardingView { + static func exerciseForTesting() { + let state = AppState(preview: true) + let discovery = GatewayDiscoveryModel() + discovery.statusText = "Searching..." + let gateway = GatewayDiscoveryModel.DiscoveredGateway( + displayName: "Test Bridge", + lanHost: "bridge.local", + tailnetDns: "bridge.ts.net", + sshPort: 2222, + cliPath: "/usr/local/bin/clawdis", + stableID: "bridge-1", + debugID: "bridge-1", + isLocal: false) + discovery.gateways = [gateway] + + var view = OnboardingView( + state: state, + permissionMonitor: PermissionMonitor.shared, + discoveryModel: discovery) + view.needsBootstrap = true + view.localGatewayProbe = LocalGatewayProbe( + port: 18789, + pid: 123, + command: "clawdis-gateway", + expected: true) + view.showAdvancedConnection = true + view.preferredGatewayID = gateway.stableID + view.cliInstalled = true + view.cliInstallLocation = "/usr/local/bin/clawdis" + view.cliStatus = "Installed" + view.workspacePath = "/tmp/clawdis" + view.workspaceStatus = "Saved workspace" + view.anthropicAuthPKCE = AnthropicOAuth.PKCE(verifier: "verifier", challenge: "challenge") + view.anthropicAuthCode = "code#state" + view.anthropicAuthStatus = "Connected" + view.anthropicAuthDetectedStatus = .connected(expiresAtMs: 1_700_000_000_000) + view.anthropicAuthConnected = true + view.anthropicAuthAutoDetectClipboard = false + view.anthropicAuthAutoConnectClipboard = false + + view.state.connectionMode = .local + _ = view.welcomePage() + _ = view.connectionPage() + _ = view.anthropicAuthPage() + _ = view.permissionsPage() + _ = view.cliPage() + _ = view.workspacePage() + _ = view.onboardingChatPage() + _ = view.readyPage() + + view.selectLocalGateway() + view.selectRemoteGateway(gateway) + view.selectUnconfiguredGateway() + + view.state.connectionMode = .remote + _ = view.connectionPage() + _ = view.workspacePage() + + view.state.connectionMode = .unconfigured + _ = view.connectionPage() + + view.currentPage = 0 + view.handleNext() + view.handleBack() + + _ = view.onboardingPage { Text("Test") } + _ = view.onboardingCard { Text("Card") } + _ = view.featureRow(title: "Feature", subtitle: "Subtitle", systemImage: "sparkles") + _ = view.featureActionRow( + title: "Action", + subtitle: "Action subtitle", + systemImage: "gearshape", + action: {}) + _ = view.gatewaySubtitle(for: gateway) + _ = view.isSelectedGateway(gateway) + } +} +#endif diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift b/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift new file mode 100644 index 000000000..983dbe420 --- /dev/null +++ b/apps/macos/Sources/Clawdis/OnboardingView+Workspace.swift @@ -0,0 +1,69 @@ +import Foundation + +extension OnboardingView { + func loadWorkspaceDefaults() { + guard self.workspacePath.isEmpty else { return } + let configured = ClawdisConfigFile.agentWorkspace() + let url = AgentWorkspace.resolveWorkspaceURL(from: configured) + self.workspacePath = AgentWorkspace.displayPath(for: url) + self.refreshBootstrapStatus() + } + + func ensureDefaultWorkspace() { + guard self.state.connectionMode == .local else { return } + let configured = ClawdisConfigFile.agentWorkspace() + let url = AgentWorkspace.resolveWorkspaceURL(from: configured) + switch AgentWorkspace.bootstrapSafety(for: url) { + case .safe: + do { + _ = try AgentWorkspace.bootstrap(workspaceURL: url) + if (configured ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + ClawdisConfigFile.setAgentWorkspace(AgentWorkspace.displayPath(for: url)) + } + } catch { + self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" + } + case let .unsafe(reason): + self.workspaceStatus = "Workspace not touched: \(reason)" + } + self.refreshBootstrapStatus() + } + + func refreshBootstrapStatus() { + let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) + self.needsBootstrap = AgentWorkspace.needsBootstrap(workspaceURL: url) + if self.needsBootstrap { + self.didAutoKickoff = false + } + } + + var workspaceBootstrapCommand: String { + let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines) + return """ + mkdir -p ~/.clawdis/workspace + cat > ~/.clawdis/workspace/AGENTS.md <<'EOF' + \(template) + EOF + """ + } + + func applyWorkspace() async { + guard !self.workspaceApplying else { return } + self.workspaceApplying = true + defer { self.workspaceApplying = false } + + do { + let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) + if case let .unsafe(reason) = AgentWorkspace.bootstrapSafety(for: url) { + self.workspaceStatus = "Workspace not created: \(reason)" + return + } + _ = try AgentWorkspace.bootstrap(workspaceURL: url) + self.workspacePath = AgentWorkspace.displayPath(for: url) + self.workspaceStatus = "Workspace ready at \(self.workspacePath)" + self.refreshBootstrapStatus() + } catch { + self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" + } + } +} diff --git a/apps/macos/Sources/Clawdis/OnboardingWidgets.swift b/apps/macos/Sources/Clawdis/OnboardingWidgets.swift new file mode 100644 index 000000000..4b9005cee --- /dev/null +++ b/apps/macos/Sources/Clawdis/OnboardingWidgets.swift @@ -0,0 +1,65 @@ +import AppKit +import SwiftUI + +struct GlowingClawdisIcon: View { + @Environment(\.scenePhase) private var scenePhase + + let size: CGFloat + let glowIntensity: Double + let enableFloating: Bool + + @State private var breathe = false + + init(size: CGFloat = 148, glowIntensity: Double = 0.35, enableFloating: Bool = true) { + self.size = size + self.glowIntensity = glowIntensity + self.enableFloating = enableFloating + } + + var body: some View { + let glowBlurRadius: CGFloat = 18 + let glowCanvasSize: CGFloat = self.size + 56 + ZStack { + Circle() + .fill( + LinearGradient( + colors: [ + Color.accentColor.opacity(self.glowIntensity), + Color.blue.opacity(self.glowIntensity * 0.6), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing)) + .frame(width: glowCanvasSize, height: glowCanvasSize) + .padding(glowBlurRadius) + .blur(radius: glowBlurRadius) + .scaleEffect(self.breathe ? 1.08 : 0.96) + .opacity(0.84) + + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: self.size, height: self.size) + .clipShape(RoundedRectangle(cornerRadius: self.size * 0.22, style: .continuous)) + .shadow(color: .black.opacity(0.18), radius: 14, y: 6) + .scaleEffect(self.breathe ? 1.02 : 1.0) + } + .frame( + width: glowCanvasSize + (glowBlurRadius * 2), + height: glowCanvasSize + (glowBlurRadius * 2)) + .onAppear { self.updateBreatheAnimation() } + .onDisappear { self.breathe = false } + .onChange(of: self.scenePhase) { _, _ in + self.updateBreatheAnimation() + } + } + + private func updateBreatheAnimation() { + guard self.enableFloating, self.scenePhase == .active else { + self.breathe = false + return + } + guard !self.breathe else { return } + withAnimation(Animation.easeInOut(duration: 3.6).repeatForever(autoreverses: true)) { + self.breathe = true + } + } +}