feat: add initial implementation of AI Workspace macOS Menu Bar App with service management functionality
This commit is contained in:
357
apps/mac/AIWorkspace/Sources/main.swift
Normal file
357
apps/mac/AIWorkspace/Sources/main.swift
Normal file
@@ -0,0 +1,357 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
private let workspaceRoot = URL(fileURLWithPath: "/Users/david/Developer/fidelity-ai-workspace", isDirectory: true)
|
||||
private let defaultProfile = "fidelity"
|
||||
|
||||
@main
|
||||
struct AIWorkspaceApp: App {
|
||||
@StateObject private var model = ServiceStatusModel(profile: defaultProfile)
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
ServiceMenuView(model: model)
|
||||
.task {
|
||||
await model.refresh()
|
||||
}
|
||||
} label: {
|
||||
Label("AI Workspace", systemImage: model.menuBarSymbol)
|
||||
}
|
||||
.menuBarExtraStyle(.menu)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ServiceStatusModel: ObservableObject {
|
||||
@Published private(set) var report: StatusReport?
|
||||
@Published private(set) var lastError: String?
|
||||
@Published private(set) var isRefreshing = false
|
||||
|
||||
let profile: String
|
||||
|
||||
init(profile: String) {
|
||||
self.profile = profile
|
||||
}
|
||||
|
||||
var menuBarSymbol: String {
|
||||
guard let report else { return "circle.dashed" }
|
||||
if report.services.contains(where: { $0.status == "unhealthy" || ($0.enabled && $0.status == "stopped") }) {
|
||||
return "exclamationmark.triangle"
|
||||
}
|
||||
if report.services.contains(where: { $0.status == "running" }) {
|
||||
return "checkmark.circle"
|
||||
}
|
||||
return "circle"
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
do {
|
||||
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"])
|
||||
report = try JSONDecoder().decode(StatusReport.self, from: data)
|
||||
lastError = nil
|
||||
} catch {
|
||||
lastError = String(describing: error)
|
||||
}
|
||||
}
|
||||
|
||||
func startProfile() {
|
||||
runAction(["start", "--profile", profile])
|
||||
}
|
||||
|
||||
func stopProfile() {
|
||||
runAction(["stop", "--profile", profile])
|
||||
}
|
||||
|
||||
func restartMCP() {
|
||||
runAction(["restart", "aiw-context-mcp", "--profile", profile])
|
||||
}
|
||||
|
||||
func runDoctor() {
|
||||
runAction(["doctor", "--profile", profile])
|
||||
}
|
||||
|
||||
func copyDoctorJSON() {
|
||||
Task {
|
||||
do {
|
||||
let data = try await ServiceManager.run(["doctor", "--profile", profile, "--json"])
|
||||
copyToPasteboard(String(data: data, encoding: .utf8) ?? "")
|
||||
} catch {
|
||||
lastError = String(describing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyPhotoInboxURL() {
|
||||
copyToPasteboard("http://127.0.0.1:8787/upload")
|
||||
}
|
||||
|
||||
func openMCPHealth() {
|
||||
if let url = URL(string: "http://127.0.0.1:8765/health") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
func openLogsFolder() {
|
||||
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent(".aiw/runtime/logs", isDirectory: true))
|
||||
}
|
||||
|
||||
func openProjectKnowledge() {
|
||||
NSWorkspace.shared.open(workspaceRoot.appendingPathComponent("project-knowledge", isDirectory: true))
|
||||
}
|
||||
|
||||
func openMattermost() {
|
||||
runAction(["start", "mattermost-desktop", "--profile", profile])
|
||||
}
|
||||
|
||||
private func copyToPasteboard(_ value: String) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(value, forType: .string)
|
||||
}
|
||||
|
||||
private func runAction(_ arguments: [String]) {
|
||||
Task {
|
||||
do {
|
||||
_ = try await ServiceManager.run(arguments)
|
||||
await refresh()
|
||||
} catch {
|
||||
lastError = String(describing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServiceMenuView: View {
|
||||
@ObservedObject var model: ServiceStatusModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
Text("AI Workspace")
|
||||
.font(.headline)
|
||||
Text("Profile: \(model.profile)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider()
|
||||
|
||||
if let report = model.report {
|
||||
ForEach(report.services) { service in
|
||||
ServiceRow(service: service)
|
||||
}
|
||||
} else if let error = model.lastError {
|
||||
Label("Status unavailable", systemImage: "exclamationmark.triangle")
|
||||
Text(error)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(3)
|
||||
} else {
|
||||
Label("Loading status...", systemImage: "hourglass")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Refresh") {
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
.keyboardShortcut("r")
|
||||
|
||||
Button("Start Fidelity") {
|
||||
model.startProfile()
|
||||
}
|
||||
|
||||
Button("Stop Fidelity") {
|
||||
model.stopProfile()
|
||||
}
|
||||
|
||||
Button("Restart Context MCP") {
|
||||
model.restartMCP()
|
||||
}
|
||||
|
||||
Button("Open Mattermost") {
|
||||
model.openMattermost()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Run Doctor") {
|
||||
model.runDoctor()
|
||||
}
|
||||
|
||||
Button("Copy Doctor JSON") {
|
||||
model.copyDoctorJSON()
|
||||
}
|
||||
|
||||
Button("Copy Photo Inbox URL") {
|
||||
model.copyPhotoInboxURL()
|
||||
}
|
||||
|
||||
Button("Open MCP Health") {
|
||||
model.openMCPHealth()
|
||||
}
|
||||
|
||||
Button("Open Logs Folder") {
|
||||
model.openLogsFolder()
|
||||
}
|
||||
|
||||
Button("Open Project Knowledge") {
|
||||
model.openProjectKnowledge()
|
||||
}
|
||||
|
||||
Divider()
|
||||
Button("Quit") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServiceRow: View {
|
||||
let service: ServiceStatus
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: symbol)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 16, alignment: .center)
|
||||
Text(service.displayName)
|
||||
.lineLimit(1)
|
||||
.frame(width: 170, alignment: .leading)
|
||||
Spacer()
|
||||
Text(service.compactStatus)
|
||||
.foregroundStyle(color)
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.frame(width: 86, alignment: .trailing)
|
||||
}
|
||||
.frame(minWidth: 300, alignment: .leading)
|
||||
.help(service.health.detail)
|
||||
}
|
||||
|
||||
private var symbol: String {
|
||||
switch service.status {
|
||||
case "running": "checkmark.circle"
|
||||
case "launcher": "arrow.up.forward.app"
|
||||
case "externally running": "link.circle"
|
||||
case "unhealthy": "exclamationmark.triangle"
|
||||
case "disabled": "minus.circle"
|
||||
default: "xmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch service.status {
|
||||
case "running", "launcher", "externally running": .green
|
||||
case "unhealthy": .orange
|
||||
case "disabled": .secondary
|
||||
default: .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ServiceManager {
|
||||
static func run(_ arguments: [String]) async throws -> Data {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
let process = Process()
|
||||
process.currentDirectoryURL = workspaceRoot
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
|
||||
process.arguments = ["python3", "scripts/aiw/services.py"] + arguments
|
||||
|
||||
let output = Pipe()
|
||||
let error = Pipe()
|
||||
process.standardOutput = output
|
||||
process.standardError = error
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
let data = output.fileHandleForReading.readDataToEndOfFile()
|
||||
let errorData = error.fileHandleForReading.readDataToEndOfFile()
|
||||
guard process.terminationStatus == 0 else {
|
||||
let message = String(data: errorData.isEmpty ? data : errorData, encoding: .utf8) ?? "service command failed"
|
||||
throw ServiceManagerError.commandFailed(message.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
}
|
||||
return data
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
enum ServiceManagerError: LocalizedError {
|
||||
case commandFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .commandFailed(let message): message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StatusReport: Decodable {
|
||||
let profile: String
|
||||
let workspace: String
|
||||
let runtime: String
|
||||
let services: [ServiceStatus]
|
||||
}
|
||||
|
||||
struct ServiceStatus: Decodable, Identifiable {
|
||||
var id: String { name }
|
||||
|
||||
let name: String
|
||||
let enabled: Bool
|
||||
let kind: String
|
||||
let status: String
|
||||
let pid: Int?
|
||||
let command: [String]
|
||||
let health: Health
|
||||
let state: [String: JSONValue]
|
||||
|
||||
var displayName: String {
|
||||
switch name {
|
||||
case "aiw-context-mcp": "Context MCP"
|
||||
case "mattermost-proxy": "Mattermost Proxy"
|
||||
case "mattermost-desktop": "Mattermost Desktop"
|
||||
case "photo-inbox": "Photo Inbox"
|
||||
default: name
|
||||
}
|
||||
}
|
||||
|
||||
var compactStatus: String {
|
||||
switch status {
|
||||
case "externally running": "external"
|
||||
default: status
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Health: Decodable {
|
||||
let ok: Bool?
|
||||
let detail: String
|
||||
}
|
||||
|
||||
enum JSONValue: Decodable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case object([String: JSONValue])
|
||||
case array([JSONValue])
|
||||
case null
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let value = try? container.decode(Bool.self) {
|
||||
self = .bool(value)
|
||||
} else if let value = try? container.decode(Double.self) {
|
||||
self = .number(value)
|
||||
} else if let value = try? container.decode(String.self) {
|
||||
self = .string(value)
|
||||
} else if let value = try? container.decode([String: JSONValue].self) {
|
||||
self = .object(value)
|
||||
} else {
|
||||
self = .array(try container.decode([JSONValue].self))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user