From 0beb7a6438cd2d4b7b1d340d3216210946366d03 Mon Sep 17 00:00:00 2001 From: adamdexter Date: Fri, 3 Jul 2026 02:17:07 -0400 Subject: [PATCH] Guard the system appearance against Night Shift toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On some macOS versions (confirmed on Sonoma 14.5), the "Auto" appearance setting is coupled to the Night Shift engine: toggling Night Shift also flips the system Light/Dark theme. Users treat Night Shift tinting and the theme as independent settings, so no ShiftChange action should ever change the theme — this affected both the new menu bar toggle and the per-app override. The bridge now snapshots the theme (SkyLight SLSGetAppearanceThemeLegacy) around every setNightShiftEnabled: call and restores it if it changes within ~3s (checks at 0.3/1.2/3.0s; a generation counter lets rapid toggles supersede older guards). Behavior-neutral on macOS versions without the coupling — the guard only acts when a flip actually happens — and theme changes made outside our own calls are never touched. Verified live on Sonoma 14.5: toggling Night Shift off flipped the theme (guard log confirms) and it was restored within 300ms; theme identical before/after a full off/on cycle. Bump version to 1.2.2. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 ++ README.md | 1 + .../CBlueLightBridge/CBlueLightBridge.m | 68 ++++++++++++++++++- .../Sources/ShiftChange/Resources/VERSION | 2 +- 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7ecbe83..c591957 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,9 @@ The bridge registers a `setStatusNotificationBlock:` handler (delivered on the m 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. +### Appearance Guard (Auto Light/Dark coupling) +On some macOS versions (confirmed on Sonoma 14.5; apparently decoupled on newer releases), the "Auto" appearance setting is tied to the Night Shift engine: toggling Night Shift flips the system Light/Dark theme as a side effect. The bridge guards every `setNightShiftEnabled:` call — it snapshots the theme (SkyLight `SLSGetAppearanceThemeLegacy`) and restores it (`SLSSetAppearanceThemeLegacy`) if it changes within ~3s of our toggle, checking at 0.3/1.2/3.0s with a generation counter so rapid toggles don't fight. The guard is behavior-neutral on OSes without the coupling (it only acts when a flip actually happens) and never touches theme changes made outside our own calls. + ### 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. @@ -117,6 +120,7 @@ When making changes: - 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 + - With Appearance set to "Auto" (System Settings → Appearance), use the menu toggle and switch in/out of an excluded app → the Light/Dark theme must NOT change (macOS couples Auto appearance to Night Shift on some OS versions; the bridge's appearance guard must undo it) - Quit the app while overriding → Night Shift should restore - Launch the packaged .app from the DMG **with `.build` renamed away** — the baked-in fallback path makes dev-machine launch tests pass even when the packaged app is broken (CI's release smoke test also covers this) 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 ""`.) diff --git a/README.md b/README.md index c5b411a..65f8066 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ That's it. Set it and forget it. - **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 +- **Theme-safe** — toggling Night Shift never flips your Light/Dark appearance (some macOS versions couple the two when Appearance is set to Auto; ShiftChange guards against it) - **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 diff --git a/ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m b/ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m index 8ddaebf..633bc82 100644 --- a/ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m +++ b/ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m @@ -14,6 +14,44 @@ unsigned char _padding[508]; } BlueLightStatus; +#pragma mark - Appearance guard (SkyLight) + +// macOS couples the "Auto" appearance setting to the Night Shift engine: +// toggling Night Shift can flip the system Light/Dark theme as a side +// effect. Night Shift tinting and the theme are independent settings from +// the user's point of view, so we snapshot the theme before our own +// setEnabled: calls and restore it if it changes in the seconds after. +// Scoped to OUR toggles only — theme changes made by the user or by the +// schedule outside our calls are left alone. + +typedef BOOL (*SLSGetAppearanceThemeLegacyFunc)(void); +typedef void (*SLSSetAppearanceThemeLegacyFunc)(BOOL); + +static SLSGetAppearanceThemeLegacyFunc slsGetTheme = NULL; +static SLSSetAppearanceThemeLegacyFunc slsSetTheme = NULL; +static NSUInteger themeGuardGeneration = 0; + +static void loadSkyLightIfNeeded(void) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + void *handle = dlopen( + "/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", + RTLD_LAZY + ); + if (!handle) { + NSLog(@"[ShiftChange] Failed to load SkyLight — appearance guard disabled"); + return; + } + slsGetTheme = (SLSGetAppearanceThemeLegacyFunc)dlsym(handle, "SLSGetAppearanceThemeLegacy"); + slsSetTheme = (SLSSetAppearanceThemeLegacyFunc)dlsym(handle, "SLSSetAppearanceThemeLegacy"); + if (!slsGetTheme || !slsSetTheme) { + NSLog(@"[ShiftChange] SkyLight appearance symbols not found — appearance guard disabled"); + slsGetTheme = NULL; + slsSetTheme = NULL; + } + }); +} + @implementation CBlueLightBridge + (id _Nullable)sharedClient { @@ -81,9 +119,35 @@ + (void)setNightShiftEnabled:(BOOL)enabled { return; } - void (*setEnabled)(id, SEL, BOOL) = + // Snapshot the theme so the appearance guard can undo any Light/Dark + // flip this toggle causes (see the guard comment above). + loadSkyLightIfNeeded(); + BOOL guardActive = (slsGetTheme != NULL && slsSetTheme != NULL); + BOOL themeBefore = guardActive ? slsGetTheme() : NO; + NSUInteger generation = ++themeGuardGeneration; + + void (*setEnabledFn)(id, SEL, BOOL) = (void (*)(id, SEL, BOOL))objc_msgSend; - setEnabled(client, sel, enabled); + setEnabledFn(client, sel, enabled); + + if (!guardActive) return; + + // The appearance engine reacts asynchronously, so check a few times. + // A newer toggle supersedes this guard via the generation counter. + void (^restoreTheme)(void) = ^{ + if (generation != themeGuardGeneration) return; + if (slsGetTheme() != themeBefore) { + NSLog(@"[ShiftChange] Night Shift toggle flipped the system appearance — restoring"); + slsSetTheme(themeBefore); + } + }; + for (NSNumber *delay in @[@0.3, @1.2, @3.0]) { + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay.doubleValue * NSEC_PER_SEC)), + dispatch_get_main_queue(), + restoreTheme + ); + } } + (BOOL)isNightShiftScheduled { diff --git a/ShiftChange/Sources/ShiftChange/Resources/VERSION b/ShiftChange/Sources/ShiftChange/Resources/VERSION index 6085e94..23aa839 100644 --- a/ShiftChange/Sources/ShiftChange/Resources/VERSION +++ b/ShiftChange/Sources/ShiftChange/Resources/VERSION @@ -1 +1 @@ -1.2.1 +1.2.2