diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 94559bf..af99e86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: push: branches: [main] paths: - - 'ShiftChange/Sources/NightShiftToggle/Resources/VERSION' + - 'ShiftChange/Sources/ShiftChange/Resources/VERSION' - '.github/workflows/release.yml' workflow_dispatch: inputs: @@ -29,7 +29,7 @@ jobs: - name: Determine version id: version run: | - FILE_VERSION=$(tr -d '[:space:]' < ShiftChange/Sources/NightShiftToggle/Resources/VERSION) + FILE_VERSION=$(tr -d '[:space:]' < ShiftChange/Sources/ShiftChange/Resources/VERSION) REQUESTED="${{ github.event.inputs.version }}" if [ -n "$REQUESTED" ] && [ "$REQUESTED" != "$FILE_VERSION" ]; then echo "::error::Requested version $REQUESTED does not match the VERSION file ($FILE_VERSION). The About screen reads the VERSION file, so these must match." @@ -142,3 +142,31 @@ jobs: git fetch origin main git rebase origin/main git push origin HEAD:main + + # The tap brew actually installs from is the separate repo + # adamdexter/homebrew-shiftchange — updating HomebrewFormula/ in this + # repo alone leaves brew users pinned to the old version. Pushing there + # needs a PAT with write access to that repo, stored as TAP_PUSH_TOKEN. + - name: Update Homebrew tap repo + if: steps.check.outputs.should_release == 'true' + env: + TAP_PUSH_TOKEN: ${{ secrets.TAP_PUSH_TOKEN }} + VERSION: ${{ steps.version.outputs.VERSION }} + SHA: ${{ steps.sha.outputs.SHA256 }} + run: | + if [ -z "$TAP_PUSH_TOKEN" ]; then + echo "::warning::TAP_PUSH_TOKEN secret not set — Homebrew tap NOT updated." + echo "::warning::Copy HomebrewFormula/shiftchange.rb to adamdexter/homebrew-shiftchange Casks/shiftchange.rb manually, or brew users stay on the old version." + exit 0 + fi + git clone "https://x-access-token:${TAP_PUSH_TOKEN}@github.com/adamdexter/homebrew-shiftchange.git" tap + # Regenerate the cask from this repo's copy (already updated above) + sed -i '' "s/version \".*\"/version \"$VERSION\"/" HomebrewFormula/shiftchange.rb + sed -i '' "s/sha256 \".*\"/sha256 \"$SHA\"/" HomebrewFormula/shiftchange.rb + cp HomebrewFormula/shiftchange.rb tap/Casks/shiftchange.rb + cd tap + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Casks/shiftchange.rb + git commit -m "Update shiftchange cask to v$VERSION" || echo "Tap already up to date" + git push diff --git a/CLAUDE.md b/CLAUDE.md index ca6be9f..6fa71b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,18 +13,19 @@ ``` ShiftChange/ -├── Package.swift +├── Package.swift # Package name MUST stay "ShiftChange" — see Resource Bundle Naming ├── Sources/ │ ├── CBlueLightBridge/ # Obj-C bridge to private CoreBrightness framework │ │ ├── CBlueLightBridge.m # Dynamic loading of CBBlueLightClient │ │ └── include/ │ │ └── CBlueLightBridge.h -│ └── NightShiftToggle/ # Main Swift app -│ ├── main.swift # Entry point -│ ├── NightShiftToggleApp.swift # AppDelegate, menu bar, About window +│ └── ShiftChange/ # Main Swift app +│ ├── main.swift # Entry point (pure AppKit, no SwiftUI App lifecycle) +│ ├── ShiftChangeApp.swift # AppDelegate, menu bar, About window │ ├── NightShiftManager.swift # Night Shift enable/disable/restore logic │ ├── FocusMonitor.swift # NSWorkspace app focus observer │ ├── ExcludeListManager.swift # User's excluded app list (UserDefaults) +│ ├── InstalledAppsFinder.swift # Scans /Applications etc. for .app bundles │ ├── ContentView.swift # Settings window UI │ └── Resources/ │ ├── AppIcon.icns @@ -34,9 +35,11 @@ ShiftChange/ scripts/ ├── create-dmg.sh # Builds .app bundle and DMG for distribution └── install.sh # curl-based installer (fetches latest GitHub release) +HomebrewFormula/ +└── shiftchange.rb # CI-updated cask copy; the live tap is a separate repo — see Distribution .github/workflows/ ├── ci.yml # Build + test (macOS) and shellcheck, on every push/PR -└── release.yml # Tag-triggered: builds DMG, creates release, updates cask +└── release.yml # On VERSION change to main: builds/signs DMG, creates release, updates cask + tap ``` ## Build, Test & Run @@ -44,11 +47,20 @@ scripts/ ```bash cd ShiftChange swift build -c release # Build binary -swift test # Run the test suite +swift test # Run the test suite (requires full Xcode for XCTest) .build/release/ShiftChange # Run directly ../scripts/create-dmg.sh # Build distributable DMG (reads version from Resources/VERSION) ``` +**iCloud gotcha:** if this checkout lives under `~/Documents` (iCloud-synced), +sync can corrupt `.build` mid-build — symptoms are `LLVM ERROR: IO failure on +output stream: Bad file descriptor`, sqlite "disk I/O error" on build.db, or +spurious SDK-mismatch errors, plus stray Finder-style duplicates like +`NightShiftManager 2.swift` appearing in Sources (delete those; they break the +build). Work around it with `swift build --scratch-path /tmp/shiftchange-build` +(`create-dmg.sh` honors `SHIFTCHANGE_SCRATCH_PATH` for the same purpose) +or keep the repo outside iCloud-synced folders. + ## Testing - Unit tests live in `ShiftChange/Tests/ShiftChangeTests/` and run via `swift test`, and automatically in CI (`.github/workflows/ci.yml`) on every push and pull request. @@ -58,6 +70,9 @@ swift test # Run the test suite ## Key Technical Details +### Resource Bundle Naming (do not rename the package) +SwiftPM names the resource bundle `_.bundle` — with both named `ShiftChange`, that's `ShiftChange_ShiftChange.bundle`. `Bundle.module` hard-crashes (fatalError) at launch if the bundle is missing from the packaged app, and `create-dmg.sh` resolves that exact path (and fails the build if absent). v1.0.0–v1.1.2 shipped without the bundle (package was still named `NightShiftToggle`, and the old `find` couldn't descend the `.build/release` symlink) and crashed on launch on every machine except the dev machine, where a baked-in fallback path to the local `.build` directory masked it. + ### CoreBrightness Bridge The app uses Apple's **private** `CoreBrightness` framework via runtime dynamic loading (`dlopen`/`objc_msgSend`). The `BlueLightStatus` struct is reverse-engineered: - `active` — the Night Shift feature is running/monitoring (true whenever a schedule is configured, even outside warming hours) @@ -73,17 +88,33 @@ When an excluded app gains focus, we only disable and later restore Night Shift `disableForExcludedApp()` must stay guarded against re-entry: when switching directly between two excluded apps, re-reading `isEnabled` would see the value we already set to false and drop the pending restore. This was also a past bug, now pinned by `testSwitchingBetweenExcludedAppsPreservesRestore`. +State updates must happen BEFORE `setEnabled` side effects in the manager: the framework notifies on every status change (including self-caused ones), and `FakeBlueLightClient` fires that handler synchronously in tests to enforce re-entrancy safety. + +### External Status Changes (schedule triggers, System Settings) +The bridge registers a `setStatusNotificationBlock:` handler (delivered on the main queue) so ShiftChange reacts to Night Shift changes it didn't make. If the schedule (or the user, via System Settings/Control Center) turns Night Shift on while an excluded app has focus, `handleExternalStatusChange()` immediately re-disables it and sets the restore intent to on — the display never warms mid-session in a color-critical app. Self-triggered notifications terminate safely: after our own disable, `enabled` is false, so the handler no-ops. Pinned by `testScheduleFiringWhileOverridingIsReDisabledAndRestoredLater`. + +Known remaining edge: if an override spans the *end* of a schedule window (e.g. in Photoshop from 11pm past sunrise), the snapshotted restore intent re-enables Night Shift outside schedule hours when focus leaves. Detecting this would require parsing schedule times from the private status struct. + +### Global Night Shift Toggle (menu bar) +The menu bar has a "Turn On/Off Night Shift" item (`NightShiftManager.setGlobalEnabled(_:)`). Calling `setEnabled:` is the same thing System Settings' toggle does — when a schedule is configured, the OS itself handles the "until tomorrow / until sunset" scheduling. + +Override interplay: if an excluded app has focus, the toggle does NOT touch the display — it only updates the restore intent (the state ShiftChange applies when focus leaves the excluded app). `effectiveEnabled` reports the user-intended state through any active override, and the menu refreshes in `menuWillOpen` plus on every status-change notification, because Night Shift state can change externally. + ## Release Checklist When making changes: -1. **Increment the version** in `ShiftChange/Sources/NightShiftToggle/Resources/VERSION` for any user-facing change. This is the single source of truth — the About screen, `create-dmg.sh`, and the release workflow all read from it. +1. **Increment the version** in `ShiftChange/Sources/ShiftChange/Resources/VERSION` for any user-facing change. This is the single source of truth — the About screen, `create-dmg.sh`, and the release workflow all read from it. 2. **Run the test suite** (`swift test`) — CI also runs it on every push. 3. **Regression test Night Shift toggling on real hardware** after any change to `NightShiftManager.swift`, `FocusMonitor.swift`, or `CBlueLightBridge.m` (unit tests cover the state machine but not the real private framework): - Switch to an excluded app while Night Shift IS warming (after sunset) → Night Shift should disable; switching back should restore it - Switch between two excluded apps, then to a normal app → Night Shift should still restore - Switch to an excluded app while Night Shift is NOT warming (before sunset, with schedule) → nothing should change in either direction; "Turn On Until Sunrise" must NOT get toggled - Switch to an excluded app with Night Shift off and no schedule → nothing should change + - With an excluded app in focus BEFORE sunset, wait for (or simulate) the schedule trigger → display must stay unshifted; leaving the excluded app afterwards should enable Night Shift + - Menu bar "Turn Off Night Shift" while warming → display unshifts; System Settings shows it off until the next schedule trigger + - Menu bar toggle while an excluded app is in focus → display must NOT change; the chosen state applies when focus leaves the excluded app + - Toggle Night Shift in System Settings/Control Center → menu status line and toggle title reflect the change - Quit the app while overriding → Night Shift should restore 4. **Release:** merge to `main` with the bumped VERSION file. The release workflow (`.github/workflows/release.yml`) triggers on VERSION changes to main, builds the DMG, creates the `v` tag and GitHub release, and updates the Homebrew cask automatically. It skips silently if the version is already tagged, so a re-run is always safe. (Manual fallback: `./scripts/create-dmg.sh` then `gh release create v ./ShiftChange-.dmg --title "ShiftChange " --notes ""`.) @@ -106,3 +137,14 @@ For local signed builds, `create-dmg.sh` honors `CODESIGN_IDENTITY` (a "Develope - **Homebrew:** `brew tap adamdexter/shiftchange && brew install --cask shiftchange` - **curl installer:** `curl -fsSL https://raw.githubusercontent.com/adamdexter/shiftchange/main/scripts/install.sh | sh` - **DMG:** GitHub Releases page + +### Homebrew tap (separate repo!) + +The tap that `brew tap adamdexter/shiftchange` actually installs from is the +**separate repo `adamdexter/homebrew-shiftchange`** (`Casks/shiftchange.rb`). +`HomebrewFormula/shiftchange.rb` in this repo is only a CI-maintained copy. +On each release, `release.yml` pushes the updated cask to the tap **if the +`TAP_PUSH_TOKEN` secret is configured** (a PAT with write access to the tap +repo); without it the workflow warns and the tap must be updated manually, +or brew users stay pinned to the old version (this happened: the tap served +v1.0.0 while v1.1.2 was current). diff --git a/README.md b/README.md index 5567ea8..c5b411a 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,8 @@ That's it. Set it and forget it. ## Features - **Per-app Night Shift control** — disable Night Shift only when specific apps are in focus +- **Global Night Shift toggle** — turn Night Shift on or off system-wide right from the menu bar, exactly like the System Settings toggle ("Turn Off Until Tomorrow" / "Turn On Until Sunset"). Per-app switching keeps working either way +- **Schedule-aware** — if your Night Shift schedule kicks in while a color-critical app is in focus, the display stays unshifted until you switch away - **Menu bar app** — runs quietly out of the way with a status icon - **Instant switching** — Night Shift toggles the moment you switch apps, no delay - **Smart restore** — respects your existing Night Shift schedule; restores it when you leave an excluded app @@ -140,7 +142,7 @@ To build a distributable `.dmg`: ./scripts/create-dmg.sh ``` -The version is read automatically from `ShiftChange/Sources/NightShiftToggle/Resources/VERSION`. +The version is read automatically from `ShiftChange/Sources/ShiftChange/Resources/VERSION`. ## Requirements @@ -176,6 +178,4 @@ Made out of necessity and with love by [Adam Dexter](https://adamdexter.net/) an

