Skip to main content

Starsky Desktop (macOS) — Specification

Version: 0.8.1
Target: macOS 13.0+, arm64 + x86_64 (universal)
Framework: AppKit + WKWebView, Swift 5.10


1. Purpose​

Starsky Desktop is a native macOS shell that wraps the Starsky photo-management web application inside a WKWebView browser control. It provides:

  • A one-click install experience with no separate server setup required (Local mode)
  • A multi-window, persistent desktop experience on top of the existing React/ASP.NET Core web UI
  • Automatic updates delivered through GitHub Releases via Sparkle 2
  • OS-level file integration — download a photo from the server and open it in any local application

The app does not reimplement any photo-management logic; all business logic lives in the bundled or remote Starsky ASP.NET Core backend.


2. Technology Stack​

ComponentTechnology
ShellAppKit, macOS 13.0+, arm64 + x86_64
Embedded browserWebKit.WKWebView
Auto-updatesSparkle 2 (SPUUpdater)
LoggingOSLog + custom DailyFileLogger
Build systemXcode + xcodegen (project.yml)
Test frameworkXCTest

3. Connection Modes​

The app operates in one of two modes, switchable at runtime in Settings without restarting the app.

3.1 Local Mode (default)​

  1. Finds a free TCP port (PortFinder.findFreePort())
  2. Launches the bundled starsky backend as a child process (Foundation.Process)
  3. Polls /api/health until the backend is ready (60-second timeout)
  4. Checks version compatibility via /api/health/version
  5. Displays the web UI pointed at http://localhost:{port}

The bundled backend is expected at:

  • <app bundle>/Contents/MacOS/runtime-starsky-osx-arm64/starsky (Apple Silicon)
  • <app bundle>/Contents/MacOS/runtime-starsky-osx-x64/starsky (Intel)

This directory is populated at build time by an Xcode "Copy Runtime" Run Script build phase.

Before launching, BackendService clears Gatekeeper quarantine on the backend binary:

xattr -rd com.apple.quarantine <backend-path>
codesign --force --deep -s - <backend-path>

The backend is configured entirely via environment variables:

Environment VariableValue
ASPNETCORE_URLShttp://localhost:{port}
app__appsettingspath~/Library/Application Support/starsky/appsettings.json
app__appsettingslocalpath~/Library/Application Support/starsky/appsettings.local.json
app__databaseConnectionData Source=~/Library/Application Support/starsky/starsky.db
app__tempFolder~/Library/Caches/starsky/tempFolder/
app__thumbnailTempFolder~/Library/Application Support/starsky/thumbnailTempFolder/
app__NoAccountLocalhosttrue
app__UseLocalDesktoptrue
app__AccountRegisterDefaultRoleAdministrator
app__ThumbnailGenerationIntervalInMinutes300
app__Verbosefalse

Backend restart: If the backend process exits unexpectedly and the app is not shutting down, it is automatically restarted once after a 2-second delay. Quarantine clearing is applied again before restart.

3.2 Remote Mode​

Connects to an existing Starsky server at a user-configured URL. The bundled backend is not started. Full authentication applies (the web UI handles login). The URL must be validated via RemoteUrlValidator before it is saved.


4. Application Startup Sequence​

AppDelegate.applicationDidFinishLaunching(_:)
│
├─ 1. ApplicationPaths.ensureDirectories()
├─ 2. Init DailyFileLogger
├─ 3. Load settings (SettingsService.load())
├─ 4. Construct services
├─ 5. Show SplashWindowController
│
├─ 6. [Local] findFreePort → BackendService.start(port:) → waitForHealth (60 s)
│ → checkVersion
│ [Remote] Validate remoteBaseUrl is set (else ErrorWindowController + terminate)
│
├─ 7. FileWatcherService.start()
├─ 7.5. [if mountWatcherEnabled] MountWatcherService.enable()
│ → if enable() returns false: clear mountWatcherEnabled preference
├─ 8. WindowManager.restoreWindows()
├─ 9. SplashWindowController.close()
│
└─ 10. (after 5 s) UpdateService.checkAsync()
→ if true: show UpdateWindowController

