nigig-org/crates/robius-notification
andodeki ac8f8aa002
Some checks failed
p2p-intel / engine (push) Waiting to run
p2p-intel / notifications (push) Waiting to run
p2p-intel / coverage (push) Waiting to run
p2p-intel / makepad-app (push) Waiting to run
p2p-intel / exchange-tab (push) Waiting to run
Payment domain, storage, platform and UI / isolated-payment-tests (push) Waiting to run
Payment domain, storage, platform and UI / payment-ui-tests (push) Waiting to run
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (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
traffic / gates (push) Has been cancelled
traffic / nigig-traffic (push) Has been cancelled
traffic / supply-chain (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
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
chore: sync full working tree to gitdab
Whole-tree sync: cad-core/cad-ui split sources, nigig-build
construction_frame migration, pdf port progress, mpesa/pay/uikit/doc
updates, workspace members/profiles/lock, CI workflows and reviews.
See individual file history for details.
2026-09-12 07:15:24 +03:00
..
src chore: sync full working tree to gitdab 2026-09-12 07:15:24 +03:00
Cargo.toml chore: sync full working tree to gitdab 2026-09-12 07:15:24 +03:00
README.md feat(robius-notification): iOS via UNUserNotificationCenter, and the Apple/Windows logic comes out of the cfg blocks 2026-09-01 20:10:50 +00:00

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.