If ShiftChange has been useful to you, consider buying me a coffee! -

- - \ No newline at end of file +

\ No newline at end of file diff --git a/ShiftChange/.gitignore b/ShiftChange/.gitignore index cb4b7a5..0a7e366 100644 --- a/ShiftChange/.gitignore +++ b/ShiftChange/.gitignore @@ -1,5 +1,5 @@ .DS_Store -.build/ +.build* .swiftpm/ *.xcodeproj xcuserdata/ diff --git a/ShiftChange/Package.swift b/ShiftChange/Package.swift index d03ff9f..df581f5 100644 --- a/ShiftChange/Package.swift +++ b/ShiftChange/Package.swift @@ -2,7 +2,11 @@ import PackageDescription let package = Package( - name: "NightShiftToggle", + // Package name must stay "ShiftChange": SwiftPM derives the resource + // bundle name (ShiftChange_ShiftChange.bundle) from it, and both + // Bundle.module and create-dmg.sh depend on that. A mismatch makes the + // packaged app crash at launch (this shipped broken in v1.0.0–v1.1.2). + name: "ShiftChange", platforms: [.macOS(.v13)], targets: [ .target( @@ -16,7 +20,7 @@ let package = Package( .executableTarget( name: "ShiftChange", dependencies: ["CBlueLightBridge"], - path: "Sources/NightShiftToggle", + path: "Sources/ShiftChange", resources: [ .process("Resources") ] diff --git a/ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m b/ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m index 97f2fe7..8ddaebf 100644 --- a/ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m +++ b/ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m @@ -26,13 +26,13 @@ + (id _Nullable)sharedClient { RTLD_LAZY ); if (!handle) { - NSLog(@"[NightShiftToggle] Failed to load CoreBrightness framework"); + NSLog(@"[ShiftChange] Failed to load CoreBrightness framework"); return; } Class CBBlueLightClient = NSClassFromString(@"CBBlueLightClient"); if (!CBBlueLightClient) { - NSLog(@"[NightShiftToggle] CBBlueLightClient class not found"); + NSLog(@"[ShiftChange] CBBlueLightClient class not found"); return; } @@ -41,40 +41,33 @@ + (id _Nullable)sharedClient { return client; } -+ (BOOL)isNightShiftEnabled { +/// Fetches the current status into *status. Returns NO if the client is +/// unavailable or the call fails. ++ (BOOL)fetchStatus:(BlueLightStatus *)status { id client = [self sharedClient]; if (!client) return NO; - BlueLightStatus status = {0}; - // -getBlueLightStatus: takes a pointer to the status struct SEL sel = NSSelectorFromString(@"getBlueLightStatus:"); if (![client respondsToSelector:sel]) { - NSLog(@"[NightShiftToggle] getBlueLightStatus: selector not found"); + NSLog(@"[ShiftChange] getBlueLightStatus: selector not found"); return NO; } // Use objc_msgSend to call the method with a struct pointer argument BOOL (*getStatus)(id, SEL, BlueLightStatus *) = (BOOL (*)(id, SEL, BlueLightStatus *))objc_msgSend; - getStatus(client, sel, &status); + return getStatus(client, sel, status); +} ++ (BOOL)isNightShiftEnabled { + BlueLightStatus status = {0}; + if (![self fetchStatus:&status]) return NO; return status.enabled; } + (BOOL)isNightShiftActive { - id client = [self sharedClient]; - if (!client) return NO; - BlueLightStatus status = {0}; - SEL sel = NSSelectorFromString(@"getBlueLightStatus:"); - if (![client respondsToSelector:sel]) { - return NO; - } - - BOOL (*getStatus)(id, SEL, BlueLightStatus *) = - (BOOL (*)(id, SEL, BlueLightStatus *))objc_msgSend; - getStatus(client, sel, &status); - + if (![self fetchStatus:&status]) return NO; return status.active; } @@ -84,7 +77,7 @@ + (void)setNightShiftEnabled:(BOOL)enabled { SEL sel = NSSelectorFromString(@"setEnabled:"); if (![client respondsToSelector:sel]) { - NSLog(@"[NightShiftToggle] setEnabled: selector not found"); + NSLog(@"[ShiftChange] setEnabled: selector not found"); return; } @@ -94,21 +87,43 @@ + (void)setNightShiftEnabled:(BOOL)enabled { } + (BOOL)isNightShiftScheduled { + BlueLightStatus status = {0}; + if (![self fetchStatus:&status]) return NO; + + // mode != 0 means a schedule is configured + return status.mode != 0; +} + +static void (^statusChangeHandler)(void) = nil; + ++ (void)setStatusChangeHandler:(void (^ _Nullable)(void))handler { + statusChangeHandler = [handler copy]; + id client = [self sharedClient]; - if (!client) return NO; + if (!client) return; - BlueLightStatus status = {0}; - SEL sel = NSSelectorFromString(@"getBlueLightStatus:"); + SEL sel = NSSelectorFromString(@"setStatusNotificationBlock:"); if (![client respondsToSelector:sel]) { - return NO; + NSLog(@"[ShiftChange] setStatusNotificationBlock: selector not found"); + return; } - BOOL (*getStatus)(id, SEL, BlueLightStatus *) = - (BOOL (*)(id, SEL, BlueLightStatus *))objc_msgSend; - getStatus(client, sel, &status); + // The block deliberately takes no parameters even though the framework + // passes a status pointer — ignoring trailing arguments is safe under + // the C calling convention, and re-querying via fetchStatus: avoids + // depending on the struct layout here. May be invoked on any thread. + dispatch_block_t block = nil; + if (handler) { + block = ^{ + dispatch_async(dispatch_get_main_queue(), ^{ + void (^h)(void) = statusChangeHandler; + if (h) h(); + }); + }; + } - // mode != 0 means a schedule is configured - return status.mode != 0; + void (*setBlock)(id, SEL, id) = (void (*)(id, SEL, id))objc_msgSend; + setBlock(client, sel, block); } @end diff --git a/ShiftChange/Sources/CBlueLightBridge/include/CBlueLightBridge.h b/ShiftChange/Sources/CBlueLightBridge/include/CBlueLightBridge.h index 3681f45..5c763ef 100644 --- a/ShiftChange/Sources/CBlueLightBridge/include/CBlueLightBridge.h +++ b/ShiftChange/Sources/CBlueLightBridge/include/CBlueLightBridge.h @@ -20,6 +20,11 @@ NS_ASSUME_NONNULL_BEGIN /// Returns YES if Night Shift has a schedule configured (sun-based or custom). + (BOOL)isNightShiftScheduled; +/// Registers a handler invoked on the main queue whenever Night Shift status +/// changes (schedule triggers, System Settings, Control Center, or our own +/// setNightShiftEnabled: calls). Pass nil to remove the handler. ++ (void)setStatusChangeHandler:(void (^ _Nullable)(void))handler; + @end NS_ASSUME_NONNULL_END diff --git a/ShiftChange/Sources/NightShiftToggle/Resources/VERSION b/ShiftChange/Sources/NightShiftToggle/Resources/VERSION deleted file mode 100644 index 45a1b3f..0000000 --- a/ShiftChange/Sources/NightShiftToggle/Resources/VERSION +++ /dev/null @@ -1 +0,0 @@ -1.1.2 diff --git a/ShiftChange/Sources/NightShiftToggle/ContentView.swift b/ShiftChange/Sources/ShiftChange/ContentView.swift similarity index 100% rename from ShiftChange/Sources/NightShiftToggle/ContentView.swift rename to ShiftChange/Sources/ShiftChange/ContentView.swift diff --git a/ShiftChange/Sources/NightShiftToggle/ExcludeListManager.swift b/ShiftChange/Sources/ShiftChange/ExcludeListManager.swift similarity index 100% rename from ShiftChange/Sources/NightShiftToggle/ExcludeListManager.swift rename to ShiftChange/Sources/ShiftChange/ExcludeListManager.swift diff --git a/ShiftChange/Sources/NightShiftToggle/FocusMonitor.swift b/ShiftChange/Sources/ShiftChange/FocusMonitor.swift similarity index 90% rename from ShiftChange/Sources/NightShiftToggle/FocusMonitor.swift rename to ShiftChange/Sources/ShiftChange/FocusMonitor.swift index 2662b63..c4d9d40 100644 --- a/ShiftChange/Sources/NightShiftToggle/FocusMonitor.swift +++ b/ShiftChange/Sources/ShiftChange/FocusMonitor.swift @@ -80,6 +80,11 @@ final class FocusMonitor: ObservableObject { } deinit { - stop() + // Don't call stop() here — it mutates @Published state, which must + // not happen while the object is deallocating. + if let observer = observer { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + } + nightShift.restoreIfNeeded() } } diff --git a/ShiftChange/Sources/NightShiftToggle/InstalledAppsFinder.swift b/ShiftChange/Sources/ShiftChange/InstalledAppsFinder.swift similarity index 100% rename from ShiftChange/Sources/NightShiftToggle/InstalledAppsFinder.swift rename to ShiftChange/Sources/ShiftChange/InstalledAppsFinder.swift diff --git a/ShiftChange/Sources/NightShiftToggle/NightShiftManager.swift b/ShiftChange/Sources/ShiftChange/NightShiftManager.swift similarity index 52% rename from ShiftChange/Sources/NightShiftToggle/NightShiftManager.swift rename to ShiftChange/Sources/ShiftChange/NightShiftManager.swift index 41c19d7..2850ee6 100644 --- a/ShiftChange/Sources/NightShiftToggle/NightShiftManager.swift +++ b/ShiftChange/Sources/ShiftChange/NightShiftManager.swift @@ -12,6 +12,10 @@ protocol BlueLightControlling { /// A Night Shift schedule is configured (sun-based or custom). var isScheduled: Bool { get } func setEnabled(_ enabled: Bool) + /// Registers a handler invoked (on the main queue in production) whenever + /// Night Shift status changes — including changes we didn't make + /// (schedule triggers, System Settings, Control Center). Pass nil to remove. + func setStatusChangeHandler(_ handler: (() -> Void)?) } /// Production implementation backed by the CoreBrightness bridge. @@ -20,6 +24,9 @@ struct CoreBrightnessBlueLightClient: BlueLightControlling { var isActive: Bool { CBlueLightBridge.isNightShiftActive() } var isScheduled: Bool { CBlueLightBridge.isNightShiftScheduled() } func setEnabled(_ enabled: Bool) { CBlueLightBridge.setNightShiftEnabled(enabled) } + func setStatusChangeHandler(_ handler: (() -> Void)?) { + CBlueLightBridge.setStatusChangeHandler(handler) + } } /// Wraps the private CoreBrightness framework bridge for Night Shift control. @@ -53,6 +60,51 @@ final class NightShiftManager { client.isScheduled } + /// The Night Shift state as the user intends it, seen through any active + /// per-app override: while overriding this is the state we'd restore to + /// on focus change; otherwise it's the live state. + var effectiveEnabled: Bool { + isOverriding ? shouldRestoreOnFocusChange : isEnabled + } + + /// Globally turn Night Shift on or off — same effect as the System + /// Settings toggle ("Turn Off Until Tomorrow" / "Turn On Until Sunset"); + /// the OS handles the until-next-schedule-trigger part itself. + /// + /// If an excluded app currently has focus, the per-app override wins: + /// the display stays unshifted and only the restore intent changes, so + /// ShiftChange keeps working as configured. + func setGlobalEnabled(_ enabled: Bool) { + if isOverriding { + shouldRestoreOnFocusChange = enabled + } else { + client.setEnabled(enabled) + } + } + + /// Starts observing Night Shift status changes from outside our control + /// (schedule triggers, System Settings, Control Center). While an + /// excluded app has focus, an external enable is immediately re-disabled + /// and folded into the restore intent, so the display never warms mid- + /// session in a color-critical app. `onChange` fires after each change + /// so the UI can refresh. + func startObservingStatusChanges(onChange: @escaping () -> Void) { + client.setStatusChangeHandler { [weak self] in + self?.handleExternalStatusChange() + onChange() + } + } + + /// If the schedule (or the user) turns Night Shift on while we're + /// overriding for an excluded app, keep the display unshifted and restore + /// to on when focus leaves. Our own re-disable triggers another + /// notification, which no-ops here because isEnabled is then false. + private func handleExternalStatusChange() { + guard isOverriding, isEnabled else { return } + shouldRestoreOnFocusChange = true + client.setEnabled(false) + } + /// Disable Night Shift because an excluded app gained focus. /// Only records a restore if Night Shift was actually enabled (manual /// toggle or schedule-triggered). A configured schedule alone does not @@ -63,23 +115,30 @@ final class NightShiftManager { // the false we just set and lose the pending restore. guard !isOverriding else { return } + // Update state BEFORE the setEnabled side effect: the framework's + // status notification may re-enter handleExternalStatusChange, which + // must observe the final state. let wasEnabled = isEnabled shouldRestoreOnFocusChange = wasEnabled + isOverriding = true if wasEnabled { client.setEnabled(false) } - isOverriding = true } /// Re-enable Night Shift if it was enabled before we overrode it. func restoreIfNeeded() { guard isOverriding else { return } - if shouldRestoreOnFocusChange { - client.setEnabled(true) - } + // Update state BEFORE the setEnabled side effect — see + // disableForExcludedApp for why. + let shouldRestore = shouldRestoreOnFocusChange isOverriding = false shouldRestoreOnFocusChange = false + + if shouldRestore { + client.setEnabled(true) + } } } diff --git a/ShiftChange/Sources/NightShiftToggle/Resources/AppIcon.icns b/ShiftChange/Sources/ShiftChange/Resources/AppIcon.icns similarity index 100% rename from ShiftChange/Sources/NightShiftToggle/Resources/AppIcon.icns rename to ShiftChange/Sources/ShiftChange/Resources/AppIcon.icns diff --git a/ShiftChange/Sources/ShiftChange/Resources/VERSION b/ShiftChange/Sources/ShiftChange/Resources/VERSION new file mode 100644 index 0000000..26aaba0 --- /dev/null +++ b/ShiftChange/Sources/ShiftChange/Resources/VERSION @@ -0,0 +1 @@ +1.2.0 diff --git a/ShiftChange/Sources/NightShiftToggle/NightShiftToggleApp.swift b/ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift similarity index 89% rename from ShiftChange/Sources/NightShiftToggle/NightShiftToggleApp.swift rename to ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift index 18dd575..12ae4a9 100644 --- a/ShiftChange/Sources/NightShiftToggle/NightShiftToggleApp.swift +++ b/ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift @@ -19,6 +19,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { // Menu items that need updating private var statusMenuItem: NSMenuItem! private var activeAppMenuItem: NSMenuItem! + private var toggleNightShiftMenuItem: NSMenuItem! private var launchAtLoginMenuItem: NSMenuItem! private static let hasLaunchedKey = "hasLaunchedBefore" @@ -49,6 +50,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { self?.updateMenuStatus() } + // React to Night Shift changes from outside (schedule triggers, + // System Settings) — keeps the override honest while an excluded + // app is focused and keeps the menu status fresh + NightShiftManager.shared.startObservingStatusChanges { [weak self] in + self?.updateMenuStatus() + } + // Set up main menu (overrides default app name in menu bar) setupMainMenu() @@ -99,14 +107,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { window.close() return .terminateCancel } else { - // Quit: actually terminate - focusMonitor.stop() + // Quit: actually terminate (monitor stops in applicationWillTerminate) return .terminateNow } } // No window open — quit directly (e.g. from menu bar Quit) - focusMonitor.stop() return .terminateNow } @@ -139,6 +145,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { statusMenu.addItem(.separator()) + // Global Night Shift toggle — same as the System Settings toggle + toggleNightShiftMenuItem = NSMenuItem(title: "Turn Off Night Shift", action: #selector(toggleNightShiftGlobally), keyEquivalent: "") + toggleNightShiftMenuItem.target = self + statusMenu.addItem(toggleNightShiftMenuItem) + + statusMenu.addItem(.separator()) + // Settings let settingsItem = NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ",") settingsItem.target = self @@ -157,6 +170,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { quitItem.target = self statusMenu.addItem(quitItem) + statusMenu.delegate = self statusItem.menu = statusMenu updateMenuStatus() @@ -165,8 +179,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { private func statusTitle() -> String { if focusMonitor.nightShiftOverridden { return "Night Shift: Disabled (excluded app)" + } else if NightShiftManager.shared.isEnabled { + return "Night Shift: On" } else { - return "Night Shift: Following schedule" + return "Night Shift: Off" } } @@ -175,6 +191,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { statusMenuItem.title = statusTitle() + toggleNightShiftMenuItem?.title = NightShiftManager.shared.effectiveEnabled + ? "Turn Off Night Shift" + : "Turn On Night Shift" + if !focusMonitor.currentAppName.isEmpty { activeAppMenuItem.title = "Active: \(focusMonitor.currentAppName)" activeAppMenuItem.isHidden = false @@ -299,6 +319,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { NSApplication.shared.activate(ignoringOtherApps: true) } + @objc private func toggleNightShiftGlobally() { + let nightShift = NightShiftManager.shared + nightShift.setGlobalEnabled(!nightShift.effectiveEnabled) + updateMenuStatus() + } + @objc private func toggleLaunchAtLogin() { let newState = !isLaunchAtLoginEnabled() setLaunchAtLogin(enabled: newState) @@ -306,7 +332,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } @objc private func quitApp() { - focusMonitor.stop() + // Monitor teardown happens in applicationWillTerminate, so choosing + // "Minimize to Menu Bar" in the confirm dialog keeps monitoring alive. NSApplication.shared.terminate(nil) } @@ -334,6 +361,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } } +// MARK: - Menu Delegate + +extension AppDelegate: NSMenuDelegate { + func menuWillOpen(_ menu: NSMenu) { + // Refresh right before display — Night Shift state can change outside + // our control (schedule triggers, System Settings, Control Center) + updateMenuStatus() + } +} + // MARK: - Window Delegate extension AppDelegate: NSWindowDelegate { diff --git a/ShiftChange/Sources/NightShiftToggle/main.swift b/ShiftChange/Sources/ShiftChange/main.swift similarity index 100% rename from ShiftChange/Sources/NightShiftToggle/main.swift rename to ShiftChange/Sources/ShiftChange/main.swift diff --git a/ShiftChange/Tests/ShiftChangeTests/Fakes.swift b/ShiftChange/Tests/ShiftChangeTests/Fakes.swift index bf93048..68e4280 100644 --- a/ShiftChange/Tests/ShiftChangeTests/Fakes.swift +++ b/ShiftChange/Tests/ShiftChangeTests/Fakes.swift @@ -9,6 +9,7 @@ final class FakeBlueLightClient: BlueLightControlling { var active = false var scheduled = false private(set) var setEnabledCalls: [Bool] = [] + private var statusChangeHandler: (() -> Void)? var isEnabled: Bool { enabled } var isActive: Bool { active } @@ -17,6 +18,21 @@ final class FakeBlueLightClient: BlueLightControlling { func setEnabled(_ enabled: Bool) { setEnabledCalls.append(enabled) self.enabled = enabled + // The real framework notifies on every status change, including ones + // we caused ourselves. Firing synchronously here is deliberately + // stricter than production (which hops to the main queue) so tests + // catch re-entrancy bugs in the override state machine. + statusChangeHandler?() + } + + func setStatusChangeHandler(_ handler: (() -> Void)?) { + statusChangeHandler = handler + } + + /// Simulates an external status change (schedule trigger, System + /// Settings toggle): mutate `enabled` first, then call this. + func fireStatusChange() { + statusChangeHandler?() } } diff --git a/ShiftChange/Tests/ShiftChangeTests/NightShiftManagerTests.swift b/ShiftChange/Tests/ShiftChangeTests/NightShiftManagerTests.swift index 68d58d8..3d32b45 100644 --- a/ShiftChange/Tests/ShiftChangeTests/NightShiftManagerTests.swift +++ b/ShiftChange/Tests/ShiftChangeTests/NightShiftManagerTests.swift @@ -97,4 +97,83 @@ final class NightShiftManagerTests: XCTestCase { manager.restoreIfNeeded() XCTAssertEqual(client.setEnabledCalls, [false, true]) } + + // MARK: - Global toggle (menu bar) + + // Toggling globally with no override active goes straight to the client — + // same as the System Settings toggle. + func testGlobalToggleOffWhileNotOverriding() { + client.enabled = true + manager.setGlobalEnabled(false) + XCTAssertEqual(client.setEnabledCalls, [false]) + XCTAssertFalse(manager.effectiveEnabled) + } + + // Turning Night Shift ON from the menu while an excluded app has focus: + // the display must stay unshifted; the ON state applies on focus leave. + func testGlobalToggleOnWhileOverridingOnlyChangesRestoreIntent() { + client.enabled = false + client.scheduled = true + manager.disableForExcludedApp() + XCTAssertEqual(client.setEnabledCalls, []) + XCTAssertFalse(manager.effectiveEnabled) + + manager.setGlobalEnabled(true) + XCTAssertEqual(client.setEnabledCalls, [], "Display must stay unshifted while an excluded app has focus") + XCTAssertTrue(manager.effectiveEnabled) + + manager.restoreIfNeeded() + XCTAssertEqual(client.setEnabledCalls, [true]) + XCTAssertTrue(client.enabled) + } + + // Turning Night Shift OFF from the menu while overriding cancels the + // pending restore. + func testGlobalToggleOffWhileOverridingCancelsRestore() { + client.enabled = true + manager.disableForExcludedApp() + XCTAssertEqual(client.setEnabledCalls, [false]) + + manager.setGlobalEnabled(false) + manager.restoreIfNeeded() + XCTAssertEqual(client.setEnabledCalls, [false], "Restore must not re-enable after a global off") + XCTAssertFalse(client.enabled) + } + + // MARK: - External status changes (schedule triggers, System Settings) + + // Sunset fires while an excluded app is focused → Night Shift is + // immediately re-disabled; leaving the excluded app then enables it. + func testScheduleFiringWhileOverridingIsReDisabledAndRestoredLater() { + var changeCount = 0 + manager.startObservingStatusChanges { changeCount += 1 } + + client.enabled = false + client.scheduled = true + manager.disableForExcludedApp() + XCTAssertEqual(client.setEnabledCalls, []) + + // Sunset: the schedule enables Night Shift externally + client.enabled = true + client.fireStatusChange() + XCTAssertEqual(client.setEnabledCalls, [false], "External enable must be immediately re-disabled") + XCTAssertFalse(client.enabled, "Display must stay unshifted") + XCTAssertTrue(manager.effectiveEnabled, "Restore intent must fold in the schedule's ON") + XCTAssertGreaterThan(changeCount, 0) + + manager.restoreIfNeeded() + XCTAssertEqual(client.setEnabledCalls, [false, true], "Night Shift should come on after leaving the excluded app") + XCTAssertTrue(client.enabled) + } + + // External changes with no override active only refresh the UI. + func testExternalChangeWhileNotOverridingOnlyNotifies() { + var changeCount = 0 + manager.startObservingStatusChanges { changeCount += 1 } + + client.enabled = true + client.fireStatusChange() + XCTAssertEqual(client.setEnabledCalls, []) + XCTAssertEqual(changeCount, 1) + } } diff --git a/scripts/create-dmg.sh b/scripts/create-dmg.sh index fa46c5b..3b971aa 100755 --- a/scripts/create-dmg.sh +++ b/scripts/create-dmg.sh @@ -7,7 +7,7 @@ BUNDLE_ID="net.adamdexter.ShiftChange" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" PKG_DIR="${PROJECT_DIR}/ShiftChange" -VERSION_FILE="${PKG_DIR}/Sources/NightShiftToggle/Resources/VERSION" +VERSION_FILE="${PKG_DIR}/Sources/ShiftChange/Resources/VERSION" if [ -f "$VERSION_FILE" ]; then DEFAULT_VERSION=$(tr -d '[:space:]' < "$VERSION_FILE") else @@ -23,17 +23,31 @@ DMG_FINAL="${PROJECT_DIR}/${DMG_NAME}.dmg" echo "==> Building ${APP_NAME} v${VERSION}..." # ── 1. Build release binary ─────────────────────────────────────── +# SHIFTCHANGE_SCRATCH_PATH overrides where SwiftPM builds (default: .build). +# Useful when the repo lives in an iCloud-synced folder, where sync can +# corrupt .build mid-build — see CLAUDE.md. +SCRATCH_PATH="${SHIFTCHANGE_SCRATCH_PATH:-${PKG_DIR}/.build}" cd "$PKG_DIR" -swift build -c release 2>&1 +swift build -c release --scratch-path "$SCRATCH_PATH" 2>&1 -BINARY="${PKG_DIR}/.build/release/ShiftChange" +BINARY="${SCRATCH_PATH}/release/ShiftChange" if [ ! -f "$BINARY" ]; then echo "ERROR: Binary not found at ${BINARY}" exit 1 fi -# Find the resource bundle -RESOURCE_BUNDLE=$(find "${PKG_DIR}/.build/release" -name "ShiftChange_ShiftChange.bundle" -maxdepth 1 | head -1) +# The resource bundle. Hard-fail if missing — without it the packaged app +# crashes at launch (Bundle.module fatalError). v1.0.0–v1.1.2 shipped broken +# this way: the bundle was named NightShiftToggle_ShiftChange.bundle (from +# the old package name) AND `find` on the .build/release symlink couldn't +# descend into it, so the copy below was silently skipped. +RESOURCE_BUNDLE="${SCRATCH_PATH}/release/ShiftChange_ShiftChange.bundle" +if [ ! -d "$RESOURCE_BUNDLE" ]; then + echo "ERROR: ${RESOURCE_BUNDLE} not found." + echo " The app would crash on launch without it. Did the package" + echo " or target name in Package.swift change?" + exit 1 +fi # ── 2. Create .app bundle ───────────────────────────────────────── echo "==> Creating ${APP_NAME}.app bundle..." @@ -44,10 +58,9 @@ mkdir -p "${APP_BUNDLE}/Contents/Resources" # Copy binary cp "$BINARY" "${APP_BUNDLE}/Contents/MacOS/${APP_NAME}" -# Copy resource bundle (contains AppIcon.icns etc.) -if [ -n "$RESOURCE_BUNDLE" ] && [ -d "$RESOURCE_BUNDLE" ]; then - cp -R "$RESOURCE_BUNDLE" "${APP_BUNDLE}/Contents/Resources/" -fi +# Copy resource bundle (contains AppIcon.icns and VERSION; existence +# guaranteed by the guard above) +cp -R "$RESOURCE_BUNDLE" "${APP_BUNDLE}/Contents/Resources/" # Copy icon cp "${PKG_DIR}/shiftchange.icns" "${APP_BUNDLE}/Contents/Resources/AppIcon.icns"