Shutdown (applicationWillTerminate(_:))​

  1. WindowManager.closeAll()
  2. [if mountWatcherEnabled] MountWatcherService.stopSync()
  3. FileWatcherService.stop()
  4. BackendService.stop() — kills the backend process, waits up to 5 seconds

5. Windows​

5.1 SplashWindowController​

  • Fixed 320×180, dark background (#1a1a2e), no title bar (NSPanel with .borderless style), level = .floating
  • Shown during backend startup; displays status messages via a centred NSTextField
  • Closed after restoreWindows() completes

5.2 MainWindowController​

Default size: 1200×800. Position: saved and restored per window index.

Layout​

┌─────────────────────────────────────────────────────────┐
│ [macOS system title bar + traffic lights] │
├─────────────────────────────────────────────────────────┤
│ │
│ WKWebView (full area) │
│ │
└─────────────────────────────────────────────────────────┘

macOS menu bar is screen-level (set in AppDelegate).

MenuItemShortcutAction
FileNew WindowCmd+NOpen another MainWindowController at ?f=/
FileReload AllCmd+Shift+R / F5Reload all open windows
FileEdit File in EditorCmd+ELocal: inject Cmd+E keydown; Remote: download + open
StarskyConnection Settings…—Open SettingsWindowController
StarskyApplication SettingsCmd+Shift+KInject Cmd+Shift+K into web app
StarskyMountWatcher ▶—Submenu (see below)
ViewDeveloper ToolsCmd+Opt+IEnable WKWebView inspector, open devtools
ViewOpen in Browser—NSWorkspace.shared.open(currentURL)
HelpDocumentation—Open https://qdraw.nl/special/starsky/docs/
HelpRelease Overview—Open https://github.com/qdraw/starsky/releases

MountWatcher Submenu​

The MountWatcher submenu is populated dynamically via NSMenuDelegate.menuNeedsUpdate(_:) each time it opens.

StateItems shown
Disabled"Enable MountWatcher"
Enabled"Status: Running / Not Running / Not Installed" (non-clickable) + "Disable MountWatcher"

Toggling calls MountWatcherService.enable() / disable() asynchronously. On success the preference is persisted; on failure an error is shown via ErrorWindowController and the preference is unchanged.

Navigation events are intercepted by WKNavigationDelegate.webView(_:decidePolicyFor:decisionHandler:):

  • Allowed: localhost (any port), or the configured remote server origin (host + scheme + port must match)
  • Blocked + opened in system browser: any other origin

target="_blank" link clicks are handled by WKUIDelegate.webView(_:createWebViewWith:for:windowFeatures:):

  • Allowed origin → open a new MainWindowController at that route
  • External origin → open in NSWorkspace.shared.open(_:)

Route Persistence​

WKNavigationDelegate.webView(_:didFinish:) triggers on each page load:

  1. Extracts path + query + fragment from the current URL
  2. Saves it along with the current window frame to RoutePersistenceService

On window close (windowWillClose), the window's route entry is removed.

User Agent​

configuration.applicationNameForUserAgent = "starsky/0.8.1"

Edit File in Editor​

  • Local mode: Injects keydown event for Cmd+E into the web app via WKWebView.evaluateJavaScript
  • Remote mode: Reads the ?f= query parameter → calls FileDownloadService.downloadAndOpen(path:baseUrl:openFile:)

5.3 SettingsWindowController​

Fixed 380×480, not resizable, shown as a sheet on the focused main window (or as a standalone window if no main window is open).

ControlBehaviour
Local radio buttonSaves Mode = .local; if switching from Remote → calls WindowManager.reopenAll()
Remote radio buttonSaves Mode = .remote; enables URL controls
Server URL text fieldEditable only in Remote mode
Save URL buttonCalls RemoteUrlValidator.validate(urlString:) → if valid, saves URL and calls reopenAll(); shows green "Setting is saved" or red error message
Check for updates checkboxToggles updateCheckEnabled in settings; saved immediately

5.4 ErrorWindowController​

Fixed 440×220 modal sheet or NSAlert. Displays a user-facing error message for:

  • Missing remote URL on startup
  • Backend startup failure / timeout
  • WKWebView initialisation failure
  • File download failure

5.5 UpdateWindowController​

Shown 5 seconds after startup when UpdateService.checkAsync() returns true.

ButtonBehaviour
Update NowCalls UpdateService.applyUpdate() → Sparkle downloads and restarts
CloseCalls UpdateService.recordWarningShown() (suppresses for 4 days) → closes window

6. Services​

6.1 BackendService​

Manages the lifecycle of the bundled starsky child process (Foundation.Process).

MethodBehaviour
start(port:)Clears quarantine, injects env vars, starts process with stdout/stderr piped to logger
stop()Sets isShuttingDown = true, terminates process, waits up to 5 s
findBackendExe()Returns path to starsky binary in runtime dir; nil if missing
setEnvironment(_:port:)Writes all required env vars into Process.environment
(private) onProcessExitedIf not shutting down and not yet restarted: waits 2 s, clears quarantine, calls start(port:) again (one restart only)

6.2 NavigationService​

URL policy for WKWebView.

MethodBehaviour
isAllowedOrigin(_:baseUrl:)Returns true if host is localhost, or if host+scheme+port match baseUrl
buildStartUrl(baseUrl:route:)Concatenates baseUrl (trailing slash stripped) with route; defaults route to ?f=/
getEffectiveBaseUrl(localPort:)Returns http://localhost:{port} in Local mode, or remoteBaseUrl in Remote mode

6.3 SettingsService​

Reads and writes DesktopSettings as JSON.

MethodBehaviour
load()Reads from settings file; falls back to defaults on missing or corrupt file
save() / save(_:)Serialises to indented JSON; logs on write failure (best-effort)

Default settings file: ~/Library/Application Support/starsky/settings.json
Path overridable via constructor (used in tests).

6.4 RoutePersistenceService​

Persists per-window URL routes + geometry through SettingsService.

MethodBehaviour
getRoutes()Returns settings.current.windows list
saveRoute(index:route:geometry:)Grows list if needed; copies geometry if provided; saves
removeRoute(index:)Removes entry at index; saves
clearAll()Clears entire list; saves

6.5 PortFinder​

findFreePort() -> Int — binds a ServerSocket / Network.NWListener to port 0 (OS assigns a free port), reads the assigned port, closes the listener, returns the port number.

Implementation: Use Foundation socket APIs — bind SOCK_STREAM to port 0, call getsockname to read assigned port, close socket.

6.6 FileWatcherService​

Watches ~/Library/Caches/starsky/tempFolder/ for file changes using DispatchSource.makeFileSystemObjectSource.

BehaviourDetail
MonitoredFile-descriptor–based watch on the temp folder directory
Skip filterFiles with .tmp extension are ignored
Debounce500 ms DispatchWorkItem; reset on rapid successive events
On fireLogs "File changed in workspace: {path}"
start()Creates temp folder if missing, opens directory fd, attaches source
stop() / dispose()Cancels dispatch source, closes fd, cancels debounce timers

6.7 FileDownloadService​

Downloads a photo from the Starsky server and opens it locally.

Steps:

  1. GET {baseUrl}/starsky/api/index?f={encodedPath} — validate file exists
  2. GET {baseUrl}/starsky/api/download-sidecar?f={encodedPath} — download XMP sidecar (best-effort)
  3. GET {baseUrl}/starsky/api/download-photo?isThumbnail=false&f={encodedPath}&cache=false — download original
  4. Write to {tempFolder}/{parentDir}/{filename}.tmp, rename to final path
  5. NSWorkspace.shared.open(finalURL) — open with default application (if openFile = true)

URLSession: 60-second timeout. Injected via constructor.

6.8 RemoteUrlValidator​

Validates a user-entered remote server URL.

Steps:

  1. Trim trailing slash
  2. URL(string:) — reject if not a valid absolute URL
  3. Reject if scheme is not http or https
  4. GET {url}/api/health — accept HTTP 200 or 503; reject all others or exceptions

Returns UrlValidationResult(success: Bool, error: String?).
URLSession: 10-second timeout. Injected via constructor.

6.9 UpdateService​

Wraps Sparkle 2 SPUUpdater for GitHub-hosted auto-updates.

Method / PropertyBehaviour
checkAsync() async -> BoolReturns false if: updateCheckEnabled = false, OR last warning < 4 days ago (suppressMinutes = 5760), OR Sparkle unavailable, OR no update found
applyUpdate()Calls SPUUpdater.checkForUpdates() — Sparkle handles download + restart
recordWarningShown()Sets lastUpdateWarningShown = Date(), saves settings
mountWatcherProviderClosure () -> (any MountWatcherServiceProtocol)? forwarded to the internal SparkleUpdaterDelegate. Before Sparkle installs an update, willInstallUpdate calls mountWatcherProvider()?.stopSync() to unload the launchd agent before the bundle is replaced.

Appcast URL: configured in Info.plist SUFeedURL key.
Gracefully degrades when Sparkle is unavailable (sets updater = nil).

6.12 MountWatcherService​

Manages the optional starskymountwatchercli background service (launchd LaunchAgent).

MethodBehaviour
enable() async -> BoolChecks CLI binary exists; calls starskymountwatchercli --install; returns true on exit 0
disable() async -> BoolCalls starskymountwatchercli --uninstall; if CLI missing, returns true only when plist is also absent
status() async -> MountWatcherStatusReturns .notInstalled if plist absent; runs launchctl list <serviceName>: exit 0 → .running, else .stopped
stopSync()Synchronously calls starskymountwatchercli --uninstall; used pre-update and at termination

The CLI binary path defaults to <runtime-dir>/starskymountwatchercli. Service name is nl.qdraw.mountwatcher (or nl.qdraw.mountwatcher.debug in Debug builds).

All process execution is abstracted behind the ProcessRunner protocol for testability. The production implementation (DefaultProcessRunner) uses Foundation.Process with stdout/stderr suppressed via FileHandle.nullDevice.

MountWatcherStatus enum:

CaseMeaning
.runningplist exists; launchctl list returns 0
.stoppedplist exists; launchctl list returns non-0
.notInstalledplist does not exist
.unknownprocess launch error

6.10 WindowManager​

Manages the collection of open MainWindowController instances.

MethodBehaviour
setLocalPort(_:)Stores port for use in getEffectiveBaseUrl
openMainWindow(route:geometry:)Creates and shows MainWindowController; cascades by count × 24 px; default 1200×800 at (100,100)
restoreWindows()Opens one window per saved SavedWindowState; opens one default window if nothing saved
closeAll()Closes all tracked windows
reopenAll()Clears saved routes → closes all → opens default window at ?f=/
reloadAll()Calls reload() on each open window (dispatched to main thread)

When the last window closes, NSApplication.shared.terminate(nil) is called.
Dock-click (applicationShouldHandleReopen): if no windows open, call openMainWindow().

6.11 DailyFileLogger​

Custom logger writing to date-stamped files: ~/Library/Application Support/starsky/logs/starsky-{yyyy-MM-dd}.log

  • Minimum level: info
  • Format: yyyy-MM-dd HH:mm:ss [Level] Category: Message
  • Exception info on next line
  • Thread-safe via NSLock; write failures silently ignored

7. Data Model​

7.1 DesktopSettings​

Persisted to ~/Library/Application Support/starsky/settings.json as indented JSON.

PropertyTypeDefaultDescription
modeRuntimeMode.localLocal or Remote
remoteBaseUrlString""URL of remote server (Remote mode only)
updateCheckEnabledBooltrueWhether to check for updates on startup
lastUpdateWarningShownDate?nilUTC timestamp of last update prompt
mountWatcherEnabledBoolfalseWhether MountWatcher LaunchAgent should be active
windows[SavedWindowState][]Per-window route + geometry

mountWatcherEnabled uses decodeIfPresent so existing settings.json files written before this field existed decode without error (defaults to false).

7.2 SavedWindowState​

PropertyTypeDefaultDescription
routeString"?f=/"URL path+query+fragment
xDouble100Window x position
yDouble100Window y position
widthDouble1200Window width
heightDouble800Window height
isMaximizedBoolfalseWhether window was maximized (zoomed)

7.3 UrlValidationResult​

struct UrlValidationResult {
let success: Bool
let error: String?
}

7.4 RuntimeMode​

enum RuntimeMode: Int, Codable {
case local = 0
case remote = 1
}

8. File System Layout​

PathPurpose
~/Library/Application Support/starsky/settings.jsonDesktop app settings
~/Library/Application Support/starsky/appsettings.jsonStarsky backend config (Local mode)
~/Library/Application Support/starsky/appsettings.local.jsonMachine-specific backend overrides
~/Library/Application Support/starsky/starsky.dbSQLite database (Local mode)
~/Library/Application Support/starsky/logs/starsky-{date}.logDaily log files
~/Library/Application Support/starsky/thumbnailTempFolder/Thumbnail cache
~/Library/Caches/starsky/tempFolder/Downloaded files
<bundle>/Contents/MacOS/runtime-starsky-osx-arm64/starskyBundled backend (Apple Silicon)
<bundle>/Contents/MacOS/runtime-starsky-osx-x64/starskyBundled backend (Intel)
<bundle>/Contents/MacOS/runtime-starsky-osx-arm64/starskymountwatchercliMountWatcher CLI (Apple Silicon)
<bundle>/Contents/MacOS/runtime-starsky-osx-x64/starskymountwatchercliMountWatcher CLI (Intel)
~/Library/LaunchAgents/nl.qdraw.mountwatcher.plistlaunchd LaunchAgent plist (created by CLI --install)

9. Build System​

Project Generation​

The Xcode project is generated from mac/project.yml using xcodegen:

brew install xcodegen
cd mac && xcodegen generate

Targets​

TargetTypeDescription
starskymacOS AppMain application
starskyTestsUnit Test BundleXCTest service tests

Universal binary: ARCHS = arm64 x86_64
Minimum deployment: macOS 13.0

Runtime Copy Build Phase (Run Script in starsky target)​

for ARCH in arm64 x64; do
RUNTIME_SRC="$SRCROOT/../starsky/osx-${ARCH}"
RUNTIME_DST="$BUILT_PRODUCTS_DIR/$PRODUCT_NAME.app/Contents/MacOS/runtime-starsky-osx-${ARCH}"
if [ -d "$RUNTIME_SRC" ]; then
mkdir -p "$RUNTIME_DST"
cp -R "$RUNTIME_SRC/." "$RUNTIME_DST/"
else
echo "warning: Runtime not found at $RUNTIME_SRC"
fi
done

Publish (CLI)​

cd mac && xcodegen generate
xcodebuild archive \
-project starsky.xcodeproj \
-scheme starsky \
-configuration Release \
-archivePath ../build/starsky.xcarchive \
ARCHS="arm64 x86_64"

xcodebuild -exportArchive \
-archivePath ../build/starsky.xcarchive \
-exportPath ../build/ \
-exportOptionsPlist ExportOptions.plist

10. Code Signing & Notarization​

Entitlements (starsky.entitlements)​

<key>com.apple.security.app-sandbox</key><false/>
<key>com.apple.security.network.client</key><true/>
<key>com.apple.security.cs.allow-jit</key><true/>

No App Sandbox (required to spawn backend subprocess and access arbitrary file paths).

Build Settings​

CODE_SIGN_IDENTITY = Developer ID Application
ENABLE_HARDENED_RUNTIME = YES
OTHER_CODE_SIGN_FLAGS = --options runtime
DEVELOPMENT_TEAM = <team-id>

Notarization Flow​

# Submit for notarization
xcrun notarytool submit build/starsky.dmg \
--apple-id "$APPLE_ID" \
--team-id "$TEAM_ID" \
--password "$NOTARYTOOL_APP_PASSWORD" \
--wait

# Staple ticket to DMG
xcrun stapler staple build/starsky.dmg

ExportOptions.plist sets signingStyle = manual, signingCertificate = Developer ID Application.


11. Test Suite​

Target: starskyTests
Framework: XCTest, macOS 13.0+
Total target: ≥ 52 tests (matching Windows coverage)

Test Infrastructure​

FakeURLProtocol — URLProtocol subclass. Configured with a queue of (Data, HTTPURLResponse) pairs; dequeues one per request. Used to test HTTP-dependent services without network access.

FakeProcessBin (CreateFakeProcessBin/) — writes a minimal shell script (#!/bin/sh\nexit N\n) with mode 755 and returns the URL. Used by MountWatcherServiceTests to test DefaultProcessRunner without spawning system binaries (which macOS 15's test runner restricts).

CreateFakeStarskyBin — writes a minimal shell script (#!/bin/sh\nexit 0) as the fake backend binary for BackendService tests.

Test Classes​

ClassWhat is covered
AppCoreTestsStartup mode dispatch; MountWatcher enable/disable on startup; preference cleared on failed enable; stopSync on termination; port-0 guard; portFinder injection
ApplicationPathsTestsAppSupport/Caches folder mapping; path structure
BackendServiceTestsstop/dispose on unstarted service; all env var keys; findBackendExe (found/not found)
DesktopSettingsTestsDefault property values; JSON round-trip; mountWatcherEnabled defaults to false; backward-compat decode
FileDownloadServiceTestsHappy path writes file; sidecar failure still downloads; photo error propagates
FileWatcherServiceTestsstart/stop/dispose lifecycle; temp folder creation
MountWatcherServiceTestsenable/disable/status/stopSync with MockProcessRunner; DefaultProcessRunner with real scripts via FakeProcessBin; MountWatcherStatus.displayString uniqueness
NavigationServiceTestsisAllowedOrigin (localhost, matching remote, different host); buildStartUrl
PortFinderTestsReturns positive port; port is bindable
RemoteUrlValidatorTestsEmpty string; invalid scheme; HTTP 200; HTTP 503; other status; exception; trailing slash
RoutePersistenceServiceTestsEmpty list; save entry; save with geometry; list expansion; remove; clear all
SettingsServiceTestsMissing file (defaults); valid JSON; corrupt JSON (defaults); round-trip
UpdateServiceTestsDisabled; recent warning suppresses; recordWarningShown; applyUpdate without update; mountWatcherProvider get/set/clear

Running Tests​

xcodebuild test \
-project mac/starsky.xcodeproj \
-scheme starsky \
-destination 'platform=macOS'

12. CI/CD​

desktop-macos-pr-build.yml​

Triggered on push/PR to master when mac/** files change.
Runner: macos-latest.

StepCommand
Install xcodegenbrew install xcodegen
Generate projectcd mac && xcodegen generate
Buildxcodebuild build -scheme starsky -configuration Debug
Testxcodebuild test -scheme starsky -destination 'platform=macOS'
Upload resultsactions/upload-artifact → test-results-macos
Publish (on tag)archive → export → notarize → staple → upload DMG

Concurrency group: one run per branch; in-progress runs cancelled on new push.


13. Keyboard Shortcuts Reference​

ShortcutScopeAction
Cmd+NMainWindowOpen new window
Cmd+Shift+RMainWindowReload all windows
F5MainWindowReload all windows
Cmd+EMainWindowEdit current file in editor
Cmd+Shift+KMainWindowOpen application settings (injected into web app)
Cmd+Opt+IMainWindowOpen WKWebView Developer Tools

14. Security Considerations​

ConcernMechanism
External navigationdecidePolicyFor: cancels non-allowlisted navigations and opens in system browser
External new-windowcreateWebViewWith: intercepts target="_blank"; only allowed origins open in-app
Local backend auth bypassapp__NoAccountLocalhost=true — safe because backend binds to 127.0.0.1
Remote URL validationScheme must be http/https; server must respond to /api/health
Credential storageNo credentials stored; Remote mode relies on web app's cookie-based session
Update integritySparkle verifies packages via EdDSA signature
GatekeeperBackend binary quarantine cleared on first launch and crash-restart

15. Known Limitations & Non-Goals​

ItemNote
Windows / LinuxNot supported; AppKit and WKWebView are macOS-only
Menu bar / status itemNot implemented; app exits when last window closes
macOS notificationsNo UNUserNotificationCenter; update prompt uses a native window
UI testingWindow controllers not covered by automated tests (require display)
FileWatcherService debounceTested only for no-throw behaviour; file-change callback is logging-only
Multi-userSingle user per installation
App SandboxDisabled — required to spawn backend subprocess