nigig-org/crates/robius-notification/README.md
andodeki 3a3db4efa6
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-map / test (push) Has been cancelled
p2p-intel / engine (push) Has been cancelled
p2p-intel / notifications (push) Has been cancelled
p2p-intel / coverage (push) Has been cancelled
p2p-intel / makepad-app (push) Has been cancelled
p2p-intel / exchange-tab (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
feat(robius-notification): iOS via UNUserNotificationCenter, and the Apple/Windows logic comes out of the cfg blocks
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.
2026-09-01 20:10:50 +00:00

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:

  1. Every error reply parsed as success. The header-field walk assumed every field was a string, but REPLY_SERIAL is a u32; reading its four bytes as a string length desynchronised the cursor, so ERROR_NAME was never found. A ServiceUnknown — no notification daemon at all — returned Ok. Precisely the bug this crate was written to remove, reintroduced by accident in its own parser.
  2. is_available() was true on a bare bus. NameHasOwner succeeds and answers false in its body; treating a successful call as availability reported a working notifier on a machine with no notification service.
  3. Replies were not correlated. The bus sends NameAcquired unprompted after Hello, so "read the next message" consumed a signal and mistook it for the answer. Replies are now matched on REPLY_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 returns false. 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. Returning false errs 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() reads areNotificationsEnabled rather than assuming, and post refuses with AuthorizationDenied.
  • API 26+ without a channel discards the post, again silently. The channel is created before each post; createNotificationChannel is 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.