diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..960bb35
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,37 @@
+name: CI
+
+on:
+ push:
+ pull_request:
+
+jobs:
+ test:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Headless suite
+ # Skip universal packaging here; the bundle job covers lipo + codesign.
+ # Skip --render-panel (needs a GUI session).
+ run: RUN_BUNDLE=0 tests/run.sh
+
+ bundle:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Universal bundle + ad-hoc sign
+ run: ./bundle.sh
+
+ - name: Verify lipo + codesign
+ run: |
+ APP="dist/Night Walker.app"
+ BIN="$APP/Contents/MacOS/color-filter-scheduler"
+ echo "==> lipo -info"
+ lipo -info "$BIN"
+ lipo -info "$BIN" | grep -q 'x86_64'
+ lipo -info "$BIN" | grep -q 'arm64'
+ echo "==> codesign --verify"
+ codesign --verify --verbose=1 "$APP"
+ echo "==> codesign identifier"
+ codesign -d --verbose=2 "$APP" 2>&1 | grep -F 'Identifier=com.flo.color-filter-scheduler'
diff --git a/AGENTS.md b/AGENTS.md
index db01b8c..538705c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -3,7 +3,9 @@
This file is the project's committed home for project-intrinsic agent knowledge: build, test, release, architecture, and sharp-edge notes that should travel with the code.
## What this is
-An `LSUIElement` macOS menu-bar app that turns Accessibility → Display → Color
+An `LSUIElement` macOS menu-bar app (**Night Walker** to users; package /
+executable stay `color-filter-scheduler`, bundle id
+`com.flo.color-filter-scheduler`) that turns Accessibility → Display → Color
Filters ON at sunset / OFF at sunrise. CLT-only (no Xcode), zero third-party
deps. See `README.md` and `EVIDENCE.md`.
@@ -38,18 +40,27 @@ The presentation is a custom near-black, borderless `NSPanel` hosting SwiftUI,
- `--render-panel
` CLI regenerates the AppKit-backed panel screenshots
(current evidence: `docs/evidence/cfs-ui3/`).
Any UI/engine testing MUST restore Color Filters to the pre-test state (see
-below); `--render-panel` is read-only w.r.t. the live filter.
+below); `--render-panel` is read-only w.r.t. the live filter **and** must not
+write `Settings.shared` (bundled binary = captain prefs domain). Output dir
+must stay under cwd.
## Build / run
- `swift build -c release` → `.build/release/color-filter-scheduler`.
-- `./bundle.sh` → assembles + ad-hoc-signs `dist/Color Filter Scheduler.app`
- (the standard CLT no-Xcode pattern: release build → hand-assembled `.app` →
- `codesign -s -`). `install.sh` installs to `~/Applications` + a launch-at-login
- LaunchAgent (`com.flo.color-filter-scheduler.plist.template`).
+- `./bundle.sh` → universal (x86_64 + arm64) ad-hoc-signed `dist/Night Walker.app`
+ (dual-arch `swift build` → `lipo` → hand-assembled `.app` → `codesign -s -`).
+ `./dmg.sh` wraps that in `dist/NightWalker-.dmg`. `install.sh` installs
+ to `~/Applications/Night Walker.app`; the signed bundled executable owns
+ launch-at-login through `SMAppService.mainApp`. It **refuses** if the
+ captain's live `~/Applications/Color Filter Scheduler.app` exists (shared
+ bundle id) unless `--replace-login-item` is passed, and only removes a legacy
+ LaunchAgent whose executable path matches a recognized install.
+ Do not run `install.sh` / `uninstall.sh` from a packaging or test lane.
- The binary doubles as a headless test CLI (`--get`, `--set-enabled`,
- `--set-intensity`, `--decide/--reconcile --lat --lon [--apply]`,
- `--engine-status`, `--engine-reconcile`, `--selftest` = key-panel architecture
- regression, exit 0 = pass). No args → menu-bar GUI.
+ `--set-intensity`, `--decide/--reconcile --lat --lon [--apply] [--now ISO8601]`,
+ `--engine-status`, `--engine-reconcile`, `--selftest` = panel architecture +
+ solar/scheduler fixtures, exit 0 = pass). `--now` is test-only. Invalid
+ lat/lon (outside [-90,90]/[-180,180] or non-finite) exits 2. No args →
+ menu-bar GUI.
## MediaAccessibility SPI (the load-bearing, non-obvious part)
Declared in `Sources/CMediaAccessibility/include/CMediaAccessibility.h`. Private
@@ -71,11 +82,16 @@ far-east/-west locations near the UTC day boundary compute the *previous* local
day's sunrise/sunset. Small/European longitudes hide the bug.
## Testing without disturbing the live Mac
-This runs on the captain's real Mac. Never leave Color Filters changed: capture
-`defaults read com.apple.mediaaccessibility` first and restore exactly. Engine
-tests use the `-key value` NSArgumentDomain (not persisted) — note it can't take
-**negative** lat/lon (a leading `-` is parsed as a flag); use positive-hemisphere
-test locations there.
+`tests/run.sh` is the single entry for local / CI / no-mistakes: debug build,
+panel/ui contracts, `--selftest`, cli-contract, hygiene, bundle-contract
+(`RUN_BUNDLE=0` to skip packaging). Never run `install.sh` from tests. Never
+leave Color Filters changed: capture `defaults read com.apple.mediaaccessibility`
+first and restore via the **.build** binary's `--set-enabled` / `--set-intensity`
+(a bare `defaults write` does not apply live). Engine tests use the `-key value`
+NSArgumentDomain (not persisted) — note it can't take **negative** lat/lon (a
+leading `-` is parsed as a flag); use positive-hemisphere test locations there.
+`--engine-status` must come *before* `-automationEnabled 0` so CLI.swift does
+not fall through to the GUI.
## Maintaining this file
diff --git a/EVIDENCE.md b/EVIDENCE.md
index 7757371..ac1e097 100644
--- a/EVIDENCE.md
+++ b/EVIDENCE.md
@@ -103,9 +103,15 @@ Before/after each: `defaults read com.flo.color-filter-scheduler` →
## 7. Build, bundle, and menu-bar app launch
+This section records the original host-architecture bundle smoke test. The
+current friend-distribution contract is the universal, ad-hoc-signed
+`dist/Night Walker.app` and `dist/NightWalker-.dmg`; see
+[`docs/evidence/cfs-prodready/PACKAGING.md`](docs/evidence/cfs-prodready/PACKAGING.md)
+for its packaging evidence.
+
```
swift build -c release -> Build complete! (CLT, no Xcode)
-./bundle.sh -> dist/Color Filter Scheduler.app
+./bundle.sh -> dist/Color Filter Scheduler.app (historical output)
Info.plist: OK (plutil -lint); LSUIElement=true; id com.flo.color-filter-scheduler
codesign: Signature=adhoc, satisfies its Designated Requirement
```
diff --git a/Package.swift b/Package.swift
index 26caddd..4849a69 100644
--- a/Package.swift
+++ b/Package.swift
@@ -18,6 +18,7 @@ let package = Package(
.linkedFramework("MediaAccessibility"),
.linkedFramework("CoreFoundation"),
.linkedFramework("CoreLocation"),
+ .linkedFramework("ServiceManagement"),
]
),
]
diff --git a/README.md b/README.md
index 87b1f96..aadd743 100644
--- a/README.md
+++ b/README.md
@@ -1,120 +1,119 @@
-# color-filter-scheduler
-
-A small, dependency-free macOS **menu-bar app** that automatically turns the
-Accessibility → Display → **Color Filters** master toggle **ON at sunset** and
-**OFF at sunrise** (Night Shift–style), for a single personal Mac — with a tiny
-menu-bar UI to control it.
-
-It flips the master on/off and adjusts the effect **strength**. It never changes
-the filter *type* — whatever you chose in System Settings (grayscale, color
-tint, protanopia, etc.) is preserved.
-
-- **Menu-bar only.** `LSUIElement` agent: an icon in the menu bar, no Dock icon,
- no main window.
-- **Zero third-party dependencies.** Pure Swift + system frameworks (AppKit +
- MediaAccessibility).
-- **Builds with `swift build` on Command Line Tools** — no full Xcode, no
- `.xcodeproj`.
-- **Self-correcting.** The app reconciles the live state to the solar schedule on
- an internal timer (every ~5 min) and immediately on wake from sleep — robust
- across sleep/wake, reboots, DST, and seasonal drift, with no fixed alarm times.
-
-## The menu-bar UI
-
-A **custom dark rounded key panel** (SwiftUI in a borderless `NSPanel`), not a
-stock menu. See `docs/evidence/cfs-ui3/` for current screenshots.
-
-**Front panel** — deliberately tiny:
-- **Header** — a day/night glyph, the name, and a bare settings gear.
-- **Run / Pause** — the primary control. **Run** turns Color Filters **ON**
- live (the screen visibly changes); **Pause** turns them **OFF** live.
-- A compact location row opens the editor.
-
-**Settings** (behind the gear):
-- A 0–100% slider for the real macOS Color Filters intensity. Applies live.
-- A single city field (Apple geocoding) with collapsible latitude / longitude
- fine-tune fields.
-- **Automatic (sunset → sunrise)** — master switch for solar automation.
-- **Quit**.
-
-**Manual Run/Pause vs. Automatic.** With Automatic **off** (the default), the
-filter follows only the Run/Pause button and the reconcile timer is inert. With
-Automatic **on**, the solar scheduler owns the filter (turning it on reconciles
-immediately); a manual Run/Pause is then a temporary override until the next
-reconcile or sunrise/sunset transition.
-
-Automation on/off and location are saved in the app's own `UserDefaults`.
-Strength lives in the OS Color Filters preference itself, so it persists
-inherently.
-
-## How it works
-
-- **Toggling + intensity** use Apple's `MediaAccessibility.framework` SPI
- (`MADisplayFilterPrefSetCategoryEnabled` for the master, and
- `MADisplayFilterPrefSetSingleColorIntensity` for strength). This is the same
- mechanism System Settings uses — the calls post the system change notification
- that makes WindowServer apply the change to the live display. (A bare
- `defaults write` does **not** do this.) See [`EVIDENCE.md`](EVIDENCE.md) for how
- the exact symbols were confirmed empirically.
-- **Sunrise/sunset** is computed in pure Swift from your latitude/longitude using
- the standard NOAA sunrise equation — no network. Polar day/night are handled
- gracefully (all-light / all-dark).
-
-## Build
+# Night Walker
+
+A small macOS menu-bar app that turns Accessibility → Display → **Color Filters**
+**ON at sunset** and **OFF at sunrise**. Night Shift for whatever filter you
+already picked — grayscale, color tint, color-vision correction, etc.
+
+It flips the master on/off and the strength slider. It never changes the filter
+*type*. Choose that once in System Settings.
+
+The name on the panel, in the Finder, and on the disk image is **Night Walker**.
+The bundle id `com.flo.color-filter-scheduler` is historical — leftover from
+the first working name, kept so existing installs and settings keep working.
+
+## For friends (you have the DMG)
+
+**Needs:** macOS 13 or later, Intel or Apple Silicon. You do **not** need Xcode
+or Command Line Tools.
+
+This is not on the App Store and it is not notarized. macOS will warn the first
+time because the app is only ad-hoc signed (the free, no-developer-account
+signature). That's expected.
+
+1. Open `NightWalker-1.0.0.dmg`.
+2. Drag **Night Walker** onto **Applications**.
+3. Eject the disk image.
+4. In Applications, **right-click Night Walker → Open → Open**. (A regular
+ double-click may be blocked by Gatekeeper until you've done this once.)
+5. A small icon appears in the menu bar. Click it.
+6. Type your city, open the gear, and turn on **Automatic (sunset → sunrise)**.
+
+On first launch, Night Walker registers itself in **System Settings → General →
+Login Items** so scheduling resumes after logout or reboot. If macOS marks it as
+requiring approval, enable Night Walker there once.
+
+**Run** turns the filter on right now; **Pause** turns it off. With Automatic
+on, the solar schedule owns the filter after that — a manual Run/Pause is a
+temporary override until the next sunrise/sunset (or the next internal
+check, about every five minutes).
+
+To uninstall: quit from the gear menu, disable Night Walker in **System Settings
+→ General → Login Items**, then drag Night Walker out of Applications to the
+Trash. If you used the builder `install.sh`, run `./uninstall.sh` from a checkout
+instead.
+
+## What it looks like
+
+A dark rounded panel from the menu-bar icon, not a stock menu.
+
+- **Front:** name, Run / Pause, a compact city row, a gear.
+- **Settings (gear):** strength 0–100% (live), city with optional lat/lon
+ fine-tune, Automatic, Quit.
+
+Automation and location are saved in the app's own settings. Strength lives in
+macOS Color Filters itself, so it persists even if you quit.
+
+## Honest caveats
+
+- Uses Apple's **private** MediaAccessibility SPI — the same calls System
+ Settings uses, so the change hits the live display. A `defaults write` does
+ not. Private SPI can break on a macOS update.
+- **Not notarized**, not App Store. First launch is right-click → Open.
+- Polar day / polar night are handled (all-light / all-dark). No network after
+ the one-time city lookup.
+
+## For builders (Command Line Tools)
+
+Needs the macOS Command Line Tools (`swiftc` / `swift`). Full Xcode is not
+required. Zero third-party dependencies.
```sh
-swift build -c release # binary at .build/release/color-filter-scheduler
-./bundle.sh # assemble + ad-hoc sign dist/Color Filter Scheduler.app
+swift build -c release # host-arch binary at .build/release/color-filter-scheduler
+./bundle.sh # universal (x86_64 + arm64) dist/Night Walker.app, ad-hoc signed
+./dmg.sh # dist/NightWalker-1.0.0.dmg (calls bundle.sh)
```
-## Install (to ~/Applications + launch at login)
+Installs to `~/Applications/Night Walker.app` and registers the same
+`SMAppService.mainApp` login item used by DMG installs. The bundle id is still
+`com.flo.color-filter-scheduler`, so this **refuses** if
+`~/Applications/Color Filter Scheduler.app` is present (shared login item and
+prefs). Friends should use the DMG, not `install.sh`.
```sh
./install.sh
+./uninstall.sh # unregister login item, remove app, leave Color Filters OFF
+./uninstall.sh --purge-settings # also delete saved on/off + location
```
-This builds and bundles the app, installs it to `~/Applications`, writes a
-per-user LaunchAgent that launches it at login, and starts it now. Then click the
-menu-bar icon; use **Run** to try the filter, and open Settings (the header gear)
-to set your location and turn on **Automatic**.
+Registration and removal use the signed bundled executable. The installer
+removes a recognized legacy LaunchAgent during migration; it refuses to remove
+an unrelated plist with the same label.
-## Test manually (headless commands)
+### Headless test CLI
-The same binary supports headless commands for testing/scripting. They take the
-location explicitly and **do not touch your saved settings**:
+The same binary is a small test CLI. Prefer the **`.build/`** binary, not the
+installed `.app` — `--engine-status` / `--engine-reconcile` read this process's
+UserDefaults (the bundled app is the captain/friend prefs domain).
```sh
-BIN="$HOME/Applications/Color Filter Scheduler.app/Contents/MacOS/color-filter-scheduler"
+BIN=".build/debug/color-filter-scheduler"
"$BIN" --get # live enabled / type / strength
-"$BIN" --set-enabled 1 # force Color Filters on
-"$BIN" --set-enabled 0 # force Color Filters off
-"$BIN" --set-intensity 0.5 # set strength to 50% (live)
-"$BIN" --decide --lat 48.137 --lon 11.575 # sunrise/sunset + on/off decision (read-only)
-"$BIN" --reconcile --lat 48.137 --lon 11.575 --apply # apply the decision
+"$BIN" --decide --lat 48.137 --lon 11.575 # sunrise/sunset + on/off (read-only)
+"$BIN" --selftest # panel + solar fixtures; exit 0 = pass
+tests/run.sh # full local / CI suite
```
-## Change the reconcile cadence
-
-Edit `reconcileInterval` in
-`Sources/color-filter-scheduler/AppDelegate.swift` (default 300s) and re-run
-`./install.sh`.
+`--set-enabled` / `--set-intensity` / `--reconcile --apply` change the live
+display (not app settings). Restore Color Filters afterward if you were testing.
-## Uninstall
+### Change the reconcile cadence
-```sh
-./uninstall.sh # unload agent, remove app, leave Color Filters OFF
-./uninstall.sh --purge-settings # also delete saved on/off + location
-```
-
-## Logs
-
-The login-item agent writes to `~/Library/Logs/color-filter-scheduler.log`
-(and `.err.log`).
+Edit `reconcileInterval` in
+`Sources/color-filter-scheduler/AppDelegate.swift` (default 300s) and rebuild.
## Requirements
-- macOS 13+ with Command Line Tools (`swiftc` / `swift`). Apple Silicon or Intel.
- (The SwiftUI panel UI sets the deployment target to macOS 13.)
-- Choose a Color Filters *type* once in System Settings → Accessibility →
- Display → Color Filters. This app flips the master and adjusts intensity; it
- doesn't pick the type.
+- **Friends:** macOS 13+, Intel or Apple Silicon, the DMG. No CLT.
+- **Builders:** macOS 13+ and Command Line Tools.
+- Pick a Color Filters *type* once in System Settings → Accessibility →
+ Display → Color Filters. This app flips the master and the intensity.
diff --git a/Sources/color-filter-scheduler/AppDelegate.swift b/Sources/color-filter-scheduler/AppDelegate.swift
index 80fc794..b883143 100644
--- a/Sources/color-filter-scheduler/AppDelegate.swift
+++ b/Sources/color-filter-scheduler/AppDelegate.swift
@@ -68,6 +68,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
private let reconcileInterval: TimeInterval = 300 // 5 minutes
func applicationDidFinishLaunching(_ notification: Notification) {
+ LoginItem.registerIfNeeded()
model = AppModel()
buildStatusItem()
buildPanel()
diff --git a/Sources/color-filter-scheduler/AppModel.swift b/Sources/color-filter-scheduler/AppModel.swift
index 2a44fb3..2d7de71 100644
--- a/Sources/color-filter-scheduler/AppModel.swift
+++ b/Sources/color-filter-scheduler/AppModel.swift
@@ -171,6 +171,8 @@ final class AppModel: ObservableObject {
/// coordinate summary, else a prompt.
var locationDisplay: String {
if let name = Settings.shared.locationName, !name.isEmpty { return name }
+ let typed = cityText.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !typed.isEmpty { return typed }
return locationSummary
}
diff --git a/Sources/color-filter-scheduler/CLI.swift b/Sources/color-filter-scheduler/CLI.swift
index 36be49e..003a965 100644
--- a/Sources/color-filter-scheduler/CLI.swift
+++ b/Sources/color-filter-scheduler/CLI.swift
@@ -1,9 +1,17 @@
import Foundation
import AppKit
-/// Headless command-line mode used for testing and evidence. It deliberately
-/// takes location explicitly on the command line and NEVER reads or writes the
-/// app's UserDefaults, so tests can't disturb the user's saved settings.
+/// Headless command-line mode used for testing and evidence.
+///
+/// `--get` / `--decide` / `--selftest` / `--help` do not read app UserDefaults
+/// and do not write Color Filters. `--set-enabled` / `--set-intensity` /
+/// `--reconcile --apply` mutate live Color Filters (`com.apple.mediaaccessibility`)
+/// but not app settings.
+///
+/// `--engine-status` / `--engine-reconcile` **do** read `UserDefaults.standard`
+/// of *this process*. The bundled app domain is `com.flo.color-filter-scheduler`
+/// (captain/friend prefs). Tests must use a `.build/` binary plus `-key value`
+/// NSArgumentDomain — never the installed `.app`.
///
/// Returns an exit code when it handles a command, or nil to fall through to the
/// normal menu-bar GUI.
@@ -17,6 +25,10 @@ enum CLI {
switch cmd {
case "--help", "-h":
printHelp(); return 0
+ case "--register-login-item":
+ return LoginItem.register() ? 0 : 1
+ case "--unregister-login-item":
+ return LoginItem.unregister() ? 0 : 1
case "--get":
print("enabled=\(ColorFilters.isEnabled)")
print("type=\(ColorFilters.filterType)")
@@ -30,8 +42,9 @@ enum CLI {
print("enabled=\(ColorFilters.isEnabled)")
return 0
case "--set-intensity":
- guard let v = opts["value"] ?? opts["_pos0"], let d = Double(v) else {
- errln("--set-intensity needs a 0..1 value"); return 2
+ guard let v = opts["value"] ?? opts["_pos0"], let d = Double(v),
+ d.isFinite, d >= 0, d <= 1 else {
+ errln("--set-intensity needs a finite 0..1 value"); return 2
}
ColorFilters.strength = d
print(String(format: "strength=%.6f", ColorFilters.strength))
@@ -41,9 +54,25 @@ enum CLI {
let lon = opts["lon"].flatMap(Double.init) else {
errln("\(cmd) needs --lat --lon "); return 2
}
+ guard Scheduler.isValidCoordinate(latitude: lat, longitude: lon) else {
+ errln("\(cmd) invalid --lat/--lon (lat in [-90,90], lon in [-180,180], finite)")
+ return 2
+ }
let srOff = opts["sr-off"].flatMap(Double.init) ?? 0
let ssOff = opts["ss-off"].flatMap(Double.init) ?? 0
- let now = Date()
+ guard srOff.isFinite, ssOff.isFinite else {
+ errln("\(cmd): --sr-off/--ss-off must be finite"); return 2
+ }
+ let now: Date
+ if let nowStr = opts["now"] {
+ guard let parsed = parseISO8601(nowStr) else {
+ errln("\(cmd) invalid --now (expected ISO8601, e.g. 2026-08-18T22:15:00Z)")
+ return 2
+ }
+ now = parsed
+ } else {
+ now = Date()
+ }
let d = Scheduler.decide(latitude: lat, longitude: lon,
sunriseOffsetMinutes: srOff, sunsetOffsetMinutes: ssOff,
now: now)
@@ -80,15 +109,20 @@ enum CLI {
}
return 0
case "--render-panel":
- // Render the redesigned panel to PNGs for evidence. Read-only w.r.t.
- // the live filter (assigns display values in memory only).
+ // PNG evidence only. Dir must stay under cwd (no absolute / .. escape).
+ // Read-only w.r.t. live Color Filters *and* app UserDefaults.
let dir = opts["dir"] ?? opts["_pos0"] ?? "docs/evidence/cfs-ui"
- renderPanel(dir)
+ guard let safe = confinedDir(dir) else {
+ errln("--render-panel: dir must be cwd or a subdirectory (no absolute / .. escape)")
+ return 2
+ }
+ renderPanel(safe)
return 0
case "--selftest":
- // Headless architecture regression for the panel-dismissal fix.
+ // Headless architecture + solar/scheduler regression.
// XCTest is unavailable under CLT-only, so assert here and return
// nonzero on failure (usable in CI / a pre-commit gate).
+ // Read-only w.r.t. the live Color Filters preference.
return runSelfTest()
case "--engine-reconcile":
let before = ColorFilters.isEnabled
@@ -122,6 +156,22 @@ enum CLI {
return out
}
+ /// Resolve `raw` against cwd and require the canonical path stay under cwd.
+ /// Symlinks that escape cwd are rejected (`standardizedFileURL` alone
+ /// does not resolve them).
+ private static func confinedDir(_ raw: String) -> String? {
+ let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath,
+ isDirectory: true).resolvingSymlinksInPath().standardizedFileURL
+ let url = URL(fileURLWithPath: raw, isDirectory: true, relativeTo: cwd)
+ .resolvingSymlinksInPath().standardizedFileURL
+ let root = cwd.path
+ let path = url.path
+ if path == root { return path }
+ let prefix = root.hasSuffix("/") ? root : root + "/"
+ guard path.hasPrefix(prefix) else { return nil }
+ return path
+ }
+
private static func renderPanel(_ dir: String) {
// ImageRenderer needs an initialized AppKit app on the main thread.
_ = NSApplication.shared
@@ -131,9 +181,9 @@ enum CLI {
}
}
- /// Guards the key-panel architecture: no popover/global mouse state remains,
- /// and the borderless panel can stay key for both controls and text fields.
- /// Returns 0 if all checks pass, otherwise 1.
+ /// Guards the key-panel architecture plus deterministic solar/scheduler
+ /// fixtures. Returns 0 if all checks pass, otherwise 1. Never touches the
+ /// live Color Filters preference.
private static func runSelfTest() -> Int32 {
var passed = 0, failed = 0
func check(_ name: String, _ cond: Bool) {
@@ -141,6 +191,8 @@ enum CLI {
else { failed += 1; print(" FAIL \(name)") }
}
+ print("selftest: panel architecture")
+ check("product name is Night Walker", Product.name == "Night Walker")
// Regression for cfs-ui3: raw global mouse monitoring is not a reliable
// dismissal boundary for an LSUIElement app. The delegate must own a key
// panel instead, with no popover/global-click-monitor state left behind.
@@ -165,10 +217,134 @@ enum CLI {
!testPanel.becomesKeyOnlyIfNeeded &&
!testPanel.hidesOnDeactivate)
+ print("selftest: solar / scheduler")
+ let munichLat = 48.137
+ let munichLon = 11.575
+ let berlin = TimeZone(identifier: "Europe/Berlin")!
+
+ let munichMidday = utcDate(2026, 8, 18, 12, 0) // 14:00 CEST
+ let munichSun = Solar.compute(latitude: munichLat, longitude: munichLon, date: munichMidday)
+ check("Munich 2026-08-18 kind is normal", munichSun.kind == .normal)
+ check("Munich sunrise exists", munichSun.sunrise != nil)
+ check("Munich sunset exists", munichSun.sunset != nil)
+ if let sr = munichSun.sunrise, let ss = munichSun.sunset {
+ check("Munich CEST sunrise ~06:11 (±3 min)",
+ minutesOff(localHM(sr, berlin), hour: 6, minute: 11) <= 3)
+ check("Munich CEST sunset ~20:23 (±3 min)",
+ minutesOff(localHM(ss, berlin), hour: 20, minute: 23) <= 3)
+ }
+
+ let munichDay = Scheduler.decide(latitude: munichLat, longitude: munichLon, now: munichMidday)
+ check("Munich 12:00 UTC (14:00 CEST) wantOn == false", munichDay.wantOn == false)
+
+ let munichNight = utcDate(2026, 8, 18, 22, 15)
+ let munichNightD = Scheduler.decide(latitude: munichLat, longitude: munichLon, now: munichNight)
+ check("Munich 22:15 UTC kind is normal", munichNightD.sun.kind == .normal)
+ check("Munich 22:15 UTC wantOn == true (dark)", munichNightD.wantOn == true)
+
+ let polarDayNow = utcDate(2026, 6, 21, 12, 0)
+ let polarDaySun = Solar.compute(latitude: 80, longitude: 15, date: polarDayNow)
+ let polarDayD = Scheduler.decide(latitude: 80, longitude: 15, now: polarDayNow)
+ check("80N 21 Jun is polarDay", polarDaySun.kind == .polarDay)
+ check("polar day wantOn == false", polarDayD.wantOn == false)
+
+ let polarNightNow = utcDate(2026, 12, 21, 12, 0)
+ let polarNightSun = Solar.compute(latitude: 80, longitude: 15, date: polarNightNow)
+ let polarNightD = Scheduler.decide(latitude: 80, longitude: 15, now: polarNightNow)
+ check("80N 21 Dec is not polarDay", polarNightSun.kind != .polarDay)
+ switch polarNightSun.kind {
+ case .polarNight:
+ check("80N 21 Dec is polarNight", true)
+ check("polar night wantOn == true", polarNightD.wantOn == true)
+ case .normal:
+ let dark: Bool
+ if let sr = polarNightD.adjustedSunrise, let ss = polarNightD.adjustedSunset {
+ dark = polarNightNow < sr || polarNightNow >= ss
+ } else {
+ dark = polarNightD.wantOn
+ }
+ check("80N 21 Dec short-day wantOn matches dark/light", polarNightD.wantOn == dark)
+ case .polarDay:
+ check("80N 21 Dec must not be polarDay", false)
+ }
+
+ let shanghaiNow = utcDate(2026, 8, 18, 0, 30)
+ let shanghaiSun = Solar.compute(latitude: 31.23, longitude: 121.47, date: shanghaiNow)
+ check("Shanghai 00:30 UTC kind is normal", shanghaiSun.kind == .normal)
+ if let sr = shanghaiSun.sunrise, let tz = TimeZone(identifier: "Asia/Shanghai") {
+ let day = ymd(sr, tz)
+ check("Shanghai sunrise local calendar is 2026-08-18 (not previous UTC day)",
+ day.0 == 2026 && day.1 == 8 && day.2 == 18)
+ } else {
+ check("Shanghai sunrise exists for day-boundary check", false)
+ }
+
+ let farEastLon = 170.0
+ let farEastNow = utcDate(2026, 8, 18, 0, 30)
+ let farEastSun = Solar.compute(latitude: 0, longitude: farEastLon, date: farEastNow)
+ let farEastTz = TimeZone(secondsFromGMT: Int((farEastLon / 15.0 * 3600.0).rounded()))!
+ check("170E 00:30 UTC kind is normal", farEastSun.kind == .normal)
+ if let sr = farEastSun.sunrise {
+ check("170E sunrise local calendar day matches input local day",
+ ymd(sr, farEastTz) == ymd(farEastNow, farEastTz))
+ } else {
+ check("170E sunrise exists for day-boundary check", false)
+ }
+
+ print("selftest: coordinate validation")
+ check("reject lat 999", !Scheduler.isValidCoordinate(latitude: 999, longitude: 0))
+ check("reject lat 91", !Scheduler.isValidCoordinate(latitude: 91, longitude: 0))
+ check("reject lat -91", !Scheduler.isValidCoordinate(latitude: -91, longitude: 0))
+ check("reject lon 181", !Scheduler.isValidCoordinate(latitude: 0, longitude: 181))
+ check("reject lon -181", !Scheduler.isValidCoordinate(latitude: 0, longitude: -181))
+ check("reject NaN lat", !Scheduler.isValidCoordinate(latitude: .nan, longitude: 0))
+ check("reject NaN lon", !Scheduler.isValidCoordinate(latitude: 0, longitude: .nan))
+ check("reject +inf lat", !Scheduler.isValidCoordinate(latitude: .infinity, longitude: 0))
+ check("reject -inf lon", !Scheduler.isValidCoordinate(latitude: 0, longitude: -.infinity))
+ check("accept Munich", Scheduler.isValidCoordinate(latitude: munichLat, longitude: munichLon))
+ check("accept poles and antimeridian",
+ Scheduler.isValidCoordinate(latitude: 90, longitude: 180)
+ && Scheduler.isValidCoordinate(latitude: -90, longitude: -180))
+
print("selftest: \(passed) passed, \(failed) failed")
return failed == 0 ? 0 : 1
}
+ private static func utcDate(_ y: Int, _ mo: Int, _ d: Int, _ h: Int, _ mi: Int, _ s: Int = 0) -> Date {
+ var cal = Calendar(identifier: .gregorian)
+ cal.timeZone = TimeZone(secondsFromGMT: 0)!
+ var c = DateComponents()
+ c.year = y; c.month = mo; c.day = d; c.hour = h; c.minute = mi; c.second = s
+ return cal.date(from: c)!
+ }
+
+ private static func ymd(_ date: Date, _ tz: TimeZone) -> (Int, Int, Int) {
+ var cal = Calendar(identifier: .gregorian)
+ cal.timeZone = tz
+ let c = cal.dateComponents([.year, .month, .day], from: date)
+ return (c.year ?? 0, c.month ?? 0, c.day ?? 0)
+ }
+
+ private static func localHM(_ date: Date, _ tz: TimeZone) -> (Int, Int) {
+ var cal = Calendar(identifier: .gregorian)
+ cal.timeZone = tz
+ let c = cal.dateComponents([.hour, .minute], from: date)
+ return (c.hour ?? 0, c.minute ?? 0)
+ }
+
+ private static func minutesOff(_ hm: (Int, Int), hour: Int, minute: Int) -> Int {
+ abs((hm.0 * 60 + hm.1) - (hour * 60 + minute))
+ }
+
+ /// Test-only freeze-time parser for `--now`. Internet-date ISO8601.
+ private static func parseISO8601(_ s: String) -> Date? {
+ let iso = ISO8601DateFormatter()
+ iso.formatOptions = [.withInternetDateTime]
+ if let d = iso.date(from: s) { return d }
+ iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return iso.date(from: s)
+ }
+
private static func boolArg(_ s: String) -> Bool? {
switch s.lowercased() {
case "1", "true", "on", "yes": return true
@@ -191,21 +367,30 @@ enum CLI {
private static func printHelp() {
print("""
- color-filter-scheduler — menu-bar app. With no arguments it launches the
- menu-bar UI. The following headless commands are for testing/scripting
- and do NOT touch the app's saved settings:
+ color-filter-scheduler — menu-bar app. No args → menu-bar UI.
- --get print live enabled / type / strength
+ Do not read/write app UserDefaults:
+ --get / --decide / --selftest / --help
--set-enabled 0|1 flip Color Filters master (live)
- --set-intensity 0..1 set Color Filters strength (live)
- --decide --lat D --lon D [--sr-off M --ss-off M]
- print sunrise/sunset + on/off decision (read-only)
- --reconcile --lat D --lon D [--apply]
- as --decide; with --apply, set the live state
- --render-panel [dir] render the UI panels to PNGs (read-only;
- default dir: docs/evidence/cfs-ui)
- --selftest run headless panel-presentation regression;
- exit 0 if all pass (read-only)
+ --set-intensity 0..1 set Color Filters strength (live; finite)
+ --reconcile --lat D --lon D [--apply] [--now ISO8601]
+ as --decide; --apply mutates live Color Filters
+
+ --decide / --reconcile:
+ --lat D --lon D [--sr-off M --ss-off M] [--now ISO8601]
+ lat in [-90,90], lon in [-180,180], finite; else exit 2.
+ --now is test-only (e.g. 2026-08-18T22:15:00Z).
+
+ Read this process's UserDefaults (bundled app = captain/friend prefs):
+ --engine-status
+ --engine-reconcile may flip live Color Filters from saved schedule
+ Use a .build/ binary plus -key value; never the installed .app.
+
+ Bundled-app lifecycle:
+ --register-login-item register the main app with SMAppService
+ --unregister-login-item remove the SMAppService registration
+
+ --render-panel [dir] PNGs only; dir must be cwd or a subdirectory
--help this help
""")
}
diff --git a/Sources/color-filter-scheduler/ColorFilters.swift b/Sources/color-filter-scheduler/ColorFilters.swift
index 9dbdb1f..1e6077d 100644
--- a/Sources/color-filter-scheduler/ColorFilters.swift
+++ b/Sources/color-filter-scheduler/ColorFilters.swift
@@ -39,6 +39,9 @@ enum ColorFilters {
/// across launches without any extra bookkeeping.
static var strength: Double {
get { MADisplayFilterPrefGetSingleColorIntensity() }
- set { MADisplayFilterPrefSetSingleColorIntensity(min(1, max(0, newValue))) }
+ set {
+ guard newValue.isFinite else { return }
+ MADisplayFilterPrefSetSingleColorIntensity(min(1, max(0, newValue)))
+ }
}
}
diff --git a/Sources/color-filter-scheduler/LoginItem.swift b/Sources/color-filter-scheduler/LoginItem.swift
new file mode 100644
index 0000000..1e97931
--- /dev/null
+++ b/Sources/color-filter-scheduler/LoginItem.swift
@@ -0,0 +1,74 @@
+import Foundation
+import ServiceManagement
+
+enum LoginItem {
+ static func registerIfNeeded() {
+ guard isBundledApp else { return }
+ _ = register()
+ }
+
+ static func register() -> Bool {
+ guard isBundledApp else {
+ report("login-item commands require the bundled app")
+ return false
+ }
+
+ let service = SMAppService.mainApp
+ switch service.status {
+ case .notRegistered:
+ do {
+ try service.register()
+ report("launch at login registered")
+ return true
+ } catch {
+ report("could not register launch at login: \(error.localizedDescription)")
+ return false
+ }
+ case .requiresApproval:
+ report("launch at login requires approval in System Settings > General > Login Items")
+ return true
+ case .enabled:
+ return true
+ case .notFound:
+ report("launch-at-login service was not found")
+ return false
+ @unknown default:
+ report("launch-at-login service returned an unknown status")
+ return false
+ }
+ }
+
+ static func unregister() -> Bool {
+ guard isBundledApp else {
+ report("login-item commands require the bundled app")
+ return false
+ }
+
+ let service = SMAppService.mainApp
+ switch service.status {
+ case .enabled, .requiresApproval:
+ do {
+ try service.unregister()
+ report("launch at login unregistered")
+ return true
+ } catch {
+ report("could not unregister launch at login: \(error.localizedDescription)")
+ return false
+ }
+ case .notRegistered, .notFound:
+ return true
+ @unknown default:
+ report("launch-at-login service returned an unknown status")
+ return false
+ }
+ }
+
+ private static var isBundledApp: Bool {
+ Bundle.main.bundleURL.pathExtension == "app"
+ && Bundle.main.bundleIdentifier == "com.flo.color-filter-scheduler"
+ }
+
+ private static func report(_ message: String) {
+ FileHandle.standardError.write(Data("color-filter-scheduler: \(message)\n".utf8))
+ }
+}
diff --git a/Sources/color-filter-scheduler/PanelView.swift b/Sources/color-filter-scheduler/PanelView.swift
index d857ff0..b7bd474 100644
--- a/Sources/color-filter-scheduler/PanelView.swift
+++ b/Sources/color-filter-scheduler/PanelView.swift
@@ -67,7 +67,7 @@ private struct FrontPage: View {
HStack(alignment: .center, spacing: 10) {
FilterGlyph().frame(width: 22, height: 22)
// Just the name — the Run/Pause button below IS the state indicator.
- Text("Color Filter")
+ Text(Product.name)
.font(.system(size: 14, weight: .semibold))
Spacer(minLength: 8)
Button(action: openSettings) {
@@ -315,17 +315,8 @@ enum PanelEvidence {
static func render(to dir: String) {
try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
- // Temporarily seed a location in THIS binary's defaults (isolated from the
- // installed app's domain) so the shots aren't empty; restored afterwards.
- let savedLat = Settings.shared.latitude, savedLon = Settings.shared.longitude
- let savedName = Settings.shared.locationName
- Settings.shared.latitude = 38.72; Settings.shared.longitude = -9.14
- Settings.shared.locationName = "Lisbon, Portugal"
- defer {
- Settings.shared.latitude = savedLat; Settings.shared.longitude = savedLon
- Settings.shared.locationName = savedName
- }
-
+ // In-memory display values only. Never write Settings.shared — the
+ // bundled binary shares the captain/friend prefs domain.
let onModel = AppModel()
onModel.filterOn = true
onModel.strength = 0.62
diff --git a/Sources/color-filter-scheduler/Product.swift b/Sources/color-filter-scheduler/Product.swift
new file mode 100644
index 0000000..e7cea46
--- /dev/null
+++ b/Sources/color-filter-scheduler/Product.swift
@@ -0,0 +1,3 @@
+enum Product {
+ static let name = "Night Walker"
+}
diff --git a/Sources/color-filter-scheduler/Scheduler.swift b/Sources/color-filter-scheduler/Scheduler.swift
index a36e434..37b2995 100644
--- a/Sources/color-filter-scheduler/Scheduler.swift
+++ b/Sources/color-filter-scheduler/Scheduler.swift
@@ -11,6 +11,14 @@ enum Scheduler {
var reason: String
}
+ /// Fail-closed coordinate check at the CLI / Settings boundary. Does not
+ /// change the NOAA formula; garbage in must not produce a schedule.
+ static func isValidCoordinate(latitude: Double, longitude: Double) -> Bool {
+ latitude.isFinite && longitude.isFinite
+ && latitude >= -90 && latitude <= 90
+ && longitude >= -180 && longitude <= 180
+ }
+
/// Positive `sunriseOffsetMinutes` shifts the morning OFF transition later;
/// positive `sunsetOffsetMinutes` shifts the evening ON transition later.
static func decide(latitude: Double,
diff --git a/Sources/color-filter-scheduler/Settings.swift b/Sources/color-filter-scheduler/Settings.swift
index 3d4fb65..30ad4d2 100644
--- a/Sources/color-filter-scheduler/Settings.swift
+++ b/Sources/color-filter-scheduler/Settings.swift
@@ -65,7 +65,7 @@ final class Settings {
var hasValidLocation: Bool {
guard let lat = latitude, let lon = longitude else { return false }
- return lat.isFinite && lon.isFinite && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180
+ return Scheduler.isValidCoordinate(latitude: lat, longitude: lon)
}
// Returns nil if the key is unset in every domain; otherwise the value,
diff --git a/bundle.sh b/bundle.sh
index 0ae1a8f..ce5b435 100755
--- a/bundle.sh
+++ b/bundle.sh
@@ -1,14 +1,17 @@
#!/usr/bin/env bash
#
-# Assemble a proper LSUIElement (.app) menu-bar bundle from the SwiftPM release
-# build and ad-hoc sign it. This is the standard CLT-only, no-Xcode pattern:
-# release build -> hand-assembled .app -> codesign -s -.
+# Assemble a proper LSUIElement (.app) menu-bar bundle from a universal
+# (x86_64 + arm64) SwiftPM release build and ad-hoc sign it. This is the
+# standard CLT-only, no-Xcode pattern: dual-arch release build -> lipo ->
+# hand-assembled .app -> codesign -s -.
#
-# Output: ./dist/Color Filter Scheduler.app
+# Output: ./dist/Night Walker.app
+# Public name is Night Walker; executable / package / bundle id stay
+# color-filter-scheduler / com.flo.color-filter-scheduler.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-APP_NAME="Color Filter Scheduler"
+APP_NAME="Night Walker"
BUNDLE_ID="com.flo.color-filter-scheduler"
EXECUTABLE="color-filter-scheduler"
VERSION="1.0.0"
@@ -16,17 +19,45 @@ VERSION="1.0.0"
DIST="$REPO_DIR/dist"
APP="$DIST/$APP_NAME.app"
-echo "==> Building (swift build -c release)…"
+find_arch_bin() {
+ local arch="$1"
+ local candidates=(
+ "$REPO_DIR/.build/${arch}-apple-macosx/release/$EXECUTABLE"
+ "$REPO_DIR/.build/${arch}-apple-macos/release/$EXECUTABLE"
+ )
+ local p
+ for p in "${candidates[@]}"; do
+ if [[ -x "$p" ]]; then
+ printf '%s\n' "$p"
+ return 0
+ fi
+ done
+ echo "error: no $arch release binary after swift build --arch $arch" >&2
+ echo "looked for:" >&2
+ for p in "${candidates[@]}"; do
+ echo " $p" >&2
+ done
+ echo ".build layout:" >&2
+ ls -la "$REPO_DIR/.build" >&2 || true
+ return 1
+}
+
+echo "==> Building universal release (arm64 + x86_64)…"
cd "$REPO_DIR"
-swift build -c release
-BUILT="$REPO_DIR/.build/release/$EXECUTABLE"
-[[ -x "$BUILT" ]] || { echo "error: build did not produce $BUILT" >&2; exit 1; }
+swift build -c release --arch arm64
+swift build -c release --arch x86_64
+
+ARM_BIN="$(find_arch_bin arm64)"
+X86_BIN="$(find_arch_bin x86_64)"
echo "==> Assembling $APP"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
-cp "$BUILT" "$APP/Contents/MacOS/$EXECUTABLE"
+echo "==> lipo $ARM_BIN + $X86_BIN"
+lipo -create "$ARM_BIN" "$X86_BIN" -output "$APP/Contents/MacOS/$EXECUTABLE"
+chmod +x "$APP/Contents/MacOS/$EXECUTABLE"
+lipo -info "$APP/Contents/MacOS/$EXECUTABLE"
echo "==> Rendering app icon (.icns)"
ICONSET="$(mktemp -d)/AppIcon.iconset"
@@ -63,6 +94,8 @@ EOF
printf 'APPL????' > "$APP/Contents/PkgInfo"
echo "==> Ad-hoc signing"
+# Ad-hoc only (no paid cert). Do not add --options runtime / Hardened Runtime.
+# --deep is unnecessary here (no nested code) and Apple-discouraged for shipping.
codesign --force --sign - --identifier "$BUNDLE_ID" "$APP"
codesign --verify --verbose=1 "$APP"
diff --git a/com.flo.color-filter-scheduler.plist.template b/com.flo.color-filter-scheduler.plist.template
deleted file mode 100644
index 76ec81f..0000000
--- a/com.flo.color-filter-scheduler.plist.template
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
-
- Label
- com.flo.color-filter-scheduler
-
- ProgramArguments
-
- __APP_BINARY__
-
-
- RunAtLoad
-
-
- KeepAlive
-
-
- LimitLoadToSessionType
- Aqua
-
- ProcessType
- Background
-
- StandardOutPath
- __LOGDIR__/color-filter-scheduler.log
- StandardErrorPath
- __LOGDIR__/color-filter-scheduler.err.log
-
-
diff --git a/dmg.sh b/dmg.sh
new file mode 100755
index 0000000..af45499
--- /dev/null
+++ b/dmg.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+#
+# Wrap the Night Walker .app in a friend-friendly compressed DMG: the app
+# plus an /Applications symlink so they can drag-install. Relies on
+# bundle.sh for the universal ad-hoc-signed app. Does not notarize and
+# does not Developer-ID sign the disk image.
+#
+# Output: ./dist/NightWalker-.dmg
+set -euo pipefail
+
+REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+APP_NAME="Night Walker"
+VERSION="$(sed -n 's/^VERSION="\(.*\)"/\1/p' "$REPO_DIR/bundle.sh" | head -1)"
+[[ -n "$VERSION" ]] || { echo "error: could not read VERSION from bundle.sh" >&2; exit 1; }
+
+DIST="$REPO_DIR/dist"
+APP="$DIST/$APP_NAME.app"
+DMG="$DIST/NightWalker-$VERSION.dmg"
+
+echo "==> Bundling app"
+"$REPO_DIR/bundle.sh"
+[[ -d "$APP" ]] || { echo "error: bundle.sh did not produce $APP" >&2; exit 1; }
+
+STAGE="$(mktemp -d)"
+cleanup() { rm -rf "$STAGE"; }
+trap cleanup EXIT
+
+echo "==> Staging DMG contents"
+cp -R "$APP" "$STAGE/"
+ln -s /Applications "$STAGE/Applications"
+
+echo "==> Creating $DMG"
+mkdir -p "$DIST"
+# UDZO = zlib-compressed read-only image. -ov overwrites an existing file.
+hdiutil create \
+ -volname "Night Walker" \
+ -srcfolder "$STAGE" \
+ -ov \
+ -format UDZO \
+ "$DMG"
+
+echo
+echo "Built: $DMG"
+ls -lh "$DMG"
diff --git a/docs/evidence/cfs-prodready/CODEX-REVIEW.md b/docs/evidence/cfs-prodready/CODEX-REVIEW.md
new file mode 100644
index 0000000..9161c07
--- /dev/null
+++ b/docs/evidence/cfs-prodready/CODEX-REVIEW.md
@@ -0,0 +1,63 @@
+# Codex review — security-sensitive surface
+
+Date: 2026-08-19
+Tool: `codex exec review --uncommitted` (model `gpt-5.6-sol`)
+Scope: `CMediaAccessibility.h`, `CLI.swift`, `ColorFilters.swift`,
+`bundle.sh`, `dmg.sh`, LaunchAgent template, `install.sh`, `uninstall.sh`.
+
+Independent review of the production-readiness patches on `fm/cfs-prodready`.
+Constraints treated as non-bugs: historical bundle id, SPI signatures,
+ad-hoc sign only, no notarization / Hardened Runtime / TCC helper.
+
+## Verdict
+
+Two real defects in the uncommitted patch; both fixed on this branch after
+the review. No remaining open defect in the scoped files that Codex proved.
+
+## Findings
+
+### P1 — CI live-toggle fails closed on a clean runner — **fixed**
+
+`tests/cli-contract.sh` treated a missing Color Filters preference
+(`ORIG_ENABLED` empty) as a test **failure**, then still ran
+`--reconcile --apply`. On GitHub Actions `macos-latest` that can:
+
+1. Fail the new `Headless suite` job even when the app is correct.
+2. Create a `com.apple.mediaaccessibility` key the restore path cannot
+ put back (restore no-ops when the snapshot is empty).
+
+**Fix landed:** skip all live mutation (`--set-enabled` flip and
+`--reconcile --apply`) when the pre-test snapshot has no enabled or
+intensity key. Read-only CLI checks still run.
+
+### P2 — `--render-panel` cwd confine missed symlink escape — **fixed**
+
+`confinedDir` used `standardizedFileURL`, which normalizes `.` / `..`
+but does **not** resolve a symlink. A cwd-relative symlink to `/tmp`
+would pass the prefix check and write the evidence PNGs outside cwd.
+
+**Fix landed:** resolve symlinks on both cwd and the candidate path
+before the prefix check.
+
+## Already closed (Codex did not re-open)
+
+- SPI header signatures left alone.
+- CLI fail-closed on NaN / Inf / out-of-range lat/lon and non-finite
+ offsets; `--set-intensity` finite 0…1; setter ignores NaN.
+- `--render-panel` no longer writes `Settings.shared`.
+- `install.sh` refuses if `Color Filter Scheduler.app` exists
+ (override `--replace-login-item`).
+- LaunchAgent paths set via `PlistBuddy` (XML-escaped; spaces in
+ `Night Walker.app` verified).
+- `uninstall.sh` refuses the legacy app; bundle-id check before delete.
+- Ad-hoc `codesign --sign - --identifier com.flo.color-filter-scheduler`
+ (no `--deep`, no Hardened Runtime).
+
+## Residual risk (unchanged, not defects)
+
+- Ad-hoc signature: Gatekeeper unidentified-developer, right-click Open.
+- Private MediaAccessibility SPI; no TCC prompt (OS design).
+- Shared bundle id with the captain live app — install/uninstall now
+ refuse that collision instead of hijacking it.
+- `--engine-reconcile` on the bundled binary still applies this
+ process’s UserDefaults; help text says so; tests use `.build/`.
diff --git a/docs/evidence/cfs-prodready/PACKAGING.md b/docs/evidence/cfs-prodready/PACKAGING.md
new file mode 100644
index 0000000..991feec
--- /dev/null
+++ b/docs/evidence/cfs-prodready/PACKAGING.md
@@ -0,0 +1,146 @@
+# Night Walker packaging
+
+Friend-distributable build: a universal, ad-hoc-signed `.app` inside a
+drag-to-Applications DMG. Not notarized, no Developer ID, no App Store.
+
+## What to ship
+
+| Thing | Value |
+|---|---|
+| Public name | Night Walker |
+| App folder | `dist/Night Walker.app` |
+| DMG | `dist/NightWalker-1.0.0.dmg` (no spaces) |
+| Volume name | Night Walker |
+| Executable | `color-filter-scheduler` (unchanged) |
+| Bundle id | `com.flo.color-filter-scheduler` (historical) |
+| VERSION | `1.0.0` in `bundle.sh` only |
+
+The captain's live app is `~/Applications/Color Filter Scheduler.app`.
+`install.sh` now targets `~/Applications/Night Walker.app` so a future
+install does not clobber it. Do not run `install.sh` / `uninstall.sh`
+from a packaging lane.
+
+## How to build the DMG
+
+From a checkout with Command Line Tools:
+
+```sh
+./bundle.sh # arm64 + x86_64 → lipo → dist/Night Walker.app → ad-hoc sign
+./dmg.sh # calls bundle.sh, then hdiutil UDZO
+```
+
+`dmg.sh` stages the `.app` plus a symlink to `/Applications` (standard
+drag-install layout) and writes `dist/NightWalker-.dmg`.
+VERSION is read from `bundle.sh`. No third-party `create-dmg`.
+
+## lipo expectation
+
+The bundled executable must be a fat Mach-O with both slices:
+
+```
+Architectures in the fat file: …/color-filter-scheduler are: x86_64 arm64
+```
+
+`bundle.sh` builds `--arch arm64` and `--arch x86_64` separately, then
+`lipo -create`. If a slice is missing it errors and dumps `.build/`.
+
+## Gatekeeper
+
+Ad-hoc signed only (`codesign --sign -`). Friends on a fresh Mac:
+
+1. Drag Night Walker → Applications.
+2. Right-click the app → Open → Open.
+3. A regular double-click is blocked until that one-time exception.
+
+Do not add hardened-runtime entitlements (those need a paid Apple cert).
+Do not notarize from this tree.
+
+## Verify (this lane)
+
+Do not open or install the app over the live captain copy. Evidence
+captured below after `./bundle.sh`, `./dmg.sh`, and `swift build -c release`.
+
+## Evidence
+
+Host: arm64, Apple Swift 6.1.2, macOS 15. Live captain app
+`~/Applications/Color Filter Scheduler.app` was not opened, copied, or
+replaced. `install.sh` / `uninstall.sh` were not run.
+`~/Applications/Night Walker.app` does not exist.
+
+### `./dmg.sh` (calls `./bundle.sh`)
+
+```
+==> Building universal release (arm64 + x86_64)…
+Build complete! (58.82s) # --arch arm64
+Build complete! (53.58s) # --arch x86_64
+==> lipo …/.build/arm64-apple-macosx/release/color-filter-scheduler
+ + …/.build/x86_64-apple-macosx/release/color-filter-scheduler
+Architectures in the fat file: …/dist/Night Walker.app/Contents/MacOS/color-filter-scheduler are: x86_64 arm64
+==> Ad-hoc signing
+…/dist/Night Walker.app: valid on disk
+…/dist/Night Walker.app: satisfies its Designated Requirement
+created: …/dist/NightWalker-1.0.0.dmg
+-rw-r--r--@ 1 flo staff 837K dist/NightWalker-1.0.0.dmg
+```
+
+SwiftPM layout on this machine: `.build/arm64-apple-macosx/release/` and
+`.build/x86_64-apple-macosx/release/`.
+
+### lipo
+
+```
+$ lipo -info "dist/Night Walker.app/Contents/MacOS/color-filter-scheduler"
+Architectures in the fat file: dist/Night Walker.app/Contents/MacOS/color-filter-scheduler are: x86_64 arm64
+```
+
+### codesign
+
+```
+$ codesign --verify --verbose=1 "dist/Night Walker.app"
+dist/Night Walker.app: valid on disk
+dist/Night Walker.app: satisfies its Designated Requirement
+
+$ codesign -dv --verbose=2 "dist/Night Walker.app"
+Executable=…/dist/Night Walker.app/Contents/MacOS/color-filter-scheduler
+Identifier=com.flo.color-filter-scheduler
+Format=app bundle with Mach-O universal (x86_64 arm64)
+CodeDirectory v=20400 size=4439 flags=0x2(adhoc) hashes=132+3 location=embedded
+Signature=adhoc
+Info.plist entries=11
+TeamIdentifier=not set
+Sealed Resources version=2 rules=13 files=1
+Internal requirements count=0 size=12
+```
+
+Info.plist: `CFBundleName` / `CFBundleDisplayName` = Night Walker,
+`CFBundleExecutable` = color-filter-scheduler,
+`CFBundleIdentifier` = com.flo.color-filter-scheduler,
+`LSMinimumSystemVersion` = 13.0, `LSUIElement` = true.
+
+### DMG contents (attach, list, detach — not installed)
+
+Volume name `Night Walker`. Listing of `/Volumes/Night Walker`:
+
+```
+lrwxr-xr-x@ Applications -> /Applications
+drwxr-xr-x@ Night Walker.app
+```
+
+### host-arch `swift build -c release`
+
+```
+Build complete! (0.19s)
+.build/release/color-filter-scheduler: Mach-O 64-bit executable arm64
+Non-fat file: .build/release/color-filter-scheduler is architecture: arm64
+```
+
+`--selftest` on the debug binary after the prodready suite: 31 passed,
+0 failed (panel architecture + Munich/polar/UTC-day-boundary solar
+fixtures + coordinate validation). Panel/UI contracts passed
+(including `Text("Night Walker")`).
+
+Re-verified after dropping `--deep` from `codesign`: identifier still
+`com.flo.color-filter-scheduler`, `lipo -info` still `x86_64 arm64`,
+`codesign --verify` still satisfies the designated requirement.
+`dist/NightWalker-1.0.0.dmg` (837K) still present from the earlier
+`dmg.sh` run.
diff --git a/docs/evidence/cfs-prodready/RUN.md b/docs/evidence/cfs-prodready/RUN.md
new file mode 100644
index 0000000..eaba134
--- /dev/null
+++ b/docs/evidence/cfs-prodready/RUN.md
@@ -0,0 +1,47 @@
+# Local test run (this machine)
+
+Date: 2026-08-19
+
+## Command
+
+```sh
+RUN_BUNDLE=0 tests/run.sh
+bash tests/bundle-contract.sh
+swift build -c release
+```
+
+`tests/run.sh` with default `RUN_BUNDLE=1` is the single entry for
+local / CI / no-mistakes. CI sets `RUN_BUNDLE=0` and runs `bundle.sh`
+in a separate job.
+
+## Result
+
+**exit 0**
+
+| suite | result |
+|---|---|
+| panel-contract | pass |
+| ui-contract | pass |
+| `--selftest` (31 checks) | pass |
+| cli-contract | pass |
+| hygiene | pass |
+| bundle-contract | pass (universal `x86_64` + `arm64`, identifier `com.flo.color-filter-scheduler`) |
+| `swift build -c release` | pass (host-arch arm64) |
+
+## Color Filters / live Mac
+
+Pre-test and post-test identical:
+
+- enabled `1`
+- intensity `0.7912975193298969`
+- type `16`
+
+`com.flo.color-filter-scheduler` (Lisbon, Automatic on) unchanged.
+`~/Applications/Color Filter Scheduler.app` still present.
+`~/Applications/Night Walker.app` was not created.
+`install.sh` / `uninstall.sh` were not run.
+LaunchAgent was not rewritten.
+
+Live-toggle coverage (cli-contract): one `--set-enabled` flip and one
+frozen-time `--reconcile --apply`, both restored via the `.build`
+binary’s SPI setters.
diff --git a/docs/evidence/cfs-prodready/SECURITY.md b/docs/evidence/cfs-prodready/SECURITY.md
new file mode 100644
index 0000000..4efd73f
--- /dev/null
+++ b/docs/evidence/cfs-prodready/SECURITY.md
@@ -0,0 +1,339 @@
+# Night Walker — security audit
+
+Date: 2026-08-19
+Scope: adversarial review of this worktree for friend-distribution
+readiness. User-facing name **Night Walker**; bundle id remains
+`com.flo.color-filter-scheduler`. CLT-only, ad-hoc signed, not notarized,
+not App Store. Read-only inspection of Swift sources, SPI header, CLI,
+LaunchAgent template, `bundle.sh` / `dmg.sh` / `install.sh` /
+`uninstall.sh`, `Package.swift`, `.gitignore`, README.
+
+Constraints honored: do not change SPI signatures, solar math, persisted
+keys, or bundle id; no TCC helper; no notarization flow; no running
+`install.sh`; no writes to captain `UserDefaults` or live Color Filters
+as part of this audit.
+
+**Status (same date, later on `fm/cfs-prodready`):** S1–S8 and S10 are
+patched in this branch. Findings below stay as the original audit; the
+“Applied on this branch” section at the end records what landed.
+
+## Findings
+
+| id | severity | file | issue | recommended fix (CLT-compatible) |
+|---|---|---|---|---|
+| S1 | medium | `Sources/color-filter-scheduler/CLI.swift`, `Scheduler.swift` | `--decide` / `--reconcile` accept any `Double` (`nan`, `inf`, `999`). `Double("nan")` succeeds. Solar then produces NaN dates; `now < sr \|\| now >= ss` is both-false, so `wantOn == false`. `--reconcile --apply --lat nan --lon 0` **turns Color Filters OFF** (fail-open). Out-of-range lat/lon still compute a decision and can `--apply`. `--sr-off` / `--ss-off` have the same NaN/Inf hole. Engine path (`Settings.hasValidLocation`) is already fail-closed; CLI is not. | Reject non-finite and out-of-range coords (and non-finite offsets) at the CLI boundary; exit 2. Do not call `Scheduler.decide` / `setEnabled` on garbage. |
+| S2 | medium | `Sources/color-filter-scheduler/PanelView.swift` (`PanelEvidence`) | `--render-panel` is **not** settings-read-only. It writes `Settings.shared` lat/lon/`locationName` (UserDefaults) then restores in `defer`. Comments claim isolation from the installed app domain. That is only true for an unpackaged SwiftPM binary. The **bundled** executable (`Night Walker.app`, id `com.flo.color-filter-scheduler`) uses the captain/friend prefs domain. A crash/kill mid-render leaves Lisbon in prefs. README even suggests running the installed binary as `BIN`. | Never assign `Settings.shared` in evidence code. Seed `AppModel` published fields only; fall back `locationDisplay` to `cityText` when Settings has no name. |
+| S3 | medium | `Sources/color-filter-scheduler/CLI.swift` | File header and `--help` say the CLI **never** reads/writes app UserDefaults. `--engine-status` / `--engine-reconcile` go through `Settings.shared` / `ReconcileEngine`. On the bundled binary, `--engine-reconcile` applies the **real** saved schedule to the live display. Commands are omitted from `--help` (hidden footgun). Tests that pass `-key value` NSArgumentDomain are safe; running the `.app` binary without those flags is not. | Keep commands for engine tests. Help must say they use **this process’s** defaults domain; instruct tests to use `.build/…`, never the installed `.app`. |
+| S4 | medium | `Sources/color-filter-scheduler/ColorFilters.swift`, `CLI.swift` | `--set-intensity` only requires `Double(v) != nil`. `nan` bypasses `min(1, max(0, x))` (both comparisons with NaN are false) and is passed to `MADisplayFilterPrefSetSingleColorIntensity` — live SPI write of NaN. `inf` clamps to 1; `-inf` clamps to 0. | CLI: require `isFinite && 0...1`, else exit 2. Setter: `guard newValue.isFinite else { return }` before clamp. |
+| S5 | high | `install.sh`, `uninstall.sh`, LaunchAgent label | `APP_NAME` is now `Night Walker`, so `rm -rf` / `cp -R` no longer smash `~/Applications/Color Filter Scheduler.app`. The LaunchAgent **label** is still `com.flo.color-filter-scheduler` (same as the live captain app). `install.sh` always `launchctl bootout` that label and rewrites `~/Library/LaunchAgents/com.flo.color-filter-scheduler.plist` to point at Night Walker. `uninstall.sh --purge-settings` runs `defaults delete com.flo.color-filter-scheduler` — **captain prefs**, because the bundle id is shared. README’s “will not touch a side-by-side Color Filter Scheduler.app” is true for the `.app` path and **false** for the login item and defaults domain. | Before bootout, require the existing plist’s `ProgramArguments` to contain this script’s `APP_BINARY`. Refuse `--purge-settings` (and refuse install) if `~/Applications/Color Filter Scheduler.app` exists. Verify `CFBundleIdentifier` before `rm -rf`. |
+| S6 | low | `install.sh` + `com.flo.color-filter-scheduler.plist.template` | `sed s#__APP_BINARY__#$APP_BINARY#g` interpolates `$HOME` paths raw into XML. `&` / `<` / `>` in a home path break or inject the plist; `#` in the path breaks the sed delimiter. Normal `/Users/name` is safe. | XML-escape (`&` first) and/or write the plist with `PlistBuddy` / a here-doc of escaped values. Keep `ProgramArguments` (spaces in `Night Walker`). |
+| S7 | low | `CLI.swift` `--render-panel` | Output dir is unsanitized (`opts["dir"]` / positional / default). `createDirectory(withIntermediateDirectories:)` + `try? png.write` will follow `..` and absolute paths and overwrite `panel-front-running.png` etc. anywhere the user can write. Personal CLI, not a network service. | Standardize the path; refuse unless it is the cwd or a subdirectory of cwd; then create + write. |
+| S8 | low | `uninstall.sh` | `rm -rf "$HOME/Applications/Night Walker.app"` with no bundle-id check. A different product named Night Walker would be deleted. After the rename this no longer targets the captain live app (good). Still no check that the binary is ours before `--set-enabled 0`. | `defaults read …/Info.plist CFBundleIdentifier` must equal `com.flo.color-filter-scheduler` before mutate/delete. |
+| S9 | info | `bundle.sh` | Ad-hoc `codesign --force --sign - --deep --identifier com.flo.color-filter-scheduler` then `--verify`. Correct for this distribution. `--deep` is redundant (no nested code) and Apple-discouraged for shipping, but not a hole here. Do **not** add `--options runtime` (Hardened Runtime) without Developer ID + notarization. No entitlements plist — keep it that way. DMG (`dmg.sh`) is unsigned; that is expected. | Keep ad-hoc; keep identifier pin; document Gatekeeper. Optional: drop `--deep`. |
+| S10 | info | LaunchAgent template | Session type Aqua, `KeepAlive=false`, no `StartInterval`, no MachServices, `ProcessType=Background`. Quit works. Logs under `~/Library/Logs` (user-owned). Missing `AssociatedBundleIdentifiers` only affects how System Settings groups the login item. | Optional: add `AssociatedBundleIdentifiers` = bundle id. |
+| S11 | info | logs / PII | GUI writes one stderr line: `color-filter-scheduler: menu-bar item created`. No city, coords, or geocode errors in LaunchAgent logs. Failures stay in the in-memory `geocodeMessage`. CLI `--decide` does not echo lat/lon (caller already passed them). | Do not start logging geocode queries or Settings. |
+| S12 | info | network | Only `CLGeocoder.geocodeAddressString` (Apple forward geocode). No `URLSession`, no ATS exception, no location TCC (forward geocode does not need it). Completions on main queue. | Keep; no custom HTTP. |
+| S13 | info | SPI | Private MediaAccessibility. Setters apply **live** (post `kMADisplayFilterSettingsChangedNotification`). Signatures in `CMediaAccessibility.h` are **not** accidentally simplified: `GetType(long category)`, intensity getter returns `double`, category `long`, Boolean `unsigned char`. Category 1 / type 16 match evidence. Type is never written. Any local binary that links this SPI can flip Color Filters with no Accessibility TCC prompt — OS design, not an extra hole. | Residual; do not wrap in a helper. Do not “simplify” the header. |
+| S14 | info | world-writable / secrets | `mkdir -p` without `chmod 777`; umask-default dirs. `dmg.sh` uses `mktemp -d` + `trap` cleanup. `dist/` gitignored. No tokens, PEM, API keys, or machine-local `/Users/…` in scripts (`$HOME` / `$REPO_DIR`). | No change. |
+
+## What is already good
+
+- Zero third-party dependencies; system frameworks only (AppKit, SwiftUI,
+ CoreLocation, MediaAccessibility).
+- `ReconcileEngine.reconcile()` no-ops when Automatic is off or
+ `hasValidLocation` fails (`isFinite` + lat ∈ [-90,90] + lon ∈ [-180,180]).
+- Fresh install defaults Automatic **OFF**, so a first launch does not
+ touch the filter until the user opts in.
+- `ColorFilters` never sets filter **type** (no SetType SPI declared).
+- `--get` / `--decide` (intended) / `--selftest` / `--help` do not write
+ Color Filters. `--set-enabled` fail-closes on a non-boolean.
+- `AppModel.applyLocation` range-checks lat/lon; NaN comparisons fail
+ the range test so NaN is not persisted from the UI.
+- LaunchAgent is a per-user Aqua agent, not a daemon; `KeepAlive` false.
+- Dismissal uses a local Esc monitor + `windowDidResignKey`, not a
+ global mouse tap (no keylogging other apps).
+- `dist/` is gitignored. No secrets in tree.
+- SPI ABI comments in the header match the empirically pinned calling
+ convention. Leave them.
+- Friend README already documents unidentified-developer / right-click
+ Open. `dmg.sh` is a drag-to-`/Applications` layout (avoids running
+ from a translocated DMG as the primary path).
+- Uninstall `rm -rf` target is `Night Walker.app`, not the captain’s
+ live `Color Filter Scheduler.app`.
+
+## Residual risk (honest)
+
+- **Ad-hoc signature.** Friends see Gatekeeper “unidentified developer”.
+ One-time **right-click → Open**. Not Developer ID, not notarized, not
+ App Store. A later macOS can tighten this. Unsigned DMG is fine; the
+ **app** is what Gatekeeper assesses. After that one-time bypass the
+ app runs with full user rights (same as any ad-hoc utility).
+- **Private Color Filters SPI.** Undocumented. ABI or category numbers
+ can change on a macOS update (the two calling-convention gotchas in
+ the header are load-bearing). Setting applies live. There is no TCC
+ prompt; that is also how System Settings does it.
+- **Shared bundle id** with the captain’s existing
+ `Color Filter Scheduler.app`. Two apps, one defaults domain, one
+ LaunchAgent label. They must not both be installed as login items.
+ Do not change the id (product constraint).
+- **Not sandboxed.** Menu-bar agent + LaunchAgent + UserDefaults +
+ geocoding. Appropriate for a personal utility; not App Store.
+- **`--set-enabled` / `--set-intensity` / `--reconcile --apply`** are
+ live mutation tools for tests and recovery. They do not persist *app*
+ settings, but they **do** change `com.apple.mediaaccessibility`.
+- **No Hardened Runtime / no entitlements.** Correct: HR without a paid
+ cert still fails Gatekeeper for downloaded copies and is not worth
+ the SPI/TCC risk. Do not invent a permission helper.
+
+## Recommended patches (parent should apply; not applied here)
+
+Priority: S5 (install/uninstall vs live captain), S1+S4 (fail-closed
+CLI), S2 (render-panel UserDefaults), S3 (help text), S6–S8 (plist
+escape, path confine, bundle-id check).
+
+### 1. CLI: finite coords/offsets, intensity range, path confine, honest help (S1, S3, S4, S7)
+
+```diff
+--- a/Sources/color-filter-scheduler/CLI.swift
++++ b/Sources/color-filter-scheduler/CLI.swift
+@@ -1,9 +1,12 @@
+ /// Headless command-line mode used for testing and evidence. It deliberately
+-/// takes location explicitly on the command line and NEVER reads or writes the
+-/// app's UserDefaults, so tests can't disturb the user's saved settings.
++/// takes location explicitly on the command line for `--decide`/`--reconcile`.
++/// Those commands do not read app UserDefaults.
++///
++/// `--engine-status` / `--engine-reconcile` **do** read `UserDefaults.standard`
++/// of *this process* (bundled app = `com.flo.color-filter-scheduler`).
++/// Run those only on a `.build/` binary, with `-key value` NSArgumentDomain.
+
+ case "--set-intensity":
+ guard let v = opts["value"] ?? opts["_pos0"], let d = Double(v) else {
+ errln("--set-intensity needs a 0..1 value"); return 2
+ }
++ guard d.isFinite, d >= 0, d <= 1 else {
++ errln("--set-intensity needs a finite 0..1 value"); return 2
++ }
+ ColorFilters.strength = d
+ print(String(format: "strength=%.6f", ColorFilters.strength))
+ return 0
+ case "--decide", "--reconcile":
+ guard let lat = opts["lat"].flatMap(Double.init),
+ let lon = opts["lon"].flatMap(Double.init) else {
+ errln("\(cmd) needs --lat --lon "); return 2
+ }
++ guard lat.isFinite, lon.isFinite,
++ lat >= -90, lat <= 90, lon >= -180, lon <= 180 else {
++ errln("\(cmd): --lat must be finite in [-90,90], --lon finite in [-180,180]"); return 2
++ }
+ let srOff = opts["sr-off"].flatMap(Double.init) ?? 0
+ let ssOff = opts["ss-off"].flatMap(Double.init) ?? 0
++ guard srOff.isFinite, ssOff.isFinite else {
++ errln("\(cmd): --sr-off/--ss-off must be finite"); return 2
++ }
+ let now = Date()
+ …
+ case "--render-panel":
+ let dir = opts["dir"] ?? opts["_pos0"] ?? "docs/evidence/cfs-ui"
+- renderPanel(dir)
++ guard let safe = confinedDir(dir) else {
++ errln("--render-panel: dir must be cwd or a subdirectory (no absolute / .. escape)"); return 2
++ }
++ renderPanel(safe)
+ return 0
+
++ /// Resolve `raw` against cwd and require it stay under cwd.
++ private static func confinedDir(_ raw: String) -> String? {
++ let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).standardizedFileURL
++ let url = URL(fileURLWithPath: raw, isDirectory: true,
++ relativeTo: cwd).standardizedFileURL
++ let root = cwd.path
++ let path = url.path
++ if path == root { return path }
++ let prefix = root.hasSuffix("/") ? root : root + "/"
++ guard path.hasPrefix(prefix) else { return nil }
++ return path
++ }
+```
+
+Help text (replace the “do NOT touch saved settings” lie):
+
+```
+color-filter-scheduler — menu-bar app. No args → menu-bar UI.
+
+Does not read/write app UserDefaults:
+ --get / --decide / --selftest / --help
+ --set-enabled / --set-intensity / --reconcile --apply
+ (these DO mutate live Color Filters / com.apple.mediaaccessibility)
+
+Reads this process's UserDefaults (bundled app = captain/friend prefs):
+ --engine-status
+ --engine-reconcile (may flip live Color Filters from saved schedule)
+ Use a .build/ binary plus -key value; never the installed .app.
+
+ --render-panel [dir] PNGs only; dir must be under cwd
+```
+
+Defense in depth on the intensity setter:
+
+```diff
+--- a/Sources/color-filter-scheduler/ColorFilters.swift
++++ b/Sources/color-filter-scheduler/ColorFilters.swift
+ static var strength: Double {
+ get { MADisplayFilterPrefGetSingleColorIntensity() }
+- set { MADisplayFilterPrefSetSingleColorIntensity(min(1, max(0, newValue))) }
++ set {
++ guard newValue.isFinite else { return }
++ MADisplayFilterPrefSetSingleColorIntensity(min(1, max(0, newValue)))
++ }
+ }
+```
+
+### 2. `--render-panel` must not touch UserDefaults (S2)
+
+```diff
+--- a/Sources/color-filter-scheduler/PanelView.swift
++++ b/Sources/color-filter-scheduler/PanelView.swift
+ static func render(to dir: String) {
+ try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+-
+- // Temporarily seed a location in THIS binary's defaults …
+- let savedLat = Settings.shared.latitude, savedLon = Settings.shared.longitude
+- let savedName = Settings.shared.locationName
+- Settings.shared.latitude = 38.72; Settings.shared.longitude = -9.14
+- Settings.shared.locationName = "Lisbon, Portugal"
+- defer {
+- Settings.shared.latitude = savedLat; Settings.shared.longitude = savedLon
+- Settings.shared.locationName = savedName
+- }
+-
+ let onModel = AppModel()
+ …
+```
+
+So the front-page screenshot still shows a city without Settings:
+
+```diff
+--- a/Sources/color-filter-scheduler/AppModel.swift
++++ b/Sources/color-filter-scheduler/AppModel.swift
+ var locationDisplay: String {
+ if let name = Settings.shared.locationName, !name.isEmpty { return name }
++ if !cityText.isEmpty { return cityText }
+ return locationSummary
+ }
+```
+
+Live UI unchanged when a resolved `locationName` exists (the usual case).
+
+### 3. LaunchAgent: do not hijack the captain login item (S5, S6, S8)
+
+`install.sh` — XML-escape + refuse to touch a live legacy install:
+
+```diff
+--- a/install.sh
++++ b/install.sh
+ LABEL="com.flo.color-filter-scheduler"
+ APP_NAME="Night Walker"
++LEGACY_APP="$HOME/Applications/Color Filter Scheduler.app"
++
++xml_escape() {
++ # & first so we do not re-escape the inserted entities.
++ local s=$1
++ s=${s//&/&}
++ s=${s//<}
++ s=${s//>/>}
++ s=${s//\"/"}
++ printf '%s' "$s"
++}
++
++if [[ -d "$LEGACY_APP" ]]; then
++ echo "error: refusing to install: $LEGACY_APP exists." >&2
++ echo " It shares LaunchAgent label $LABEL and bundle id $LABEL." >&2
++ echo " Friend path is the DMG; do not run install.sh on the captain Mac." >&2
++ exit 1
++fi
+
+ echo "==> Writing LaunchAgent -> $PLIST"
+ mkdir -p "$AGENTS_DIR" "$LOG_DIR"
+-sed -e "s#__APP_BINARY__#$APP_BINARY#g" \
+- -e "s#__LOGDIR__#$LOG_DIR#g" \
+- "$REPO_DIR/$LABEL.plist.template" > "$PLIST"
++APP_BINARY_XML="$(xml_escape "$APP_BINARY")"
++LOG_DIR_XML="$(xml_escape "$LOG_DIR")"
++sed -e "s#__APP_BINARY__#$APP_BINARY_XML#g" \
++ -e "s#__LOGDIR__#$LOG_DIR_XML#g" \
++ "$REPO_DIR/$LABEL.plist.template" > "$PLIST"
+```
+
+`uninstall.sh`:
+
+```diff
+--- a/uninstall.sh
++++ b/uninstall.sh
+ LEGACY_APP="$HOME/Applications/Color Filter Scheduler.app"
++
++if [[ -d "$LEGACY_APP" ]]; then
++ echo "error: refusing to uninstall/purge: $LEGACY_APP exists (shared bundle id $LABEL)." >&2
++ exit 1
++fi
++
++if [[ -d "$INSTALLED_APP" ]]; then
++ BID="$(defaults read "$INSTALLED_APP/Contents/Info" CFBundleIdentifier 2>/dev/null || true)"
++ if [[ "$BID" != "$LABEL" ]]; then
++ echo "error: $INSTALLED_APP is not $LABEL (id='$BID'); not deleting." >&2
++ exit 1
++ fi
++fi
++
++# Only bootout if the on-disk plist already points at this app binary.
+ if [[ -f "$PLIST" ]] && grep -F -q "$APP_BINARY" "$PLIST"; then
+ launchctl bootout "gui/$UID_NUM/$LABEL" 2>/dev/null || true
+ fi
+```
+
+Optional template addition (S10):
+
+```xml
+AssociatedBundleIdentifiers
+
+ com.flo.color-filter-scheduler
+
+```
+
+### 4. Do not apply (explicit non-fixes)
+
+- Do not change `CMediaAccessibility.h` signatures.
+- Do not change solar math or UserDefaults key names.
+- Do not add entitlements, Hardened Runtime, sandbox, or a TCC helper.
+- Do not notarize or Developer-ID sign.
+- Do not rename the bundle id.
+
+## Gatekeeper / friends (residual, not a code bug)
+
+1. Drag **Night Walker** from the DMG onto **Applications** (avoid
+ running from the image / App Translocation).
+2. **Right-click → Open → Open** once. Double-click stays blocked until
+ that exception exists.
+3. `spctl --assess` will fail; that is the ad-hoc signature working as
+ designed.
+4. Replacing the `.app` with a new unsigned/ad-hoc copy can require the
+ right-click dance again.
+5. After the bypass, the binary can toggle Color Filters with no extra
+ prompt. Distribution trust is “you got this DMG from me”.
+
+## Applied on this branch
+
+| id | what landed |
+|---|---|
+| S1 | CLI rejects non-finite / out-of-range lat/lon and non-finite `--sr-off`/`--ss-off`; `Scheduler.isValidCoordinate` shared with Settings. |
+| S2 | `PanelEvidence` no longer writes `Settings.shared`. `locationDisplay` falls back to `cityText`. |
+| S3 | CLI header + `--help` distinguish UserDefaults-free commands from `--engine-*`. |
+| S4 | `--set-intensity` requires finite 0…1; `ColorFilters.strength` setter ignores NaN. |
+| S5 | `install.sh` refuses if `Color Filter Scheduler.app` exists (override: `--replace-login-item`). `uninstall.sh` refuses if that legacy app exists. |
+| S6 | `install.sh` copies the template and sets paths with `PlistBuddy` (XML-escaped). |
+| S7 | `--render-panel` dir must be cwd or a subdirectory; symlinks that escape cwd are resolved and rejected (Codex P2). |
+| S8 | `uninstall.sh` checks `CFBundleIdentifier` before delete; only `bootout`/`rm` plist if it points at Night Walker. |
+| S9 | `bundle.sh` ad-hoc `codesign --sign - --identifier …` (no `--deep`, no Hardened Runtime). |
+| S10 | LaunchAgent `AssociatedBundleIdentifiers` = bundle id. |
+
+Not applied (intentional): notarization, Hardened Runtime, TCC helper, SPI/solar/key/bundle-id changes.
diff --git a/install.sh b/install.sh
index dd10197..0de102c 100755
--- a/install.sh
+++ b/install.sh
@@ -1,52 +1,77 @@
#!/usr/bin/env bash
#
-# Build + bundle + install the menu-bar app to ~/Applications and register it to
-# launch at login (via a per-user LaunchAgent). Idempotent: safe to re-run.
+# Build + bundle + install Night Walker to ~/Applications and register the
+# bundled main app to launch at login. Idempotent: safe to re-run on a machine
+# that does NOT already have the historical Color Filter Scheduler.app.
+#
+# The bundle id stays com.flo.color-filter-scheduler. That is the same domain as
+# the captain's live install, so this script REFUSES if
+# ~/Applications/Color Filter Scheduler.app exists unless you pass
+# --replace-login-item.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-LABEL="com.flo.color-filter-scheduler"
-APP_NAME="Color Filter Scheduler"
+BUNDLE_ID="com.flo.color-filter-scheduler"
+APP_NAME="Night Walker"
EXECUTABLE="color-filter-scheduler"
+LEGACY_APP="$HOME/Applications/Color Filter Scheduler.app"
+
+REPLACE_LOGIN=0
+if [[ "${1:-}" == "--replace-login-item" ]]; then
+ REPLACE_LOGIN=1
+elif [[ -n "${1:-}" ]]; then
+ echo "usage: $0 [--replace-login-item]" >&2
+ exit 2
+fi
APPS_DIR="$HOME/Applications"
INSTALLED_APP="$APPS_DIR/$APP_NAME.app"
APP_BINARY="$INSTALLED_APP/Contents/MacOS/$EXECUTABLE"
-AGENTS_DIR="$HOME/Library/LaunchAgents"
-PLIST="$AGENTS_DIR/$LABEL.plist"
-LOG_DIR="$HOME/Library/Logs"
+PLIST="$HOME/Library/LaunchAgents/$BUNDLE_ID.plist"
+LEGACY_BINARY="$LEGACY_APP/Contents/MacOS/$EXECUTABLE"
+
+if [[ -d "$LEGACY_APP" && "$REPLACE_LOGIN" -ne 1 ]]; then
+ echo "error: refusing to install: $LEGACY_APP exists." >&2
+ echo " It shares bundle id $BUNDLE_ID." >&2
+ echo " Friends should install from the DMG (drag to Applications)." >&2
+ echo " Pass --replace-login-item only if you intend to migrate this Mac." >&2
+ exit 1
+fi
echo "==> Building app bundle"
"$REPO_DIR/bundle.sh"
BUILT_APP="$REPO_DIR/dist/$APP_NAME.app"
+[[ -d "$BUILT_APP" ]] || { echo "error: bundle.sh did not produce $BUILT_APP" >&2; exit 1; }
+
+if [[ -f "$PLIST" ]]; then
+ PLIST_BINARY="$(/usr/libexec/PlistBuddy -c 'Print :ProgramArguments:0' "$PLIST" 2>/dev/null || true)"
+ if [[ "$PLIST_BINARY" != "$APP_BINARY" && "$PLIST_BINARY" != "$LEGACY_BINARY" ]]; then
+ echo "error: refusing to remove unrelated LaunchAgent $PLIST" >&2
+ exit 1
+ fi
+ echo "==> Removing managed legacy LaunchAgent"
+ launchctl bootout "gui/$(id -u)/$BUNDLE_ID" 2>/dev/null || true
+ rm -f "$PLIST"
+fi
echo "==> Installing -> $INSTALLED_APP"
mkdir -p "$APPS_DIR"
rm -rf "$INSTALLED_APP"
cp -R "$BUILT_APP" "$INSTALLED_APP"
-echo "==> Writing LaunchAgent -> $PLIST"
-mkdir -p "$AGENTS_DIR" "$LOG_DIR"
-sed -e "s#__APP_BINARY__#$APP_BINARY#g" \
- -e "s#__LOGDIR__#$LOG_DIR#g" \
- "$REPO_DIR/$LABEL.plist.template" > "$PLIST"
-
-echo "==> Loading agent (launch at login + start now)"
-UID_NUM="$(id -u)"
-launchctl bootout "gui/$UID_NUM/$LABEL" 2>/dev/null || true
-launchctl bootstrap "gui/$UID_NUM" "$PLIST"
-launchctl enable "gui/$UID_NUM/$LABEL" 2>/dev/null || true
+echo "==> Registering main app with SMAppService"
+"$APP_BINARY" --register-login-item
+open "$INSTALLED_APP"
echo
echo "Installed. A menu-bar icon should appear now and at every login."
echo " App : $INSTALLED_APP"
-echo " Plist : $PLIST"
-echo " Logs : $LOG_DIR/color-filter-scheduler.log"
+echo " Login : System Settings > General > Login Items"
echo
echo "Open the menu-bar icon, then:"
echo " 1. Press 'Run' to try the filter live (or 'Pause' to turn it off)."
-echo " 2. Open 'Settings' (the header pill) to set latitude/longitude,"
-echo " adjust 'Strength', and turn on 'Automatic (sunset → sunrise)'."
+echo " 2. Open Settings (the header gear) to set your city, adjust Strength,"
+echo " and turn on Automatic."
echo
echo "To change the reconcile cadence, edit reconcileInterval in the source"
echo "(Sources/color-filter-scheduler/AppDelegate.swift) and re-run this script."
diff --git a/tests/bundle-contract.sh b/tests/bundle-contract.sh
new file mode 100755
index 0000000..e22442c
--- /dev/null
+++ b/tests/bundle-contract.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+# bundle-contract: ./bundle.sh produces a signed .app with the historical
+# identifier com.flo.color-filter-scheduler.
+#
+# The shipped executable must be universal (x86_64 + arm64).
+#
+# Does not install. Does not touch ~/Applications.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+failures=0
+ok() { echo "ok: $*"; }
+fail() { echo "FAIL: $*"; failures=$((failures + 1)); }
+
+if [ ! -x ./bundle.sh ]; then
+ echo "bundle-contract: ./bundle.sh missing or not executable" >&2
+ exit 1
+fi
+
+./bundle.sh
+
+APP="dist/Night Walker.app"
+if [ ! -d "$APP" ]; then
+ echo "FAIL: bundle.sh did not produce $APP"
+ exit 1
+fi
+echo "bundle-contract: inspecting $APP"
+
+if codesign --verify --verbose=1 "$APP"; then
+ ok "codesign --verify"
+else
+ fail "codesign --verify"
+fi
+
+IDENT="$(codesign -d --verbose=2 "$APP" 2>&1 | awk -F= '/^Identifier=/{print $2; exit}')"
+if [ "$IDENT" = "com.flo.color-filter-scheduler" ]; then
+ ok "codesign identifier is com.flo.color-filter-scheduler"
+else
+ fail "codesign identifier is '${IDENT:-}' (want com.flo.color-filter-scheduler)"
+fi
+
+INFO="$APP/Contents/Info.plist"
+if [ -f "$INFO" ]; then
+ PLIST_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$INFO" 2>/dev/null || true)"
+ if [ "$PLIST_ID" = "com.flo.color-filter-scheduler" ]; then
+ ok "Info.plist CFBundleIdentifier is com.flo.color-filter-scheduler"
+ else
+ fail "Info.plist CFBundleIdentifier is '${PLIST_ID:-}'"
+ fi
+ EXEC_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$INFO" 2>/dev/null || echo color-filter-scheduler)"
+else
+ fail "Info.plist missing"
+ EXEC_NAME="color-filter-scheduler"
+fi
+
+EXEC="$APP/Contents/MacOS/$EXEC_NAME"
+if [ ! -x "$EXEC" ]; then
+ fail "missing executable $EXEC"
+ echo "bundle-contract: $failures failure(s)"
+ exit 1
+fi
+
+LIPO_INFO="$(lipo -info "$EXEC" 2>/dev/null || echo "")"
+echo "note: $LIPO_INFO"
+
+has_x86=0
+has_arm=0
+case "$LIPO_INFO" in *x86_64*) has_x86=1 ;; esac
+case "$LIPO_INFO" in *arm64*) has_arm=1 ;; esac
+
+if [ "$has_x86" = "1" ] && [ "$has_arm" = "1" ]; then
+ ok "universal binary (x86_64 + arm64)"
+else
+ fail "$EXEC is not universal x86_64+arm64"
+fi
+
+if otool -L "$EXEC" | awk '{print $1}' | grep -q '/ServiceManagement.framework/'; then
+ ok "executable links ServiceManagement for launch at login"
+else
+ fail "executable does not link ServiceManagement"
+fi
+
+if [ "$failures" -ne 0 ]; then
+ echo "bundle-contract: $failures failure(s)"
+ exit 1
+fi
+echo "bundle-contract: all checks passed"
diff --git a/tests/cli-contract.sh b/tests/cli-contract.sh
new file mode 100755
index 0000000..9e2fdaf
--- /dev/null
+++ b/tests/cli-contract.sh
@@ -0,0 +1,258 @@
+#!/usr/bin/env bash
+# CLI contract: read-only commands must not touch Color Filters; mutating
+# commands restore via the unpackaged .build binary (SPI, not defaults write).
+# Never invokes ~/Applications. Never writes com.flo.color-filter-scheduler.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+if [ -z "${BIN:-}" ]; then
+ echo "cli-contract: BIN is unset (run via tests/run.sh, or export BIN=)" >&2
+ exit 1
+fi
+if [ ! -x "$BIN" ]; then
+ echo "cli-contract: BIN is not executable: $BIN" >&2
+ exit 1
+fi
+
+# Refuse to talk to the installed app even if someone exports the wrong BIN.
+case "$BIN" in
+ "$HOME/Applications"/*)
+ echo "cli-contract: refusing installed app at $BIN" >&2
+ exit 1
+ ;;
+esac
+
+failures=0
+OUT="$(mktemp)"
+ERR="$(mktemp)"
+APP_SNAP="$(mktemp)"
+MA_SNAP="$(mktemp)"
+APP_DOMAIN="com.flo.color-filter-scheduler"
+
+ok() { echo "ok: $*"; }
+fail() { echo "FAIL: $*"; failures=$((failures + 1)); }
+
+nums_close() {
+ awk -v a="$1" -v b="$2" 'BEGIN {
+ d = a - b
+ if (d < 0) d = -d
+ exit (d < 1e-9) ? 0 : 1
+ }'
+}
+
+read_ma() {
+ defaults read com.apple.mediaaccessibility "$1" 2>/dev/null || true
+}
+
+ORIG_ENABLED="$(read_ma "__Color__-MADisplayFilterCategoryEnabled")"
+ORIG_INTENSITY="$(read_ma MADisplayFilterSingleColorIntensity)"
+ORIG_TYPE="$(read_ma "__Color__-MADisplayFilterType")"
+defaults read com.apple.mediaaccessibility >"$MA_SNAP" 2>/dev/null || true
+
+HAD_APP=0
+if defaults read "$APP_DOMAIN" >"$APP_SNAP" 2>/dev/null; then
+ HAD_APP=1
+fi
+
+RESTORED=0
+restore_color_filters() {
+ if [ "$RESTORED" = "1" ]; then
+ return 0
+ fi
+ RESTORED=1
+ if [ -z "$ORIG_ENABLED" ] || [ -z "$ORIG_INTENSITY" ]; then
+ echo "cli-contract: no original Color Filters snapshot; skip restore" >&2
+ return 0
+ fi
+ "$BIN" --set-enabled "$ORIG_ENABLED" >/dev/null || \
+ echo "cli-contract: ERROR restoring --set-enabled $ORIG_ENABLED" >&2
+ "$BIN" --set-intensity "$ORIG_INTENSITY" >/dev/null || \
+ echo "cli-contract: ERROR restoring --set-intensity $ORIG_INTENSITY" >&2
+}
+
+verify_color_filters() {
+ local en int typ
+ en="$(read_ma "__Color__-MADisplayFilterCategoryEnabled")"
+ int="$(read_ma MADisplayFilterSingleColorIntensity)"
+ typ="$(read_ma "__Color__-MADisplayFilterType")"
+ if [ "$en" != "$ORIG_ENABLED" ]; then
+ fail "Color Filters enabled restored ($en != $ORIG_ENABLED)"
+ return 1
+ fi
+ if [ -n "$ORIG_TYPE" ] && [ "$typ" != "$ORIG_TYPE" ]; then
+ fail "Color Filters type restored ($typ != $ORIG_TYPE)"
+ return 1
+ fi
+ if ! nums_close "$int" "$ORIG_INTENSITY"; then
+ fail "Color Filters intensity restored ($int != $ORIG_INTENSITY)"
+ return 1
+ fi
+ ok "Color Filters restored (enabled=$en intensity=$int type=$typ)"
+ return 0
+}
+
+check_app_untouched() {
+ local after
+ after="$(mktemp)"
+ if [ "$HAD_APP" = "1" ]; then
+ if ! defaults read "$APP_DOMAIN" >"$after" 2>/dev/null; then
+ fail "captain settings domain $APP_DOMAIN disappeared"
+ rm -f "$after"
+ return 1
+ fi
+ if ! cmp -s "$APP_SNAP" "$after"; then
+ fail "captain settings $APP_DOMAIN changed (tests must not persist)"
+ diff -u "$APP_SNAP" "$after" || true
+ rm -f "$after"
+ return 1
+ fi
+ ok "captain settings $APP_DOMAIN unchanged"
+ else
+ if defaults read "$APP_DOMAIN" >"$after" 2>/dev/null; then
+ fail "captain settings $APP_DOMAIN was created"
+ rm -f "$after"
+ return 1
+ fi
+ ok "captain settings $APP_DOMAIN still absent"
+ fi
+ rm -f "$after"
+ return 0
+}
+
+cleanup() {
+ local rc=$?
+ restore_color_filters
+ verify_color_filters || rc=1
+ check_app_untouched || rc=1
+ rm -f "$OUT" "$ERR" "$APP_SNAP" "$MA_SNAP"
+ trap - EXIT INT TERM
+ exit "$rc"
+}
+trap cleanup EXIT INT TERM
+
+expect_exit() {
+ local want="$1"
+ shift
+ set +e
+ "$@" >"$OUT" 2>"$ERR"
+ local st=$?
+ set -e
+ if [ "$st" -eq "$want" ]; then
+ ok "$* (exit $want)"
+ else
+ fail "$* (exit $st, want $want)"
+ if [ -s "$ERR" ]; then sed 's/^/ stderr: /' "$ERR"; fi
+ if [ -s "$OUT" ]; then sed 's/^/ stdout: /' "$OUT"; fi
+ fi
+}
+
+contains() {
+ local needle="$1"
+ if grep -F -q -- "$needle" "$OUT"; then
+ ok "output contains '$needle'"
+ else
+ fail "output missing '$needle'"
+ sed 's/^/ stdout: /' "$OUT"
+ fi
+}
+
+# --- read-only ---
+expect_exit 0 "$BIN" --get
+expect_exit 2 "$BIN" --decide
+expect_exit 2 "$BIN" --decide --lat 91 --lon 0
+expect_exit 2 "$BIN" --decide --lat 999 --lon 0
+expect_exit 2 "$BIN" --decide --lat nan --lon 0
+expect_exit 2 "$BIN" --decide --lat inf --lon 0
+expect_exit 2 "$BIN" --decide --lat 48.137 --lon 11.575 --now not-a-date
+expect_exit 0 "$BIN" --decide --lat 48.137 --lon 11.575
+contains "decision:"
+
+expect_exit 0 "$BIN" --help
+contains "--now"
+
+expect_exit 0 "$BIN" --decide --lat 48.137 --lon 11.575 --now 2026-08-18T12:00:00Z
+contains "want OFF"
+
+expect_exit 0 "$BIN" --decide --lat 48.137 --lon 11.575 --now 2026-08-18T22:15:00Z
+contains "want ON"
+
+expect_exit 2 "$BIN" --decide --lat 48.137 --lon 11.575 --sr-off nan
+expect_exit 2 "$BIN" --set-intensity nan
+expect_exit 2 "$BIN" --set-intensity inf
+expect_exit 2 "$BIN" --set-intensity 1.5
+expect_exit 2 "$BIN" --set-intensity -0.1
+expect_exit 2 "$BIN" --render-panel /tmp/cfs-render-panel-test
+mkdir -p .build
+ESCAPE_LINK=".build/cfs-render-escape"
+rm -f "$ESCAPE_LINK"
+ln -s /tmp "$ESCAPE_LINK"
+expect_exit 2 "$BIN" --render-panel "$ESCAPE_LINK"
+rm -f "$ESCAPE_LINK"
+
+expect_exit 0 "$BIN" --decide --lat 80 --lon 15 --now 2026-06-21T12:00:00Z
+contains "polar day"
+contains "want OFF"
+
+expect_exit 0 "$BIN" --reconcile --lat 48.137 --lon 11.575 --now 2026-08-18T12:00:00Z
+# no --apply: still read-only
+
+MA_AFTER="$(mktemp)"
+defaults read com.apple.mediaaccessibility >"$MA_AFTER" 2>/dev/null || true
+if cmp -s "$MA_SNAP" "$MA_AFTER"; then
+ ok "--get/--decide/--reconcile (no --apply) left com.apple.mediaaccessibility identical"
+else
+ fail "read-only commands mutated com.apple.mediaaccessibility"
+ diff -u "$MA_SNAP" "$MA_AFTER" || true
+fi
+rm -f "$MA_AFTER"
+
+# NSArgumentDomain on the unpackaged binary. Command must start with --engine-status
+# so CLI.swift does not fall through to the GUI (--flag parsing vs -key value).
+expect_exit 0 "$BIN" --engine-status -automationEnabled 0
+contains "automationEnabled=false"
+contains "fail-safe"
+
+# --- live toggle (minimal): one flip + restore ---
+# Skip on a clean machine (e.g. GitHub Actions) where Color Filters prefs
+# were never created. Mutating then would leave a preference we cannot
+# restore from an empty snapshot.
+if [ -z "$ORIG_ENABLED" ] || [ -z "$ORIG_INTENSITY" ]; then
+ echo "note: skipping live Color Filters mutation (no pre-existing preference)"
+else
+ if [ "$ORIG_ENABLED" = "1" ]; then
+ FLIP_TO=0
+ else
+ FLIP_TO=1
+ fi
+ expect_exit 0 "$BIN" --set-enabled "$FLIP_TO"
+ NOW_EN="$(read_ma "__Color__-MADisplayFilterCategoryEnabled")"
+ if [ "$NOW_EN" = "$FLIP_TO" ]; then
+ ok "--set-enabled $FLIP_TO applied live"
+ else
+ fail "--set-enabled $FLIP_TO did not apply (now $NOW_EN)"
+ fi
+ restore_color_filters
+ RESTORED=0 # allow a second restore after reconcile
+ verify_color_filters || true
+
+ # --reconcile --apply with --now frozen so wantOn matches the restored
+ # state. Restore is still mandatory in case the engine disagrees.
+ if [ "$ORIG_ENABLED" = "1" ]; then
+ APPLY_NOW="2026-08-18T22:15:00Z" # Munich night → want ON
+ else
+ APPLY_NOW="2026-08-18T12:00:00Z" # Munich day → want OFF
+ fi
+ expect_exit 0 "$BIN" --reconcile --lat 48.137 --lon 11.575 --now "$APPLY_NOW" --apply
+ restore_color_filters
+ verify_color_filters || true
+fi
+
+check_app_untouched || true
+
+if [ "$failures" -ne 0 ]; then
+ echo "cli-contract: $failures failure(s)"
+ exit 1
+fi
+echo "cli-contract: all checks passed"
diff --git a/tests/hygiene.sh b/tests/hygiene.sh
new file mode 100755
index 0000000..6f91036
--- /dev/null
+++ b/tests/hygiene.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+# hygiene: no committed secrets; no machine-local absolute paths in scripts.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+failures=0
+ok() { echo "ok: $*"; }
+fail() { echo "FAIL: $*"; failures=$((failures + 1)); }
+
+# Collect source-like files, skipping build products and historical evidence dumps.
+# Secret scan excludes EVIDENCE.md and docs/evidence/* (they can quote logs).
+secret_files=()
+while IFS= read -r f; do
+ case "$f" in
+ ./EVIDENCE.md|./docs/evidence/*) continue ;;
+ ./.build/*|./dist/*|./.git/*) continue ;;
+ esac
+ secret_files+=("${f#./}")
+done < <(find . \
+ \( -path './.build' -o -path './dist' -o -path './.git' -o -path './docs/evidence' \) -prune -o \
+ -type f \( \
+ -name '*.swift' -o -name '*.sh' -o -name '*.yml' -o -name '*.yaml' \
+ -o -name '*.md' -o -name '*.template' -o -name '*.h' -o -name '*.c' \
+ -o -name '*.plist' -o -name 'Package.swift' \
+ \) -print | sort)
+
+# AWS access key, PEM private keys, GitHub fine-grained PAT, Slack bot token.
+# Split so this scanner file does not match itself.
+secret_re="AKIA[0-9A-Z]{16}|BEGIN (RSA |OPENSSH |EC )?PRIVATE KEY|github""_pat_|xox""b-"
+
+secret_hits=0
+if [ ${#secret_files[@]} -gt 0 ]; then
+ for f in "${secret_files[@]}"; do
+ [ -f "$f" ] || continue
+ if grep -E -n -e "$secret_re" "$f" >/dev/null 2>&1; then
+ grep -E -n -e "$secret_re" "$f" | while IFS= read -r line; do
+ echo "FAIL: secret-like pattern in $f:$line"
+ done
+ secret_hits=$((secret_hits + 1))
+ fi
+ done
+fi
+if [ "$secret_hits" -eq 0 ]; then
+ ok "no secret-like patterns in source files"
+else
+ fail "$secret_hits file(s) matched secret-like patterns"
+fi
+
+# Absolute machine-local paths: only scripts, Package.swift, and .github — not
+# docs/evidence historical logs.
+# Split so this scanner file does not match itself.
+user_path="$(printf '%s%s' '/Users/' 'flo')"
+path_hits=0
+scan_path() {
+ local f="$1"
+ [ -e "$f" ] || return 0
+ if [ -d "$f" ]; then
+ while IFS= read -r p; do
+ if grep -n -e "$user_path" "$p" >/dev/null 2>&1; then
+ grep -n -e "$user_path" "$p" | while IFS= read -r line; do
+ echo "FAIL: machine-local path in $p:$line"
+ done
+ path_hits=$((path_hits + 1))
+ fi
+ done < <(find "$f" -type f \( -name '*.yml' -o -name '*.yaml' -o -name '*.sh' -o -name '*.md' \) -print)
+ return 0
+ fi
+ if grep -n -e "$user_path" "$f" >/dev/null 2>&1; then
+ grep -n -e "$user_path" "$f" | while IFS= read -r line; do
+ echo "FAIL: machine-local path in $f:$line"
+ done
+ path_hits=$((path_hits + 1))
+ fi
+}
+
+while IFS= read -r shf; do
+ scan_path "$shf"
+done < <(find . \
+ \( -path './.build' -o -path './dist' -o -path './.git' \) -prune -o \
+ -type f -name '*.sh' -print | sort)
+
+scan_path Package.swift
+scan_path .github
+
+if [ "$path_hits" -eq 0 ]; then
+ ok "no machine-local /Users paths in scripts, Package.swift, .github"
+else
+ fail "$path_hits file(s) contain a machine-local /Users path"
+fi
+
+if [ "$failures" -ne 0 ]; then
+ echo "hygiene: $failures failure(s)"
+ exit 1
+fi
+echo "hygiene: all checks passed"
diff --git a/tests/panel-contract.sh b/tests/panel-contract.sh
index 5b7814b..70d0d3a 100755
--- a/tests/panel-contract.sh
+++ b/tests/panel-contract.sh
@@ -4,10 +4,21 @@ set -eu
source_file="Sources/color-filter-scheduler/AppDelegate.swift"
failures=0
+# rg is nicer locally; grep -F is what GitHub Actions macos-latest has.
+grep_fixed() {
+ pattern="$1"
+ file="$2"
+ if command -v rg >/dev/null 2>&1; then
+ rg -q --fixed-strings "$pattern" "$file"
+ else
+ grep -F -q "$pattern" "$file"
+ fi
+}
+
require_absent() {
pattern="$1"
description="$2"
- if rg -q --fixed-strings "$pattern" "$source_file"; then
+ if grep_fixed "$pattern" "$source_file"; then
echo "FAIL: $description"
failures=$((failures + 1))
else
@@ -18,7 +29,7 @@ require_absent() {
require_present() {
pattern="$1"
description="$2"
- if rg -q --fixed-strings "$pattern" "$source_file"; then
+ if grep_fixed "$pattern" "$source_file"; then
echo "ok: $description"
else
echo "FAIL: $description"
diff --git a/tests/run.sh b/tests/run.sh
new file mode 100755
index 0000000..d75e8d2
--- /dev/null
+++ b/tests/run.sh
@@ -0,0 +1,139 @@
+#!/usr/bin/env bash
+# tests/run.sh — single entry for local / CI / no-mistakes
+#
+# Builds the debug binary once, then runs the contract suite. Never installs
+# the app, never writes captain settings, never leaves Color Filters changed.
+# Skips GUI-only --render-panel unless RUN_GUI=1.
+#
+# Env:
+# RUN_BUNDLE=1 (default) also run tests/bundle-contract.sh (release + codesign)
+# RUN_BUNDLE=0 skip packaging
+# RUN_GUI=1 also --render-panel (skipped by default)
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+RUN_BUNDLE="${RUN_BUNDLE:-1}"
+RUN_GUI="${RUN_GUI:-0}"
+
+nums_close() {
+ awk -v a="$1" -v b="$2" 'BEGIN {
+ d = a - b
+ if (d < 0) d = -d
+ exit (d < 1e-9) ? 0 : 1
+ }'
+}
+
+read_ma() {
+ defaults read com.apple.mediaaccessibility "$1" 2>/dev/null || true
+}
+
+ORIG_ENABLED="$(read_ma "__Color__-MADisplayFilterCategoryEnabled")"
+ORIG_INTENSITY="$(read_ma MADisplayFilterSingleColorIntensity)"
+ORIG_TYPE="$(read_ma "__Color__-MADisplayFilterType")"
+echo "run.sh: Color Filters snapshot enabled=${ORIG_ENABLED:-?} intensity=${ORIG_INTENSITY:-?} type=${ORIG_TYPE:-?}"
+
+BIN=""
+restore_color_filters() {
+ if [ -z "${BIN:-}" ] || [ ! -x "${BIN:-}" ]; then
+ return 0
+ fi
+ if [ -z "$ORIG_ENABLED" ] || [ -z "$ORIG_INTENSITY" ]; then
+ return 0
+ fi
+ local en int
+ en="$(read_ma "__Color__-MADisplayFilterCategoryEnabled")"
+ int="$(read_ma MADisplayFilterSingleColorIntensity)"
+ if [ "$en" = "$ORIG_ENABLED" ] && nums_close "${int:-0}" "$ORIG_INTENSITY"; then
+ return 0
+ fi
+ echo "run.sh: restoring Color Filters (enabled=$ORIG_ENABLED intensity=$ORIG_INTENSITY)" >&2
+ "$BIN" --set-enabled "$ORIG_ENABLED" >/dev/null || \
+ echo "run.sh: ERROR restoring --set-enabled" >&2
+ "$BIN" --set-intensity "$ORIG_INTENSITY" >/dev/null || \
+ echo "run.sh: ERROR restoring --set-intensity" >&2
+}
+
+trap restore_color_filters EXIT INT TERM
+
+echo "==> swift build (debug)"
+swift build
+BIN="$(swift build --show-bin-path)/color-filter-scheduler"
+if [ ! -x "$BIN" ]; then
+ echo "run.sh: debug binary missing at $BIN" >&2
+ exit 1
+fi
+export BIN
+echo "run.sh: BIN=$BIN"
+
+passed=0
+failed=0
+failed_names=()
+
+run_one() {
+ local name="$1"
+ shift
+ echo
+ echo "==> $name"
+ if "$@"; then
+ echo "PASS $name"
+ passed=$((passed + 1))
+ else
+ echo "FAIL $name"
+ failed=$((failed + 1))
+ failed_names+=("$name")
+ fi
+}
+
+run_one panel-contract sh tests/panel-contract.sh
+run_one ui-contract sh tests/ui-contract.sh
+run_one selftest "$BIN" --selftest
+run_one cli-contract bash tests/cli-contract.sh
+run_one hygiene bash tests/hygiene.sh
+
+if [ "$RUN_BUNDLE" = "1" ]; then
+ run_one bundle-contract bash tests/bundle-contract.sh
+else
+ echo
+ echo "==> bundle-contract skipped (RUN_BUNDLE=$RUN_BUNDLE)"
+fi
+
+if [ "$RUN_GUI" = "1" ]; then
+ run_one render-panel "$BIN" --render-panel .build/panel-render
+else
+ echo
+ echo "==> --render-panel skipped (set RUN_GUI=1 to enable)"
+fi
+
+restore_color_filters
+
+echo
+echo "========================================"
+echo "run.sh: $passed passed, $failed failed"
+if [ "$failed" -ne 0 ]; then
+ echo "failed: ${failed_names[*]}"
+fi
+
+en="$(read_ma "__Color__-MADisplayFilterCategoryEnabled")"
+int="$(read_ma MADisplayFilterSingleColorIntensity)"
+typ="$(read_ma "__Color__-MADisplayFilterType")"
+echo "Color Filters after tests: enabled=$en intensity=$int type=$typ"
+if [ -n "$ORIG_ENABLED" ] && [ "$en" != "$ORIG_ENABLED" ]; then
+ echo "ERROR: Color Filters enabled drifted ($en != $ORIG_ENABLED)" >&2
+ failed=$((failed + 1))
+fi
+if [ -n "$ORIG_INTENSITY" ] && ! nums_close "${int:-0}" "$ORIG_INTENSITY"; then
+ echo "ERROR: Color Filters intensity drifted ($int != $ORIG_INTENSITY)" >&2
+ failed=$((failed + 1))
+fi
+if [ -n "$ORIG_TYPE" ] && [ "$typ" != "$ORIG_TYPE" ]; then
+ echo "ERROR: Color Filters type drifted ($typ != $ORIG_TYPE)" >&2
+ failed=$((failed + 1))
+fi
+
+if [ "$failed" -ne 0 ]; then
+ exit 1
+fi
+echo "run.sh: all checks passed"
+exit 0
diff --git a/tests/ui-contract.sh b/tests/ui-contract.sh
index f518fe4..4e08473 100755
--- a/tests/ui-contract.sh
+++ b/tests/ui-contract.sh
@@ -4,10 +4,21 @@ set -eu
source_file="Sources/color-filter-scheduler/PanelView.swift"
failures=0
+# rg is nicer locally; grep -F is what GitHub Actions macos-latest has.
+grep_fixed() {
+ pattern="$1"
+ file="$2"
+ if command -v rg >/dev/null 2>&1; then
+ rg -q --fixed-strings "$pattern" "$file"
+ else
+ grep -F -q "$pattern" "$file"
+ fi
+}
+
require_absent() {
pattern="$1"
description="$2"
- if rg -q --fixed-strings "$pattern" "$source_file"; then
+ if grep_fixed "$pattern" "$source_file"; then
echo "FAIL: $description"
failures=$((failures + 1))
else
@@ -18,7 +29,7 @@ require_absent() {
require_present() {
pattern="$1"
description="$2"
- if rg -q --fixed-strings "$pattern" "$source_file"; then
+ if grep_fixed "$pattern" "$source_file"; then
echo "ok: $description"
else
echo "FAIL: $description"
diff --git a/uninstall.sh b/uninstall.sh
index ddb8654..fad257e 100755
--- a/uninstall.sh
+++ b/uninstall.sh
@@ -1,24 +1,43 @@
#!/usr/bin/env bash
#
-# Reverse install.sh: unload the login-item agent, quit and remove the app, and
-# leave Color Filters OFF. UserDefaults settings live in the app's own domain;
-# pass --purge-settings to remove them too.
+# Reverse install.sh: unregister the login item, quit and remove Night Walker,
+# and leave Color Filters OFF. UserDefaults settings live in the app's own
+# domain; pass --purge-settings to remove them too.
+#
+# Refuses if the historical Color Filter Scheduler.app is still present — that
+# app shares this bundle id.
set -euo pipefail
-LABEL="com.flo.color-filter-scheduler"
-APP_NAME="Color Filter Scheduler"
+BUNDLE_ID="com.flo.color-filter-scheduler"
+APP_NAME="Night Walker"
EXECUTABLE="color-filter-scheduler"
INSTALLED_APP="$HOME/Applications/$APP_NAME.app"
APP_BINARY="$INSTALLED_APP/Contents/MacOS/$EXECUTABLE"
-PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
-LOG_DIR="$HOME/Library/Logs"
-UID_NUM="$(id -u)"
+LEGACY_APP="$HOME/Applications/Color Filter Scheduler.app"
PURGE=0
[[ "${1:-}" == "--purge-settings" ]] && PURGE=1
-echo "==> Unloading login-item agent"
-launchctl bootout "gui/$UID_NUM/$LABEL" 2>/dev/null || true
+if [[ -d "$LEGACY_APP" ]]; then
+ echo "error: refusing to uninstall/purge: $LEGACY_APP exists (shared bundle id $BUNDLE_ID)." >&2
+ echo " That is the captain live app; this script would alter its login item" >&2
+ echo " and --purge-settings would delete its preferences." >&2
+ exit 1
+fi
+
+if [[ -d "$INSTALLED_APP" ]]; then
+ BID="$(defaults read "$INSTALLED_APP/Contents/Info" CFBundleIdentifier 2>/dev/null || true)"
+ if [[ "$BID" != "$BUNDLE_ID" ]]; then
+ echo "error: $INSTALLED_APP is not $BUNDLE_ID (id='${BID:-}'); not deleting." >&2
+ exit 1
+ fi
+fi
+
+echo "==> Unregistering main app login item"
+if [[ -x "$APP_BINARY" ]]; then
+ "$APP_BINARY" --unregister-login-item
+ pkill -f "$APP_BINARY" 2>/dev/null || true
+fi
echo "==> Turning Color Filters OFF"
if [[ -x "$APP_BINARY" ]]; then
@@ -26,13 +45,11 @@ if [[ -x "$APP_BINARY" ]]; then
fi
echo "==> Removing files"
-rm -f "$PLIST"
rm -rf "$INSTALLED_APP"
-rm -f "$LOG_DIR/color-filter-scheduler.log" "$LOG_DIR/color-filter-scheduler.err.log"
if [[ "$PURGE" == "1" ]]; then
- defaults delete "$LABEL" 2>/dev/null || true
- echo " removed saved settings ($LABEL)"
+ defaults delete "$BUNDLE_ID" 2>/dev/null || true
+ echo " removed saved settings ($BUNDLE_ID)"
else
echo " left saved settings in place (use --purge-settings to remove)."
fi