From 98e448fbc694e4c0fb01a14cff36f0f1f77d3f04 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Fri, 30 Oct 2026 09:50:11 -0700 Subject: [PATCH 1/2] fix(macos): run the service from its own helper app so it is never the Driga app Package drigad in a nested background-only app with the run.driga.service identity, matching release metadata, and Driga branding. Sign the helper before the GUI bundle and validate both bundles in release archives. Migrate launchd from the old same-bundle executable during service install, GUI startup, and Homebrew upgrades. Preserve the installed service environment during cask migration so custom data and configuration paths keep working. Activate the main app on notification clicks and deliver the target through the private stdin hand-off. Keep the GUI independent of the notification callback deadline and query notification permission under the helper identity. Document the new permission prompt and the manual Launch Services host check. Validate with native, Linux, and Windows go vet; the requested package tests in local time and UTC; helper, migration, signing, and notification regressions; and Python compilation and shell syntax checks. No real notarization was run. Claude-Session: https://claude.ai/code/session_01T2vtcW6ihn1ywy9vxUq9a4 --- .github/workflows/macos-signing.yml | 5 + .github/workflows/release.yml | 2 +- .goreleaser.yaml | 25 +++- cmd/driga/main_darwin.go | 24 +++- cmd/driga/reexec_darwin_test.go | 14 +++ docs/release.md | 37 ++++-- docs/usage.md | 8 ++ internal/attention/notifications.go | 32 ++++-- internal/attention/notifications_darwin.go | 15 ++- internal/attention/notifications_test.go | 8 +- .../attention/private_notification_test.go | 19 ++- internal/cli/doctor_test.go | 2 +- internal/self/self.go | 12 +- internal/self/self_test.go | 13 ++- internal/service/bundle.go | 14 ++- internal/service/bundle_test.go | 7 +- internal/service/launchd_darwin.go | 6 +- internal/service/launchd_darwin_test.go | 9 +- internal/service/service.go | 2 +- scripts/build-macos-app.sh | 10 +- scripts/macos-app.py | 20 +++- scripts/macos_app_test.go | 108 ++++++++++++++++++ scripts/verify-macos-app.py | 20 +++- scripts/verify-release-dist.sh | 13 ++- scripts/verify_release_dist_test.go | 16 ++- .../src/content/docs/guides/notifications.mdx | 8 +- 26 files changed, 362 insertions(+), 87 deletions(-) diff --git a/.github/workflows/macos-signing.yml b/.github/workflows/macos-signing.yml index 3bf520c3..f029a2e4 100644 --- a/.github/workflows/macos-signing.yml +++ b/.github/workflows/macos-signing.yml @@ -10,6 +10,9 @@ on: - .github/workflows/macos-signing.yml - scripts/build-macos-app.sh - scripts/macos-app.py + - scripts/verify-macos-app.py + - scripts/verify-release-dist.sh + - .goreleaser.yaml - scripts/macos-signing-keychain.sh permissions: @@ -57,6 +60,8 @@ jobs: mkdir -p "$app/Contents/MacOS" go build -trimpath -ldflags "-s -w" -o "$app/Contents/MacOS/driga" ./cmd/driga bash scripts/build-macos-app.sh "$app/Contents/MacOS/driga" darwin 0.0.0-signing-check false + codesign --verify --strict --verbose=2 "$app/Contents/Helpers/Driga Service.app" + codesign -dv --verbose=2 "$app/Contents/Helpers/Driga Service.app" 2>&1 | grep -E '^(Authority|TeamIdentifier|Identifier)=' codesign -dv --verbose=2 "$app" 2>&1 | grep -E '^(Authority|TeamIdentifier|Identifier)=' - name: Delete temporary signing keychain diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72a5c24e..d1028347 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: # The published artifacts, not a snapshot of them. Until this ran here, # nothing checked what a user actually downloads. - - name: Verify published artifacts + - name: Verify published artifacts and nested service signatures env: DRIGA_VERIFY_SIGNATURES: "true" run: scripts/verify-release-dist.sh dist diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c16cc95a..996896a2 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -41,7 +41,7 @@ builds: output: true # drigad, the Driga service: the same program under the name it runs as the - # service by. On macOS the bundle script copies it into the app before + # service by. On macOS the bundle script copies it into Driga Service.app before # signing; here it is built for Linux and Windows, beside driga. - id: drigad main: ./cmd/driga @@ -90,7 +90,7 @@ archives: ids: - driga - drigad - # macOS ships drigad inside Driga.app, so its archive has one binary + # macOS ships drigad in the nested helper app, so its archive has one build binary # where Linux has two. allow_different_binary_count: true name_template: "driga_{{ .Version }}_{{ .Os }}_{{ .Arch }}" @@ -158,7 +158,8 @@ archives: - goos: windows formats: [none] # Preserve the binary's bundle path. The hook stages all other app files - # separately so the archive includes the executable exactly once. + # separately, including Helpers/Driga Service.app and its signature, so + # the archive includes the GUI executable exactly once. strip_binary_directory: false files: - src: '{{ dir (dir (dir (dir .ArtifactPath))) }}/bundle-resources' @@ -228,12 +229,26 @@ homebrew_casks: # `app` (DMG) integration. custom_block: | app "Driga.app" + require "json" + postflight do + # Migrate before open -a can activate an old same-bundle drigad. + # Fresh installs still wait for the app's first launch. + if File.exist?(File.expand_path("~/Library/LaunchAgents/run.driga.service.plist")) + previous = system_command "/usr/bin/plutil", + args: ["-convert", "json", "-o", "-", File.expand_path("~/Library/LaunchAgents/run.driga.service.plist")], + must_succeed: true + system_command "#{appdir}/Driga.app/Contents/MacOS/driga", + args: ["service", "install"], + env: JSON.parse(previous.stdout).fetch("EnvironmentVariables", {}), + must_succeed: true + end + end caveats: | Driga.app is in /Applications, and driga links to the CLI inside it. Open Driga from Finder, Spotlight or with driga app. Its first launch installs the Driga service (drigad), which collects at login whether or - not the app is open. After upgrading from an earlier release, open Driga - once: it replaces the old collector. + not the app is open. Upgrades migrate an existing service automatically. + Open Driga once to finish first-launch setup. url: template: "https://github.com/labstack/driga/releases/download/{{ .Tag }}/{{ .ArtifactName }}" commit_msg_template: "chore: update driga to {{ .Tag }}" diff --git a/cmd/driga/main_darwin.go b/cmd/driga/main_darwin.go index 472b7bf4..64d3a892 100644 --- a/cmd/driga/main_darwin.go +++ b/cmd/driga/main_darwin.go @@ -9,6 +9,7 @@ import ( "syscall" "github.com/labstack/driga/internal/attention" + "github.com/labstack/driga/internal/service" ) // Cocoa's application/run loop must own the initial OS thread. Lock in init, @@ -30,10 +31,16 @@ func notificationMain(args []string, run func()) { // symlink such as /opt/homebrew/bin/driga it would not be Driga.app, // so restart from the real path for the commands that use that identity. if exe, err := os.Executable(); err == nil { - if real, err := filepath.EvalSymlinks(exe); err == nil && real != exe { - // The real executable supplies Cocoa's bundle identity, while - // the original invocation name still identifies drigad. Startup - // then adds "service run" exactly once. + real := exe + if resolved, err := filepath.EvalSymlinks(exe); err == nil { + real = resolved + } + if cmd == "doctor" { + real = doctorExecutable(real) + } + if real != exe { + // Preserve argv[0]: doctor still runs as the CLI, while Cocoa + // queries permission using the service helper's bundle identity. _ = reexecResolved(real, os.Args, syscall.Exec) } } @@ -50,3 +57,12 @@ func notificationMain(args []string, run func()) { func reexecResolved(real string, argv []string, exec func(string, []string, []string) error) error { return exec(real, argv, os.Environ()) } + +// doctorExecutable gives notification diagnostics the identity that posts alerts. +// Keeping argv[0] unchanged prevents drigad's ordinary service startup dispatch. +func doctorExecutable(exe string) string { + if service.BundlePath(exe) != "" { + return service.DaemonPath(exe) + } + return exe +} diff --git a/cmd/driga/reexec_darwin_test.go b/cmd/driga/reexec_darwin_test.go index cd25b2fe..0b9ce22d 100644 --- a/cmd/driga/reexec_darwin_test.go +++ b/cmd/driga/reexec_darwin_test.go @@ -40,3 +40,17 @@ func TestResolvedReexecKeepsInvocationIdentity(t *testing.T) { }) } } + +func TestDoctorUsesServiceIdentity(t *testing.T) { + const app = "/Applications/Driga.app/Contents/" + helper := app + "Helpers/Driga Service.app/Contents/MacOS/drigad" + if got := doctorExecutable(app + "MacOS/driga"); got != helper { + t.Fatal(got) + } + if got := doctorExecutable(helper); got != helper { + t.Fatal("doctor reexec loop:", got) + } + if got := doctorExecutable("/tmp/driga"); got != "/tmp/driga" { + t.Fatal(got) + } +} diff --git a/docs/release.md b/docs/release.md index 9adb5cb6..3a8a10b9 100644 --- a/docs/release.md +++ b/docs/release.md @@ -169,20 +169,39 @@ Linux CI consumes the committed ICNS and validates every representation. [Apple's helper documentation](https://developer.apple.com/documentation/servicemanagement/updating-helper-executables-from-earlier-versions-of-macos) explains the organization-name fallback and `AssociatedBundleIdentifiers`. `service install`, which the app runs on launch, points the launch agent at -the bundle's `drigad`, associates `run.driga`, registers the app with Launch -Services, and atomically repairs an existing stale plist. It stops a running -service before replacing its stale definition, then starts it. Reinstalling a -current definition is harmless. It first removes the `run.driga.collector` +`Contents/Helpers/Driga Service.app/Contents/MacOS/drigad`, associates +`run.driga.service`, registers both bundles with Launch Services, and atomically +repairs an existing stale plist. It unloads the running service before replacing +its stale definition, then starts it. This migrates the former +`Contents/MacOS/drigad` path even when the version has not changed. Reinstalling +a current definition is harmless. It also removes the `run.driga.collector` agent earlier releases installed. Development binaries outside the app keep their ordinary path and have no association; a `driga-dev` build installs as -`run.driga.service.dev`, so it never takes over the release's service. `drigad` -is a copy of the main executable that the bundle script places in -`Contents/MacOS` and signs before the bundle, so both share the app's team. Registration matters: Apple requires Launch Services to know -the associated app. +`run.driga.service.dev`, so it never takes over the release's service. + +The service has its own nested app, `Contents/Helpers/Driga Service.app`, with +bundle identifier `run.driga.service`, `LSBackgroundOnly=true`, matching version +metadata, and Driga's icon and display name. `Contents/Helpers` is for this +launchd-managed helper; `Contents/Library/LoginItems` would imply a Service +Management login item, which this service does not use. The GUI retains +`run.driga`, its existing executable, and the Homebrew `bin/driga` symlink. +The cask postflight runs `service install` when a service plist already exists, +so an upgrade stops the old same-bundle process before the first `open -a`. +It passes the saved launch agent environment through to preserve custom data +paths, configuration, and PATH during that repair. +Fresh cask installs still wait for the first GUI launch to install the service. +The helper can never register as the GUI's running instance. It also keeps +Prohibited activation policy on the main thread as defense in depth. + +Notifications now belong to `run.driga.service`. macOS will ask once more for +permission after upgrading because the identity changed. Clicks activate the +main app with `open -b run.driga`, then deliver the target through the main +CLI's existing private stdin and single-instance IPC hand-off. The entire release job runs on GitHub-hosted `macos-15`. An OSS per-build post hook assembles the app, then `codesign --options runtime --timestamp` signs the -whole bundle with a Developer ID Application identity and seals its resources. +helper bundle first and the outer GUI bundle second with a Developer ID Application identity and seals its resources. +Neither executable needs sandbox, JIT, or library-validation exception entitlements. Apple documents the [hardened-runtime and timestamp requirements](https://developer.apple.com/documentation/security/resolving-common-notarization-issues). It submits a temporary ZIP through `notarytool`, requires Accepted, retrieves the log, staples and validates the app, and verifies its signature and Gatekeeper diff --git a/docs/usage.md b/docs/usage.md index 2b05d7e4..b272e8f9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -368,6 +368,14 @@ driga service uninstall - **Windows:** Task Scheduler lists **Driga service** under `\Driga\`. Task Manager shows **Driga** with the Murmur icon. +On macOS, the bundled service runs from +`Driga.app/Contents/Helpers/Driga Service.app` as `run.driga.service`, with +Driga's notification name and icon. Upgrading from the service that shared +`run.driga` asks for notification permission once more. Open Driga once after +upgrading, or run `driga service install`, to migrate the launch agent and stop +the old `Contents/MacOS/drigad` process. Notification clicks activate the main +`run.driga` app and pass the target through its existing private hand-off. + On macOS logs are at `~/Library/Logs/driga/service.log`; Linux uses `journalctl --user -u driga` (`driga service logs -f` wraps it). On a Linux VM, allow the user service to run after logout: diff --git a/internal/attention/notifications.go b/internal/attention/notifications.go index 624aae9d..0b2bc3dd 100644 --- a/internal/attention/notifications.go +++ b/internal/attention/notifications.go @@ -6,11 +6,14 @@ import ( "encoding/json" "errors" "fmt" - "github.com/labstack/driga/internal/model" "log/slog" "path/filepath" + "strings" "sync" "time" + + "github.com/labstack/driga/internal/model" + "github.com/labstack/driga/internal/self" ) // ErrNotificationsDenied means macOS has Driga's notifications turned off. @@ -19,7 +22,7 @@ var ErrNotificationsDenied = errors.New("Driga is not allowed to send notificati // MacNotificationSettings opens Driga's page in System Settings > Notifications. // macOS asks only once, so after a refusal or a prompt withdrawn by a restart // this is the only way to turn notifications on. -const MacNotificationSettings = `open "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=run.driga"` +const MacNotificationSettings = `open "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=run.driga.service"` // NativeNotificationRequest is the value-only boundary to UserNotifications. // No Objective-C objects escape the Darwin implementation. @@ -163,9 +166,19 @@ func notificationResponse(ctx context.Context, r Runner, path, action, itemID, m if action != notificationDefaultAction || path == "" || r == nil { return nil } - if filepath.Base(path) == "drigad" { - path = filepath.Join(filepath.Dir(path), "driga") - } + cli := self.CLI(path) + if cli != path && strings.Contains(path, "Driga Service.app") { + // Activate the GUI identity first. The CLI below delivers the private + // target to its single-instance IPC without putting it in open's argv. + if _, err := r.Run(ctx, "open", "-b", "run.driga"); err != nil { + return err + } + // If the stdin launcher wins the GUI's single-instance lock during + // startup, it becomes the app. A notification callback's deadline + // must never kill that app fifteen seconds later. + ctx = context.WithoutCancel(ctx) + } + path = cli target, _ := json.Marshal(model.NotificationTarget{ItemID: itemID, MachineID: machineID, Kind: kind}) input, ok := r.(interface { RunInput(context.Context, []byte, string, ...string) ([]byte, error) @@ -177,15 +190,12 @@ func notificationResponse(ctx context.Context, r Runner, path, action, itemID, m return err } -// drigaAppExecutable reports whether path is one of Driga.app's executables: -// driga, the app and CLI, or drigad, the Driga service. +// drigaAppExecutable permits native notifications only in the service helper. func drigaAppExecutable(path string) bool { resolved, err := filepath.EvalSymlinks(path) if err != nil { return false } - base := filepath.Base(resolved) - return (base == "driga" || base == "drigad") && filepath.Base(filepath.Dir(resolved)) == "MacOS" && - filepath.Base(filepath.Dir(filepath.Dir(resolved))) == "Contents" && - filepath.Base(filepath.Dir(filepath.Dir(filepath.Dir(resolved)))) == "Driga.app" + return filepath.Base(resolved) == "drigad" && self.CLI(resolved) != resolved && + strings.Contains(resolved, string(filepath.Separator)+filepath.Join("Contents", "Helpers", "Driga Service.app")+string(filepath.Separator)) } diff --git a/internal/attention/notifications_darwin.go b/internal/attention/notifications_darwin.go index 26a9ab44..6e4056c6 100644 --- a/internal/attention/notifications_darwin.go +++ b/internal/attention/notifications_darwin.go @@ -86,8 +86,8 @@ func macBundleIdentity() error { } defer autoreleasePool()() bundle := nsClass("NSBundle").Send(objc.RegisterName("mainBundle")) - if goString(bundle.Send(objc.RegisterName("bundleIdentifier"))) != "run.driga" { - return fmt.Errorf("Driga.app must have the run.driga bundle identity; reinstall the app") + if goString(bundle.Send(objc.RegisterName("bundleIdentifier"))) != "run.driga.service" { + return fmt.Errorf("Driga Service.app must have the run.driga.service bundle identity; reinstall the app") } return nil } @@ -166,10 +166,13 @@ func startMacNotifications(exe string) (objc.ID, *macNotificationCenter, error) var app objc.ID c.onMainSync(func() { app = nsClass("NSApplication").Send(objc.RegisterName("sharedApplication")) - // Accessory still permits activation and can swallow open -a Driga. - // Do not set LSBackgroundOnly in the shared bundle: the GUI needs windows. - if !objc.Send[bool](app, objc.RegisterName("setActivationPolicy:"), serviceActivationPolicy) { - err = fmt.Errorf("cannot make notification service background-only") + // Keep the helper prohibited as defense in depth alongside its + // LSBackgroundOnly plist, including after launchd restarts it. + // An LSBackgroundOnly bundle is already prohibited, and AppKit answers + // NO to setting the policy it has; only the resulting policy matters. + objc.Send[bool](app, objc.RegisterName("setActivationPolicy:"), serviceActivationPolicy) + if policy := objc.Send[int64](app, objc.RegisterName("activationPolicy")); policy != serviceActivationPolicy { + err = fmt.Errorf("cannot make notification service background-only (activation policy %d)", policy) } }) if err != nil { diff --git a/internal/attention/notifications_test.go b/internal/attention/notifications_test.go index 83309fab..8c88a555 100644 --- a/internal/attention/notifications_test.go +++ b/internal/attention/notifications_test.go @@ -186,7 +186,7 @@ func TestNativeNotificationClickKeepsIdentifiersOutOfArgv(t *testing.T) { func TestDrigaAppExecutable(t *testing.T) { root := t.TempDir() - for _, rel := range []string{"Driga.app/Contents/MacOS/driga", "Driga.app/Contents/MacOS/drigad", "bin/driga", "Other.app/Contents/MacOS/driga", "Driga.app/Contents/MacOS/other"} { + for _, rel := range []string{"Driga.app/Contents/Helpers/Driga Service.app/Contents/MacOS/drigad", "Driga.app/Contents/MacOS/driga", "Driga.app/Contents/MacOS/drigad", "bin/driga", "Other.app/Contents/MacOS/driga", "Driga.app/Contents/MacOS/other"} { path := filepath.Join(root, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { t.Fatal(err) @@ -194,7 +194,7 @@ func TestDrigaAppExecutable(t *testing.T) { if err := os.WriteFile(path, nil, 0600); err != nil { t.Fatal(err) } - if drigaAppExecutable(path) != (rel == "Driga.app/Contents/MacOS/driga" || rel == "Driga.app/Contents/MacOS/drigad") { + if drigaAppExecutable(path) != (rel == "Driga.app/Contents/Helpers/Driga Service.app/Contents/MacOS/drigad") { t.Fatal(rel) } } @@ -279,10 +279,10 @@ func TestServiceNotificationClickRunsTheCLI(t *testing.T) { calls = append(calls, append([]string{name}, args...)) return nil, nil }) - if err := notificationResponse(t.Context(), r, filepath.FromSlash("/Applications/Driga.app/Contents/MacOS/drigad"), notificationDefaultAction, "w1", "local", ""); err != nil { + if err := notificationResponse(t.Context(), r, filepath.FromSlash("/Applications/Driga.app/Contents/Helpers/Driga Service.app/Contents/MacOS/drigad"), notificationDefaultAction, "w1", "local", ""); err != nil { t.Fatal(err) } - if !reflect.DeepEqual(calls, [][]string{{filepath.FromSlash("/Applications/Driga.app/Contents/MacOS/driga"), "app", "--notification-stdin"}}) { + if !reflect.DeepEqual(calls, [][]string{{"open", "-b", "run.driga"}, {filepath.FromSlash("/Applications/Driga.app/Contents/MacOS/driga"), "app", "--notification-stdin"}}) { t.Fatal(calls) } } diff --git a/internal/attention/private_notification_test.go b/internal/attention/private_notification_test.go index 7c812480..3958848c 100644 --- a/internal/attention/private_notification_test.go +++ b/internal/attention/private_notification_test.go @@ -6,26 +6,37 @@ import ( "github.com/labstack/driga/internal/model" "strings" "testing" + "time" ) type privateNotificationRunner struct { + ctx context.Context input []byte args []string } -func (r *privateNotificationRunner) Run(context.Context, string, ...string) ([]byte, error) { - panic("notification used argv transport") +func (r *privateNotificationRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) { + if name != "open" || strings.Join(args, " ") != "-b run.driga" { + panic("notification used argv transport") + } + return nil, nil } -func (r *privateNotificationRunner) RunInput(_ context.Context, input []byte, name string, args ...string) ([]byte, error) { +func (r *privateNotificationRunner) RunInput(ctx context.Context, input []byte, name string, args ...string) ([]byte, error) { + r.ctx = ctx r.input = append([]byte(nil), input...) r.args = append([]string{name}, args...) return nil, nil } func TestNotificationTargetUsesPrivateInput(t *testing.T) { r := &privateNotificationRunner{} - if err := notificationResponse(context.Background(), r, "/app/driga", notificationDefaultAction, "private-item", "private-machine", "machine_health"); err != nil { + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + if err := notificationResponse(ctx, r, "/Applications/Driga.app/Contents/Helpers/Driga Service.app/Contents/MacOS/drigad", notificationDefaultAction, "private-item", "private-machine", "machine_health"); err != nil { t.Fatal(err) } + if r.ctx.Done() != nil { + t.Fatal("notification callback must not own the GUI lifetime") + } if strings.Contains(strings.Join(r.args, " "), "private-") { t.Fatal(r.args) } diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index 9e33ef9c..c8d7588e 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -124,7 +124,7 @@ func TestDoctorMacNotificationsDenied(t *testing.T) { d, _ := doctorFixture(t) d.OS = "darwin" d.NotificationStatus = func() (string, error) { return "", attention.ErrNotificationsDenied } - c := requireCheck(t, Doctor(context.Background(), d), "notifications", "warn", `open "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=run.driga"`) + c := requireCheck(t, Doctor(context.Background(), d), "notifications", "warn", `open "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=run.driga.service"`) if c.Detail != attention.ErrNotificationsDenied.Error() { t.Fatal(c) } diff --git a/internal/self/self.go b/internal/self/self.go index 40315dc6..849c7022 100644 --- a/internal/self/self.go +++ b/internal/self/self.go @@ -37,8 +37,18 @@ func executable(exe string) (string, error) { return cli, nil } -// CLI maps a drigad path to the driga beside it; other paths are unchanged. +// CLI maps drigad to the CLI, in the outer Driga.app or beside an unbundled +// service executable. Other paths are unchanged. func CLI(exe string) string { + // The nested service has its own bundle identity. CLI clients must return + // to the outer bundle rather than looking beside the helper executable. + suffix := filepath.Join("Contents", "Helpers", "Driga Service.app", "Contents", "MacOS", "drigad") + if strings.HasSuffix(exe, string(filepath.Separator)+suffix) { + app := strings.TrimSuffix(exe, string(filepath.Separator)+suffix) + if filepath.Base(app) == "Driga.app" { + return filepath.Join(app, "Contents", "MacOS", "driga") + } + } dir, base := filepath.Split(exe) ext := filepath.Ext(base) if rest, ok := strings.CutPrefix(strings.TrimSuffix(base, ext), "drigad"); ok && (rest == "" || strings.HasPrefix(rest, "-")) { diff --git a/internal/self/self_test.go b/internal/self/self_test.go index 6a8fe99f..1de0fdbb 100644 --- a/internal/self/self_test.go +++ b/internal/self/self_test.go @@ -11,12 +11,13 @@ import ( func TestCLI(t *testing.T) { for in, want := range map[string]string{ - "/Applications/Driga.app/Contents/MacOS/drigad": "/Applications/Driga.app/Contents/MacOS/driga", - "/home/me/.local/libexec/drigad-dev": "/home/me/.local/libexec/driga-dev", - "/opt/drigad.exe": "/opt/driga.exe", - "/opt/driga": "/opt/driga", - "/opt/driga-dev": "/opt/driga-dev", - "/opt/drigadx": "/opt/drigadx", + "/Applications/Driga.app/Contents/Helpers/Driga Service.app/Contents/MacOS/drigad": "/Applications/Driga.app/Contents/MacOS/driga", + "/Applications/Driga.app/Contents/MacOS/drigad": "/Applications/Driga.app/Contents/MacOS/driga", + "/home/me/.local/libexec/drigad-dev": "/home/me/.local/libexec/driga-dev", + "/opt/drigad.exe": "/opt/driga.exe", + "/opt/driga": "/opt/driga", + "/opt/driga-dev": "/opt/driga-dev", + "/opt/drigadx": "/opt/drigadx", } { in, want = filepath.FromSlash(in), filepath.FromSlash(want) if got := CLI(in); got != want { diff --git a/internal/service/bundle.go b/internal/service/bundle.go index ffb54d6e..b5a1071b 100644 --- a/internal/service/bundle.go +++ b/internal/service/bundle.go @@ -7,6 +7,8 @@ import ( "os" "path/filepath" "strings" + + "github.com/labstack/driga/internal/self" ) // BundlePath identifies our CLI's app layout, including when invoked via a @@ -15,6 +17,7 @@ func BundlePath(binPath string) string { if resolved, err := filepath.EvalSymlinks(binPath); err == nil { binPath = resolved } + binPath = self.CLI(binPath) if base := filepath.Base(binPath); base != "driga" && base != "drigad" || filepath.Base(filepath.Dir(binPath)) != "MacOS" { return "" } @@ -35,10 +38,13 @@ func ExecutablePath(binPath string) string { return binPath } -// DaemonPath is the drigad executable that sits beside a driga executable: -// drigad for driga, drigad-dev for driga-dev, drigad.exe for driga.exe. It -// is the same program; started under that name it runs the Driga service. +// DaemonPath selects the nested helper for Driga.app. Outside the bundle, +// drigad sits beside driga (including development variants and Windows). +// Started under that name, the same program runs the Driga service. func DaemonPath(binPath string) string { + if app := BundlePath(binPath); app != "" { + return filepath.Join(app, "Contents", "Helpers", "Driga Service.app", "Contents", "MacOS", "drigad") + } dir, base := filepath.Split(binPath) ext := filepath.Ext(base) name := strings.TrimSuffix(base, ext) @@ -109,7 +115,7 @@ func PlistStale(path, binPath string) (bool, error) { } if key == "AssociatedBundleIdentifiers" { for _, id := range array.Strings { - if id == "run.driga" { + if id == "run.driga.service" { associated = true } } diff --git a/internal/service/bundle_test.go b/internal/service/bundle_test.go index 07203a1a..225fe1f5 100644 --- a/internal/service/bundle_test.go +++ b/internal/service/bundle_test.go @@ -33,8 +33,9 @@ func TestLaunchdPlistBundleAndDevelopment(t *testing.T) { bundle bool }{ {"development", "/opt/dev & tools/driga", "/opt/dev & tools/drigad", false}, - {"bundle", bundled, DaemonPath(bundled), true}, - {"Homebrew symlink", link, DaemonPath(bundled), true}, + {"bundle", bundled, filepath.Join(root, "Driga.app", "Contents", "Helpers", "Driga Service.app", "Contents", "MacOS", "drigad"), true}, + {"Homebrew symlink", link, filepath.Join(root, "Driga.app", "Contents", "Helpers", "Driga Service.app", "Contents", "MacOS", "drigad"), true}, + {"helper executable", filepath.Join(root, "Driga.app", "Contents", "Helpers", "Driga Service.app", "Contents", "MacOS", "drigad"), filepath.Join(root, "Driga.app", "Contents", "Helpers", "Driga Service.app", "Contents", "MacOS", "drigad"), true}, } { t.Run(tc.name, func(t *testing.T) { c := configForOS(tc.path, "darwin") @@ -65,7 +66,7 @@ func TestLaunchdPlistBundleAndDevelopment(t *testing.T) { if associated != tc.bundle { t.Fatalf("association: %s", body) } - if tc.bundle && (len(arrays[0].Strings) != 1 || arrays[0].Strings[0] != "run.driga") { + if tc.bundle && (len(arrays[0].Strings) != 1 || arrays[0].Strings[0] != "run.driga.service") { t.Fatalf("association: %s", body) } plistPath := filepath.Join(t.TempDir(), "collector.plist") diff --git a/internal/service/launchd_darwin.go b/internal/service/launchd_darwin.go index 038c8bf3..cc1a31ee 100644 --- a/internal/service/launchd_darwin.go +++ b/internal/service/launchd_darwin.go @@ -53,8 +53,10 @@ func (s *launchdController) Start() error { func (s *launchdController) Install() error { if app := BundlePath(s.binPath); app != "" { - if err := s.register(app); err != nil { - return err + for _, bundle := range []string{filepath.Join(app, "Contents", "Helpers", "Driga Service.app"), app} { + if err := s.register(bundle); err != nil { + return err + } } } _, err := os.Stat(s.plistPath) diff --git a/internal/service/launchd_darwin_test.go b/internal/service/launchd_darwin_test.go index 05e0a53e..a1c2e49d 100644 --- a/internal/service/launchd_darwin_test.go +++ b/internal/service/launchd_darwin_test.go @@ -27,10 +27,11 @@ func TestLaunchdInstallRepairsAndIsIdempotent(t *testing.T) { root := t.TempDir() bin := filepath.Join(root, "Driga.app", "Contents", "MacOS", "driga") path := filepath.Join(root, "collector.plist") - old, err := renderLaunchd(configForOS("/opt/homebrew/bin/driga", "darwin")) + old, err := renderLaunchd(configForOS(bin, "darwin")) if err != nil { t.Fatal(err) } + old = []byte(strings.ReplaceAll(strings.ReplaceAll(string(old), "Contents/Helpers/Driga Service.app/Contents/MacOS/drigad", "Contents/MacOS/drigad"), "run.driga.service", "run.driga")) if err := os.WriteFile(path, old, 0o600); err != nil { t.Fatal(err) } @@ -38,7 +39,7 @@ func TestLaunchdInstallRepairsAndIsIdempotent(t *testing.T) { registrations := 0 s := &launchdController{Controller: controller, binPath: bin, plistPath: path, register: func(app string) error { - if app != filepath.Join(root, "Driga.app") { + if app != filepath.Join(root, "Driga.app") && app != filepath.Join(root, "Driga.app", "Contents", "Helpers", "Driga Service.app") { t.Fatal(app) } registrations++ @@ -75,11 +76,11 @@ func TestLaunchdInstallRepairsAndIsIdempotent(t *testing.T) { if err != nil { t.Fatal(err) } - if string(before) != string(after) || controller.stops != 1 || registrations != 2 { + if string(before) != string(after) || controller.stops != 1 || registrations != 4 { t.Fatal("reinstallation changed a current service") } // Missing association is repairable even when ProgramArguments is current. - withoutAssociation := strings.Replace(string(after), "AssociatedBundleIdentifiersrun.driga", "", 1) + withoutAssociation := strings.Replace(string(after), "AssociatedBundleIdentifiersrun.driga.service", "", 1) if err := os.WriteFile(path, []byte(withoutAssociation), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/service/service.go b/internal/service/service.go index a88fb5ac..fb832266 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -80,7 +80,7 @@ func configForOS(binPath, goos string) *ks.Config { if goos == "darwin" { binPath = ExecutablePath(binPath) if BundlePath(binPath) != "" { - association = "AssociatedBundleIdentifiersrun.driga" + association = "AssociatedBundleIdentifiersrun.driga.service" } } template = strings.ReplaceAll(template, "BUNDLE_ASSOCIATION", association) diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index ce4df6d9..408d7cf3 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -10,9 +10,6 @@ snapshot=${4:?snapshot flag required} contents=$(dirname "$(dirname "$binary")") app=$(dirname "$contents") build_dir=$(dirname "$app") -# drigad, the Driga service, is the same program under the name it runs as -# the service by. It sits beside driga so launchd attributes it to Driga.app. -cp "$binary" "$contents/MacOS/drigad" python3 scripts/macos-app.py "$app" "$version" if [ "$snapshot" != true ]; then @@ -25,10 +22,10 @@ if [ "$snapshot" != true ]; then # Credentials go to notarytool directly: a keychain profile stored in the # temporary keychain is not found by later notarytool calls on runners. notary=(--key "$DRIGA_NOTARY_KEY" --key-id "$DRIGA_NOTARY_KEY_ID" --issuer "$DRIGA_NOTARY_ISSUER") - # drigad is a second executable, signed first; signing the outer bundle - # then signs its main executable and seals resources. + # Sign inside out: the helper bundle (including drigad), then the GUI. + # Neither Go executable needs sandbox, JIT, or library-validation exceptions. codesign --force --sign "$DRIGA_SIGNING_IDENTITY" --keychain "$DRIGA_SIGNING_KEYCHAIN" \ - --options runtime --timestamp "$contents/MacOS/drigad" + --options runtime --timestamp "$contents/Helpers/Driga Service.app" codesign --force --sign "$DRIGA_SIGNING_IDENTITY" --keychain "$DRIGA_SIGNING_KEYCHAIN" \ --options runtime --timestamp "$app" codesign --verify --deep --strict --verbose=2 "$app" @@ -57,5 +54,6 @@ fi # bundle (including the staple) outside the sealed app, preserving its layout. resources="$build_dir/bundle-resources" mkdir -p "$resources" +rm -rf "$resources/Contents" cp -R "$app/Contents" "$resources/Contents" rm "$resources/Contents/MacOS/driga" diff --git a/scripts/macos-app.py b/scripts/macos-app.py index 1d2cc1b6..8ca1e907 100755 --- a/scripts/macos-app.py +++ b/scripts/macos-app.py @@ -10,9 +10,8 @@ def assemble(app, version): contents = app / "Contents" - for name in ("driga", "drigad"): - if not (contents / "MacOS" / name).is_file(): - raise ValueError(f"bundle executable {name} is missing") + if not (contents / "MacOS" / "driga").is_file(): + raise ValueError("bundle executable driga is missing") # CFBundleVersion is numeric; the user-facing version retains the alpha or # snapshot suffix. CalVer's three numeric components come from the tag. match = re.fullmatch(r"(\d+\.\d+\.\d+)(?:-[A-Za-z0-9.-]+)?", version) @@ -39,6 +38,21 @@ def assemble(app, version): "LSEnvironment": {"DRIGA_APP": "1"}, "NSHighResolutionCapable": True, } + # A launchd agent belongs in Helpers; LoginItems is for SMAppService. + # Never leave a service executable in the GUI bundle's MacOS directory. + helper = contents / "Helpers" / "Driga Service.app" / "Contents" + (helper / "MacOS").mkdir(parents=True, exist_ok=True) + (helper / "Resources").mkdir(parents=True, exist_ok=True) + shutil.copy2(contents / "MacOS" / "driga", helper / "MacOS" / "drigad") + (contents / "MacOS" / "drigad").unlink(missing_ok=True) + shutil.copyfile(icon, helper / "Resources" / icon.name) + helper_info = dict(info) + helper_info.update(CFBundleIdentifier="run.driga.service", + CFBundleName="Driga Service", CFBundleDisplayName="Driga", + CFBundleExecutable="drigad", LSBackgroundOnly=True) + del helper_info["LSEnvironment"] + with (helper / "Info.plist").open("wb") as output: + plistlib.dump(helper_info, output, sort_keys=True) with (contents / "Info.plist").open("wb") as output: plistlib.dump(info, output, sort_keys=True) diff --git a/scripts/macos_app_test.go b/scripts/macos_app_test.go index 5d2abe2f..dfbfbe54 100644 --- a/scripts/macos_app_test.go +++ b/scripts/macos_app_test.go @@ -2,12 +2,15 @@ package scripts_test import ( "encoding/json" + "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" + + "go.yaml.in/yaml/v3" ) func TestMacOSAppBuildHook(t *testing.T) { @@ -82,6 +85,11 @@ esac if !strings.Contains(calls, "--options runtime --timestamp") || !strings.Contains(calls, "notarytool submit") || !strings.Contains(calls, "--key-id KEYID") || strings.Contains(calls, "keychain-profile") { t.Fatal(calls) } + helperSign := strings.Index(calls, "--options runtime --timestamp "+filepath.Join(root, "target", "Driga.app", "Contents", "Helpers", "Driga Service.app")+"\n") + outerSign := strings.Index(calls, "--options runtime --timestamp "+filepath.Join(root, "target", "Driga.app")+"\n") + if helperSign < 0 || outerSign <= helperSign { + t.Fatal("helper must be signed with hardened runtime before outer app:", calls) + } staple := strings.Contains(calls, "stapler staple") if staple != test.succeeds { t.Fatalf("staple on %s: %s", test.status, calls) @@ -169,3 +177,103 @@ func TestMacOSAppPlistLaunchesInAppMode(t *testing.T) { t.Errorf("NSHighResolutionCapable = %v, want true", info["NSHighResolutionCapable"]) } } + +func TestMacOSHelperPlist(t *testing.T) { + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 unavailable") + } + app := filepath.Join(t.TempDir(), "Driga.app") + bin := filepath.Join(app, "Contents", "MacOS", "driga") + if err := os.MkdirAll(filepath.Dir(bin), 0700); err != nil { + t.Fatal(err) + } + writeFixtureFile(t, bin, "fixture", 0700) + cmd := exec.CommandContext(t.Context(), "python3", "macos-app.py", app, "2026.10.23-alpha") + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %s", err, out) + } + check := `import pathlib,plistlib,sys +app=pathlib.Path(sys.argv[1])/"Contents" +h=app/"Helpers/Driga Service.app/Contents" +i=plistlib.loads((h/"Info.plist").read_bytes()) +assert i["CFBundleIdentifier"] == "run.driga.service" +assert i["CFBundleExecutable"] == "drigad" +assert i["CFBundlePackageType"] == "APPL" +assert i["LSBackgroundOnly"] is True +assert "LSEnvironment" not in i +assert i["CFBundleVersion"] == "2026.10.23" +assert i["DrigaReleaseVersion"] == "2026.10.23-alpha" +assert (h/"Resources"/i["CFBundleIconFile"]).read_bytes() == (app/"Resources/Driga.icns").read_bytes() +assert (h/"MacOS/drigad").read_bytes() == (app/"MacOS/driga").read_bytes() +assert (h/"MacOS/drigad").stat().st_mode & 0o111 +assert not (app/"MacOS/drigad").exists() +` + cmd = exec.CommandContext(t.Context(), "python3", "-c", check, app) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%v: %s", err, out) + } +} + +func TestMacOSReleaseVerificationOrder(t *testing.T) { + data, err := os.ReadFile("verify-release-dist.sh") + if err != nil { + t.Fatal(err) + } + s := string(data) + helper := strings.Index(s, `codesign --verify --strict --verbose=2 "$app_temp/Driga.app/Contents/Helpers/Driga Service.app"`) + outer := strings.Index(s, `codesign --verify --deep --strict --verbose=2 "$app_temp/Driga.app"`) + if helper < 0 || outer <= helper { + t.Fatal("verify nested bundle before outer seal") + } +} + +// Execute the generated Ruby block with a fake cask DSL. No installation or +// launchctl is reachable; only an existing service should trigger migration. +func TestMacOSCaskRepairsExistingServiceOnly(t *testing.T) { + if _, err := exec.LookPath("ruby"); err != nil { + t.Skip("ruby unavailable") + } + var config struct { + Casks []struct { + Block string `yaml:"custom_block"` + } `yaml:"homebrew_casks"` + } + if err := yaml.Unmarshal([]byte(readRepositoryFile(t, "../.goreleaser.yaml")), &config); err != nil { + t.Fatal(err) + } + if len(config.Casks) != 1 { + t.Fatal("expected one cask") + } + for _, existing := range []bool{false, true} { + setup := ` +$calls = [] +def app(name); end +def appdir; "/Applications"; end +def postflight; yield; end +def system_command(name, **opts) + $calls << [name, opts] + Struct.new(:stdout).new('{"EnvironmentVariables":{"DRIGA_DATA_DIR":"/custom/data","PATH":"/custom/bin:/usr/bin"}}') +end +class File + def self.exist?(path) + raise "wrong migration guard" unless path.end_with?("/Library/LaunchAgents/run.driga.service.plist") + ENV["EXISTING_SERVICE"] == "true" + end +end +` + check := ` +if ENV["EXISTING_SERVICE"] == "true" + raise "plist not read" unless $calls.length == 2 && $calls[0] == ["/usr/bin/plutil", {args: ["-convert", "json", "-o", "-", File.expand_path("~/Library/LaunchAgents/run.driga.service.plist")], must_succeed: true}] + expected = ["/Applications/Driga.app/Contents/MacOS/driga", {args: ["service", "install"], env: {"DRIGA_DATA_DIR" => "/custom/data", "PATH" => "/custom/bin:/usr/bin"}, must_succeed: true}] + raise "environment or command changed: #{$calls}" unless $calls[1] == expected +else + raise "fresh install started service" unless $calls.empty? +end +` + cmd := exec.CommandContext(t.Context(), "ruby", "-e", setup+config.Casks[0].Block+check) + cmd.Env = append(releaseTestEnv(t), "EXISTING_SERVICE="+fmt.Sprint(existing)) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("existing=%v: %v: %s", existing, err, out) + } + } +} diff --git a/scripts/verify-macos-app.py b/scripts/verify-macos-app.py index a1fe4753..ea06966f 100755 --- a/scripts/verify-macos-app.py +++ b/scripts/verify-macos-app.py @@ -50,6 +50,7 @@ def verify_icns(data): def verify_archive(path, version): + helper = "Driga.app/Contents/Helpers/Driga Service.app/Contents" with tarfile.open(path, "r:gz") as archive: members = archive.getmembers() names = [entry.name for entry in members if not entry.isdir()] @@ -57,9 +58,9 @@ def verify_archive(path, version): require(all(entry.isfile() or entry.isdir() for entry in members), "unexpected archive link or special file") required = {"LICENSE", "NOTICE", "README.md", "third-party/fonts-LICENSE.txt", "third-party/app-THIRD-PARTY.txt", "third-party/panels-THIRD-PARTY.txt", "Driga.app/Contents/Info.plist", - "Driga.app/Contents/MacOS/driga", "Driga.app/Contents/MacOS/drigad", "Driga.app/Contents/Resources/Driga.icns"} + "Driga.app/Contents/MacOS/driga", f"{helper}/MacOS/drigad", f"{helper}/Info.plist", f"{helper}/Resources/Driga.icns", "Driga.app/Contents/Resources/Driga.icns"} # CodeResources is Apple's app staple; the resource seal is separate. - allowed = required | {"Driga.app/Contents/_CodeSignature/CodeResources", "Driga.app/Contents/CodeResources"} + allowed = required | {"Driga.app/Contents/_CodeSignature/CodeResources", "Driga.app/Contents/CodeResources", f"{helper}/_CodeSignature/CodeResources", f"{helper}/CodeResources"} require(required <= set(names) <= allowed, "unexpected contents in Darwin archive") directories = {str(parent) for name in allowed for parent in Path(name).parents if str(parent) != "."} require(all(entry.name.rstrip("/") in directories for entry in members if entry.isdir()), "unexpected archive directory") @@ -76,10 +77,19 @@ def read(name): "NSHighResolutionCapable": True}.items(): require(info.get(key) == value, f"incorrect {key} in Info.plist") require("LSUIElement" not in info, "Info.plist must not set LSUIElement") - for name in ("driga", "drigad"): - binary = archive.getmember(f"Driga.app/Contents/MacOS/{name}") + helper_info = plistlib.loads(read(f"{helper}/Info.plist")) + expected = dict(info) + expected.update(CFBundleIdentifier="run.driga.service", CFBundleName="Driga Service", + CFBundleDisplayName="Driga", CFBundleExecutable="drigad", LSBackgroundOnly=True) + del expected["LSEnvironment"] + require(helper_info == expected, "incorrect helper Info.plist") + require("LSBackgroundOnly" not in info, "GUI must allow windows") + for name in ("Driga.app/Contents/MacOS/driga", f"{helper}/MacOS/drigad"): + binary = archive.getmember(name) require(binary.mode & 0o111 and binary.size > 0, f"bundle executable {name} is empty or not executable") - verify_icns(read("Driga.app/Contents/Resources/Driga.icns")) + icon = read("Driga.app/Contents/Resources/Driga.icns") + verify_icns(icon) + require(read(f"{helper}/Resources/Driga.icns") == icon, "helper branding differs from GUI") if __name__ == "__main__": diff --git a/scripts/verify-release-dist.sh b/scripts/verify-release-dist.sh index 2eb79f0d..a5668717 100755 --- a/scripts/verify-release-dist.sh +++ b/scripts/verify-release-dist.sh @@ -72,6 +72,8 @@ for arch in amd64 arm64; do app_temp=$(mktemp -d) trap 'rm -rf "$app_temp"' EXIT tar -xzf "$archive" -C "$app_temp" Driga.app + # Verify the nested service before the outer resource seal. + codesign --verify --strict --verbose=2 "$app_temp/Driga.app/Contents/Helpers/Driga Service.app" codesign --verify --deep --strict --verbose=2 "$app_temp/Driga.app" xcrun stapler validate "$app_temp/Driga.app" spctl --assess --type execute --verbose=2 "$app_temp/Driga.app" @@ -162,8 +164,15 @@ if ! grep -Fqx " version \"${release_version}\"" "$cask"; then echo "Homebrew Cask version does not match ${release_version}." >&2 exit 1 fi -if grep -Eq 'service install|caskroom_path' "$cask"; then - echo "Homebrew Cask still references Caskroom paths or driga service install." >&2 +if grep -Fq 'caskroom_path' "$cask"; then + echo "Homebrew Cask still references Caskroom paths." >&2 + exit 1 +fi +if ! grep -Fq 'if File.exist?(File.expand_path("~/Library/LaunchAgents/run.driga.service.plist"))' "$cask" || + ! grep -Fq 'system_command "#{appdir}/Driga.app/Contents/MacOS/driga",' "$cask" || + ! grep -Fq 'args: ["service", "install"],' "$cask" || + ! grep -Fq 'env: JSON.parse(previous.stdout).fetch("EnvironmentVariables", {}),' "$cask"; then + echo "Homebrew Cask is missing the existing-service upgrade repair." >&2 exit 1 fi if grep -Eq '^[[:space:]]+verified:' "$cask"; then diff --git a/scripts/verify_release_dist_test.go b/scripts/verify_release_dist_test.go index bc523b20..6a656cf6 100644 --- a/scripts/verify_release_dist_test.go +++ b/scripts/verify_release_dist_test.go @@ -150,6 +150,18 @@ func releaseDistFixture(t *testing.T, version, tag string) (string, string) { end app "Driga.app" + require "json" + postflight do + if File.exist?(File.expand_path("~/Library/LaunchAgents/run.driga.service.plist")) + previous = system_command "/usr/bin/plutil", + args: ["-convert", "json", "-o", "-", File.expand_path("~/Library/LaunchAgents/run.driga.service.plist")], + must_succeed: true + system_command "#{appdir}/Driga.app/Contents/MacOS/driga", + args: ["service", "install"], + env: JSON.parse(previous.stdout).fetch("EnvironmentVariables", {}), + must_succeed: true + end + end binary "#{appdir}/Driga.app/Contents/MacOS/driga" end `, version, hashes[fmt.Sprintf("driga_%s_darwin_amd64.tar.gz", version)], hashes[fmt.Sprintf("driga_%s_darwin_arm64.tar.gz", version)]) @@ -235,13 +247,13 @@ func writeTarGzipFixture(t *testing.T, path, version string) { bundled = append(bundled, entry) } } - for _, name := range []string{"Info.plist", "Resources/Driga.icns", "MacOS/driga", "MacOS/drigad"} { + for _, name := range []string{"Info.plist", "Resources/Driga.icns", "MacOS/driga", "Helpers/Driga Service.app/Contents/MacOS/drigad", "Helpers/Driga Service.app/Contents/Info.plist", "Helpers/Driga Service.app/Contents/Resources/Driga.icns"} { body, err := os.ReadFile(filepath.Join(app, "Contents", filepath.FromSlash(name))) if err != nil { t.Fatal(err) } mode := int64(0o644) - if strings.HasPrefix(name, "MacOS/") { + if strings.Contains(name, "MacOS/") { mode = 0o755 } bundled = append(bundled, struct { diff --git a/site/src/content/docs/guides/notifications.mdx b/site/src/content/docs/guides/notifications.mdx index d8332364..f9d828d2 100644 --- a/site/src/content/docs/guides/notifications.mdx +++ b/site/src/content/docs/guides/notifications.mdx @@ -30,11 +30,13 @@ provides: brew install --cask labstack/tap/driga ``` -They use Driga's name and icon through the native notification API; no helper -is needed. Builds run from a checkout or `go run` cannot send macOS +They use Driga's name and icon through the native notification API from the +bundled Driga Service helper (`run.driga.service`). Builds run from a checkout or `go run` cannot send macOS notifications, and `driga doctor` explains how to install the app. -macOS asks once whether Driga may send notifications. If the prompt was +macOS asks once whether Driga may send notifications. After upgrading from +the service that shared the GUI identity, macOS asks once more for the new +service identity; the old permission cannot transfer. If the prompt was declined, turn on **Allow notifications** in **System Settings → Notifications → Driga**. `driga doctor` warns when notifications are off and prints the command that From eea1c71390a6005780630202413d9902e5f2df7d Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Fri, 30 Oct 2026 10:28:27 -0700 Subject: [PATCH 2/2] test(macos): skip the execute-bit check for the helper on Windows Windows file systems have no execute permission bits, so the helper plist test failed on the Windows CI job at the st_mode assertion. --- scripts/macos_app_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/macos_app_test.go b/scripts/macos_app_test.go index dfbfbe54..8f13f789 100644 --- a/scripts/macos_app_test.go +++ b/scripts/macos_app_test.go @@ -205,7 +205,7 @@ assert i["CFBundleVersion"] == "2026.10.23" assert i["DrigaReleaseVersion"] == "2026.10.23-alpha" assert (h/"Resources"/i["CFBundleIconFile"]).read_bytes() == (app/"Resources/Driga.icns").read_bytes() assert (h/"MacOS/drigad").read_bytes() == (app/"MacOS/driga").read_bytes() -assert (h/"MacOS/drigad").stat().st_mode & 0o111 +assert sys.platform == "win32" or (h/"MacOS/drigad").stat().st_mode & 0o111 # Windows has no execute bits assert not (app/"MacOS/drigad").exists() ` cmd = exec.CommandContext(t.Context(), "python3", "-c", check, app)