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
5 changes: 5 additions & 0 deletions .github/workflows/macos-signing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 20 additions & 5 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}"
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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 }}"
Expand Down
24 changes: 20 additions & 4 deletions cmd/driga/main_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
}
}
Expand All @@ -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
}
14 changes: 14 additions & 0 deletions cmd/driga/reexec_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
37 changes: 28 additions & 9 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,14 @@ driga service uninstall
- **Windows:** Task Scheduler lists **Driga service** under
`\Driga\<user SID>`. 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:
Expand Down
32 changes: 21 additions & 11 deletions internal/attention/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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))
}
15 changes: 9 additions & 6 deletions internal/attention/notifications_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions internal/attention/notifications_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,15 +186,15 @@ 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)
}
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)
}
}
Expand Down Expand Up @@ -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)
}
}
Expand Down
19 changes: 15 additions & 4 deletions internal/attention/private_notification_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading