diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af99e86..3e3c1b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 6fa71b9..7ecbe83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `_.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 `_.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: @@ -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` 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 ""`.) ## Code Signing & Notarization diff --git a/ShiftChange/Sources/ShiftChange/AppResources.swift b/ShiftChange/Sources/ShiftChange/AppResources.swift new file mode 100644 index 0000000..078c7d0 --- /dev/null +++ b/ShiftChange/Sources/ShiftChange/AppResources.swift @@ -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 + }() +} diff --git a/ShiftChange/Sources/ShiftChange/Resources/VERSION b/ShiftChange/Sources/ShiftChange/Resources/VERSION index 26aaba0..6085e94 100644 --- a/ShiftChange/Sources/ShiftChange/Resources/VERSION +++ b/ShiftChange/Sources/ShiftChange/Resources/VERSION @@ -1 +1 @@ -1.2.0 +1.2.1 diff --git a/ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift b/ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift index 12ae4a9..41602c1 100644 --- a/ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift +++ b/ShiftChange/Sources/ShiftChange/ShiftChangeApp.swift @@ -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 @@ -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) } @@ -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()