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
32 changes: 30 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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."
Expand Down Expand Up @@ -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
56 changes: 49 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -34,21 +35,32 @@ 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

```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.
Expand All @@ -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 `<package>_<target>.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)
Expand All @@ -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<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 All @@ -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).
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -176,6 +178,4 @@ Made out of necessity and with love by [Adam Dexter](https://adamdexter.net/) an
<p align="center">
If ShiftChange has been useful to you, consider
<a href="https://buymeacoffee.com/adamdexter">buying me a coffee</a>!
</p>
</content>
</invoke>
</p>
2 changes: 1 addition & 1 deletion ShiftChange/.gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.DS_Store
.build/
.build*
.swiftpm/
*.xcodeproj
xcuserdata/
Expand Down
8 changes: 6 additions & 2 deletions ShiftChange/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -16,7 +20,7 @@ let package = Package(
.executableTarget(
name: "ShiftChange",
dependencies: ["CBlueLightBridge"],
path: "Sources/NightShiftToggle",
path: "Sources/ShiftChange",
resources: [
.process("Resources")
]
Expand Down
73 changes: 44 additions & 29 deletions ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 0 additions & 1 deletion ShiftChange/Sources/NightShiftToggle/Resources/VERSION

This file was deleted.

Loading