Troubleshooting¶
Common symptoms and how to fix them. Each section names a
specific observable problem (roar send exits cleanly but no
notification; click handler does nothing; schedule never fires)
and walks through the diagnostics. In most cases the fix is at
the OS layer — System Settings, Focus rules, provisional auth
state — but the diagnostic commands here will point you at
whether the problem is in Roar or in the OS configuration.
"I ran roar send and nothing happened"¶
The most common cause is provisional authorization: the first time Roar runs, macOS grants it provisional auth silently. The trade-off is that notifications post quietly to Notification Center — no banner, no sound.
Diagnose:
If the output includes authorization-status: provisional,
notifications are being delivered, just quietly. Open
Notification Center (click the clock at the top-right, or
swipe-from-the-right) and you'll see them.
To promote to full authorization:
- Tap Keep on the next notification Roar delivers, or
- System Settings → Notifications → Roar → toggle Allow Notifications on, then choose Banner or Alert.
After promotion, roar settings reports
authorization-status: authorized and subsequent sends have
banner+sound affordances.
"authorization-status: denied"¶
You (or somebody) said No to the notification permission dialog. Re-enable it:
- System Settings → Notifications.
- Scroll to Roar.
- Toggle Allow Notifications on.
- Choose Banner or Alert, set sound preferences.
Roar exits with code 1 and prints to stderr in this state, so
scripts can branch on it.
"I get authorization-status: not-determined every run"¶
If you installed via Homebrew (
brew install --cask dalemyers/tap/roar), this is handled for you automatically. The cask dropsRoar.appinto/Applicationsand symlinks the binary onto your Homebrew bin path, which is exactly the layout LaunchServices needs — no manuallsregisterstep required. Skip the rest of this section.
Roar requests authorization on every cold start. If you keep seeing
not-determined, the request is failing — usually because the
bundle id isn't registered with LaunchServices. This only applies
to manual installs; lsregister lives inside the CoreServices
framework, so use the full path or alias it onto your PATH:
/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f /Applications/Roar.app
If Roar is run from somewhere other than /Applications, every
relaunch will potentially re-register and confuse the permission
record. Install the .app into /Applications and ln -s the
binary onto your PATH (or just use the Homebrew cask, which
does both for you).
"I see the notification but it doesn't play a sound"¶
Check the cause in order:
- Did you pass
--sound? Roar doesn't play a sound by default. - Provisional auth?
roar settings→authorization-status: provisional. Provisional mode silently strips sound; promote (see above). - Focus mode? Open Control Center, check the Focus toggle. Time-sensitive sends bypass it; passive/active do not.
- Sound name typo?
UNNotificationSound(named:)falls back silently to no sound. Roar's--soundvalidator probes/System/Library/Soundsand/Library/Soundsfor an.aiffor.caffile with that name; unknown names are rejected. Usedefaultfor the default notification sound: - Custom sound file? macOS only resolves sounds by name from
system directories.
~/Library/Sounds/Foo.aiffexists on disk but the framework won't find it. Use--sound default, install the sound to/Library/Sounds, or pre-play withafplayvia--exec.
"--interruption-level time-sensitive doesn't break through Focus"¶
Roar can request a time-sensitive interruption level, but the user has to permit it in Focus configuration:
- System Settings → Focus → (the active Focus).
- Allowed Notifications → Apps.
- Add Roar, then toggle Time Sensitive Notifications on for Roar within that Focus.
Without that, time-sensitive gets demoted to active and respects
the Focus mute rules like any normal notification.
The .critical level is gated behind an Apple-granted entitlement
(com.apple.developer.usernotifications.critical-alerts); an
ad-hoc-signed CLI cannot claim it. There's no flag for it.
(The similarly-named com.apple.developer.usernotifications.time-sensitive
entitlement gates time-sensitive interruptions; --interruption-level
time-sensitive works without it, because macOS lets unentitled
apps request the level — it just defers to the user's Focus
configuration for the actual break-through.)
"My --at schedule fired immediately"¶
Most likely the timestamp is in the past. Roar rejects past timestamps at parse time, but only if the year/month/day are absolute. Common gotchas:
- Wrong year.
--at "2024-12-31"in 2026 is past. Roar prints: - Local-time confusion.
--at "2026-12-31 09:00"is interpreted in the system's timezone at parse time. If you copy-paste a UTC timestamp into the local-time form, the schedule fires earlier or later than you expect. Use the ISO 8601 form (...Zor...+01:00) to be explicit. - Less than 1 second in the future. Roar's minimum schedule interval is 1s. Anything closer is rejected:
"My --in value was rejected"¶
Grammar is strict: <number><unit> where unit is s / m / h /
d. Floats are allowed (1.5h). Rejected shapes:
- Hex floats (
0x1p3s) - Scientific notation (
1e10s) - Signed (
+30s) - NaN / Inf literals
- Bare numbers (
30)
Min 1s, max 365d. The error message says exactly which constraint fired.
"--repeat monthly:31:.. doesn't fire some months"¶
macOS silently skips months without that day-of-month — February,
April, June, September, November don't have a 31st. The recurring
trigger uses the framework's UNCalendarNotificationTrigger, which
matches "the 31st of every month" against the calendar's actual
day-of-month field. There's no "last day of month" semantics
exposed; if you need that, schedule separate daily:HH:MM runs
or post once per month from cron.
"My --attachment was rejected"¶
Roar accepts local paths only. Common rejections:
http://...orhttps://...— pre-download withcurl -fsSL -o /tmp/foo URLand pass the local path.file://hostname/...— usefile:///...(three slashes, empty host).~user/foo—~only expands the current user. Use an absolute path or~/foo.- Path is a symlink. Roar refuses leaf symlinks because the target may differ from what you typed. Pass the real path:
- Intermediate symlink not on the allow-list. Only
/tmp,/var,/etc(the macOS system symlinks) are accepted as intermediates. A custom/Users/me/cache -> /Volumes/EXT/cachesymlink will refuse — make the path use the volume's real mount point. - Not a regular file. Directories, FIFOs, sockets, and devices are rejected with the file-type bits in the diagnostic.
See SECURITY.md for the threat model.
"--open-url 'vscode://...' was rejected"¶
Only http, https, mailto are accepted by default. Custom
schemes must be opted in one at a time with --allow-url-scheme:
For multiple schemes, repeat the flag:
There is deliberately no global "accept anything" flag. The
allow-list is a default-deny posture: by default --open-url
rejects any scheme outside http / https / mailto, which is what
catches the case where a script passes --open-url "$URL" and
$URL turned out to be javascript:.... Without the allow-list,
that would silently post a notification whose click runs JS. With
it, the send is rejected at parse time and no notification is
created. The pinned allow-list is also re-validated at click time
against the userInfo blob, so a click later — possibly in a
different process — can't open a scheme the send didn't agree to.
For the full rationale (and the cases the allow-list does NOT defend against, like a same-bundle-id spoofer or a hostile https destination), see SECURITY.md.
"--exec was rejected"¶
Two common causes:
- Missing
--allow-shell-on-click.--execis consent-gated. Re-run with both flags: - NUL byte in the command.
posix_spawn'sstrduptruncates at NUL, so a command containing\0would silently change. Roar rejects up front.
"--exec ran but my script didn't seem to do anything"¶
The exec command runs in a fresh, scrubbed environment, not in your interactive shell. Common causes of "ran silently but did nothing":
PATHis pinned to the system default (/usr/bin:/bin:/usr/sbin:/sbin). Tools installed in/opt/homebrew/bin,/usr/local/bin, or~/.local/binare NOT on it. Use absolute paths or setPATHinside the command:- Working directory is
$HOME, not your project. Usecdinside the command: - Most env vars are stripped.
BASH_ENV,IFS,LD_*,DYLD_*,SHELLOPTS, and any value containing=or NUL are filtered. If your command depends on a non-standard variable, set it inside the command. - The command runs but stdout / stderr are discarded. The
click handler is a launchd-style relaunch with no terminal;
output goes nowhere by default. To see what your command
produces, redirect to a file:
Then
tail /tmp/roar-exec.logafter clicking. ROAR_DEBUG=1at SEND time embeds nothing useful — the variable is read at CLICK time, by the click-handler process. The click handler is spawned by launchd (open -nastyle), which does not inherit your interactive shell's environment, so a one-shotROAR_DEBUG=1 roar send ...invocation only affects the send-side. To see exec-time diagnostics, exportROAR_DEBUG=1into the launchd-visible environment vialaunchctl setenv ROAR_DEBUG 1(effective for the rest of the login session), then click the banner and read the result withlog show --predicate 'process == "roar"' --last 5m. Clear it again withlaunchctl unsetenv ROAR_DEBUGwhen you're done.
Exit code 1 from the parent roar send means the click
handler signaled a failure (auth denied, URL open refused,
exec returned non-zero). The command itself reaching code 1
is propagated.
"--activate-bundle-id doesn't open anything when I click"¶
urlForApplication(withBundleIdentifier:) returned nil. Three
common causes:
- Typo. Bundle ids are reverse-DNS strings, case-sensitive.
Verify with
osascript -e 'id of app "Safari"'(replaceSafariwith the app's display name). - The app isn't installed, or LaunchServices doesn't know about it. Try opening the app from Finder once to force a registration, then click the notification again.
- The bundle id resolves to a non-launchable component. Some framework / helper bundles register a bundle id but aren't directly openable. Use the main app's bundle id.
Set ROAR_DEBUG=1 before clicking to see which id was
rejected:
"--filter-criteria doesn't seem to do anything"¶
--filter-criteria is a Focus-filter input, not a generic
delivery filter. macOS only consults it when the user has
configured a Focus filter that matches against
UNMutableNotificationContent.filterCriteria. If you haven't
set up a Focus filter for Roar, the value just rides along on
the notification with no observable effect.
To wire up a Focus filter:
- System Settings → Focus → (pick a Focus mode, e.g. Work).
- Under Allowed People & Apps → Apps, add Roar.
- Click Roar in the list → Filters → add a filter matching the criteria string you pass.
The user-controlled string must equal the value you pass to
--filter-criteria for the notification to bypass that Focus.
See also Cookbook → Focus and Do-Not-Disturb.
"--wait hangs forever"¶
It doesn't, but it can sit for up to 5 minutes by default. The
--wait-timeout is 5m unless you override:
The minimum is 1s; the maximum is 365d (same bounds as --in).
If you genuinely want forever, pass 365d.
"roar dismiss <id> exits 4"¶
That's the documented exit code for "no id matched any delivered or pending notification." Either:
- The id you typed is misspelled. Run
roar listto see what Roar knows about. - The notification was already dismissed by another process.
- The notification was posted by another app under a different
bundle id — Roar only sees notifications under
io.myers.roar.
"roar list shows nothing"¶
Each app sees only its own notifications. roar list lists
notifications posted under io.myers.roar. To see all OS-level
notifications, use Notification Center (click the clock).
"roar clear didn't clear scheduled notifications"¶
Bare roar clear clears only delivered notifications. Use
--all for both buckets:
This is the safe default — a typo'd roar clear from the shell
prompt no longer destroys upcoming scheduled work.
"I see categories accumulating in some inspection tool"¶
Every roar send with --action/--text-action registers a
roar.dyn.<hash> category. They never expire; the set grows
monotonically. Prune them:
roar clear --categories # prune unused
roar clear --all --categories # clear notifications, then prune
The pruner only drops roar.dyn.* categories that aren't
referenced by a currently-delivered or pending notification. It's
idempotent and uses a double-snapshot protocol so a concurrent
roar send doesn't get clobbered.
"Notifications stay in NC forever"¶
That's the OS's behaviour, not Roar's. To clear:
Or use Notification Center's "Clear" affordance directly.
Debugging the click handler¶
The click handler runs out-of-process from your shell — when you click a banner, macOS spawns Roar's bundle to receive the click delivery. By default Roar is silent in the click path so URLs and shell commands don't leak into the unified log under launchd.
Enable verbose stderr logging with ROAR_DEBUG. Because the
click handler is a launchd-style relaunch — not a child of your
shell — the variable has to be visible to launchd, not just to
the current shell. Export it once with launchctl setenv:
launchctl setenv ROAR_DEBUG 1
roar send --title "Click me" --body "Test" --open-url https://example.com
# Click the banner. Diagnostics now print to stderr in the
# click-handler process. Find them with:
log stream --predicate 'process == "roar"' --info --debug
# When done:
launchctl unsetenv ROAR_DEBUG
The ROAR_DEBUG env var is read fresh on every diagnostic write —
no caching — so you can flip it on/off across runs without
restarting anything. A one-shot ROAR_DEBUG=1 roar send ...
inline assignment only affects the send-side process; the
click-time relaunch won't see it.
For send-time errors, ROAR_DEBUG is unnecessary: those go to the
shell's stderr directly. Increased verbosity is mostly useful for
the click handler.
Reading the unified log¶
When the click handler does something noteworthy, the trace lands in the unified log:
# Live tail:
log stream --predicate 'process == "roar"' --info
# Last 5 minutes:
log show --predicate 'process == "roar"' --info --last 5m
usernoted (the system notification daemon) logs sends and
deliveries:
Reset Roar's notification state¶
If you've gotten the permission record into a weird state:
# Drop all cached defaults for Roar.
defaults delete io.myers.roar 2>/dev/null
# Bounce usernoted (launchd respawns it immediately).
killall usernoted
# Re-register the bundle. lsregister isn't on PATH by default;
# use the full path inside the CoreServices framework.
/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -f /Applications/Roar.app
Then run roar send --body "test" and walk through the first-run
permission flow again.
Quick diagnostic checklist¶
When in doubt, paste this output into an issue:
echo "=== roar --version ==="
roar --version
echo
echo "=== roar settings ==="
roar settings
echo
echo "=== auth & focus ==="
defaults read com.apple.controlcenter 2>/dev/null | grep -i focus
echo
echo "=== lsregister ==="
/System/Library/Frameworks/CoreServices.framework/Versions/Current/Frameworks/LaunchServices.framework/Versions/Current/Support/lsregister -dump 2>/dev/null | grep -A2 -i roar | head -20
echo
echo "=== recent usernoted entries ==="
log show --predicate 'process == "usernoted" OR process == "roar"' \
--info --last 5m 2>/dev/null | head -50
The first three give the OS state; the last surfaces any errors from the recent send / click attempts.
See also¶
COOKBOOK.md— task-oriented recipes.SCRIPTING.md—--waitprotocol and exit codes.SECURITY.md— the threat model that motivates the consent gates.man roar— the full reference.