macOS notification authorization
Which macOS mechanism decides notification authorization, why a helper must carry the app signing identity to read it, and what is measured versus still assumed.
getPermissionInfo('notifications') reports the real macOS authorization, read
through today-notification-agent — a helper in Contents/MacOS signed under
the app's own signing identifier.
That signing detail is the whole feature. This page records why, what the measurements do not establish, and which obvious generalizations are wrong, so the next change here does not repeat the sequence.
The mechanism
Notification authorization is decided by the code signing identity of the
calling process. It is not decided by Bundle.main.bundleIdentifier.
A helper placed in Contents/MacOS resolves Bundle.main.bundleIdentifier to
the app's identifier — and still reads its own, unrelated, empty authorization,
because it is signed under its own identifier.
Three mechanisms, three different inputs
This is the trap. The repo already ships helpers that successfully inherit capabilities from the enclosing app, which makes generalization tempting. Each keys on something different:
| Mechanism | Decided by | Bare helper in Contents/MacOS |
|---|---|---|
| SMAppService | the main bundle's directory | Works — today-power-admin relies on it |
| TCC | code signature and responsible process | Works — inherits the outer app |
| UserNotifications | the calling process's signing identity | Reads the wrong authorization |
Do not extrapolate from the first two to the third.
What was measured
Measured 2026-08-15 against a signed dev build (ai.today.macos.app.canary),
with System Settings showing notifications allowed and alert, badge and sound
all enabled.
Location alone does not fix it
| Probe location | Bundle.main.bundleIdentifier | Authorization |
|---|---|---|
| Outside any bundle | null | terminates early |
Contents/Resources | null | terminates early |
Contents/MacOS | ai.today.macos.app.canary | notDetermined, all settings notSupported |
The third row resolves the identifier and still disagrees with System Settings. Making the app deliver a real notification did not change it.
Signing identity does fix it
Re-signing the same binary at the same path under the app's identifier:
| Signing identifier | Read | requestAuthorization |
|---|---|---|
today-notification-probe | notDetermined, settings notSupported | granted:false, UNErrorCodeNotificationsNotAllowed |
ai.today.macos.app.canary | authorized, settings enabled | granted:true, no error |
The first row's error is an identity rejection, not a state result. Under the app's identity the call is accepted.
What these measurements do not establish
Being explicit here, because the first version of this page overreached on every one of these.
"Only the identifier changed." Re-signing also rewrites the CodeDirectory, the CDHash and the default designated requirement. The experiment shows the signing identity package decides the outcome; it does not isolate the identifier string as the sole input.
A prompt appearing from notDetermined. The successful
requestAuthorization ran against an already-authorized state, which returns
immediately. Whether a same-identity bare helper can actually present the system
prompt is untested. It never became load-bearing: Electron prompts on its
own, confirmed by resetting authorization and watching a fresh launch prompt
with nothing in this repo asking, after which the agent read denied.
A correctly signed bundle. The experiment re-signed the nested helper
after the outer app was signed, which is the wrong order — nested code must be
signed inside-out. The resulting copy fails codesign --verify --deep --strict
with nested code is modified or invalid. The read result stands, but a bundle
signed in the correct order has not been produced.
A helper being ruled out. An earlier revision of this page claimed a helper could not work "wherever it sits". That contradicts the table above. What is ruled out is a helper signed under its own identifier.
Electron uses UNUserNotificationCenter
Electron Framework links both NSUserNotification and
UNUserNotificationCenter, which makes the delivery path ambiguous from the
outside. The selectors settle it — these are present:
requestAuthorizationWithOptions:completionHandler:
addNotificationRequest:withCompletionHandler:
currentNotificationCenterand the legacy delivery selectors are not.
Electron requests authorization itself, which is why the app reads authorized
even though nothing in our code ever asked. This happens when the notification
presenter initializes rather than strictly on first delivery, so notDetermined
is largely a startup-transient state in practice — which is what makes the
untested prompt path less load-bearing than it first appears.
Why not the alternatives
A bare helper under its own identifier — ruled out by the mechanism. It reads its own empty authorization no matter where it sits in the bundle.
A native addon in the Electron main process — the main process holds the
app's identity by construction, so it needs no identity trick. Rejected on cost:
the repo has full precedent for native macOS artifacts, universal builds and
signing, but none for a first-party .node module, and an addon fault takes
down the main process. Worth revisiting if the identity sharing below ever
becomes a problem.
Notarization does not distinguish these: desktop-pr-packages.yml runs
notarytool --wait and spctl for each opt-in PR package build, so a signing
arrangement Apple rejects fails in CI rather than at release.
The packaging constraints
Three of them, each of which silently restores the original bug if broken.
Sign the agent before the outer app. Nested code is signed inside-out.
Signing it afterwards leaves the bundle failing codesign --verify --deep --strict with nested code is modified or invalid. after-pack.mjs runs
before electron-builder signs anything, which is why it lives there.
Keep it out of binaries and inside signIgnore. Either path re-signs it
under the default identifier — derived from the filename — and the agent goes
back to reading its own empty authorization. Nothing fails loudly; the state
just silently reads notDetermined forever.
Verify on a clean output directory. --dir incremental packaging can leave
Electron Framework dylibs signed after the framework's own seal is computed,
which fails verification for reasons unrelated to the agent. CI checks out
fresh, so this only bites local runs.
Security cost of sharing the app's identity
Same identifier means the same default designated requirement. What actually changes:
- The helper matches the app's UserNotifications record. This is the goal.
- Legacy Keychain ACLs keyed on the designated requirement may treat the helper as the app.
- Anything validating "is this the main app" through
SecCodeor a code requirement may accept the helper. - Some TCC services match on the calling code requirement; whether a given service is affected depends on the service and responsible-process attribution.
What it does not grant: sandbox container, App Group, Keychain access group, APNs topic, entitlements, elevated Unix privileges, or LaunchServices identity.
So it is a genuine reduction in process-level least privilege, but not an external privilege escalation: helper and app are signed together by the same publisher and sealed in one bundle. The agent is kept input-free, short-lived and free of IPC to hold that line.
What the contract exposes
notifications reports Granted, Denied, NotDetermined or Limited from
the agent, and falls back to Unknown whenever the agent is missing, times out
or returns something unexpected — guessing would either make the settings page
lie or send the user to fix a setting that is fine.
It offers open-settings and deliberately not request. Electron already calls
requestAuthorization when its notification presenter initializes, which is why
a fresh install prompts without anything in this repo asking. A same-identity
helper's requestAuthorization is accepted rather than rejected, but whether a
bare helper can actually present the prompt is untested, and there is no reason
to find out while Electron handles it.
Denied is the case that motivated all of this: the user had turned notifications off and the client could not tell. The settings toggle now reflects it and opens the app's own pane in System Settings rather than only saying it is blocked.
Clearing the badge through the same helper
The agent has a second, argument-selected mode: --clear-badge calls
UNUserNotificationCenter.setBadgeCount(0) and prints {"badge":"cleared"}
(or failed with a reason, noBundleIdentifier, timeout, each with a
non-zero exit). No arguments keeps the authorization read; anything else prints
{"error":"unknownArgument"}.
It exists because the Dock badge has two stores. Electron's
app.setBadgeCount(0) only writes NSDockTile.badgeLabel. A badge carried by
an APNs aps.badge or a UNNotificationContent.badge is recorded by the
notification system under the signing identity, and nothing the Electron main
process can call clears it — while both apps share a bundle identifier (see
below), the native app's notifications land there too, and the count survives
every activation of the Electron app. setBadgeCount(0) is the only API that
clears it, and it has the same signing-identity requirement as the
authorization read, so the agent is the one process already positioned to call
it.
The Node adapter's badge host invokes it, through ClientNodeShellFacade .clearSystemBadge(), every time it writes zero to the dock tile; concurrent
zero writes are coalesced into one running agent plus at most one follow-up.
Unpackaged builds skip the call: a bare agent has no bundle identity, and there
is no notification-system badge to clear in that setup.
A consequence of the shared bundle identifier
The Electron build and the native macOS app share a bundle identifier by design
(build-environment.mjs). LaunchServices resolves that identifier to one app,
so while both are installed the authorization prompt and the System Settings
entry can carry the other app's name and icon. This resolves once the transition
leaves one app installed.
Cross-platform headers
How web and Electron identify Linux, macOS, and Windows requests with X-Client-Platform and X-App-Version, including the Linux cloud rollout boundary.
macOS screen recording helper
Why GhostOS runs in a separately identified Today Helper app installed outside the bundle, how screen recording and accessibility grants are attributed, and what is measured versus assumed.