diff --git a/apps/mac/AIWorkspace/.gitignore b/apps/mac/AIWorkspace/.gitignore new file mode 100644 index 0000000..600105f --- /dev/null +++ b/apps/mac/AIWorkspace/.gitignore @@ -0,0 +1,8 @@ +.build/ +.swiftpm/ +DerivedData/ +*.xcodeproj +*.xcworkspace +xcuserdata/ +*.xcuserstate +.DS_Store diff --git a/apps/mac/AIWorkspace/Package.swift b/apps/mac/AIWorkspace/Package.swift new file mode 100644 index 0000000..29f9bd1 --- /dev/null +++ b/apps/mac/AIWorkspace/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "AIWorkspace", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "AIWorkspace", targets: ["AIWorkspace"]) + ], + targets: [ + .executableTarget( + name: "AIWorkspace", + path: "Sources" + ) + ] +) diff --git a/apps/mac/AIWorkspace/README.md b/apps/mac/AIWorkspace/README.md new file mode 100644 index 0000000..887a8f5 --- /dev/null +++ b/apps/mac/AIWorkspace/README.md @@ -0,0 +1,43 @@ +# AI Workspace macOS Menu Bar App + +Minimal SwiftUI `MenuBarExtra` app for controlling local AI Workspace services. + +The app is intentionally a thin UI over the service manager. It reads live status from: + +```bash +python3 scripts/aiw/services.py status --profile fidelity --json +``` + +and sends lifecycle actions through `scripts/aiw/services.py`. + +## Build + +```bash +swift build --package-path apps/mac/AIWorkspace +``` + +## Run during development + +```bash +swift run --package-path apps/mac/AIWorkspace AIWorkspace +``` + +## Current actions + +- Refresh status +- Start Fidelity services +- Stop Fidelity services +- Restart Context MCP +- Open Mattermost through the service manager +- Run Doctor +- Copy Doctor JSON +- Copy Photo Inbox URL +- Open MCP Health +- Open logs folder +- Open project knowledge + +## Notes + +- This is not yet packaged as a signed `.app` bundle. +- Start at login should be implemented later through a LaunchAgent or app login item. +- The app should remain a UI layer; service lifecycle remains in `scripts/aiw/services.py`. diff --git a/apps/mac/AIWorkspace/Sources/main.swift b/apps/mac/AIWorkspace/Sources/main.swift new file mode 100644 index 0000000..60828a2 --- /dev/null +++ b/apps/mac/AIWorkspace/Sources/main.swift @@ -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)) + } + } +}