Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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<VERSION>` 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<VERSION> ./ShiftChange-<VERSION>.dmg --title "ShiftChange <VERSION>" --notes "<changelog>"`.)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 66 additions & 2 deletions ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion ShiftChange/Sources/ShiftChange/Resources/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.2.1
1.2.2