feat: enhance AI Workspace Menu Bar App with packaging scripts, login management, and service status improvements

This commit is contained in:
2026-05-20 16:11:45 -06:00
parent ab36e4b465
commit 4000747641
8 changed files with 349 additions and 66 deletions

View File

@@ -18,7 +18,7 @@ struct AIWorkspaceApp: App {
} label: {
Label("AI Workspace", systemImage: model.menuBarSymbol)
}
.menuBarExtraStyle(.menu)
.menuBarExtraStyle(.window)
}
}
@@ -26,6 +26,7 @@ struct AIWorkspaceApp: App {
final class ServiceStatusModel: ObservableObject {
@Published private(set) var report: StatusReport?
@Published private(set) var lastError: String?
@Published private(set) var lanIP: String?
@Published private(set) var isRefreshing = false
let profile: String
@@ -51,6 +52,7 @@ final class ServiceStatusModel: ObservableObject {
do {
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"])
report = try JSONDecoder().decode(StatusReport.self, from: data)
lanIP = await NetworkInfo.primaryLANIP()
lastError = nil
} catch {
lastError = String(describing: error)
@@ -85,7 +87,25 @@ final class ServiceStatusModel: ObservableObject {
}
func copyPhotoInboxURL() {
copyToPasteboard("http://127.0.0.1:8787/upload")
let host = lanIP ?? "127.0.0.1"
copyToPasteboard("http://\(host):8787/upload")
}
func copyRecentLogs() {
Task {
do {
var chunks: [String] = []
for service in report?.services ?? [] {
let data = try await ServiceManager.run(["logs", service.name, "--profile", profile, "--lines", "30"])
if let text = String(data: data, encoding: .utf8), !text.isEmpty {
chunks.append(text)
}
}
copyToPasteboard(chunks.joined(separator: "\n\n"))
} catch {
lastError = String(describing: error)
}
}
}
func openMCPHealth() {
@@ -127,18 +147,32 @@ 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)
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text("AI Workspace")
.font(.title3.bold())
Text("Profile: \(model.profile)")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
Task { await model.refresh() }
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help("Refresh")
}
Divider()
if let report = model.report {
ForEach(report.services) { service in
ServiceRow(service: service)
VStack(spacing: 8) {
ForEach(report.services) { service in
ServiceRow(service: service)
}
}
} else if let error = model.lastError {
Label("Status unavailable", systemImage: "exclamationmark.triangle")
@@ -150,61 +184,74 @@ struct ServiceMenuView: View {
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()
if let lanIP = model.lanIP {
HStack(spacing: 8) {
Image(systemName: "wifi")
.foregroundStyle(.secondary)
Text("Photo Inbox LAN")
Spacer()
Text("http://\(lanIP):8787/upload")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
.textSelection(.enabled)
}
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()
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ActionButton(title: "Start Fidelity", systemImage: "play.fill", action: model.startProfile)
ActionButton(title: "Stop Fidelity", systemImage: "stop.fill", role: .destructive, action: model.stopProfile)
ActionButton(title: "Restart MCP", systemImage: "arrow.triangle.2.circlepath", action: model.restartMCP)
ActionButton(title: "Mattermost", systemImage: "message", action: model.openMattermost)
}
Divider()
Button("Quit") {
NSApplication.shared.terminate(nil)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ActionButton(title: "Run Doctor", systemImage: "stethoscope", action: model.runDoctor)
ActionButton(title: "Copy Doctor JSON", systemImage: "doc.on.doc", action: model.copyDoctorJSON)
ActionButton(title: "Copy Photo URL", systemImage: "link", action: model.copyPhotoInboxURL)
ActionButton(title: "Copy Logs", systemImage: "doc.text", action: model.copyRecentLogs)
ActionButton(title: "MCP Health", systemImage: "heart.text.square", action: model.openMCPHealth)
ActionButton(title: "Logs Folder", systemImage: "folder", action: model.openLogsFolder)
}
ActionButton(title: "Open Project Knowledge", systemImage: "books.vertical", action: model.openProjectKnowledge)
Divider()
HStack {
if let error = model.lastError {
Label(error, systemImage: "exclamationmark.triangle")
.font(.caption)
.foregroundStyle(.orange)
.lineLimit(2)
}
Spacer()
Button("Quit") {
NSApplication.shared.terminate(nil)
}
.keyboardShortcut("q")
}
.keyboardShortcut("q")
}
.padding(18)
.frame(width: 430)
}
}
struct ActionButton: View {
let title: String
let systemImage: String
var role: ButtonRole?
let action: () -> Void
var body: some View {
Button(role: role, action: action) {
Label(title, systemImage: systemImage)
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
@@ -216,17 +263,20 @@ struct ServiceRow: View {
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)
VStack(alignment: .leading, spacing: 2) {
Text(service.displayName)
.font(.body.weight(.medium))
Text(service.health.detail)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 12)
StatusBadge(text: service.compactStatus, color: color)
}
.frame(minWidth: 300, alignment: .leading)
.padding(.vertical, 7)
.padding(.horizontal, 10)
.background(.quaternary.opacity(0.6), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.help(service.health.detail)
}
@@ -251,6 +301,20 @@ struct ServiceRow: View {
}
}
struct StatusBadge: View {
let text: String
let color: Color
var body: some View {
Text(text)
.font(.caption.weight(.semibold))
.foregroundStyle(color)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.14), in: Capsule())
}
}
enum ServiceManager {
static func run(_ arguments: [String]) async throws -> Data {
try await Task.detached(priority: .userInitiated) {
@@ -278,6 +342,33 @@ enum ServiceManager {
}
}
enum NetworkInfo {
static func primaryLANIP() async -> String? {
for interface in ["en0", "en1"] {
if let value = try? await runIPConfig(interface), !value.isEmpty {
return value
}
}
return nil
}
private static func runIPConfig(_ interface: String) async throws -> String {
try await Task.detached(priority: .utility) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/sbin/ipconfig")
process.arguments = ["getifaddr", interface]
let output = Pipe()
process.standardOutput = output
process.standardError = Pipe()
try process.run()
process.waitUntilExit()
let data = output.fileHandleForReading.readDataToEndOfFile()
guard process.terminationStatus == 0 else { return "" }
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}.value
}
}
enum ServiceManagerError: LocalizedError {
case commandFailed(String)