diff --git a/apps/mac/AIWorkspace/README.md b/apps/mac/AIWorkspace/README.md index f11d38c..a5631f3 100644 --- a/apps/mac/AIWorkspace/README.md +++ b/apps/mac/AIWorkspace/README.md @@ -28,6 +28,20 @@ Install to `~/Applications/AIWorkspace.app`: apps/mac/AIWorkspace/scripts/package-app.sh --install ``` +## Build a DMG + +```bash +apps/mac/AIWorkspace/scripts/build-dmg.sh +``` + +This creates: + +```text +apps/mac/AIWorkspace/dist/AIWorkspace.dmg +``` + +The DMG contains `AIWorkspace.app` and an `Applications` shortcut for drag-and-drop installation. + One-step local install, optionally enabling start at login and opening the app: ```bash @@ -36,7 +50,9 @@ apps/mac/AIWorkspace/scripts/install.sh --start-at-login --open ## Start at login -After installing the app bundle: +Preferred: open the installed app and use the **Start at Login** toggle in the UI. The app uses macOS `SMAppService` for login item registration. + +Development fallback after installing the app bundle: ```bash apps/mac/AIWorkspace/scripts/install-start-at-login.sh @@ -74,4 +90,5 @@ swift run --package-path apps/mac/AIWorkspace AIWorkspace - This is not yet packaged as a signed `.app` bundle. - Start at login should be implemented later through a LaunchAgent or app login item. +- Start at Login is available in the app UI through `SMAppService`. The LaunchAgent scripts are retained as development fallback utilities. - The app should remain a UI layer; service lifecycle remains in `scripts/aiw/services.py`. diff --git a/apps/mac/AIWorkspace/Sources/main.swift b/apps/mac/AIWorkspace/Sources/main.swift index 37bcf5e..7db94b0 100644 --- a/apps/mac/AIWorkspace/Sources/main.swift +++ b/apps/mac/AIWorkspace/Sources/main.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import ServiceManagement import SwiftUI private let workspaceRoot = URL(fileURLWithPath: "/Users/david/Developer/fidelity-ai-workspace", isDirectory: true) @@ -27,6 +28,7 @@ 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 loginItemStatus: SMAppService.Status = .notRegistered @Published private(set) var isRefreshing = false let profile: String @@ -53,12 +55,42 @@ final class ServiceStatusModel: ObservableObject { let data = try await ServiceManager.run(["status", "--profile", profile, "--json"]) report = try JSONDecoder().decode(StatusReport.self, from: data) lanIP = await NetworkInfo.primaryLANIP() + loginItemStatus = SMAppService.mainApp.status lastError = nil } catch { lastError = String(describing: error) } } + var startAtLoginEnabled: Bool { + loginItemStatus == .enabled + } + + var startAtLoginStatusText: String { + switch loginItemStatus { + case .enabled: "enabled" + case .notRegistered: "off" + case .notFound: "not found" + case .requiresApproval: "requires approval" + @unknown default: "unknown" + } + } + + func setStartAtLogin(_ enabled: Bool) { + do { + if enabled { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + loginItemStatus = SMAppService.mainApp.status + lastError = nil + } catch { + loginItemStatus = SMAppService.mainApp.status + lastError = "Start at Login: \(error.localizedDescription)" + } + } + func startProfile() { runAction(["start", "--profile", profile]) } @@ -219,6 +251,21 @@ struct ServiceMenuView: View { ActionButton(title: "Open Project Knowledge", systemImage: "books.vertical", action: model.openProjectKnowledge) + Divider() + VStack(alignment: .leading, spacing: 6) { + Toggle(isOn: Binding( + get: { model.startAtLoginEnabled }, + set: { model.setStartAtLogin($0) } + )) { + Label("Start at Login", systemImage: "poweron") + } + .toggleStyle(.switch) + + Text("Login item: \(model.startAtLoginStatusText)") + .font(.caption2) + .foregroundStyle(.secondary) + } + Divider() HStack { if let error = model.lastError { diff --git a/apps/mac/AIWorkspace/scripts/build-dmg.sh b/apps/mac/AIWorkspace/scripts/build-dmg.sh new file mode 100644 index 0000000..7600567 --- /dev/null +++ b/apps/mac/AIWorkspace/scripts/build-dmg.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +APP_NAME="AIWorkspace" +DIST_DIR="$APP_ROOT/dist" +APP_BUNDLE="$DIST_DIR/$APP_NAME.app" +DMG_STAGING="$DIST_DIR/dmg-staging" +DMG_PATH="$DIST_DIR/$APP_NAME.dmg" + +bash "$SCRIPT_DIR/package-app.sh" + +rm -rf "$DMG_STAGING" "$DMG_PATH" +mkdir -p "$DMG_STAGING" +cp -R "$APP_BUNDLE" "$DMG_STAGING/$APP_NAME.app" +ln -s /Applications "$DMG_STAGING/Applications" + +hdiutil create \ + -volname "AI Workspace" \ + -srcfolder "$DMG_STAGING" \ + -ov \ + -format UDZO \ + "$DMG_PATH" + +rm -rf "$DMG_STAGING" +echo "Built $DMG_PATH" diff --git a/apps/mac/AIWorkspace/scripts/install.sh b/apps/mac/AIWorkspace/scripts/install.sh index 6aabdd2..0436210 100644 --- a/apps/mac/AIWorkspace/scripts/install.sh +++ b/apps/mac/AIWorkspace/scripts/install.sh @@ -22,10 +22,10 @@ for arg in "$@"; do esac done -"$SCRIPT_DIR/package-app.sh" --install +bash "$SCRIPT_DIR/package-app.sh" --install if [[ "$INSTALL_LOGIN_ITEM" == "1" ]]; then - "$SCRIPT_DIR/install-start-at-login.sh" + bash "$SCRIPT_DIR/install-start-at-login.sh" fi if [[ "$OPEN_APP" == "1" ]]; then diff --git a/core/services/macos-installation-model.md b/core/services/macos-installation-model.md index fcd5ff5..beb72f6 100644 --- a/core/services/macos-installation-model.md +++ b/core/services/macos-installation-model.md @@ -32,10 +32,30 @@ Use a staged model: - Avoid privileged helpers until a real system-level requirement appears. 3. **Future polished distribution** - - Create a signed/notarized `.app` or `.pkg`. - - Consider `SMAppService` for login item management from inside the app. + - Create a signed/notarized `.app` distributed in a `.dmg` with an Applications shortcut, or a `.pkg` only if privileged installation becomes necessary. + - Use `SMAppService` for login item management from inside the app so the user can toggle Start at Login in the UI instead of running `launchctl` scripts manually. - Add a small daemon API if the UI needs richer lifecycle control than shelling out to `services.py`. +## Desired user-grade install flow + +For a Cloudflare/Docker-like local experience, the target should be: + +1. User opens `AIWorkspace.dmg`. +2. User drags `AIWorkspace.app` to `/Applications` or `~/Applications`. +3. User launches the app. +4. App shows service status and a **Start at Login** toggle. +5. App registers/unregisters itself as a login item using `SMAppService`. +6. App starts/stops workspace services through the service manager. + +The existing shell scripts remain useful for development and bootstrap, but should not be the primary end-user experience once the app handles login item registration itself. + +## Current implementation state + +- `AIWorkspace.app` can be packaged from `apps/mac/AIWorkspace/scripts/package-app.sh`. +- `AIWorkspace.dmg` can be built from `apps/mac/AIWorkspace/scripts/build-dmg.sh`. +- The app UI includes a **Start at Login** toggle backed by `SMAppService.mainApp`. +- LaunchAgent scripts remain as development fallbacks, not the preferred user path. + ## Why not LaunchDaemon now The current services are user-context services: