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

@@ -1,5 +1,6 @@
.build/ .build/
.swiftpm/ .swiftpm/
dist/
DerivedData/ DerivedData/
*.xcodeproj *.xcodeproj
*.xcworkspace *.xcworkspace

View File

@@ -16,6 +16,32 @@ and sends lifecycle actions through `scripts/aiw/services.py`.
swift build --package-path apps/mac/AIWorkspace swift build --package-path apps/mac/AIWorkspace
``` ```
## Package as `.app`
```bash
apps/mac/AIWorkspace/scripts/package-app.sh
```
Install to `~/Applications/AIWorkspace.app`:
```bash
apps/mac/AIWorkspace/scripts/package-app.sh --install
```
## Start at login
After installing the app bundle:
```bash
apps/mac/AIWorkspace/scripts/install-start-at-login.sh
```
To remove the login item:
```bash
apps/mac/AIWorkspace/scripts/uninstall-start-at-login.sh
```
## Run during development ## Run during development
```bash ```bash
@@ -32,6 +58,7 @@ swift run --package-path apps/mac/AIWorkspace AIWorkspace
- Run Doctor - Run Doctor
- Copy Doctor JSON - Copy Doctor JSON
- Copy Photo Inbox URL - Copy Photo Inbox URL
- Copy recent logs
- Open MCP Health - Open MCP Health
- Open logs folder - Open logs folder
- Open project knowledge - Open project knowledge

View File

