Two gaps remained on the Apple and Windows side after the last commit, and
they are different in kind.
The first was a missing backend. iOS returned PermanentlyUnavailable with a
note saying it was not implemented. It now uses UNUserNotificationCenter --
the only option there, since every iOS process is bundled, so the
Objective-C exception that rules that API out on macOS cannot occur. The
objc2-user-notifications bindings are real and fetchable, so this
type-checks for aarch64-apple-ios against the actual framework, and the
module was confirmed genuinely reachable by injecting a type error and
watching the target build fail.
Its is_available() returns a constant false, and that is a limit worth
naming rather than burying. The real answer comes from
getNotificationSettingsWithCompletionHandler:, which is asynchronous -- it
hands the settings to a block on an arbitrary queue. A synchronous
is_available() could only produce that by blocking on a completion handler,
which on the main thread is a deadlock rather than a delay. Returning false
errs toward telling the user delivery is unverified; the alternative is
claiming an availability the platform never confirmed, which is the whole
failure this crate was written to remove. A correct answer needs an async
entry point, which is a change to the public API rather than a bug fix, so
it is recorded in the ADR instead of being quietly wrong.
The second gap is subtler and, I think, the more valuable fix. The Apple and
Windows backends contain pure logic -- XML escaping, tag clamping -- that
was sitting inside #[cfg(target_os = "windows")], where cargo test on the
only available machine could never reach it. Those functions had zero tests
and no prospect of any.
They now live in src/payload.rs, which is cfg-free and runs on every target.
Eleven tests cover them, and they cover exactly the rules a compiler cannot:
an unescaped `&` in a merchant name makes the toast XML malformed and
Windows discards the whole notification rather than showing a mangled
character, and the Binance P2P book is full of `&`. A byte-wise truncation
of the 64-character tag limit panics outright on the full-width names that
book also contains -- so the clamp cuts on character boundaries, pinned by a
test that would panic if anyone changed it back.
One test pins something deliberately counter-intuitive: escaping is not
idempotent, and escape_xml("&") is "&". That is correct, and the
test exists so nobody "fixes" double-escaping by teaching the function to
detect already-escaped input, which is precisely how escaping filters grow
holes.
The distinction this commit is really about: the last one made the Apple and
Windows backends *compile*, which catches wrong selectors -- it found
CreateToastNotifier(&HSTRING), which does not exist. It could not catch
wrong behaviour. Extracting the logic is what makes the behaviour testable
on a machine that will never run either platform.
62 tests, up from 51. Clippy clean with -D warnings on all five targets:
linux-gnu, linux-android, apple-darwin, apple-ios, windows-msvc. p2p-intel
unchanged at 225.
8.1 KiB
robius-notification
Rust abstractions for multi-platform native notifications.
use robius_notification::{post, is_available, Notification, Urgency};
if !is_available() {
// Tell the user, rather than letting them assume they are covered.
}
post(&Notification::new("USDT KES", "spread +2.00%")
.urgency(Urgency::Critical)
.tag("kes-spread"))?;
Why this exists
It replaces a no-op sink in p2p-intel that returned success and delivered
nothing. That is worse than having no notifier: the user believes they are
covered and stops watching.
So the API is shaped around not repeating that. post returning Ok means
the platform accepted the notification, never that the user saw it, and
is_available() is a first-class call that a UI is expected to display
rather than merely branch on.
Platform support
| Platform | Backend | Compiles | Executed |
|---|---|---|---|
| Linux / BSD | org.freedesktop.Notifications over D-Bus |
✅ | ✅ tested against a live dbus-daemon |
| Android | NotificationManager via JNI |
✅ aarch64-linux-android |
❌ no device |
| macOS | NSUserNotificationCenter via objc2 |
✅ aarch64-apple-darwin |
❌ no machine |
| Windows | Toast XML via WinRT | ✅ x86_64-pc-windows-msvc |
❌ no machine |
| iOS | UNUserNotificationCenter via objc2 |
✅ aarch64-apple-ios |
❌ no device |
The two columns are separate on purpose. Only Linux has executed. The other three are type-checked against the real frameworks — the compiler has confirmed every selector, interface and signature exists — which is worth much more than nothing and much less than a run.
Compile-checking is not ceremony: it caught
CreateToastNotifier(&HSTRING), which does not exist. The AUMID overload is
CreateToastNotifierWithId, and nothing but a compiler with the real WinRT
metadata would have said so.
To confirm a backend is genuinely being compiled rather than cfg'd away,
inject a type error into it and check the target fails. That was done for
both macOS and Windows while writing them.
REVIEWS/adr/0036 records what each backend can and cannot claim.
No dependencies on Linux
Posting a notification is one D-Bus method call. The options were:
| Approach | Cost |
|---|---|
libdbus-sys |
12 crates, but a C library — needs pkg-config, breaks the Android/iOS cross-compile |
zbus |
pure Rust, but 169 crates including an async executor |
| this crate | ~250 lines against a frozen wire format, zero crates |
robius-sms deleted polkit and gio for exactly this reason — its E9 note
records that they were the sole source of two RUSTSEC advisories and an LGPL
question for every consumer, and that nothing referenced them. Pulling a
169-crate tree back into the same crate family for one method call would
reverse that decision.
The wire format is in src/sys/linux/wire.rs, with the alignment rules that
actually bite pinned by tests against byte sequences from the specification.
What the tests found
Writing the D-Bus client by hand meant getting the protocol exactly right, and the integration tests against a real bus caught three bugs that unit tests could not:
- Every error reply parsed as success. The header-field walk assumed
every field was a string, but
REPLY_SERIALis au32; reading its four bytes as a string length desynchronised the cursor, soERROR_NAMEwas never found. AServiceUnknown— no notification daemon at all — returnedOk. Precisely the bug this crate was written to remove, reintroduced by accident in its own parser. is_available()was true on a bare bus.NameHasOwnersucceeds and answersfalsein its body; treating a successful call as availability reported a working notifier on a machine with no notification service.- Replies were not correlated. The bus sends
NameAcquiredunprompted afterHello, so "read the next message" consumed a signal and mistook it for the answer. Replies are now matched onREPLY_SERIAL.
None of these is visible without a real daemon on the other end: the marshaller and the parser were written from the same reading of the spec, so them agreeing with each other proved only self-consistency.
The suite also serialises every test that touches
DBUS_SESSION_BUS_ADDRESS behind a mutex. The variable is process-wide and
cargo test runs threads in parallel; --test-threads=1 would also have
worked and would have hidden the hazard from the next reader.
Windows notes
CreateToastNotifierWithId requires an AppUserModelID and throws
E_INVALIDARG without one. An AUMID comes from an MSIX manifest or a Start
Menu shortcut — packaging, which a library cannot invent. A fabricated id
produces a notifier that constructs happily and fails at Show, which is
the accepted-and-discarded failure this crate exists to remove.
So the host supplies it:
robius_notification::set_app_user_model_id("com.example.MyApp");
A no-op on every other platform, so portable code can call it
unconditionally. Until it is called, is_available() is false with a
reason naming exactly what is missing.
Toast text is XML, so & and < in a merchant nickname are escaped — the
same class of bug as the Telegram MarkdownV2 escaping p2p-intel needed
before it dropped Telegram.
iOS notes
Uses UNUserNotificationCenter, the only option on iOS. Two honest limits:
is_available()always returnsfalse.getNotificationSettingsWithCompletionHandler:is asynchronous — it hands the settings to a block on an arbitrary queue.is_available()is synchronous, so answering truthfully would mean blocking on a completion handler, which on the main thread is a deadlock rather than a delay. Returningfalseerrs toward telling the user delivery is unverified, which is the safe direction; the alternative is claiming an availability the platform never confirmed. Fixing it needs an async entry point, which is a change to this crate's public API rather than to that function.- The crate does not call
requestAuthorization. It shows a system prompt, and raising one as a side effect of posting takes a decision that belongs to the host app. Post-without-authorisation is accepted by the framework and dropped by the system — the accepted-and-discarded failure this crate exists to remove — so hosts should authorise first.
macOS notes
NSUserNotification is used, not UNUserNotificationCenter, and that is a
deliberate downgrade. UNUserNotificationCenter.current() raises an
Objective-C exception when the process has no bundle identifier — it unwinds
through Rust frames and aborts — so a plain cargo run host would crash
rather than report unavailable. NSUserNotification is deprecated by Apple;
crashing the host is worse.
Android notes
Add to the manifest:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
Two things Android drops silently, both handled:
- API 33+ without the runtime grant discards every post and returns no
error.
is_available()readsareNotificationsEnabledrather than assuming, andpostrefuses withAuthorizationDenied. - API 26+ without a channel discards the post, again silently. The
channel is created before each post;
createNotificationChannelis documented as a no-op when it already exists, so this is idempotent rather than something the host app must remember.
Tests
62 tests.
The Apple and Windows backends cannot run here, but their logic is not
locked inside cfg blocks. XML escaping and tag clamping live in
src/payload.rs, which is cfg-free and tested on every target — a
merchant name containing & makes the toast XML malformed and Windows
discards the whole notification, and a byte-wise tag truncation panics on
the full-width names Binance actually returns. Neither is something a
compiler catches. The bus-backed ones skip when dbus-daemon is absent so the suite
stays green on a bare machine — and a_real_bus_is_available_in_ci fails
loudly when ROBIUS_NOTIFICATION_REQUIRE_DBUS=1, so the skip cannot go
silent in CI. A test that quietly stops running is worse than no test.