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
24 changes: 24 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,30 @@ jobs:
chmod +x scripts/create-dmg.sh
scripts/create-dmg.sh "${{ steps.version.outputs.VERSION }}"

# Launch the app from the freshly built DMG with the build directory
# hidden. Bundle-loading fallback paths baked into the binary make the
# build machine lie about packaged-app health — the v1.0.0–v1.2.0
# launch crash passed every on-machine test this way.
- name: Smoke test packaged app
if: steps.check.outputs.should_release == 'true'
run: |
mv ShiftChange/.build /tmp/hidden-build
MOUNT=$(hdiutil attach -nobrowse -readonly "ShiftChange-${{ steps.version.outputs.VERSION }}.dmg" | grep -o '/Volumes/.*' | head -1)
"$MOUNT/ShiftChange.app/Contents/MacOS/ShiftChange" &
APP_PID=$!
sleep 5
if kill -0 "$APP_PID" 2>/dev/null; then
echo "Packaged app launched and stayed alive."
kill "$APP_PID" 2>/dev/null || true
RESULT=0
else
echo "::error::Packaged app crashed on launch — aborting release."
RESULT=1
fi
hdiutil detach "$MOUNT" -quiet || true
mv /tmp/hidden-build ShiftChange/.build
exit $RESULT

- name: Compute SHA256
if: steps.check.outputs.should_release == 'true'
id: sha
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,10 @@ or keep the repo outside iCloud-synced folders.

## 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.
### Resource Bundle Naming & Loading (read before touching resources)
SwiftPM names the resource bundle `<package>_<target>.bundle` — with both named `ShiftChange`, that's `ShiftChange_ShiftChange.bundle`. `create-dmg.sh` resolves that exact path (and fails the build if absent) and copies it into `Contents/Resources`.

**Never use `Bundle.module` directly in app code — go through `AppResources.bundle`.** SwiftPM's generated accessor for *executable* targets only checks the .app ROOT (`Bundle.main.bundleURL`) and a baked-in absolute path into the build machine's `.build` directory; it never checks `Contents/Resources`, so `Bundle.module` fatalErrors at launch in the packaged app. This crashed every release from v1.0.0 through v1.2.0 — and passed every on-machine test, because on the build machine the baked-in `.build` fallback path exists. **Launch tests of the packaged app only count with the build directory renamed/hidden** (the release workflow's smoke-test step does this automatically).

### CoreBrightness Bridge
The app uses Apple's **private** `CoreBrightness` framework via runtime dynamic loading (`dlopen`/`objc_msgSend`). The `BlueLightStatus` struct is reverse-engineered:
Expand Down Expand Up @@ -116,6 +118,7 @@ When making changes:
- 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
- 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>"`.)

## Code Signing & Notarization
Expand Down
29 changes: 29 additions & 0 deletions ShiftChange/Sources/ShiftChange/AppResources.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation

/// Resolves the SwiftPM resource bundle in every context the app runs in.
///
/// Do NOT use `Bundle.module` directly from app code. SwiftPM's generated
/// accessor for executable targets only checks two places: the .app bundle
/// ROOT (`Bundle.main.bundleURL`) and a baked-in absolute path into the
/// build machine's `.build` directory. It never looks in Contents/Resources,
/// where create-dmg.sh places the bundle — so `Bundle.module` fatalErrors at
/// launch in the packaged app on any machine but the one that built it.
/// That was the v1.0.0–v1.2.0 launch crash, masked on dev machines by the
/// baked-in fallback path.
enum AppResources {
static let bundle: Bundle = {
let name = "ShiftChange_ShiftChange.bundle"

// Packaged .app: Contents/Resources. Dev build (`swift build` and
// running the bare binary): the directory containing the executable.
// Bundle.main.resourceURL covers both.
if let url = Bundle.main.resourceURL?.appendingPathComponent(name),
let bundle = Bundle(url: url) {
return bundle
}

// Last resort (e.g. unusual test runners). May trap if the bundle is
// truly absent — same behavior as before this helper existed.
return Bundle.module
}()
}
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.0
1.2.1
6 changes: 3 additions & 3 deletions ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
NSApplication.shared.setActivationPolicy(.accessory)

// Set the app icon (for window title bar, About, etc.)
if let iconURL = Bundle.module.url(forResource: "AppIcon", withExtension: "icns"),
if let iconURL = AppResources.bundle.url(forResource: "AppIcon", withExtension: "icns"),
let icon = NSImage(contentsOf: iconURL) {
let size: CGFloat = 1024
let padding: CGFloat = size * 0.10
Expand Down Expand Up @@ -391,7 +391,7 @@ extension AppDelegate: NSWindowDelegate {
struct AboutView: View {
static let appVersion: String = {
// Read from the bundled VERSION file (single source of truth)
if let url = Bundle.module.url(forResource: "VERSION", withExtension: nil),
if let url = AppResources.bundle.url(forResource: "VERSION", withExtension: nil),
let contents = try? String(contentsOf: url, encoding: .utf8) {
return contents.trimmingCharacters(in: .whitespacesAndNewlines)
}
Expand All @@ -405,7 +405,7 @@ struct AboutView: View {
var body: some View {
VStack(spacing: 16) {
// App icon
if let iconURL = Bundle.module.url(forResource: "AppIcon", withExtension: "icns"),
if let iconURL = AppResources.bundle.url(forResource: "AppIcon", withExtension: "icns"),
let icon = NSImage(contentsOf: iconURL) {
Image(nsImage: icon)
.resizable()
Expand Down