diff --git a/contrib/macos-control-panel/LICENSE b/contrib/macos-control-panel/LICENSE
new file mode 100644
index 000000000..b77bf2ab7
--- /dev/null
+++ b/contrib/macos-control-panel/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/contrib/macos-control-panel/Makefile b/contrib/macos-control-panel/Makefile
new file mode 100644
index 000000000..015d648b0
--- /dev/null
+++ b/contrib/macos-control-panel/Makefile
@@ -0,0 +1,13 @@
+.PHONY: build install clean
+
+build:
+ @./build.sh
+
+install:
+ @./install.sh
+
+clean:
+ rm -rf build/
+
+run: build
+ open "build/Clawdbot Control.app"
diff --git a/contrib/macos-control-panel/README.md b/contrib/macos-control-panel/README.md
new file mode 100644
index 000000000..6130d27a3
--- /dev/null
+++ b/contrib/macos-control-panel/README.md
@@ -0,0 +1,129 @@
+# Clawdbot Control
+
+A native macOS application for managing Clawdbot. Provides a graphical interface for users who prefer not to use the command line.
+
+## Requirements
+
+- macOS 13.0 (Ventura) or later
+- Apple Silicon (ARM64) Mac
+- [Clawdbot](https://github.com/clawdbot/clawdbot) installed via Homebrew at `/opt/homebrew/bin/clawdbot`
+
+## Installation
+
+### Quick Install
+
+```bash
+./install.sh
+```
+
+### Manual Build
+
+```bash
+./build.sh
+cp -R build/Clawdbot\ Control.app /Applications/
+```
+
+## Features
+
+### Main Dashboard
+
+- Start, stop, and restart the Clawdbot gateway
+- View running status with uptime display
+- Monitor CPU and memory usage
+- View and search logs in real-time
+- Run health checks (`clawdbot doctor`)
+- View channel status (WhatsApp, Signal)
+
+### Menu Bar
+
+- Quick access from the menu bar icon
+- Start/Stop/Restart controls
+- Open dashboard
+- Run health check
+- Toggle auto-start
+
+### Settings
+
+- Auto-start on login (via LaunchAgent)
+- System notifications for status changes
+- Configurable refresh interval
+- Log display options
+
+## Usage
+
+### Opening the App
+
+- **Spotlight**: Press `Cmd+Space`, type "Clawdbot", press Enter
+- **Menu Bar**: Click the antenna icon in the top-right corner
+- **Applications**: Double-click in `/Applications/Clawdbot Control.app`
+
+### Basic Controls
+
+| Action | Description |
+|--------|-------------|
+| Start | Starts the Clawdbot gateway |
+| Stop | Stops the Clawdbot gateway |
+| Restart | Stops and starts the gateway |
+| Dashboard | Opens the web dashboard at http://127.0.0.1:18789 |
+| Health Check | Runs `clawdbot doctor` diagnostics |
+
+### Auto-Start on Login
+
+Enable auto-start from the Settings sidebar. This creates a LaunchAgent at:
+```
+~/Library/LaunchAgents/com.clawdbot.autostart.plist
+```
+
+## File Structure
+
+```
+clawdbot-control-app/
+├── src/
+│ ├── main.swift # SwiftUI application source
+│ └── Info.plist # Application metadata
+├── resources/
+│ ├── AppIcon.icns # Application icon
+│ └── icon_1024.png # Icon source image
+├── build.sh # Build script
+├── install.sh # Build and install script
+└── README.md
+```
+
+## Configuration
+
+The app reads Clawdbot configuration from:
+```
+~/.clawdbot/clawdbot.json
+```
+
+Logs are read from:
+```
+/tmp/clawdbot/clawdbot-YYYY-MM-DD.log
+```
+
+## Technical Details
+
+- **Language**: Swift 5 with SwiftUI
+- **Frameworks**: AppKit, SwiftUI, UserNotifications
+- **Architecture**: Native ARM64
+- **Bundle ID**: com.clawdbot.control
+
+## Troubleshooting
+
+### App does not detect Clawdbot
+
+Ensure Clawdbot is installed at `/opt/homebrew/bin/clawdbot`. The app expects this path for Homebrew installations on Apple Silicon Macs.
+
+### Menu bar icon not appearing
+
+The app must be running for the menu bar icon to appear. Check Activity Monitor for "ClawdbotControl" process.
+
+### Auto-start not working
+
+1. Check that the LaunchAgent file exists at `~/Library/LaunchAgents/com.clawdbot.autostart.plist`
+2. Run `launchctl list | grep clawdbot` to verify it is loaded
+3. Check Console.app for any error messages
+
+## License
+
+MIT License
diff --git a/contrib/macos-control-panel/build.sh b/contrib/macos-control-panel/build.sh
new file mode 100755
index 000000000..b34f273ac
--- /dev/null
+++ b/contrib/macos-control-panel/build.sh
@@ -0,0 +1,41 @@
+#!/bin/bash
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+APP_NAME="Clawdbot Control"
+BUNDLE_NAME="Clawdbot Control.app"
+BUILD_DIR="$SCRIPT_DIR/build"
+
+echo "Building $APP_NAME..."
+
+# Create build directory
+rm -rf "$BUILD_DIR"
+mkdir -p "$BUILD_DIR"
+
+# Compile Swift source
+echo "Compiling Swift source..."
+swiftc -parse-as-library \
+ -o "$BUILD_DIR/ClawdbotControl" \
+ "$SCRIPT_DIR/src/main.swift" \
+ -framework AppKit \
+ -framework SwiftUI \
+ -framework UserNotifications
+
+# Create app bundle structure
+echo "Creating app bundle..."
+mkdir -p "$BUILD_DIR/$BUNDLE_NAME/Contents/MacOS"
+mkdir -p "$BUILD_DIR/$BUNDLE_NAME/Contents/Resources"
+
+# Copy files
+cp "$BUILD_DIR/ClawdbotControl" "$BUILD_DIR/$BUNDLE_NAME/Contents/MacOS/"
+cp "$SCRIPT_DIR/src/Info.plist" "$BUILD_DIR/$BUNDLE_NAME/Contents/"
+cp "$SCRIPT_DIR/resources/AppIcon.icns" "$BUILD_DIR/$BUNDLE_NAME/Contents/Resources/"
+
+# Set executable permission
+chmod +x "$BUILD_DIR/$BUNDLE_NAME/Contents/MacOS/ClawdbotControl"
+
+echo ""
+echo "Build complete: $BUILD_DIR/$BUNDLE_NAME"
+echo ""
+echo "To install, run:"
+echo " cp -R \"$BUILD_DIR/$BUNDLE_NAME\" /Applications/"
diff --git a/contrib/macos-control-panel/install.sh b/contrib/macos-control-panel/install.sh
new file mode 100755
index 000000000..f12fcfc27
--- /dev/null
+++ b/contrib/macos-control-panel/install.sh
@@ -0,0 +1,23 @@
+#!/bin/bash
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+# Build first
+"$SCRIPT_DIR/build.sh"
+
+# Install to Applications
+echo "Installing to /Applications..."
+rm -rf "/Applications/Clawdbot Control.app"
+cp -R "$SCRIPT_DIR/build/Clawdbot Control.app" /Applications/
+
+# Register with Launch Services
+/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -f "/Applications/Clawdbot Control.app"
+
+echo ""
+echo "Installation complete."
+echo ""
+echo "You can now:"
+echo " - Open from Spotlight (Cmd+Space, type 'Clawdbot')"
+echo " - Open from Applications folder"
+echo " - Use the menu bar icon"
diff --git a/contrib/macos-control-panel/resources/AppIcon.icns b/contrib/macos-control-panel/resources/AppIcon.icns
new file mode 100644
index 000000000..60f421636
Binary files /dev/null and b/contrib/macos-control-panel/resources/AppIcon.icns differ
diff --git a/contrib/macos-control-panel/resources/icon_1024.png b/contrib/macos-control-panel/resources/icon_1024.png
new file mode 100644
index 000000000..59f74e22b
Binary files /dev/null and b/contrib/macos-control-panel/resources/icon_1024.png differ
diff --git a/contrib/macos-control-panel/src/Info.plist b/contrib/macos-control-panel/src/Info.plist
new file mode 100644
index 000000000..e6af97024
--- /dev/null
+++ b/contrib/macos-control-panel/src/Info.plist
@@ -0,0 +1,38 @@
+
+
+
+
+ CFBundleExecutable
+ ClawdbotControl
+ CFBundleIconFile
+ AppIcon
+ CFBundleIdentifier
+ com.clawdbot.control
+ CFBundleName
+ Clawdbot Control
+ CFBundleDisplayName
+ Clawdbot Control
+ CFBundleShortVersionString
+ 1.0.0
+ CFBundleVersion
+ 1
+ CFBundlePackageType
+ APPL
+ CFBundleInfoDictionaryVersion
+ 6.0
+ LSMinimumSystemVersion
+ 13.0
+ LSUIElement
+
+ NSHighResolutionCapable
+
+ NSSupportsAutomaticTermination
+
+ NSSupportsSuddenTermination
+
+ LSApplicationCategoryType
+ public.app-category.utilities
+ NSAppleEventsUsageDescription
+ Clawdbot Control needs to run shell commands to manage the Clawdbot gateway.
+
+
diff --git a/contrib/macos-control-panel/src/main.swift b/contrib/macos-control-panel/src/main.swift
new file mode 100644
index 000000000..eaa92b114
--- /dev/null
+++ b/contrib/macos-control-panel/src/main.swift
@@ -0,0 +1,965 @@
+import SwiftUI
+import AppKit
+import UserNotifications
+
+@main
+struct ClawdbotControlApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+ @StateObject private var manager = ClawdbotManager()
+
+ var body: some Scene {
+ WindowGroup {
+ DashboardView()
+ .environmentObject(manager)
+ .frame(minWidth: 680, minHeight: 700)
+ }
+ .windowStyle(.titleBar)
+ .windowToolbarStyle(.unified(showsTitle: true))
+
+ MenuBarExtra("Clawdbot", systemImage: manager.isRunning ? "antenna.radiowaves.left.and.right.circle.fill" : "antenna.radiowaves.left.and.right.circle") {
+ MenuBarView()
+ .environmentObject(manager)
+ }
+ .menuBarExtraStyle(.menu)
+ }
+}
+
+class AppDelegate: NSObject, NSApplicationDelegate {
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ NSApp.setActivationPolicy(.regular)
+ UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, _ in }
+ }
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
+}
+
+// MARK: - Settings Store
+class SettingsStore: ObservableObject {
+ @AppStorage("autoStart") var autoStart = false
+ @AppStorage("showMenuBar") var showMenuBar = true
+ @AppStorage("notificationsEnabled") var notificationsEnabled = true
+ @AppStorage("refreshInterval") var refreshInterval = 4.0
+ @AppStorage("showResourcesInFooter") var showResourcesInFooter = true
+ @AppStorage("compactLogs") var compactLogs = true
+ @AppStorage("logLines") var logLines = 30
+}
+
+// MARK: - Manager
+class ClawdbotManager: ObservableObject {
+ @Published var isRunning = false
+ @Published var version = ""
+ @Published var logs: [String] = []
+ @Published var channels: [(name: String, enabled: Bool)] = []
+ @Published var startedAt: Date?
+ @Published var autoStartEnabled = false
+ @Published var cpuUsage: Double = 0
+ @Published var memoryUsage: Double = 0
+ @Published var isRestarting = false
+ @Published var lastChecked = Date()
+ @Published var healthStatus: String = "Unknown"
+ @Published var healthDetails: [String] = []
+ @Published var isCheckingHealth = false
+ @Published var showSettings = false
+
+ let settings = SettingsStore()
+ private var timer: Timer?
+ private let path = "/opt/homebrew/bin/clawdbot"
+ private var pid: Int32?
+ private var wasRunning = false
+
+ init() {
+ checkAutoStart()
+ refresh()
+ startTimer()
+ }
+
+ func startTimer() {
+ timer?.invalidate()
+ timer = Timer.scheduledTimer(withTimeInterval: settings.refreshInterval, repeats: true) { [weak self] _ in
+ self?.refresh()
+ }
+ }
+
+ func refresh() {
+ DispatchQueue.global(qos: .utility).async { [weak self] in
+ self?.checkStatus()
+ self?.loadVersion()
+ self?.loadChannels()
+ self?.loadLogs()
+ self?.loadResources()
+ DispatchQueue.main.async { self?.lastChecked = Date() }
+ }
+ }
+
+ private func checkStatus() {
+ let result = shell("pgrep -f 'clawdbot-gateway' 2>/dev/null").trimmingCharacters(in: .whitespacesAndNewlines)
+ let running = !result.isEmpty
+ if let p = Int32(result.components(separatedBy: "\n").first ?? "") { pid = p }
+
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ if running && !self.isRunning { self.startedAt = Date() }
+ if !running { self.startedAt = nil; self.pid = nil }
+ if self.wasRunning != running && self.settings.notificationsEnabled { self.notify(running: running) }
+ self.wasRunning = running
+ self.isRunning = running
+ }
+ }
+
+ private func loadVersion() {
+ let v = shell("\(path) --version 2>&1 | head -1").trimmingCharacters(in: .whitespacesAndNewlines)
+ DispatchQueue.main.async { [weak self] in self?.version = v }
+ }
+
+ private func loadChannels() {
+ let configPath = NSHomeDirectory() + "/.clawdbot/clawdbot.json"
+ guard let data = FileManager.default.contents(atPath: configPath),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let plugins = json["plugins"] as? [String: Any],
+ let entries = plugins["entries"] as? [String: Any] else { return }
+
+ var ch: [(String, Bool)] = []
+ if let w = entries["whatsapp"] as? [String: Any] { ch.append(("WhatsApp", w["enabled"] as? Bool ?? false)) }
+ if let s = entries["signal"] as? [String: Any] { ch.append(("Signal", s["enabled"] as? Bool ?? false)) }
+ DispatchQueue.main.async { [weak self] in self?.channels = ch }
+ }
+
+ private func loadLogs() {
+ let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
+ let logPath = "/tmp/clawdbot/clawdbot-\(df.string(from: Date())).log"
+ let lines = Int(settings.logLines)
+ let result = shell("tail -\(lines) '\(logPath)' 2>/dev/null")
+ let logLines = result.components(separatedBy: "\n").filter { !$0.isEmpty }
+ DispatchQueue.main.async { [weak self] in self?.logs = Array(logLines.suffix(lines)) }
+ }
+
+ private func loadResources() {
+ guard let p = pid else {
+ DispatchQueue.main.async { [weak self] in self?.cpuUsage = 0; self?.memoryUsage = 0 }
+ return
+ }
+ let result = shell("ps -p \(p) -o %cpu,%mem 2>/dev/null | tail -1")
+ let parts = result.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .whitespaces).filter { !$0.isEmpty }
+ DispatchQueue.main.async { [weak self] in
+ self?.cpuUsage = Double(parts.first ?? "0") ?? 0
+ self?.memoryUsage = Double(parts.dropFirst().first ?? "0") ?? 0
+ }
+ }
+
+ func start() {
+ DispatchQueue.global().async { [weak self] in
+ guard let self = self else { return }
+ _ = self.shell("\(self.path) gateway start >/dev/null 2>&1 &")
+ sleep(2)
+ self.refresh()
+ }
+ }
+
+ func stop() {
+ DispatchQueue.global().async { [weak self] in
+ guard let self = self else { return }
+ _ = self.shell("\(self.path) gateway stop >/dev/null 2>&1")
+ sleep(1)
+ self.refresh()
+ }
+ }
+
+ func restart() {
+ DispatchQueue.main.async { self.isRestarting = true }
+ DispatchQueue.global().async { [weak self] in
+ guard let self = self else { return }
+ _ = self.shell("\(self.path) gateway stop >/dev/null 2>&1")
+ sleep(2)
+ _ = self.shell("\(self.path) gateway start >/dev/null 2>&1 &")
+ sleep(2)
+ DispatchQueue.main.async { self.isRestarting = false }
+ self.refresh()
+ }
+ }
+
+ func runHealthCheck() {
+ DispatchQueue.main.async { self.isCheckingHealth = true }
+ DispatchQueue.global().async { [weak self] in
+ guard let self = self else { return }
+ let result = self.shell("\(self.path) doctor 2>&1")
+ let lines = result.components(separatedBy: "\n").filter { !$0.isEmpty }
+ DispatchQueue.main.async {
+ self.healthDetails = Array(lines.prefix(10))
+ if result.lowercased().contains("error") || result.lowercased().contains("fail") {
+ self.healthStatus = "Issues Found"
+ } else if result.lowercased().contains("warn") {
+ self.healthStatus = "Warnings"
+ } else {
+ self.healthStatus = "Healthy"
+ }
+ self.isCheckingHealth = false
+ }
+ }
+ }
+
+ func openDashboard() { NSWorkspace.shared.open(URL(string: "http://127.0.0.1:18789/")!) }
+ func openConfig() { NSWorkspace.shared.selectFile(NSHomeDirectory() + "/.clawdbot/clawdbot.json", inFileViewerRootedAtPath: "") }
+ func openLogs() {
+ let df = DateFormatter(); df.dateFormat = "yyyy-MM-dd"
+ let logPath = "/tmp/clawdbot/clawdbot-\(df.string(from: Date())).log"
+ NSWorkspace.shared.selectFile(logPath, inFileViewerRootedAtPath: "")
+ }
+
+ func checkAutoStart() {
+ let exists = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/com.clawdbot.autostart.plist")
+ DispatchQueue.main.async { [weak self] in
+ self?.autoStartEnabled = exists
+ self?.settings.autoStart = exists
+ }
+ }
+
+ func setAutoStart(_ on: Bool) {
+ DispatchQueue.global().async { [weak self] in
+ guard let self = self else { return }
+ let dir = NSHomeDirectory() + "/Library/LaunchAgents"
+ let plist = dir + "/com.clawdbot.autostart.plist"
+ if on {
+ try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ let content = """
+
+
+
+Labelcom.clawdbot.autostart
+ProgramArguments\(self.path)gatewaystart
+RunAtLoad
+
+"""
+ try? content.write(toFile: plist, atomically: true, encoding: .utf8)
+ _ = self.shell("launchctl load '\(plist)' 2>/dev/null")
+ } else {
+ _ = self.shell("launchctl unload '\(plist)' 2>/dev/null")
+ try? FileManager.default.removeItem(atPath: plist)
+ }
+ self.checkAutoStart()
+ }
+ }
+
+ private func notify(running: Bool) {
+ let c = UNMutableNotificationContent()
+ c.title = running ? "Clawdbot Started" : "Clawdbot Stopped"
+ c.sound = running ? .default : .defaultCritical
+ UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: UUID().uuidString, content: c, trigger: nil))
+ }
+
+ func shell(_ cmd: String) -> String {
+ let task = Process(); let pipe = Pipe()
+ task.standardOutput = pipe; task.standardError = pipe
+ task.launchPath = "/bin/zsh"; task.arguments = ["-c", cmd]
+ try? task.run(); task.waitUntilExit()
+ return String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
+ }
+}
+
+// MARK: - Menu Bar
+struct MenuBarView: View {
+ @EnvironmentObject var m: ClawdbotManager
+ var body: some View {
+ Group {
+ Label(m.isRunning ? "Running" : "Stopped", systemImage: m.isRunning ? "circle.fill" : "circle")
+ Divider()
+ if m.isRunning {
+ Button("Stop") { m.stop() }
+ Button("Restart") { m.restart() }.disabled(m.isRestarting)
+ } else {
+ Button("Start") { m.start() }
+ }
+ Button("Dashboard") { m.openDashboard() }.disabled(!m.isRunning)
+ Divider()
+ Button("Run Health Check") { m.runHealthCheck() }
+ Button(m.autoStartEnabled ? "Disable Auto-start" : "Enable Auto-start") { m.setAutoStart(!m.autoStartEnabled) }
+ Divider()
+ Button("Show Window") { NSApp.activate(ignoringOtherApps: true); NSApp.windows.first?.makeKeyAndOrderFront(nil) }
+ Button("Quit") { NSApp.terminate(nil) }.keyboardShortcut("q")
+ }
+ }
+}
+
+// MARK: - Dashboard View
+struct DashboardView: View {
+ @EnvironmentObject var m: ClawdbotManager
+
+ var body: some View {
+ HStack(spacing: 0) {
+ // Main Content
+ ScrollView {
+ VStack(spacing: 16) {
+ HeaderCard().environmentObject(m)
+
+ HStack(spacing: 12) {
+ StatusCard(title: "Status", value: m.isRunning ? "Running" : "Stopped", icon: "power", color: m.isRunning ? .green : .red)
+ StatusCard(title: "Port", value: "18789", icon: "network", color: .blue)
+ StatusCard(title: "Channels", value: "\(m.channels.filter { $0.enabled }.count) active", icon: "message.fill", color: .purple)
+ StatusCard(title: "Health", value: m.healthStatus, icon: healthIcon, color: healthColor)
+ }
+
+ InfoCard().environmentObject(m)
+ QuickActionsCard().environmentObject(m)
+ ResourceCard().environmentObject(m)
+ HealthCard().environmentObject(m)
+ ChannelsCard().environmentObject(m)
+ LogsCard().environmentObject(m)
+ FooterView().environmentObject(m)
+ }
+ .padding(20)
+ }
+ .background(Color(nsColor: .windowBackgroundColor))
+
+ // Settings Sidebar
+ if m.showSettings {
+ Divider()
+ SettingsSidebar()
+ .environmentObject(m)
+ .frame(width: 220)
+ .transition(.move(edge: .trailing))
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button(action: { withAnimation(.easeInOut(duration: 0.2)) { m.showSettings.toggle() } }) {
+ Image(systemName: "gear")
+ }
+ .help("Settings")
+ }
+ }
+ }
+
+ var healthIcon: String {
+ switch m.healthStatus {
+ case "Healthy": return "checkmark.circle.fill"
+ case "Warnings": return "exclamationmark.triangle.fill"
+ case "Issues Found": return "xmark.circle.fill"
+ default: return "questionmark.circle"
+ }
+ }
+
+ var healthColor: Color {
+ switch m.healthStatus {
+ case "Healthy": return .green
+ case "Warnings": return .orange
+ case "Issues Found": return .red
+ default: return .gray
+ }
+ }
+}
+
+// MARK: - Settings Sidebar
+struct SettingsSidebar: View {
+ @EnvironmentObject var m: ClawdbotManager
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ Text("Settings")
+ .font(.headline)
+ .padding(.bottom, 4)
+
+ // Startup
+ VStack(alignment: .leading, spacing: 10) {
+ Label("Startup", systemImage: "power")
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(.secondary)
+
+ Toggle("Auto-start on login", isOn: Binding(
+ get: { m.autoStartEnabled },
+ set: { m.setAutoStart($0) }
+ ))
+ .toggleStyle(.switch)
+ .controlSize(.small)
+ }
+
+ Divider()
+
+ // Notifications
+ VStack(alignment: .leading, spacing: 10) {
+ Label("Notifications", systemImage: "bell")
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(.secondary)
+
+ Toggle("Enable notifications", isOn: m.settings.$notificationsEnabled)
+ .toggleStyle(.switch)
+ .controlSize(.small)
+ }
+
+ Divider()
+
+ // Display
+ VStack(alignment: .leading, spacing: 10) {
+ Label("Display", systemImage: "rectangle.3.group")
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(.secondary)
+
+ Toggle("Show resources in footer", isOn: m.settings.$showResourcesInFooter)
+ .toggleStyle(.switch)
+ .controlSize(.small)
+
+ Toggle("Compact log view", isOn: m.settings.$compactLogs)
+ .toggleStyle(.switch)
+ .controlSize(.small)
+ }
+
+ Divider()
+
+ // Refresh
+ VStack(alignment: .leading, spacing: 10) {
+ Label("Refresh", systemImage: "arrow.clockwise")
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(.secondary)
+
+ HStack {
+ Text("Interval")
+ .font(.caption)
+ Spacer()
+ Picker("", selection: m.settings.$refreshInterval) {
+ Text("2s").tag(2.0)
+ Text("4s").tag(4.0)
+ Text("10s").tag(10.0)
+ Text("30s").tag(30.0)
+ }
+ .pickerStyle(.menu)
+ .frame(width: 70)
+ .onChange(of: m.settings.refreshInterval) { _ in
+ m.startTimer()
+ }
+ }
+
+ HStack {
+ Text("Log lines")
+ .font(.caption)
+ Spacer()
+ Picker("", selection: m.settings.$logLines) {
+ Text("10").tag(10)
+ Text("30").tag(30)
+ Text("50").tag(50)
+ Text("100").tag(100)
+ }
+ .pickerStyle(.menu)
+ .frame(width: 70)
+ }
+ }
+
+ Divider()
+
+ // Actions
+ VStack(alignment: .leading, spacing: 10) {
+ Label("Actions", systemImage: "folder")
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(.secondary)
+
+ Button(action: { m.openConfig() }) {
+ Label("Open Config File", systemImage: "doc.text")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+
+ Button(action: { m.openLogs() }) {
+ Label("Open Log File", systemImage: "doc.plaintext")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+
+ Button(action: { openTerminal() }) {
+ Label("Open Terminal", systemImage: "terminal")
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+
+ Divider()
+
+ // About
+ VStack(alignment: .leading, spacing: 6) {
+ Label("About", systemImage: "info.circle")
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(.secondary)
+
+ Text(m.version.isEmpty ? "Clawdbot" : m.version)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Text("Control App v1.0")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+
+ Spacer()
+ }
+ .padding(16)
+ }
+ .background(.regularMaterial)
+ }
+
+ func openTerminal() {
+ NSAppleScript(source: "tell application \"Terminal\" to do script \"cd ~/.clawdbot && clawdbot\"")?.executeAndReturnError(nil)
+ }
+}
+
+// MARK: - Header Card
+struct HeaderCard: View {
+ @EnvironmentObject var m: ClawdbotManager
+
+ var body: some View {
+ HStack(spacing: 16) {
+ ZStack {
+ Circle()
+ .fill(m.isRunning ? Color.green.opacity(0.2) : Color.red.opacity(0.2))
+ .frame(width: 60, height: 60)
+ Circle()
+ .fill(m.isRunning ? Color.green : Color.red)
+ .frame(width: 20, height: 20)
+ .shadow(color: m.isRunning ? .green.opacity(0.5) : .red.opacity(0.5), radius: 8)
+ }
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Clawdbot")
+ .font(.title.weight(.bold))
+ HStack(spacing: 6) {
+ Circle()
+ .fill(m.isRunning ? Color.green : Color.red)
+ .frame(width: 8, height: 8)
+ Text(m.isRunning ? "Running" : "Stopped")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ if let started = m.startedAt {
+ Text("•").foregroundStyle(.tertiary)
+ Text("Uptime: \(formatUptime(started))")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ Spacer()
+
+ HStack(spacing: 8) {
+ Button(action: { m.isRunning ? m.stop() : m.start() }) {
+ Label(m.isRunning ? "Stop" : "Start", systemImage: m.isRunning ? "stop.fill" : "play.fill")
+ .frame(width: 70)
+ }
+ .buttonStyle(.borderedProminent)
+ .tint(m.isRunning ? .red : .green)
+
+ Button(action: { m.restart() }) {
+ Label("Restart", systemImage: "arrow.clockwise")
+ }
+ .buttonStyle(.bordered)
+ .disabled(!m.isRunning || m.isRestarting)
+ }
+ }
+ .padding(16)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ }
+
+ func formatUptime(_ date: Date) -> String {
+ let s = Int(Date().timeIntervalSince(date))
+ if s >= 3600 { return "\(s/3600)h \((s%3600)/60)m" }
+ if s >= 60 { return "\(s/60)m" }
+ return "\(s)s"
+ }
+}
+
+// MARK: - Status Card
+struct StatusCard: View {
+ let title: String
+ let value: String
+ let icon: String
+ let color: Color
+
+ var body: some View {
+ VStack(spacing: 8) {
+ Image(systemName: icon)
+ .font(.title2)
+ .foregroundStyle(color)
+ Text(value)
+ .font(.system(.subheadline, weight: .semibold))
+ .lineLimit(1)
+ Text(title)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(color.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
+ }
+}
+
+// MARK: - Info Card
+struct InfoCard: View {
+ @EnvironmentObject var m: ClawdbotManager
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Label("Information", systemImage: "info.circle")
+ .font(.headline)
+ .foregroundStyle(.secondary)
+
+ Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
+ GridRow {
+ Text("Version").foregroundStyle(.secondary)
+ Text(m.version.isEmpty ? "Unknown" : m.version)
+ }
+ GridRow {
+ Text("Config Path").foregroundStyle(.secondary)
+ Text("~/.clawdbot/clawdbot.json").textSelection(.enabled)
+ }
+ GridRow {
+ Text("Dashboard").foregroundStyle(.secondary)
+ Link("http://127.0.0.1:18789/", destination: URL(string: "http://127.0.0.1:18789/")!)
+ }
+ GridRow {
+ Text("Auto-start").foregroundStyle(.secondary)
+ Text(m.autoStartEnabled ? "Enabled" : "Disabled")
+ .foregroundStyle(m.autoStartEnabled ? .green : .secondary)
+ }
+ GridRow {
+ Text("Last Checked").foregroundStyle(.secondary)
+ Text(formatTime(m.lastChecked))
+ }
+ }
+ .font(.system(.body, design: .monospaced))
+ }
+ .padding(16)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ }
+
+ func formatTime(_ date: Date) -> String {
+ let f = DateFormatter(); f.timeStyle = .medium
+ return f.string(from: date)
+ }
+}
+
+// MARK: - Quick Actions Card
+struct QuickActionsCard: View {
+ @EnvironmentObject var m: ClawdbotManager
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Label("Quick Actions", systemImage: "bolt.fill")
+ .font(.headline)
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 12) {
+ ActionButton(title: "Dashboard", icon: "globe", color: .blue) { m.openDashboard() }
+ .opacity(m.isRunning ? 1 : 0.5)
+ ActionButton(title: "Config", icon: "folder", color: .orange) { m.openConfig() }
+ ActionButton(title: "Health", icon: "stethoscope", color: .pink) { m.runHealthCheck() }
+ ActionButton(title: "Refresh", icon: "arrow.clockwise", color: .teal) { m.refresh() }
+ }
+ }
+ .padding(16)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ }
+}
+
+struct ActionButton: View {
+ let title: String
+ let icon: String
+ let color: Color
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ VStack(spacing: 8) {
+ Image(systemName: icon)
+ .font(.title2)
+ .foregroundStyle(color)
+ Text(title)
+ .font(.caption)
+ .foregroundStyle(.primary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 14)
+ .background(color.opacity(0.1), in: RoundedRectangle(cornerRadius: 10))
+ }
+ .buttonStyle(.plain)
+ }
+}
+
+// MARK: - Resource Card
+struct ResourceCard: View {
+ @EnvironmentObject var m: ClawdbotManager
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Label("Resource Usage", systemImage: "cpu")
+ .font(.headline)
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 20) {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ Text("CPU")
+ Spacer()
+ Text(String(format: "%.1f%%", m.cpuUsage))
+ .font(.system(.body, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+ ProgressView(value: min(m.cpuUsage / 100, 1.0))
+ .tint(m.cpuUsage > 80 ? .red : (m.cpuUsage > 50 ? .orange : .green))
+ }
+
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ Text("Memory")
+ Spacer()
+ Text(String(format: "%.1f%%", m.memoryUsage))
+ .font(.system(.body, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+ ProgressView(value: min(m.memoryUsage / 100, 1.0))
+ .tint(m.memoryUsage > 80 ? .red : (m.memoryUsage > 50 ? .orange : .blue))
+ }
+ }
+
+ if let started = m.startedAt {
+ HStack {
+ Text("Uptime")
+ Spacer()
+ Text(formatUptime(started))
+ .font(.system(size: 24, weight: .medium, design: .rounded))
+ .foregroundStyle(.green)
+ }
+ .padding(.top, 4)
+ }
+ }
+ .padding(16)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ }
+
+ func formatUptime(_ date: Date) -> String {
+ let s = Int(Date().timeIntervalSince(date))
+ let d = s / 86400, h = (s % 86400) / 3600, m = (s % 3600) / 60, sec = s % 60
+ if d > 0 { return String(format: "%dd %dh %dm", d, h, m) }
+ if h > 0 { return String(format: "%dh %dm %ds", h, m, sec) }
+ return String(format: "%dm %ds", m, sec)
+ }
+}
+
+// MARK: - Health Card
+struct HealthCard: View {
+ @EnvironmentObject var m: ClawdbotManager
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Label("Health Check", systemImage: "stethoscope")
+ .font(.headline)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Button(action: { m.runHealthCheck() }) {
+ if m.isCheckingHealth {
+ ProgressView().controlSize(.small)
+ } else {
+ Label("Run", systemImage: "arrow.clockwise")
+ }
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .disabled(m.isCheckingHealth)
+ }
+
+ HStack(spacing: 8) {
+ Image(systemName: healthIcon)
+ .foregroundStyle(healthColor)
+ Text(m.healthStatus)
+ .font(.subheadline.weight(.medium))
+ }
+
+ if !m.healthDetails.isEmpty {
+ Divider()
+ VStack(alignment: .leading, spacing: 4) {
+ ForEach(Array(m.healthDetails.prefix(5).enumerated()), id: \.offset) { _, line in
+ Text(line)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ }
+ }
+ .padding(16)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ }
+
+ var healthIcon: String {
+ switch m.healthStatus {
+ case "Healthy": return "checkmark.circle.fill"
+ case "Warnings": return "exclamationmark.triangle.fill"
+ case "Issues Found": return "xmark.circle.fill"
+ default: return "questionmark.circle"
+ }
+ }
+
+ var healthColor: Color {
+ switch m.healthStatus {
+ case "Healthy": return .green
+ case "Warnings": return .orange
+ case "Issues Found": return .red
+ default: return .gray
+ }
+ }
+}
+
+// MARK: - Channels Card
+struct ChannelsCard: View {
+ @EnvironmentObject var m: ClawdbotManager
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Label("Channels", systemImage: "antenna.radiowaves.left.and.right")
+ .font(.headline)
+ .foregroundStyle(.secondary)
+
+ if m.channels.isEmpty {
+ Text("No channels configured")
+ .font(.subheadline)
+ .foregroundStyle(.tertiary)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.vertical, 8)
+ } else {
+ HStack(spacing: 12) {
+ ForEach(m.channels, id: \.name) { ch in
+ HStack(spacing: 10) {
+ Image(systemName: ch.name == "WhatsApp" ? "message.fill" : "lock.shield.fill")
+ .font(.title3)
+ .foregroundStyle(ch.enabled ? .green : .secondary)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(ch.name)
+ .font(.subheadline.weight(.medium))
+ HStack(spacing: 4) {
+ Circle()
+ .fill(ch.enabled && m.isRunning ? Color.green : Color.gray.opacity(0.4))
+ .frame(width: 6, height: 6)
+ Text(ch.enabled ? (m.isRunning ? "Connected" : "Enabled") : "Disabled")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ Spacer()
+ if ch.enabled {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ }
+ }
+ .padding(12)
+ .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
+ }
+ }
+ }
+ }
+ .padding(16)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ }
+}
+
+// MARK: - Logs Card
+struct LogsCard: View {
+ @EnvironmentObject var m: ClawdbotManager
+ @State private var expanded = false
+ @State private var searchText = ""
+
+ var filteredLogs: [String] {
+ let logs = expanded ? m.logs : Array(m.logs.suffix(5))
+ if searchText.isEmpty { return logs }
+ return logs.filter { $0.localizedCaseInsensitiveContains(searchText) }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Label("Logs", systemImage: "doc.text")
+ .font(.headline)
+ .foregroundStyle(.secondary)
+ Spacer()
+ Button(action: { withAnimation { expanded.toggle() } }) {
+ Image(systemName: expanded ? "chevron.up" : "chevron.down")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+
+ if expanded {
+ HStack {
+ Image(systemName: "magnifyingglass")
+ .foregroundStyle(.tertiary)
+ TextField("Search logs...", text: $searchText)
+ .textFieldStyle(.plain)
+ }
+ .padding(8)
+ .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
+ }
+
+ if filteredLogs.isEmpty {
+ Text("No logs available")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .padding(.vertical, 8)
+ } else {
+ VStack(alignment: .leading, spacing: m.settings.compactLogs ? 2 : 4) {
+ ForEach(Array(filteredLogs.enumerated()), id: \.offset) { _, log in
+ HStack(alignment: .top, spacing: 6) {
+ Circle()
+ .fill(logColor(log))
+ .frame(width: 6, height: 6)
+ .padding(.top, 5)
+ Text(log)
+ .font(.system(m.settings.compactLogs ? .caption2 : .caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .lineLimit(expanded ? nil : 1)
+ }
+ }
+ }
+ }
+ }
+ .padding(16)
+ .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
+ }
+
+ func logColor(_ log: String) -> Color {
+ let l = log.lowercased()
+ if l.contains("error") || l.contains("fail") { return .red }
+ if l.contains("warn") { return .orange }
+ if l.contains("success") || l.contains("started") || l.contains("connected") { return .green }
+ return .gray
+ }
+}
+
+// MARK: - Footer
+struct FooterView: View {
+ @EnvironmentObject var m: ClawdbotManager
+
+ var body: some View {
+ HStack {
+ if m.isRunning && m.settings.showResourcesInFooter {
+ HStack(spacing: 12) {
+ Label(String(format: "%.1f%%", m.cpuUsage), systemImage: "cpu")
+ Label(String(format: "%.1f%%", m.memoryUsage), systemImage: "memorychip")
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ Text("Last updated: \(formatTime(m.lastChecked))")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ Button(action: { m.refresh() }) {
+ Image(systemName: "arrow.clockwise")
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.horizontal, 4)
+ }
+
+ func formatTime(_ date: Date) -> String {
+ let f = DateFormatter(); f.timeStyle = .medium
+ return f.string(from: date)
+ }
+}