Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -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'
44 changes: 30 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -38,18 +40,27 @@ The presentation is a custom near-black, borderless `NSPanel` hosting SwiftUI,
- `--render-panel <dir>` 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-<version>.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
Expand All @@ -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

Expand Down
8 changes: 7 additions & 1 deletion EVIDENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>.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
```
Expand Down
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ let package = Package(
.linkedFramework("MediaAccessibility"),
.linkedFramework("CoreFoundation"),
.linkedFramework("CoreLocation"),
.linkedFramework("ServiceManagement"),
]
),
]
Expand Down
197 changes: 98 additions & 99 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions Sources/color-filter-scheduler/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions Sources/color-filter-scheduler/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading
Loading