feat: add DMG build script and enhance README with installation instructions and Start at Login feature
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
27
apps/mac/AIWorkspace/scripts/build-dmg.sh
Normal file
27
apps/mac/AIWorkspace/scripts/build-dmg.sh
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user