Add native macOS control panel for non-technical users

Adds a SwiftUI-based GUI application that provides:
- Start/Stop/Restart controls for the gateway
- Real-time status monitoring (CPU, memory, uptime)
- Log viewer with search
- Health check integration (clawdbot doctor)
- Channel status display
- Menu bar quick access
- Auto-start on login option
- System notifications

Built for users who prefer a graphical interface over terminal commands.
This commit is contained in:
mannyrepos 2026-01-26 01:40:21 -08:00
parent 6859e1e6a6
commit 215803b4d4
9 changed files with 1230 additions and 0 deletions

View File

@ -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.

View File

@ -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"

View File

@ -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

View File

@ -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/"

View File

@ -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"

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>ClawdbotControl</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>com.clawdbot.control</string>
<key>CFBundleName</key>
<string>Clawdbot Control</string>
<key>CFBundleDisplayName</key>
<string>Clawdbot Control</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<false/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticTermination</key>
<true/>
<key>NSSupportsSuddenTermination</key>
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSAppleEventsUsageDescription</key>
<string>Clawdbot Control needs to run shell commands to manage the Clawdbot gateway.</string>
</dict>
</plist>

View File

@ -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 = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>com.clawdbot.autostart</string>
<key>ProgramArguments</key><array><string>\(self.path)</string><string>gateway</string><string>start</string></array>
<key>RunAtLoad</key><true/>
</dict></plist>
"""
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)
}
}