feat: enhance AI Workspace Menu Bar App with packaging scripts, login management, and service status improvements
This commit is contained in:
1
apps/mac/AIWorkspace/.gitignore
vendored
1
apps/mac/AIWorkspace/.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
dist/
|
||||
DerivedData/
|
||||
*.xcodeproj
|
||||
*.xcworkspace
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,19 +147,33 @@ struct ServiceMenuView: View {
|
||||
@ObservedObject var model: ServiceStatusModel
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("AI Workspace")
|
||||
.font(.headline)
|
||||
.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 {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(report.services) { service in
|
||||
ServiceRow(service: service)
|
||||
}
|
||||
}
|
||||
} else if let error = model.lastError {
|
||||
Label("Status unavailable", systemImage: "exclamationmark.triangle")
|
||||
Text(error)
|
||||
@@ -150,62 +184,75 @@ struct ServiceMenuView: View {
|
||||
Label("Loading status...", systemImage: "hourglass")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Refresh") {
|
||||
Task { await model.refresh() }
|
||||
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)
|
||||
}
|
||||
.keyboardShortcut("r")
|
||||
|
||||
Button("Start Fidelity") {
|
||||
model.startProfile()
|
||||
}
|
||||
|
||||
Button("Stop Fidelity") {
|
||||
model.stopProfile()
|
||||
}
|
||||
|
||||
Button("Restart Context MCP") {
|
||||
model.restartMCP()
|
||||
}
|
||||
|
||||
Button("Open Mattermost") {
|
||||
model.openMattermost()
|
||||
.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()
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
@@ -216,17 +263,20 @@ struct ServiceRow: View {
|
||||
Image(systemName: symbol)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 16, alignment: .center)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(service.displayName)
|
||||
.font(.body.weight(.medium))
|
||||
Text(service.health.detail)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
40
apps/mac/AIWorkspace/scripts/install-start-at-login.sh
Normal file
40
apps/mac/AIWorkspace/scripts/install-start-at-login.sh
Normal 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"
|
||||
64
apps/mac/AIWorkspace/scripts/package-app.sh
Normal file
64
apps/mac/AIWorkspace/scripts/package-app.sh
Normal 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
|
||||
7
apps/mac/AIWorkspace/scripts/uninstall-start-at-login.sh
Normal file
7
apps/mac/AIWorkspace/scripts/uninstall-start-at-login.sh
Normal 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"
|
||||
@@ -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.
|
||||
|
||||
49
core/services/multi-profile-runtime-model.md
Normal file
49
core/services/multi-profile-runtime-model.md
Normal 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.
|
||||
Reference in New Issue
Block a user