@@ -18,7 +18,7 @@ struct AIWorkspaceApp: App {
} label: { } label: {
Label("AI Workspace", systemImage: model.menuBarSymbol) Label("AI Workspace", systemImage: model.menuBarSymbol)
} }
.menuBarExtraStyle(.menu) .menuBarExtraStyle(.window)
} }
} }
@@ -26,6 +26,7 @@ struct AIWorkspaceApp: App {
final class ServiceStatusModel: ObservableObject { final class ServiceStatusModel: ObservableObject {
@Published private(set) var report: StatusReport? @Published private(set) var report: StatusReport?
@Published private(set) var lastError: String? @Published private(set) var lastError: String?
@Published private(set) var lanIP: String?
@Published private(set) var isRefreshing = false @Published private(set) var isRefreshing = false
let profile: String let profile: String
@@ -51,6 +52,7 @@ final class ServiceStatusModel: ObservableObject {
do { do {
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"]) let data = try await ServiceManager.run(["status", "--profile", profile, "--json"])
report = try JSONDecoder().decode(StatusReport.self, from: data) report = try JSONDecoder().decode(StatusReport.self, from: data)
lanIP = await NetworkInfo.primaryLANIP()
lastError = nil lastError = nil
} catch { } catch {
lastError = String(describing: error) lastError = String(describing: error)
@@ -85,7 +87,25 @@ final class ServiceStatusModel: ObservableObject {
} }
func copyPhotoInboxURL() { 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() { func openMCPHealth() {
@@ -127,19 +147,33 @@ struct ServiceMenuView: View {
@ObservedObject var model: ServiceStatusModel @ObservedObject var model: ServiceStatusModel
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 3) {
Text("AI Workspace") Text("AI Workspace")
.font(.headline) .font(.title3.bold())
Text("Profile: \(model.profile)") Text("Profile: \(model.profile)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
}
Spacer()
Button {
Task { await model.refresh() }
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help("Refresh")
}
Divider() Divider()
if let report = model.report { if let report = model.report {
VStack(spacing: 8) {
ForEach(report.services) { service in ForEach(report.services) { service in
ServiceRow(service: service) ServiceRow(service: service)
} }
}
} else if let error = model.lastError { } else if let error = model.lastError {
Label("Status unavailable", systemImage: "exclamationmark.triangle") Label("Status unavailable", systemImage: "exclamationmark.triangle")
Text(error) Text(error)
@@ -150,62 +184,75 @@ struct ServiceMenuView: View {
Label("Loading status...", systemImage: "hourglass") Label("Loading status...", systemImage: "hourglass")
} }
Divider() if let lanIP = model.lanIP {
HStack(spacing: 8) {
Button("Refresh") { Image(systemName: "wifi")
Task { await model.refresh() } .foregroundStyle(.secondary)
Text("Photo Inbox LAN")
Spacer()
Text("http://\(lanIP):8787/upload")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
} }
.keyboardShortcut("r") .textSelection(.enabled)
Button("Start Fidelity") {
model.startProfile()
}
Button("Stop Fidelity") {
model.stopProfile()
}
Button("Restart Context MCP") {
model.restartMCP()
}
Button("Open Mattermost") {
model.openMattermost()
} }
Divider() Divider()
Button("Run Doctor") { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
model.runDoctor() 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)
Button("Copy Doctor JSON") { ActionButton(title: "Mattermost", systemImage: "message", action: model.openMattermost)
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() Divider()
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") { Button("Quit") {
NSApplication.shared.terminate(nil) 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)
}
} }
struct ServiceRow: View { struct ServiceRow: View {
@@ -216,17 +263,20 @@ struct ServiceRow: View {
Image(systemName: symbol) Image(systemName: symbol)
.foregroundStyle(color) .foregroundStyle(color)
.frame(width: 16, alignment: .center) .frame(width: 16, alignment: .center)
VStack(alignment: .leading, spacing: 2) {
Text(service.displayName) Text(service.displayName)
.font(.body.weight(.medium))
Text(service.health.detail)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1) .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) Spacer(minLength: 12)
StatusBadge(text: service.compactStatus, color: color)
}
.padding(.vertical, 7)
.padding(.horizontal, 10)
.background(.quaternary.opacity(0.6), in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.help(service.health.detail) .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 { enum ServiceManager {
static func run(_ arguments: [String]) async throws -> Data { static func run(_ arguments: [String]) async throws -> Data {
try await Task.detached(priority: .userInitiated) { 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 { enum ServiceManagerError: LocalizedError {
case commandFailed(String) case commandFailed(String)

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
APP_PATH="${APP_PATH:-$HOME/Applications/AIWorkspace.app}"
PLIST_PATH="$HOME/Library/LaunchAgents/com.aiworkspace.menu.plist"
if [[ ! -d "$APP_PATH" ]]; then
echo "App not found: $APP_PATH" >&2
echo "Run apps/mac/AIWorkspace/scripts/package-app.sh --install first." >&2
exit 1
fi
mkdir -p "$HOME/Library/LaunchAgents"
cat > "$PLIST_PATH" <<PLIST
<?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.aiworkspace.menu</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/open</string>
<string>-a</string>
<string>$APP_PATH</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>$HOME/Library/Logs/AIWorkspace-menu.log</string>
<key>StandardErrorPath</key>
<string>$HOME/Library/Logs/AIWorkspace-menu.err.log</string>
</dict>
</plist>
PLIST
launchctl unload "$PLIST_PATH" >/dev/null 2>&1 || true
launchctl load "$PLIST_PATH"
echo "Installed and loaded LaunchAgent: $PLIST_PATH"

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
WORKSPACE_ROOT="$(cd "$APP_ROOT/../../.." && pwd)"
CONFIGURATION="${CONFIGURATION:-release}"
APP_NAME="AIWorkspace"
BUILD_DIR="$APP_ROOT/.build/$CONFIGURATION"
OUTPUT_DIR="$APP_ROOT/dist"
APP_BUNDLE="$OUTPUT_DIR/$APP_NAME.app"
INSTALL_DIR="${INSTALL_DIR:-$HOME/Applications}"
INSTALL=0
if [[ "${1:-}" == "--install" ]]; then
INSTALL=1
fi
swift build --package-path "$APP_ROOT" -c "$CONFIGURATION"
rm -rf "$APP_BUNDLE"
mkdir -p "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources"
cp "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
cat > "$APP_BUNDLE/Contents/Info.plist" <<PLIST
<?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>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$APP_NAME</string>
<key>CFBundleIdentifier</key>
<string>com.aiworkspace.menu</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>AI Workspace</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Local AI Workspace utility.</string>
</dict>
</plist>
PLIST
echo "Built $APP_BUNDLE"
if [[ "$INSTALL" == "1" ]]; then
mkdir -p "$INSTALL_DIR"
rm -rf "$INSTALL_DIR/$APP_NAME.app"
cp -R "$APP_BUNDLE" "$INSTALL_DIR/$APP_NAME.app"
echo "Installed $INSTALL_DIR/$APP_NAME.app"
fi

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
PLIST_PATH="$HOME/Library/LaunchAgents/com.aiworkspace.menu.plist"
launchctl unload "$PLIST_PATH" >/dev/null 2>&1 || true
rm -f "$PLIST_PATH"
echo "Removed LaunchAgent: $PLIST_PATH"

View File

@@ -12,6 +12,7 @@ The app should not reimplement service logic. It should call the service manager
- Local-only, no cloud dependency. - Local-only, no cloud dependency.
- Start at login optional through `LaunchAgent` later. - Start at login optional through `LaunchAgent` later.
- Read-only status by default; explicit user actions for start/stop/restart. - Read-only status by default; explicit user actions for start/stop/restart.
- The UI should not own MCP profile selection. AI clients pass profiles to MCP tools/resources; the menu bar app is only a local operations surface.
## Initial UI ## Initial UI
@@ -32,6 +33,7 @@ AI Workspace ▾
Open Mattermost Open Mattermost
Open Photo Inbox Folder Open Photo Inbox Folder
Copy Photo Inbox Upload URL Copy Photo Inbox Upload URL
Copy Recent Logs
Open Project Knowledge Open Project Knowledge
Open Logs Open Logs
@@ -75,3 +77,5 @@ Use `status --json` for frequent UI refreshes and `doctor --json` for explicit d
2. Add profile selector and service action buttons. 2. Add profile selector and service action buttons.
3. Add LaunchAgent support for start at login. 3. Add LaunchAgent support for start at login.
4. Replace shell parsing with a daemon API if daily use proves stable. 4. Replace shell parsing with a daemon API if daily use proves stable.
See `multi-profile-runtime-model.md` for how this should evolve when multiple profiles run in parallel.

View File

@@ -0,0 +1,49 @@
# Multi-Profile Runtime Model
## Principle
Profiles are selected by clients and services, not by the menu bar UI.
The menu bar app is a local operations surface for the current operator machine. It can monitor the local services that are enabled now, but it should not become the source of truth for which project an AI client is allowed to query.
## Desired Model
- MCP clients choose the profile at call time, for example `{"profile":"fidelity"}`.
- Multiple profiles may run in parallel when their service ports and inbox paths do not conflict.
- Capture services can be profile-specific or shared:
- shared service: one Mattermost mirror with profile-scoped query filters;
- profile-specific service: separate mirror/output path/port per profile.
- The MCP query layer should remain profile-aware and read from profile manifests/config.
- The menu bar app should show local runtime health, not force a single global profile selection.
## Current Fidelity Setup
The first menu bar version targets the Fidelity service set because that is the active local workflow. This does not prevent MCP clients from querying other profiles when those profiles have canonical memory or context-source config.
## Parallel Profile Requirements
Before running another profile in parallel, define unique values for any conflicting service:
- MCP HTTP port if using separate MCP instances.
- Mattermost proxy listen port if using separate proxy instances.
- Photo Inbox port if using separate upload receivers.
- Mirror/inbox output directory.
- Profile-specific context channels.
Example future split:
```text
fidelity:
aiw-context-mcp: 127.0.0.1:8765
mattermost-proxy: 127.0.0.1:8080
photo-inbox: 0.0.0.0:8787
it-support:
aiw-context-mcp: same shared MCP, profile argument selects context
mattermost-proxy: 127.0.0.1:8081 if a separate capture session is needed
photo-inbox: 0.0.0.0:8788 if a separate receiver is needed
```
## Menu Bar Direction
The menu bar app should evolve toward showing service groups or all running profiles, but not a manual profile selector for MCP query behavior. Profile selection belongs in the MCP tool/resource arguments and client prompts.