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
|
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`.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user