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//\"/"} ++ 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