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,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)
|
||||
|
||||
|
||||
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"
|
||||
Reference in New Issue
Block a user