feat: add DMG build script and enhance README with installation instructions and Start at Login feature

This commit is contained in:
2026-05-20 16:41:18 -06:00
parent b7ce929c50
commit 7da22da168
5 changed files with 116 additions and 5 deletions

View File

@@ -28,6 +28,20 @@ Install to `~/Applications/AIWorkspace.app`:
apps/mac/AIWorkspace/scripts/package-app.sh --install 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: One-step local install, optionally enabling start at login and opening the app:
```bash ```bash
@@ -36,7 +50,9 @@ apps/mac/AIWorkspace/scripts/install.sh --start-at-login --open
## Start at login ## 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 ```bash
apps/mac/AIWorkspace/scripts/install-start-at-login.sh 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. - 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 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`. - The app should remain a UI layer; service lifecycle remains in `scripts/aiw/services.py`.

View File

@@ -1,5 +1,6 @@
import AppKit import AppKit
import Foundation import Foundation
import ServiceManagement
import SwiftUI import SwiftUI
private let workspaceRoot = URL(fileURLWithPath: "/Users/david/Developer/fidelity-ai-workspace", isDirectory: true) 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 report: StatusReport?
@Published private(set) var lastError: String? @Published private(set) var lastError: String?
@Published private(set) var lanIP: String? @Published private(set) var lanIP: String?
@Published private(set) var loginItemStatus: SMAppService.Status = .notRegistered
@Published private(set) var isRefreshing = false @Published private(set) var isRefreshing = false
let profile: String let profile: String
@@ -53,12 +55,42 @@ final class ServiceStatusModel: ObservableObject {
let data = try await ServiceManager.run(["status", "--profile", profile, "--json"]) let data = try await ServiceManager.run(["status", "--profile", profile, "--json"])
report = try JSONDecoder().decode(StatusReport.self, from: data) report = try JSONDecoder().decode(StatusReport.self, from: data)
lanIP = await NetworkInfo.primaryLANIP() lanIP = await NetworkInfo.primaryLANIP()
loginItemStatus = SMAppService.mainApp.status
lastError = nil lastError = nil
} catch { } catch {
lastError = String(describing: error) 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() { func startProfile() {
runAction(["start", "--profile", profile]) runAction(["start", "--profile", profile])
} }
@@ -219,6 +251,21 @@ struct ServiceMenuView: View {
ActionButton(title: "Open Project Knowledge", systemImage: "books.vertical", action: model.openProjectKnowledge) 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() Divider()
HStack { HStack {
if let error = model.lastError { if let error = model.lastError {

View File

@@ -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"

View File

@@ -22,10 +22,10 @@ for arg in "$@"; do
esac esac
done done
"$SCRIPT_DIR/package-app.sh" --install bash "$SCRIPT_DIR/package-app.sh" --install
if [[ "$INSTALL_LOGIN_ITEM" == "1" ]]; then if [[ "$INSTALL_LOGIN_ITEM" == "1" ]]; then
"$SCRIPT_DIR/install-start-at-login.sh" bash "$SCRIPT_DIR/install-start-at-login.sh"
fi fi
if [[ "$OPEN_APP" == "1" ]]; then if [[ "$OPEN_APP" == "1" ]]; then

View File

@@ -32,10 +32,30 @@ Use a staged model:
- Avoid privileged helpers until a real system-level requirement appears. - Avoid privileged helpers until a real system-level requirement appears.
3. **Future polished distribution** 3. **Future polished distribution**
- Create a signed/notarized `.app` or `.pkg`. - Create a signed/notarized `.app` distributed in a `.dmg` with an Applications shortcut, or a `.pkg` only if privileged installation becomes necessary.
- Consider `SMAppService` for login item management from inside the app. - 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`. - 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 ## Why not LaunchDaemon now
The current services are user-context services: The current services are user-context services: