# robius-notification Rust abstractions for multi-platform native notifications. ```rust 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: ```rust 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: ```xml ``` 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.