diff --git a/apps/mac/AIWorkspace/.gitignore b/apps/mac/AIWorkspace/.gitignore index 600105f..b6fefec 100644 --- a/apps/mac/AIWorkspace/.gitignore +++ b/apps/mac/AIWorkspace/.gitignore @@ -1,5 +1,6 @@ .build/ .swiftpm/ +dist/ DerivedData/ *.xcodeproj *.xcworkspace diff --git a/apps/mac/AIWorkspace/README.md b/apps/mac/AIWorkspace/README.md index 887a8f5..aad5a5f 100644 --- a/apps/mac/AIWorkspace/README.md +++ b/apps/mac/AIWorkspace/README.md @@ -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 diff --git a/apps/mac/AIWorkspace/Sources/main.swift b/apps/mac/AIWorkspace/Sources/main.swift index 60828a2..eee4e61 100644 --- a/apps/mac/AIWorkspace/Sources/main.swift +++ b/apps/mac/AIWorkspace/Sources/main.swift @@ -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) diff --git a/apps/mac/AIWorkspace/scripts/install-start-at-login.sh b/apps/mac/AIWorkspace/scripts/install-start-at-login.sh new file mode 100644 index 0000000..37abc47 --- /dev/null +++ b/apps/mac/AIWorkspace/scripts/install-start-at-login.sh @@ -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" < + + + + Label + com.aiworkspace.menu + ProgramArguments + + /usr/bin/open + -a + $APP_PATH + + RunAtLoad + + StandardOutPath + $HOME/Library/Logs/AIWorkspace-menu.log + StandardErrorPath + $HOME/Library/Logs/AIWorkspace-menu.err.log + + +PLIST + +launchctl unload "$PLIST_PATH" >/dev/null 2>&1 || true +launchctl load "$PLIST_PATH" +echo "Installed and loaded LaunchAgent: $PLIST_PATH" diff --git a/apps/mac/AIWorkspace/scripts/package-app.sh b/apps/mac/AIWorkspace/scripts/package-app.sh new file mode 100644 index 0000000..cbb2612 --- /dev/null +++ b/apps/mac/AIWorkspace/scripts/package-app.sh @@ -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" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $APP_NAME + CFBundleIdentifier + com.aiworkspace.menu + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + AI Workspace + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHumanReadableCopyright + Local AI Workspace utility. + + +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 diff --git a/apps/mac/AIWorkspace/scripts/uninstall-start-at-login.sh b/apps/mac/AIWorkspace/scripts/uninstall-start-at-login.sh new file mode 100644 index 0000000..1546f30 --- /dev/null +++ b/apps/mac/AIWorkspace/scripts/uninstall-start-at-login.sh @@ -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" diff --git a/core/services/menu-bar-app-design.md b/core/services/menu-bar-app-design.md index 91f729a..d883c76 100644 --- a/core/services/menu-bar-app-design.md +++ b/core/services/menu-bar-app-design.md @@ -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. diff --git a/core/services/multi-profile-runtime-model.md b/core/services/multi-profile-runtime-model.md new file mode 100644 index 0000000..eba5ed5 --- /dev/null +++ b/core/services/multi-profile-runtime-model.md @@ -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.