Skip to main content

Starsky Desktop — macOS

Native macOS desktop app wrapping the Starsky photo-management backend inside a WKWebView.
See SPEC.md for the full architecture specification.


Getting Started (local development)

Prerequisites

  • macOS 13 or later
  • Xcode 15 or later (xcode-select -p should point to /Applications/Xcode.app/…)
  • xcodegen — generates the Xcode project from project.yml
brew install xcodegen

First-time setup

# From the repo root
cd mac
xcodegen generate # creates starsky.xcodeproj
open starsky.xcodeproj # or double-click in Finder

Run xcodegen generate again any time you add or remove Swift source files, or after pulling changes that modify project.yml.

Build & run from the command line

cd mac
xcodebuild build \
-project starsky.xcodeproj \
-scheme starsky \
-configuration Debug \
-destination 'platform=macOS' \
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO

Run the tests

cd mac
xcodebuild test \
-project starsky.xcodeproj \
-scheme starskyTests \
-destination 'platform=macOS' \
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO

Expected output: ** TEST SUCCEEDED ** (57 tests, 0 failures).

Bundled backend

The app looks for the Starsky ASP.NET Core binary at:

starsky.app/Contents/MacOS/runtime-starsky-osx-arm64/starsky # Apple Silicon
starsky.app/Contents/MacOS/runtime-starsky-osx-x64/starsky # Intel

These are copied at build time from starsky/osx-arm64/ and starsky/osx-x64/. A build warning is emitted when they are missing; Local mode will not work without them.


Versioning

The app uses two version fields, both kept in starsky/Info.plist:

KeyExamplePurpose
CFBundleShortVersionString0.9.0-beta.2Human-readable semver shown in the UI and in Sparkle's update dialog
CFBundleVersion90002Machine-readable integer used by Sparkle and the App Store for version comparison. Must be numeric-only per Apple's requirements.

Both fields are updated automatically by starsky-tools/build-tools/app-version-update.js. Run it with the new semver as argument:

node starsky-tools/build-tools/app-version-update.js 0.9.0-beta.2

CFBundleVersion formula

CFBundleVersion is derived from the semver string using:

major × 1 000 000 + minor × 10 000 + patch × 100 + preType × 30 + preNumber

Pre-release type slots — preNumber must be < 30:

Pre-release typepreType
alpha0
beta1
rc2
stable3 (preNumber = 9 → slot value 99)

Stable releases always sort above any pre-release of the same version:

VersionCFBundleVersion
0.9.0-alpha.190001
0.9.0-alpha.290002
0.9.0-beta.190031
0.9.0-beta.290032
0.9.0-rc.190061
0.9.090099
0.9.190199
1.0.0-alpha.11000001
1.0.0-beta.11000031
1.0.0-rc.11000061
1.0.01000099

Sparkle compares the CFBundleVersion of the installed app against the sparkle:version attribute in the appcast to decide whether an update is available.


Before Releasing

1. Generate a Sparkle EdDSA keypair

Sparkle 2 requires an EdDSA key to sign update packages. Do this once and store the private key securely (it never goes in the repo).

# Download the Sparkle release and extract generate_keys
curl -L https://github.com/sparkle-project/Sparkle/releases/latest/download/Sparkle-2.x.x.tar.xz | tar -xJ
./bin/generate_keys

The tool prints:

Private key saved to ~/Library/Preferences/Sparkle/Sparkle_private_key
Public key (add to Info.plist): <base64-string>

Open starsky/Info.plist and set:

<key>SUPublicEDKey</key>
<string>PASTE_PUBLIC_KEY_HERE</string>

2. Set your Apple Developer Team ID

In project.yml, replace the empty DEVELOPMENT_TEAM value:

DEVELOPMENT_TEAM: "XXXXXXXXXX" # your 10-character Team ID

Also fill in the teamID field in ExportOptions.plist:

<key>teamID</key>
<string>XXXXXXXXXX</string>

3. Install the Developer ID certificate

Ensure "Developer ID Application: <your name> (<team-id>)" is installed in Keychain Access.
Download it from developer.apple.com/account → Certificates if needed.

4. Set up GitHub secrets for CI

Go to Settings → Secrets and variables → Actions in your GitHub repo and add:

Secret nameValue
STARSKY_APPLE_IDYour Apple ID email (e.g. you@example.com)
STARSKY_APPLE_TEAM_IDYour 10-character Team ID
STARSKY_NOTARYTOOL_APP_PASSWORDAn app-specific password from appleid.apple.com → App-Specific Passwords
STARSKY_MACOS_CERTIFICATEBase64-encoded Developer ID Application .p12: base64 -i cert.p12 | pbcopy
STARSKY_MACOS_CERTIFICATE_PWDPassword that protects the .p12 file
STARSKY_MACOS_KEYCHAIN_PASSWORDAny strong random string — used only for the throwaway CI keychain

Verifying CI signing

