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:
parent
6859e1e6a6
commit
215803b4d4
21
contrib/macos-control-panel/LICENSE
Normal file
21
contrib/macos-control-panel/LICENSE
Normal 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.
|
||||
13
contrib/macos-control-panel/Makefile
Normal file
13
contrib/macos-control-panel/Makefile
Normal 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"
|
||||
129
contrib/macos-control-panel/README.md
Normal file
129
contrib/macos-control-panel/README.md
Normal 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
|
||||
41
contrib/macos-control-panel/build.sh
Executable file
41
contrib/macos-control-panel/build.sh
Executable 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/"
|
||||
23
contrib/macos-control-panel/install.sh
Executable file
23
contrib/macos-control-panel/install.sh
Executable 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"
|
||||
BIN
contrib/macos-control-panel/resources/AppIcon.icns
Normal file
BIN
contrib/macos-control-panel/resources/AppIcon.icns
Normal file
Binary file not shown.
BIN
contrib/macos-control-panel/resources/icon_1024.png
Normal file
BIN
contrib/macos-control-panel/resources/icon_1024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
38
contrib/macos-control-panel/src/Info.plist
Normal file
38
contrib/macos-control-panel/src/Info.plist
Normal 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>
|
||||
965
contrib/macos-control-panel/src/main.swift
Normal file
965
contrib/macos-control-panel/src/main.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user