A tiny local-only macOS menu-bar app that pulls one-time passcodes (2FA / verification codes) out of your notifications so you can copy them with one click — and, if you like, run a shell command on each code.
macOS only autofills codes from its own Messages and Mail. Codes that arrive any other way — Google Voice in the browser, Telegram, WhatsApp, banking apps — show up as notifications you have to read and retype. Notiful reads them for you.
- 🔒 No network. Ever. Everything happens on your Mac.
- 🪶 No dependencies. Pure Swift + Apple frameworks.
- 🧭 Lives in the menu bar — no Dock icon, no window in your way.
| A detected code (click to copy) | The menu | Add sources visually |
|---|---|---|
![]() |
![]() |
![]() |
Universal (Apple Silicon + Intel), macOS 13+. Notiful is signed with a Developer ID certificate and notarized by Apple, so it opens normally — no Gatekeeper bypass needed. It makes no network calls — every line of source is in this repo.
brew install ptrinh/notiful/notiful
open -a NotifulLater: brew upgrade --cask notiful to update, brew uninstall --cask notiful to remove
(add --zap to also delete your settings).
- Download
Notiful.zipfrom the latest release. - Unzip and move Notiful.app to /Applications.
- Double-click to open (
open -a Notiful).
- A 🔑 icon appears in the menu bar and a welcome popup explains the next step.
- Grant Full Disk Access (Notiful needs it to read the notification database):
System Settings → Privacy & Security → Full Disk Access → add/enable Notiful, then relaunch.
Until you do, the icon shows a
⚠️ and Notiful posts a notification linking you to the right pane. - Open Sources & advanced settings… from the menu and pick which notifications to watch (defaults for Google Voice, Telegram and WhatsApp are already included).
- Important: to avoid seeing two banners per code, mute the source app's own banner — see Stop double banners below.
That's it for everyday use. The sections below cover faster capture, Google Voice, and advanced options.
Click the 🔑 icon for the menu:
- Status line (top) — shows whether Notiful is watching, how many sources, and the last code captured.
- Pause / Resume watching — temporarily stop or restart detection.
- Settings
- Show a banner when a code is found — post Notiful's own clickable banner (click it to copy).
- Instant capture — sub-second capture; see Instant capture.
- Launch at login — start Notiful automatically.
- Sources & advanced settings… — the visual editor (below).
- Open Notiful's log…, Grant Full Disk Access…, Hide menu bar icon…, About, Quit.
Open Sources & advanced settings…. The top table lists your recent notifications — pick one and:
- Add this app — watch everything from that app (best for native apps like Telegram/WhatsApp).
- Add by matching text… — watch notifications whose title/subtitle contains text you specify (best for browser sources like Google Voice).
For a source you're already watching, select it and use Edit… (rename or fix the matching text), Auto-copy on/off, Run command…, or Remove. No JSON required — though Edit raw config (JSON) is there if you want it.
You can't redirect another app's notification — whoever posts it owns the click. So Notiful posts its own banner, and you mute the source app's banner so you only see one.
For each source app, go to System Settings → Notifications → <the app> and:
- Allow Notifications: ON ← keep this on, or the code never reaches Notiful.
- Uncheck "Desktop" — this is the on-screen banner you want to hide.
- Notification Center: ON — keeps the code flowing to the database Notiful reads.
(On macOS 15/26 there's no "None" style — the on-screen banner is the Desktop checkbox.)
Why codes appear a few seconds later: macOS holds a notification in memory (~5s) before writing it to the database Notiful reads. Hiding the "Desktop" banner removes the duplicate but doesn't change this delay — it's inherent to the database approach. For instant copies, turn on Instant capture.
Instant capture reads the banner straight off the screen via the macOS Accessibility API the moment it appears — no ~5s database delay. The database watcher stays on as a fallback.
Turn it on from the menu, then grant Accessibility in System Settings → Privacy & Security → Accessibility (it starts working automatically once granted).
- The source app's banner must stay visible (don't hide its "Desktop" banner) — there has to be a banner on screen to read. Toggle Auto-hide the original banner after capture so you still end up seeing only Notiful's banner.
- Matching here is text-based, so it covers sources matched by text (e.g. Google Voice). Apps matched only by bundle id (Telegram, WhatsApp) still come through the database fallback.
- Codes are still never written to disk.
Google Voice has no Mac app — it runs in Chrome, which by default files all web notifications under "Google Chrome", so you can't mute just Google Voice. Install it as its own app:
- Install as an app — in Chrome, open voice.google.com, then ⋮ → Cast, Save, and Share → Install page as app… (or the install icon in the address bar).
- Enable PWA notification attribution — open
chrome://flags/#enable-mac-pwas-notification-attribution, set it to Enabled, then fully quit and reopen Chrome (the flag only applies on a fresh launch). - Use the app, not a tab — close any
voice.google.comtab, then open the Google Voice app from Launchpad and allow notifications. - Mute its banner — trigger one code so macOS registers the app, then under System Settings → Notifications → Google Voice: Allow Notifications ON, uncheck Desktop, Notification Center ON.
The built-in voice.google.com rule already matches it. The same pattern works for any browser source.
Run a command when a code arrives
Each source can run a shell command on detection (set it via Run command…, or actions.runCommand
in the config). The notification text and code are passed as environment variables (never
interpolated into the string — avoids injection):
NOTIFUL_CODE, NOTIFUL_SOURCE, NOTIFUL_APP, NOTIFUL_TITLE, NOTIFUL_SUBTITLE, NOTIFUL_BODY
"actions": { "runCommand": "echo \"$NOTIFUL_SOURCE: $NOTIFUL_CODE\" >> ~/otp.log" }config.json reference
~/Library/Application Support/Notiful/config.json — created with sensible defaults on first run.
Edit it visually via Sources & advanced settings…, or open the raw JSON from there. Any omitted key
falls back to its default.
A source has:
| field | meaning |
|---|---|
name |
label shown in the notification & menu |
match |
how to recognise it (below). Matches when any positive criterion hits. |
otpRegex |
(optional) per-source regex override (capture group 1 = the code) |
actions |
autoCopy, showActionableNotification, openButton, openTarget, runCommand |
match options (any one is enough):
appBundleIds— apps that posted it (case-insensitive). Use for native apps.senderContains— substring of the title or subtitle. Use for browser sources.titleContains— substring of the title only.bodyContains— (optional gate) the body must also contain one of these.
actions:
autoCopy(defaultfalse) — copy the instant a code arrives, not just on click.showActionableNotification(defaulttrue) — post Notiful's clickable banner.openButton/openTarget— add an "Open Source" button; target is a URL (https://voice.google.com) or a bundle id (com.tdesktop.Telegram).
Examples (these ship as defaults):
{ "name": "Google Voice",
"match": { "senderContains": ["Google Voice", "voice.google.com"] },
"actions": { "openButton": true, "openTarget": "https://voice.google.com" } }
{ "name": "Telegram",
"match": { "appBundleIds": ["com.tdesktop.Telegram", "ru.keepcoder.Telegram"] },
"actions": { "openButton": true, "openTarget": "com.tdesktop.Telegram" } }
{ "name": "WhatsApp",
"match": { "appBundleIds": ["net.whatsapp.WhatsApp"] },
"actions": { "openButton": true, "openTarget": "net.whatsapp.WhatsApp" } }Find an app's bundle id: osascript -e 'id of app "Telegram"'.
Headless / scripted use
Notiful.app/Contents/MacOS/Notiful --onceScans the latest matching notification, copies the code, and prints a masked result
(e.g. Google Voice · 6••••0 — copied to clipboard). Exit 0 on a hit, 1 if nothing matched.
Build from source
Needs the Swift toolchain (Xcode or Command Line Tools — xcode-select --install).
./scripts/build-app.sh # compiles a release binary and assembles a signed Notiful.app
open Notiful.appRun the tests (dependency-free; works without full Xcode):
swift run NotifulTestsHow it works
- Locates the notification DB (
~/Library/Group Containers/group.com.apple.usernoted/db2/db). - Copies it (+ WAL/SHM) to a temp dir and opens the copy read-only — avoids locking the live DB while still seeing just-delivered notifications.
- Watches the WAL with a kqueue file monitor (event-driven, ~0% CPU idle), with a sparse safety-net
timer. A cheap
mtimecheck skips work when nothing changed. - Decodes each new record, runs it through your source matchers, and extracts the code (keyword-biased; rejects phone numbers, dates, and currency amounts).
- De-dupes against a watermark + launch time so no code is acted on twice.
- Local only — no network code exists. Audit it: nothing imports URLSession/Network.
- Full Disk Access lets Notiful read every app's notifications, but it only acts on the sources you
configure. The codebase is small and auditable — read
Sources/NotifulCorebefore trusting it. - Codes are never written to disk and are masked everywhere in logs (e.g.
6••••0). - Click-to-copy by default (
autoCopyoff), so your clipboard is only overwritten on intent. clipboardAutoClearSecondsclears the clipboard after a timeout — but only if it still holds that exact code.
- Quit Notiful (menu → Quit), and turn Launch at login off first if you enabled it.
- Delete the app and its data:
rm -rf /Applications/Notiful.app rm -rf "$HOME/Library/Application Support/Notiful" - Remove Notiful from System Settings → Privacy & Security → Full Disk Access (and Accessibility, if you enabled instant capture).
- Restore any source apps' "Desktop" banners you muted.



{ "defaultOTPRegex": "\\b(\\d{4,8})\\b", // global fallback; smart extraction runs first "clipboardAutoClearSeconds": 0, // 0 = never auto-clear "pollIntervalSeconds": 15, // fallback poll / watch debounce "sources": [ /* … */ ] }