diff --git a/apps/macos/Sources/Clawdis/AppMain.swift b/apps/macos/Sources/Clawdis/AppMain.swift
index 3157b7f96..13650b37c 100644
--- a/apps/macos/Sources/Clawdis/AppMain.swift
+++ b/apps/macos/Sources/Clawdis/AppMain.swift
@@ -13,6 +13,7 @@ import SwiftUI
import UserNotifications
private let serviceName = "com.steipete.clawdis.xpc"
+private let launchdLabel = "com.steipete.clawdis"
private let pauseDefaultsKey = "clawdis.pauseEnabled"
// MARK: - App model
@@ -31,12 +32,16 @@ final class AppState: ObservableObject {
@Published var onboardingSeen: Bool {
didSet { UserDefaults.standard.set(onboardingSeen, forKey: "clawdis.onboardingSeen") }
}
+ @Published var debugPaneEnabled: Bool {
+ didSet { UserDefaults.standard.set(debugPaneEnabled, forKey: "clawdis.debugPaneEnabled") }
+ }
init() {
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
self.defaultSound = UserDefaults.standard.string(forKey: "clawdis.defaultSound") ?? ""
self.launchAtLogin = SMAppService.mainApp.status == .enabled
self.onboardingSeen = UserDefaults.standard.bool(forKey: "clawdis.onboardingSeen")
+ self.debugPaneEnabled = UserDefaults.standard.bool(forKey: "clawdis.debugPaneEnabled")
}
}
@@ -353,7 +358,7 @@ struct ClawdisApp: App {
}
var body: some Scene {
- MenuBarExtra { menuContent } label: { CritterStatusLabel(isPaused: state.isPaused) }
+ MenuBarExtra { MenuContent(state: state) } label: { CritterStatusLabel(isPaused: state.isPaused) }
.menuBarExtraStyle(.menu)
Settings {
@@ -361,21 +366,32 @@ struct ClawdisApp: App {
.frame(minWidth: 520, minHeight: 460)
}
}
+}
- @ViewBuilder
- private var menuContent: some View {
+private struct MenuContent: View {
+ @ObservedObject var state: AppState
+ @Environment(\.openSettings) private var openSettings
+
+ var body: some View {
Toggle(isOn: $state.isPaused) {
Text(state.isPaused ? "Clawdis Paused" : "Pause Clawdis")
}
- Button("Settings…") {
- NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
- }
+ Button("Settings…") { open(tab: .general) }
+ .keyboardShortcut(",", modifiers: [.command])
+ Button("About Clawdis") { open(tab: .about) }
Divider()
Button("Test Notification") {
Task { _ = await NotificationManager().send(title: "Clawdis", body: "Test notification", sound: nil) }
}
Button("Quit") { NSApplication.shared.terminate(nil) }
}
+
+ private func open(tab: SettingsTab) {
+ SettingsTabRouter.request(tab)
+ NSApp.activate(ignoringOtherApps: true)
+ openSettings()
+ NotificationCenter.default.post(name: .clawdisSelectSettingsTab, object: tab)
+ }
}
private struct CritterStatusLabel: View {
@@ -394,6 +410,8 @@ private struct CritterStatusLabel: View {
.frame(width: 18, height: 16)
.rotationEffect(.degrees(wiggleAngle), anchor: .center)
.offset(x: wiggleOffset)
+ .foregroundStyle(isPaused ? .secondary : .primary)
+ .opacity(isPaused ? 0.45 : 1.0)
.onReceive(ticker) { now in
guard !isPaused else {
resetMotion()
@@ -537,9 +555,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
state = AppStateStore.shared
+ LaunchdManager.startClawdis()
startListener()
}
+ func applicationWillTerminate(_ notification: Notification) {
+ LaunchdManager.stopClawdis()
+ }
+
@MainActor
private func startListener() {
guard state != nil else { return }
@@ -576,18 +599,43 @@ struct SettingsRootView: View {
.tabItem { Label("Permissions", systemImage: "lock.shield") }
.tag(SettingsTab.permissions)
- DebugSettings()
- .tabItem { Label("Debug", systemImage: "ant") }
- .tag(SettingsTab.debug)
+ if state.debugPaneEnabled {
+ DebugSettings()
+ .tabItem { Label("Debug", systemImage: "ant") }
+ .tag(SettingsTab.debug)
+ }
AboutSettings()
.tabItem { Label("About", systemImage: "info.circle") }
.tag(SettingsTab.about)
}
- .padding(12)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 14)
+ .onReceive(NotificationCenter.default.publisher(for: .clawdisSelectSettingsTab)) { note in
+ if let tab = note.object as? SettingsTab {
+ withAnimation(.spring(response: 0.32, dampingFraction: 0.85)) {
+ selectedTab = tab
+ }
+ }
+ }
+ .onAppear {
+ if let pending = SettingsTabRouter.consumePending() {
+ selectedTab = validTab(for: pending)
+ }
+ }
+ .onChange(of: state.debugPaneEnabled) { _, enabled in
+ if !enabled && selectedTab == .debug {
+ selectedTab = .general
+ }
+ }
.task { await refreshPerms() }
}
+ private func validTab(for requested: SettingsTab) -> SettingsTab {
+ if requested == .debug && !state.debugPaneEnabled { return .general }
+ return requested
+ }
+
@MainActor
private func refreshPerms() async {
guard !loadingPerms else { return }
@@ -609,33 +657,64 @@ enum SettingsTab: CaseIterable {
}
}
+@MainActor
+enum SettingsTabRouter {
+ private static var pending: SettingsTab?
+
+ static func request(_ tab: SettingsTab) {
+ self.pending = tab
+ }
+
+ static func consumePending() -> SettingsTab? {
+ defer { self.pending = nil }
+ return self.pending
+ }
+}
+
+extension Notification.Name {
+ static let clawdisSelectSettingsTab = Notification.Name("clawdisSelectSettingsTab")
+}
+
struct GeneralSettings: View {
@ObservedObject var state: AppState
@State private var isInstallingCLI = false
@State private var cliStatus: String?
var body: some View {
- VStack(alignment: .leading, spacing: 12) {
+ VStack(alignment: .leading, spacing: 16) {
if !state.onboardingSeen {
Label("Complete onboarding to finish setup", systemImage: "sparkles")
.foregroundColor(.accentColor)
+ .padding(.bottom, 4)
}
- Toggle(isOn: $state.isPaused) { Text("Pause Clawdis (disables notifications & privileged actions)") }
- Toggle(isOn: $state.launchAtLogin) { Text("Launch at login") }
- HStack {
- Text("Default sound")
- Spacer()
- Picker("Sound", selection: $state.defaultSound) {
- Text("None").tag("")
- Text("Glass").tag("Glass")
- Text("Basso").tag("Basso")
- Text("Ping").tag("Ping")
+
+ VStack(alignment: .leading, spacing: 10) {
+ Toggle(isOn: $state.isPaused) { Text("Pause Clawdis (disables notifications & privileged actions)") }
+ Toggle(isOn: $state.launchAtLogin) { Text("Launch at login") }
+ #if DEBUG
+ Toggle(isOn: $state.debugPaneEnabled) { Text("Enable debug tools") }
+ .help("Show the Debug tab with development utilities")
+ #endif
+
+ LabeledContent("Default sound") {
+ Picker("Sound", selection: $state.defaultSound) {
+ Text("None").tag("")
+ Text("Glass").tag("Glass")
+ Text("Basso").tag("Basso")
+ Text("Ping").tag("Ping")
+ }
+ .labelsHidden()
+ .frame(width: 140)
}
- .labelsHidden()
- .frame(width: 140)
}
- Divider().padding(.vertical, 6)
- cliInstaller
+ .padding(14)
+ .background(RoundedRectangle(cornerRadius: 12).fill(Color(NSColor.controlBackgroundColor)))
+
+ GroupBox("CLI helper") {
+ cliInstaller
+ }
+ .groupBoxStyle(.automatic)
+
Spacer()
HStack {
Spacer()
@@ -643,10 +722,11 @@ struct GeneralSettings: View {
.buttonStyle(.borderedProminent)
}
}
+ .frame(maxWidth: .infinity, alignment: .leading)
}
private var cliInstaller: some View {
- VStack(alignment: .leading, spacing: 6) {
+ VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Button {
Task { await installCLI() }
@@ -669,6 +749,7 @@ struct GeneralSettings: View {
Text("Symlink \"clawdis-mac\" into /usr/local/bin and /opt/homebrew/bin for scripts.")
.font(.callout)
.foregroundStyle(.secondary)
+ .padding(.leading, 2)
}
}
@@ -676,27 +757,8 @@ struct GeneralSettings: View {
guard !isInstallingCLI else { return }
isInstallingCLI = true
defer { isInstallingCLI = false }
-
- let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/ClawdisCLI")
- guard FileManager.default.isExecutableFile(atPath: helper.path) else {
- await MainActor.run { cliStatus = "Helper missing in bundle; rebuild via scripts/package-mac-app.sh" }
- return
- }
-
- let targets = ["/usr/local/bin/clawdis-mac", "/opt/homebrew/bin/clawdis-mac"]
- var messages: [String] = []
- for target in targets {
- do {
- try FileManager.default.createDirectory(atPath: (target as NSString).deletingLastPathComponent, withIntermediateDirectories: true)
- try? FileManager.default.removeItem(atPath: target)
- try FileManager.default.createSymbolicLink(atPath: target, withDestinationPath: helper.path)
- messages.append("Linked \(target)")
- } catch {
- messages.append("Failed \(target): \(error.localizedDescription)")
- }
- }
- await MainActor.run {
- cliStatus = messages.joined(separator: "; ")
+ await CLIInstaller.install { status in
+ await MainActor.run { cliStatus = status }
}
}
}
@@ -707,9 +769,12 @@ struct PermissionsSettings: View {
let showOnboarding: () -> Void
var body: some View {
- VStack(alignment: .leading, spacing: 12) {
+ VStack(alignment: .leading, spacing: 14) {
Text("Allow these so Clawdis can notify and capture when needed.")
+ .padding(.bottom, 2)
PermissionStatusList(status: status, refresh: refresh)
+ .padding(14)
+ .background(RoundedRectangle(cornerRadius: 12).fill(Color(NSColor.controlBackgroundColor)))
Button("Show onboarding") { showOnboarding() }
Spacer()
}
@@ -750,18 +815,91 @@ struct DebugSettings: View {
}
struct AboutSettings: View {
+ @State private var iconHover = false
+
var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- Text("Clawdis Companion")
- .font(.title2.bold())
- let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
- Text("Version \(version)")
- Text("Menu bar helper for notifications, screenshots, and privileged actions.")
- .foregroundColor(.secondary)
- Divider()
- Link("View repository", destination: URL(string: "https://github.com/steipete/warelay")!)
+ VStack(spacing: 10) {
+ let appIcon = NSApplication.shared.applicationIconImage ?? CritterIconRenderer.makeIcon(blink: 0)
+ Button {
+ if let url = URL(string: "https://github.com/steipete/warelay") {
+ NSWorkspace.shared.open(url)
+ }
+ } label: {
+ Image(nsImage: appIcon)
+ .resizable()
+ .frame(width: 86, height: 86)
+ .cornerRadius(18)
+ .shadow(color: iconHover ? .accentColor.opacity(0.25) : .clear, radius: 8)
+ .scaleEffect(iconHover ? 1.06 : 1.0)
+ .padding(.bottom, 4)
+ }
+ .buttonStyle(.plain)
+ .onHover { hover in
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.72)) {
+ iconHover = hover
+ }
+ }
+
+ VStack(spacing: 2) {
+ Text("Clawdis")
+ .font(.title3.bold())
+ Text("Version \(versionString)")
+ .foregroundStyle(.secondary)
+ Text("Menu bar companion for notifications, screenshots, and privileged agent actions.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 12)
+ }
+
+ VStack(alignment: .center, spacing: 6) {
+ AboutLinkRow(icon: "chevron.left.slash.chevron.right", title: "GitHub", url: "https://github.com/steipete/warelay")
+ AboutLinkRow(icon: "globe", title: "Website", url: "https://steipete.me")
+ AboutLinkRow(icon: "bird", title: "Twitter", url: "https://twitter.com/steipete")
+ AboutLinkRow(icon: "envelope", title: "Email", url: "mailto:peter@steipete.me")
+ }
+ .padding(.vertical, 10)
+
+ Text("© 2025 Peter Steinberger — MIT License.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .padding(.top, 6)
+
Spacer()
}
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(.horizontal, 18)
+ .padding(.bottom, 22)
+ }
+
+ private var versionString: String {
+ let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev"
+ let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
+ return build.map { "\(version) (\($0))" } ?? version
+ }
+}
+
+@MainActor
+private struct AboutLinkRow: View {
+ let icon: String
+ let title: String
+ let url: String
+
+ @State private var hovering = false
+
+ var body: some View {
+ Button {
+ if let url = URL(string: url) { NSWorkspace.shared.open(url) }
+ } label: {
+ HStack(spacing: 6) {
+ Image(systemName: icon)
+ Text(title)
+ .underline(hovering, color: .accentColor)
+ }
+ .foregroundColor(.accentColor)
+ }
+ .buttonStyle(.plain)
+ .onHover { hovering = $0 }
}
}
@@ -770,7 +908,7 @@ struct PermissionStatusList: View {
let refresh: () async -> Void
var body: some View {
- VStack(alignment: .leading, spacing: 8) {
+ VStack(alignment: .leading, spacing: 10) {
row(label: "Notifications", cap: .notifications, action: requestNotifications)
row(label: "Accessibility", cap: .accessibility) {
openSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")
@@ -780,6 +918,7 @@ struct PermissionStatusList: View {
}
Button("Refresh status") { Task { await refresh() } }
.font(.footnote)
+ .padding(.top, 2)
}
}
@@ -811,92 +950,100 @@ struct PermissionStatusList: View {
}
}
-// MARK: - Permissions window stub
+enum LaunchdManager {
+ private static func runLaunchctl(_ args: [String]) {
+ let process = Process()
+ process.launchPath = "/bin/launchctl"
+ process.arguments = args
+ try? process.run()
+ }
-@MainActor
-final class PermissionsSheetController {
- static let shared = PermissionsSheetController()
+ static func startClawdis() {
+ let userTarget = "gui/\(getuid())/\(launchdLabel)"
+ runLaunchctl(["kickstart", "-k", userTarget])
+ }
- private var window: NSWindow?
-
- func show(state: AppState) {
- if let window {
- window.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- return
- }
-
- let hosting = NSHostingController(rootView: PermissionsView())
- let window = NSWindow(contentViewController: hosting)
- window.title = "Permissions"
- window.setContentSize(NSSize(width: 360, height: 220))
- window.styleMask = [.titled, .closable, .miniaturizable]
- window.isReleasedWhenClosed = false
- window.center()
- window.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- self.window = window
+ static func stopClawdis() {
+ let userTarget = "gui/\(getuid())/\(launchdLabel)"
+ runLaunchctl(["stop", userTarget])
}
}
-struct PermissionsView: View {
- @State private var notificationStatus: String = "Unknown"
+@MainActor
+enum CLIInstaller {
+ static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
+ let helper = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/ClawdisCLI")
+ guard FileManager.default.isExecutableFile(atPath: helper.path) else {
+ await statusHandler("Helper missing in bundle; rebuild via scripts/package-mac-app.sh")
+ return
+ }
+
+ let targets = ["/usr/local/bin/clawdis-mac", "/opt/homebrew/bin/clawdis-mac"]
+ var messages: [String] = []
+ for target in targets {
+ do {
+ try FileManager.default.createDirectory(atPath: (target as NSString).deletingLastPathComponent, withIntermediateDirectories: true)
+ try? FileManager.default.removeItem(atPath: target)
+ try FileManager.default.createSymbolicLink(atPath: target, withDestinationPath: helper.path)
+ messages.append("Linked \(target)")
+ } catch {
+ messages.append("Failed \(target): \(error.localizedDescription)")
+ }
+ }
+ await statusHandler(messages.joined(separator: "; "))
+ }
+}
+
+private struct PermissionRow: View {
+ let capability: Capability
+ let status: Bool
+ let action: () -> Void
var body: some View {
- VStack(alignment: .leading, spacing: 12) {
- Text("Grant the permissions below so Clawdis can help.")
- if AppStateStore.isPausedFlag {
- Text("Clawdis is paused. Unpause to enable actions.")
- .foregroundColor(.orange)
+ HStack(spacing: 12) {
+ ZStack {
+ Circle().fill(status ? Color.green.opacity(0.2) : Color.gray.opacity(0.15))
+ .frame(width: 32, height: 32)
+ Image(systemName: icon)
+ .foregroundStyle(status ? Color.green : Color.secondary)
}
- Divider()
- HStack {
- Text("Notifications")
- Spacer()
- Text(notificationStatus).foregroundColor(.secondary)
- Button("Request") { requestNotifications() }
- }
- HStack {
- Text("Accessibility")
- Spacer()
- Button("Open Settings") { openSettings(path: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") }
- }
- HStack {
- Text("Screen Recording")
- Spacer()
- Button("Open Settings") { openSettings(path: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenRecording") }
+ VStack(alignment: .leading, spacing: 2) {
+ Text(title).font(.body.weight(.semibold))
+ Text(subtitle).font(.caption).foregroundStyle(.secondary)
}
Spacer()
- Text("Tip: run 'clawdis-mac ensure-permissions --interactive' from terminal to trigger prompts.")
- .font(.footnote)
- .foregroundColor(.secondary)
+ if status {
+ Label("Granted", systemImage: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ } else {
+ Button("Grant") { action() }
+ .buttonStyle(.bordered)
+ }
}
- .padding()
- .task { await refreshNotificationStatus() }
+ .padding(.vertical, 6)
}
- private func requestNotifications() {
- Task {
- let center = UNUserNotificationCenter.current()
- _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge])
- await refreshNotificationStatus()
+ private var title: String {
+ switch capability {
+ case .notifications: return "Notifications"
+ case .accessibility: return "Accessibility"
+ case .screenRecording: return "Screen Recording"
}
}
- @MainActor
- private func refreshNotificationStatus() async {
- let settings = await UNUserNotificationCenter.current().notificationSettings()
- switch settings.authorizationStatus {
- case .notDetermined: notificationStatus = "Not determined"
- case .denied: notificationStatus = "Denied"
- case .authorized, .provisional, .ephemeral: notificationStatus = "Authorized"
- @unknown default: notificationStatus = "Unknown"
+ private var subtitle: String {
+ switch capability {
+ case .notifications: return "Show desktop alerts for agent activity"
+ case .accessibility: return "Control UI elements when an action requires it"
+ case .screenRecording: return "Capture the screen for context or screenshots"
}
}
- private func openSettings(path: String) {
- if let url = URL(string: path) {
- NSWorkspace.shared.open(url)
+ private var icon: String {
+ switch capability {
+ case .notifications: return "bell"
+ case .accessibility: return "hand.raised"
+ case .screenRecording: return "display"
}
}
}
@@ -917,7 +1064,7 @@ final class OnboardingController {
let hosting = NSHostingController(rootView: OnboardingView())
let window = NSWindow(contentViewController: hosting)
window.title = "Welcome to Clawdis"
- window.setContentSize(NSSize(width: 520, height: 420))
+ window.setContentSize(NSSize(width: 640, height: 520))
window.styleMask = [.titled, .closable]
window.center()
window.makeKeyAndOrderFront(nil)
@@ -935,63 +1082,118 @@ struct OnboardingView: View {
@State private var stepIndex = 0
@State private var permStatus: [Capability: Bool] = [:]
@State private var copied = false
+ @State private var isRequesting = false
+ @ObservedObject private var state = AppStateStore.shared
private var steps: [OnboardingStep] {
[
- .init(title: "Welcome aboard", detail: "Clawdis is your macOS companion for notifications and privileged agent actions.", accent: "sparkles"),
- .init(title: "Grant permissions", detail: "Enable Notifications, Accessibility, and Screen Recording so actions succeed.", accent: "lock.shield", showsPermissions: true),
- .init(title: "Install the CLI", detail: "Make the helper available to scripts via a quick symlink.", accent: "terminal", showsCLI: true),
- .init(title: "Done", detail: "You can pause Clawdis anytime from the menu. Happy automating!", accent: "hand.thumbsup")
+ .init(title: "Meet Clawdis", detail: "A focused menu bar companion for notifications, screenshots, and privileged agent actions.", systemImage: "sparkles"),
+ .init(title: "Permissions", detail: "Grant Notifications, Accessibility, and Screen Recording so tasks don't get blocked.", systemImage: "lock.shield", showsPermissions: true),
+ .init(title: "CLI helper", detail: "Install the `clawdis-mac` helper so scripts can talk to the app.", systemImage: "terminal", showsCLI: true),
+ .init(title: "Stay running", detail: "Enable launch at login so Clawdis is ready before the agent asks.", systemImage: "arrow.triangle.2.circlepath", showsLogin: true),
+ .init(title: "You're set", detail: "Keep Clawdis running, pause from the menu anytime, and re-run onboarding later if needed.", systemImage: "checkmark.seal")
]
}
var body: some View {
let step = steps[stepIndex]
- VStack(spacing: 16) {
- header(step: step)
+ VStack(spacing: 18) {
+ heroHeader(step: step)
contentCard(step: step)
progressDots
footerButtons
}
- .padding(20)
+ .padding(22)
+ .background(Color(NSColor.windowBackgroundColor))
.task { await refreshPerms() }
}
@ViewBuilder
- private func header(step: OnboardingStep) -> some View {
- ZStack(alignment: .leading) {
- RoundedRectangle(cornerRadius: 14)
- .fill(LinearGradient(colors: [Color.blue.opacity(0.9), Color.purple.opacity(0.85)], startPoint: .topLeading, endPoint: .bottomTrailing))
- .frame(height: 100)
- VStack(alignment: .leading, spacing: 6) {
- Label(step.title, systemImage: step.accent)
- .font(.title3.bold())
- .foregroundColor(.white)
- Text(step.detail)
- .foregroundColor(Color.white.opacity(0.92))
- .font(.subheadline)
+ private func heroHeader(step: OnboardingStep) -> some View {
+ HStack(spacing: 16) {
+ ZStack {
+ LinearGradient(colors: [.accentColor.opacity(0.75), .purple.opacity(0.65)], startPoint: .topLeading, endPoint: .bottomTrailing)
+ .frame(width: 96, height: 96)
+ .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
+ .shadow(color: .accentColor.opacity(0.35), radius: 12, y: 6)
+ Image(nsImage: CritterIconRenderer.makeIcon(blink: 0))
+ .resizable()
+ .renderingMode(.template)
+ .foregroundStyle(.white)
+ .frame(width: 54, height: 48)
}
- .padding(.horizontal, 16)
+
+ VStack(alignment: .leading, spacing: 6) {
+ Label(step.title, systemImage: step.systemImage)
+ .font(.title3.bold())
+ Text(step.detail)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
}
}
@ViewBuilder
private func contentCard(step: OnboardingStep) -> some View {
- VStack(alignment: .leading, spacing: 12) {
+ VStack(alignment: .leading, spacing: 14) {
if step.showsPermissions {
- PermissionStatusList(status: permStatus, refresh: refreshPerms)
- .padding(10)
- .background(RoundedRectangle(cornerRadius: 10).fill(Color(NSColor.controlBackgroundColor)))
+ permissionsCard
}
if step.showsCLI {
CLIInstallCard(copied: $copied)
}
+ if step.showsLoginToggle {
+ loginCard
+ }
if !step.showsPermissions && !step.showsCLI {
- Text("Keep Clawdis running in your menu bar. Use the Pause toggle anytime if you need to mute actions.")
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Keep Clawdis running in your menu bar. Pause from the menu if you need silence, or open Settings to adjust permissions later.")
+ Button("Open Settings") {
+ NSApp.activate(ignoringOtherApps: true)
+ SettingsTabRouter.request(.general)
+ NSApplication.shared.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil)
+ }
+ }
}
}
- .padding(14)
- .background(RoundedRectangle(cornerRadius: 14).stroke(Color.gray.opacity(0.2)))
+ .padding(16)
+ .background(RoundedRectangle(cornerRadius: 16, style: .continuous).fill(Color(NSColor.controlBackgroundColor)))
+ }
+
+ private var loginCard: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Launch at login")
+ .font(.headline)
+ Text("Keep the companion ready before automations start. You can change this anytime in Settings.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Toggle("Enable launch at login", isOn: $state.launchAtLogin)
+ .toggleStyle(.switch)
+ .onChange(of: state.launchAtLogin) { _, newValue in
+ AppStateStore.updateLaunchAtLogin(enabled: newValue)
+ }
+ }
+ }
+
+ private var permissionsCard: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Give Clawdis the access it needs")
+ .font(.headline)
+ Text("Grant these once; the CLI will reuse the same approvals.")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+
+ ForEach(Capability.allCases, id: \.self) { cap in
+ PermissionRow(capability: cap, status: permStatus[cap] ?? false) {
+ Task { await request(cap) }
+ }
+ }
+
+ Button("Refresh status") { Task { await refreshPerms() } }
+ .font(.footnote)
+ .padding(.top, 4)
+ }
}
private var progressDots: some View {
@@ -1016,11 +1218,10 @@ struct OnboardingView: View {
if stepIndex > 0 {
Button("Back") { stepIndex = max(0, stepIndex - 1) }
}
- Button(stepIndex == steps.count - 1 ? "Finish" : "Next") {
- advance()
- }
- .buttonStyle(.borderedProminent)
+ Button(stepIndex == steps.count - 1 ? "Finish" : "Next") { advance() }
+ .buttonStyle(.borderedProminent)
}
+ .padding(.top, 4)
}
private func advance() {
@@ -1040,36 +1241,68 @@ struct OnboardingView: View {
private func refreshPerms() async {
permStatus = await PermissionManager.status()
}
+
+ @MainActor
+ private func request(_ cap: Capability) async {
+ guard !isRequesting else { return }
+ isRequesting = true
+ defer { isRequesting = false }
+ _ = await PermissionManager.ensure([cap], interactive: true)
+ await refreshPerms()
+ }
}
struct OnboardingStep {
let title: String
let detail: String
- let accent: String
+ let systemImage: String
var showsPermissions: Bool = false
var showsCLI: Bool = false
+ var showsLogin: Bool = false
+
+ var showsLoginToggle: Bool { showsLogin }
}
struct CLIInstallCard: View {
@Binding var copied: Bool
+ @State private var installing = false
+ @State private var status: String?
private let command = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac"
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Install the helper CLI")
.font(.headline)
- Text("Run this once to expose the helper to your shell:")
- HStack {
- Text(command)
- .font(.system(.footnote, design: .monospaced))
- .lineLimit(2)
- Spacer()
- Button(copied ? "Copied" : "Copy") {
+ Text("Link `clawdis-mac` so scripts and the agent can call the companion.")
+
+ HStack(spacing: 10) {
+ Button {
+ Task {
+ guard !installing else { return }
+ installing = true
+ defer { installing = false }
+ await CLIInstaller.install { msg in await MainActor.run { status = msg } }
+ }
+ } label: {
+ if installing { ProgressView() } else { Text("Install helper") }
+ }
+ .buttonStyle(.borderedProminent)
+
+ Button(copied ? "Copied" : "Copy command") {
copyToPasteboard(command)
}
+ .disabled(installing)
}
- .padding(8)
- .background(RoundedRectangle(cornerRadius: 8).fill(Color(NSColor.controlBackgroundColor)))
+
+ if let status {
+ Text(status)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Text("You can rerun this anytime; we install into /usr/local/bin and /opt/homebrew/bin.")
+ .font(.footnote)
+ .foregroundStyle(.secondary)
}
}
diff --git a/scripts/build-and-run-mac.sh b/scripts/build-and-run-mac.sh
index 585b297f9..198f48818 100755
--- a/scripts/build-and-run-mac.sh
+++ b/scripts/build-and-run-mac.sh
@@ -7,7 +7,7 @@ PRODUCT="Clawdis"
BIN="$BUILD_PATH/debug/$PRODUCT"
printf "\n▶️ Building $PRODUCT (debug, build path: $BUILD_PATH)\n"
-swift build -c debug --product "$PRODUCT" --build-path "$BUILD_PATH"
+swift build -c debug --product "$PRODUCT" --product "${PRODUCT}CLI" --build-path "$BUILD_PATH"
printf "\n⏹ Stopping existing $PRODUCT...\n"
killall -q "$PRODUCT" 2>/dev/null || true
diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh
index d4f455a31..9f73cd32a 100755
--- a/scripts/package-mac-app.sh
+++ b/scripts/package-mac-app.sh
@@ -12,7 +12,7 @@ PRODUCT="Clawdis"
cd "$ROOT_DIR/apps/macos"
echo "🔨 Building $PRODUCT (debug)"
-swift build -c debug --product "$PRODUCT" --build-path "$BUILD_PATH"
+swift build -c debug --product "$PRODUCT" --product "${PRODUCT}CLI" --build-path "$BUILD_PATH"
BIN="$BUILD_PATH/debug/$PRODUCT"
CLI_BIN="$BUILD_PATH/debug/ClawdisCLI"
@@ -39,6 +39,12 @@ cat > "$APP_ROOT/Contents/Info.plist" <<'PLIST'
15.0
LSUIElement
+ NSUserNotificationUsageDescription
+ Clawdis needs notification permission to show alerts for agent actions.
+ NSScreenCaptureDescription
+ Clawdis captures the screen when the agent needs screenshots for context.
+ NSMicrophoneUsageDescription
+ Clawdis may record screen or audio when requested by the agent.
PLIST
diff --git a/scripts/restart-mac.sh b/scripts/restart-mac.sh
new file mode 100755
index 000000000..163032006
--- /dev/null
+++ b/scripts/restart-mac.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+# Kill any running Clawdis, rebuild/package, relaunch packaged app, and verify it is alive.
+
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+APP_BUNDLE="${ROOT_DIR}/dist/Clawdis.app"
+APP_PROCESS_PATTERN="Clawdis.app/Contents/MacOS/Clawdis"
+DEBUG_PROCESS_PATTERN="${ROOT_DIR}/apps/macos/.build-local/debug/Clawdis"
+RELEASE_PROCESS_PATTERN="${ROOT_DIR}/apps/macos/.build-local/release/Clawdis"
+
+log() { printf '%s\n' "$*"; }
+fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
+
+kill_all_clawdis() {
+ for _ in {1..10}; do
+ pkill -f "${APP_PROCESS_PATTERN}" 2>/dev/null || true
+ pkill -f "${DEBUG_PROCESS_PATTERN}" 2>/dev/null || true
+ pkill -f "${RELEASE_PROCESS_PATTERN}" 2>/dev/null || true
+ pkill -x "Clawdis" 2>/dev/null || true
+ if ! pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1 \
+ && ! pgrep -f "${DEBUG_PROCESS_PATTERN}" >/dev/null 2>&1 \
+ && ! pgrep -f "${RELEASE_PROCESS_PATTERN}" >/dev/null 2>&1 \
+ && ! pgrep -x "Clawdis" >/dev/null 2>&1; then
+ return 0
+ fi
+ sleep 0.25
+ done
+}
+
+log "==> Killing existing Clawdis instances"
+kill_all_clawdis
+
+log "==> Packaging + launching app"
+"${ROOT_DIR}/scripts/package-mac-app.sh"
+
+log "==> Verifying app is running"
+sleep 1
+if pgrep -f "${APP_PROCESS_PATTERN}" >/dev/null 2>&1; then
+ log "OK: Clawdis is running."
+else
+ fail "App exited immediately. Check /tmp/clawdis.log or Console.app (User Reports)."
+fi