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/
.swiftpm/
dist/
DerivedData/
*.xcodeproj
*.xcworkspace

View File

@@ -16,6 +16,32 @@ and sends lifecycle actions through `scripts/aiw/services.py`.
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
```bash
@@ -32,6 +58,7 @@ swift run --package-path apps/mac/AIWorkspace AIWorkspace
- Run Doctor
- Copy Doctor JSON
- Copy Photo Inbox URL
- Copy recent logs
- Open MCP Health
- Open logs folder
- Open project knowledge

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)

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.
- Start at login optional through `LaunchAgent` later.
- 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
@@ -32,6 +33,7 @@ AI Workspace ▾
Open Mattermost
Open Photo Inbox Folder
Copy Photo Inbox Upload URL
Copy Recent Logs
Open Project Knowledge
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.
3. Add LaunchAgent support for start at login.
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.