From 9d537a70d7c420d40073a2cf5073bdaefbf9a6bb Mon Sep 17 00:00:00 2001
From: adamdexter
Date: Fri, 3 Jul 2026 00:28:37 -0400
Subject: [PATCH 1/4] =?UTF-8?q?Rename=20package=20to=20ShiftChange=20?=
=?UTF-8?q?=E2=80=94=20fixes=20packaged=20app=20crashing=20on=20launch?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Every distributed build to date (v1.0.0 through the signed v1.1.2)
crashes at launch on any machine except the dev machine, verified by
running the released DMGs directly:
Fatal error: could not load resource bundle
Two stacked bugs: the SwiftPM package was still named NightShiftToggle,
so the resource bundle was NightShiftToggle_ShiftChange.bundle while
create-dmg.sh looked for ShiftChange_ShiftChange.bundle — and the
script's `find .build/release` couldn't descend into the symlinked
release directory anyway, so the copy was silently skipped regardless.
Without the bundle, Bundle.module hits fatalError during
applicationDidFinishLaunching. Dev machines were masked by the
binary's baked-in fallback path to the local .build checkout.
Changes:
- Package name NightShiftToggle → ShiftChange
(bundle becomes ShiftChange_ShiftChange.bundle)
- Sources/NightShiftToggle/ → Sources/ShiftChange/,
NightShiftToggleApp.swift → ShiftChangeApp.swift
- create-dmg.sh: resolve the bundle by direct path and hard-fail if
missing instead of silently shipping a broken app
- release.yml: update the VERSION path trigger and version check to
the renamed directory (releases would stop triggering otherwise)
Co-Authored-By: Claude Fable 5
---
.github/workflows/release.yml | 4 +--
ShiftChange/Package.swift | 8 ++++--
.../ContentView.swift | 0
.../ExcludeListManager.swift | 0
.../FocusMonitor.swift | 0
.../InstalledAppsFinder.swift | 0
.../NightShiftManager.swift | 0
.../Resources/AppIcon.icns | Bin
.../Resources/VERSION | 0
.../ShiftChangeApp.swift} | 0
.../main.swift | 0
scripts/create-dmg.sh | 23 ++++++++++++------
12 files changed, 24 insertions(+), 11 deletions(-)
rename ShiftChange/Sources/{NightShiftToggle => ShiftChange}/ContentView.swift (100%)
rename ShiftChange/Sources/{NightShiftToggle => ShiftChange}/ExcludeListManager.swift (100%)
rename ShiftChange/Sources/{NightShiftToggle => ShiftChange}/FocusMonitor.swift (100%)
rename ShiftChange/Sources/{NightShiftToggle => ShiftChange}/InstalledAppsFinder.swift (100%)
rename ShiftChange/Sources/{NightShiftToggle => ShiftChange}/NightShiftManager.swift (100%)
rename ShiftChange/Sources/{NightShiftToggle => ShiftChange}/Resources/AppIcon.icns (100%)
rename ShiftChange/Sources/{NightShiftToggle => ShiftChange}/Resources/VERSION (100%)
rename ShiftChange/Sources/{NightShiftToggle/NightShiftToggleApp.swift => ShiftChange/ShiftChangeApp.swift} (100%)
rename ShiftChange/Sources/{NightShiftToggle => ShiftChange}/main.swift (100%)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 94559bf..edcad70 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."
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/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 100%
rename from ShiftChange/Sources/NightShiftToggle/FocusMonitor.swift
rename to ShiftChange/Sources/ShiftChange/FocusMonitor.swift
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 100%
rename from ShiftChange/Sources/NightShiftToggle/NightShiftManager.swift
rename to ShiftChange/Sources/ShiftChange/NightShiftManager.swift
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/NightShiftToggle/Resources/VERSION b/ShiftChange/Sources/ShiftChange/Resources/VERSION
similarity index 100%
rename from ShiftChange/Sources/NightShiftToggle/Resources/VERSION
rename to ShiftChange/Sources/ShiftChange/Resources/VERSION
diff --git a/ShiftChange/Sources/NightShiftToggle/NightShiftToggleApp.swift b/ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift
similarity index 100%
rename from ShiftChange/Sources/NightShiftToggle/NightShiftToggleApp.swift
rename to ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift
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/scripts/create-dmg.sh b/scripts/create-dmg.sh
index fa46c5b..940cf46 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
@@ -32,8 +32,18 @@ if [ ! -f "$BINARY" ]; then
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="${PKG_DIR}/.build/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 +54,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"
From e545c3ec306ae1a9125ffd249944beb9fb53538d Mon Sep 17 00:00:00 2001
From: adamdexter
Date: Fri, 3 Jul 2026 00:29:50 -0400
Subject: [PATCH 2/4] Audit fixes: quit/minimize monitoring loss, deinit
safety, bridge cleanup
- Choosing "Minimize to Menu Bar" in the quit confirmation no longer
leaves focus monitoring permanently stopped: quitApp and
applicationShouldTerminate no longer stop the monitor eagerly;
teardown happens only in applicationWillTerminate
- FocusMonitor.deinit no longer mutates @Published state during
deallocation (removes the observer and restores directly)
- CBlueLightBridge: dedupe the status-fetch boilerplate into
fetchStatus:, check the getBlueLightStatus: return value, and unify
the log prefix to [ShiftChange]
- README: remove stray artifact tags at end of file
Co-Authored-By: Claude Fable 5
---
README.md | 4 +-
.../CBlueLightBridge/CBlueLightBridge.m | 45 ++++++-------------
.../Sources/ShiftChange/FocusMonitor.swift | 7 ++-
.../Sources/ShiftChange/ShiftChangeApp.swift | 7 ++-
4 files changed, 24 insertions(+), 39 deletions(-)
diff --git a/README.md b/README.md
index 5567ea8..319c5e0 100644
--- a/README.md
+++ b/README.md
@@ -176,6 +176,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/Sources/CBlueLightBridge/CBlueLightBridge.m b/ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m
index 97f2fe7..a71d5b1 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,18 +87,8 @@ + (void)setNightShiftEnabled:(BOOL)enabled {
}
+ (BOOL)isNightShiftScheduled {
- 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;
// mode != 0 means a schedule is configured
return status.mode != 0;
diff --git a/ShiftChange/Sources/ShiftChange/FocusMonitor.swift b/ShiftChange/Sources/ShiftChange/FocusMonitor.swift
index 2662b63..c4d9d40 100644
--- a/ShiftChange/Sources/ShiftChange/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/ShiftChange/ShiftChangeApp.swift b/ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift
index 18dd575..b9096c7 100644
--- a/ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift
+++ b/ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift
@@ -99,14 +99,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
}
@@ -306,7 +304,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)
}
From 471704659c0af2ed565e145418267e69c8c0c683 Mon Sep 17 00:00:00 2001
From: adamdexter
Date: Fri, 3 Jul 2026 00:34:09 -0400
Subject: [PATCH 3/4] Add global Night Shift toggle and react to external
status changes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two related additions to the override state machine:
1. Menu bar "Turn On/Off Night Shift" item. Uses CBBlueLightClient
setEnabled: via the client protocol — the exact call behind the
System Settings toggle, so with a schedule configured the OS itself
handles "until tomorrow / until sunset". While an excluded app has
focus the toggle only updates the restore intent (display untouched),
so per-app switching keeps working regardless of global state.
2. The bridge now registers CBBlueLightClient's status notification
block (delivered on the main queue; the block takes no parameters
and re-queries status rather than trusting the private struct
layout). If the schedule or the user turns Night Shift on while an
excluded app is focused, it is immediately re-disabled and folded
into the restore intent — the display no longer warms mid-session
in color-critical apps. Previously this went unnoticed until the
next app switch.
Supporting changes:
- BlueLightControlling gains setStatusChangeHandler so the state
machine stays fully testable; FakeBlueLightClient fires the handler
synchronously on self-caused changes (stricter than production) to
catch re-entrancy bugs
- disableForExcludedApp/restoreIfNeeded update state before calling
setEnabled, making the state machine safe against synchronous
notification delivery
- Menu refreshes on open (NSMenuDelegate) and on external changes;
status line shows On/Off instead of "Following schedule"
- Six new unit tests covering the toggle and external-change paths
Co-Authored-By: Claude Fable 5
---
.../CBlueLightBridge/CBlueLightBridge.m | 32 ++++++++
.../include/CBlueLightBridge.h | 5 ++
.../ShiftChange/NightShiftManager.swift | 67 +++++++++++++++-
.../Sources/ShiftChange/ShiftChangeApp.swift | 40 +++++++++-
.../Tests/ShiftChangeTests/Fakes.swift | 16 ++++
.../NightShiftManagerTests.swift | 79 +++++++++++++++++++
6 files changed, 234 insertions(+), 5 deletions(-)
diff --git a/ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m b/ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m
index a71d5b1..8ddaebf 100644
--- a/ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m
+++ b/ShiftChange/Sources/CBlueLightBridge/CBlueLightBridge.m
@@ -94,4 +94,36 @@ + (BOOL)isNightShiftScheduled {
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;
+
+ SEL sel = NSSelectorFromString(@"setStatusNotificationBlock:");
+ if (![client respondsToSelector:sel]) {
+ NSLog(@"[ShiftChange] setStatusNotificationBlock: selector not found");
+ return;
+ }
+
+ // 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();
+ });
+ };
+ }
+
+ 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/ShiftChange/NightShiftManager.swift b/ShiftChange/Sources/ShiftChange/NightShiftManager.swift
index 41c19d7..2850ee6 100644
--- a/ShiftChange/Sources/ShiftChange/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/ShiftChange/ShiftChangeApp.swift b/ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift
index b9096c7..12ae4a9 100644
--- a/ShiftChange/Sources/ShiftChange/ShiftChangeApp.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()
@@ -137,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
@@ -155,6 +170,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
quitItem.target = self
statusMenu.addItem(quitItem)
+ statusMenu.delegate = self
statusItem.menu = statusMenu
updateMenuStatus()
@@ -163,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"
}
}
@@ -173,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
@@ -297,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)
@@ -333,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/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)
+ }
}
From 5f9ad65d671f372790a6f599946b030f459c0be4 Mon Sep 17 00:00:00 2001
From: adamdexter
Date: Fri, 3 Jul 2026 00:38:47 -0400
Subject: [PATCH 4/4] Release: tap auto-update, docs refresh, v1.2.0
- release.yml: push the updated cask to the live tap repo
(adamdexter/homebrew-shiftchange) when the TAP_PUSH_TOKEN secret is
configured; warn loudly when it isn't. The tap was stuck at v1.0.0
because nothing ever updated it.
- create-dmg.sh: honor SHIFTCHANGE_SCRATCH_PATH for the swift build
(iCloud-synced checkouts corrupt .build mid-build; default unchanged)
- CLAUDE.md: renamed paths, resource-bundle naming warning, external
status changes + global toggle sections, expanded regression
checklist, tap-repo documentation, iCloud build gotcha
- README: global toggle + schedule-aware feature bullets, renamed
VERSION path
- Bump version to 1.2.0
Co-Authored-By: Claude Fable 5
---
.github/workflows/release.yml | 28 ++++++++++
CLAUDE.md | 56 ++++++++++++++++---
README.md | 4 +-
ShiftChange/.gitignore | 2 +-
.../Sources/ShiftChange/Resources/VERSION | 2 +-
scripts/create-dmg.sh | 10 +++-
6 files changed, 89 insertions(+), 13 deletions(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index edcad70..af99e86 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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 319c5e0..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
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/Sources/ShiftChange/Resources/VERSION b/ShiftChange/Sources/ShiftChange/Resources/VERSION
index 45a1b3f..26aaba0 100644
--- a/ShiftChange/Sources/ShiftChange/Resources/VERSION
+++ b/ShiftChange/Sources/ShiftChange/Resources/VERSION
@@ -1 +1 @@
-1.1.2
+1.2.0
diff --git a/scripts/create-dmg.sh b/scripts/create-dmg.sh
index 940cf46..3b971aa 100755
--- a/scripts/create-dmg.sh
+++ b/scripts/create-dmg.sh
@@ -23,10 +23,14 @@ 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
@@ -37,7 +41,7 @@ fi
# 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="${PKG_DIR}/.build/release/ShiftChange_ShiftChange.bundle"
+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"