Watch the build_mac_native, build_mac_arm64, and build_mac_x64 jobs after pushing a tag.

  • Archive step — if the keychain import worked, xcodebuild archive completes without "No signing certificate found." A failure here means STARSKY_MACOS_CERTIFICATE or STARSKY_MACOS_CERTIFICATE_PWD is wrong.
  • Notarize step — signing and notarizing are independent; a passing archive does not guarantee notarization succeeds. If notarization fails, check STARSKY_APPLE_ID, STARSKY_APPLE_TEAM_ID, and STARSKY_NOTARYTOOL_APP_PASSWORD.

5. Create the Sparkle appcast

After your first signed build, generate an appcast XML file and host it at the SUFeedURL configured in starsky/Info.plist:

https://qdraw.nl/special/starsky/appcast-macos.xml

Use sparkle-generate-appcast (included in the Sparkle distribution):

./bin/generate_appcast /path/to/release/folder/

Upload the resulting appcast.xml to your web server at the URL above.

6. Release build (manual)

cd mac
xcodegen generate

# Archive
xcodebuild archive \
-project starsky.xcodeproj \
-scheme starsky \
-configuration Release \
-archivePath ../build/starsky.xcarchive \
ARCHS="arm64 x86_64"

# Export signed app
xcodebuild -exportArchive \
-archivePath ../build/starsky.xcarchive \
-exportPath ../build/ \
-exportOptionsPlist ExportOptions.plist

# Create DMG
brew install create-dmg
create-dmg \
--volname "Starsky" \
--window-size 600 400 \
--icon-size 100 \
--icon "starsky.app" 175 190 \
--app-drop-link 425 190 \
../build/starsky.dmg \
"../build/starsky.app"

# Notarize
xcrun notarytool submit ../build/starsky.dmg \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$NOTARYTOOL_APP_PASSWORD" \
--wait

# Staple
xcrun stapler staple ../build/starsky.dmg

# Verify
spctl -a -vvv ../build/starsky.app

Release builds on tagged commits are automated via .github/workflows/desktop-macos-pr-build.yml.


Project structure

mac/
├── SPEC.md full architecture specification
├── README.md this file
├── project.yml xcodegen spec (source of truth for the Xcode project)
├── ExportOptions.plist Developer ID export settings for notarization
├── starsky/
│ ├── App/
│ │ ├── AppDelegate.swift startup / shutdown / menu bar
│ │ └── ApplicationInfo.swift version string from bundle
│ ├── Models/ Codable data types
│ ├── Services/
│ │ ├── BackendService.swift manages the bundled ASP.NET Core process
│ │ ├── FileWatcherService.swift watches settings.json for live changes
│ │ ├── MountWatcherService.swift shells out to starskymountwatchercli
│ │ ├── MountWatcherServiceProtocol.swift protocol + MountWatcherStatus enum
│ │ ├── PortFinder.swift finds a free TCP port
│ │ ├── SettingsService.swift reads/writes DesktopSettings
│ │ └── UpdateService.swift Sparkle auto-update wrapper
│ ├── Windows/ NSWindowController subclasses + WKWebView
│ ├── WindowManager.swift manages open MainWindowController instances
│ └── Resources/Assets.xcassets AppIcon (populate before release)
└── starskyTests/
├── Helpers/FakeURLProtocol.swift offline HTTP testing
├── FakeCreateAn/
│ ├── CreateFakeStarskyBin/ fake ASP.NET Core binary for BackendService tests
│ └── CreateFakeProcessBin/ fake shell scripts for MountWatcherService tests
├── Models/ model tests
└── Services/ service tests

MountWatcher

The macOS desktop app can manage MountWatcher — a background service that auto-imports photos when a camera card is connected.

Enabling from the menu

Open the Starsky menu bar icon → MountWatcherEnable MountWatcher.

The submenu refreshes each time it opens and shows:

Service stateMenu shows
Disabled"Enable MountWatcher"
Enabled, running"Status: Running" + "Disable MountWatcher"
Enabled, stopped"Status: Not Running" + "Disable MountWatcher"

How it works

  • The app shells out to starskymountwatchercli --install / --uninstall (bundled in the runtime directory) for service management. No launchd logic is duplicated in Swift.
  • The enabled/disabled preference is stored in DesktopSettings (~/Library/Application Support/starsky/settings.json), so it survives restarts and Sparkle updates.
  • On startup the app re-enables MountWatcher if the preference is set. If enabling fails, the preference is cleared and an error is shown.
  • Before Sparkle installs an update, stopSync() is called so the launchd agent is unloaded before the bundle is replaced. The next launch re-enables it automatically.

Requirement

starskymountwatchercli must be present in the app bundle at:

starsky.app/Contents/MacOS/runtime-starsky-osx-arm64/starskymountwatchercli # Apple Silicon
starsky.app/Contents/MacOS/runtime-starsky-osx-x64/starskymountwatchercli # Intel

It is included automatically in release builds alongside the starsky backend binary.