# ADR 0036 — robius-notification: a zero-dependency D-Bus client, and three honest gaps Status: accepted Date: 2026-09-01 ## Context ADR 0035 left one of p2p-intel's four limits open: there was no OS notification backend, so alerts stopped when the app window closed. The seam existed (`NotificationSink`) with one implementation that truthfully did nothing. This records building the crate that fills it, because three decisions in it are not obvious and one of them reverses on evidence. ## Decision ### Linux is written by hand, with no dependencies Posting a notification is one D-Bus method call. Three ways to make it were measured in the sandbox rather than guessed at: | Approach | Crates | Problem | |---|---|---| | `notify-rust` + `libdbus-sys` | 12 | a C library; needs `pkg-config`, breaks the Android and iOS cross-compile | | `notify-rust` + `zbus` | 169 | an async executor, for one method call | | hand-rolled | **0** | ~250 lines that must be exactly right | `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-2.1 question for every consumer, and that nothing in the crate referenced them. Adding a 169-crate tree back into the same family would reverse that decision for a worse reason. The cost is real: the D-Bus wire format has to be correct, and a mistake makes the daemon disconnect with no diagnostic. That cost was paid in tests (below) rather than in production. ### The tests run against a real daemon, not a mock `dbus-daemon` is available, so the suite starts a private session bus per test. This matters more than it sounds. The marshaller and the parser were written from the same reading of the specification, so **them agreeing with each other proves only that I was consistently wrong or consistently right**. Only a third party — an actual bus — can tell which. It found three bugs no unit test would have: 1. **Every error reply parsed as success.** The header walk assumed all fields were strings; `REPLY_SERIAL` is a `u32`, and reading its four bytes as a string length desynchronised the cursor so `ERROR_NAME` was never reached. `post` returned `Ok` against a bus with no notification service. That is the exact bug this crate was written to eliminate, reintroduced inside its own parser — which is the strongest argument available for testing against something you did not write. 2. **`is_available()` was true on a bare bus.** `NameHasOwner` *succeeds* and reports `false` in its body. Checking only for an error meant reporting a working notifier on a machine with no notification daemon. 3. **Replies were not correlated.** The bus sends `NameAcquired` unprompted after `Hello`. "Read the next message" consumed that signal and treated it as the reply. Now matched on `REPLY_SERIAL`. The suite also serialises env-var mutation behind a mutex. `--test-threads=1` would have made the failures go away too, and would have hidden a real hazard from the next reader. ### All four are written; only Linux is executed An earlier revision of this ADR refused to write the Apple and Windows backends, on the grounds that code no compiler has seen is not an implementation. That reasoning was right; the premise was wrong. `cargo check` needs the **target's standard library**, not a linker or an SDK. `rustup target add x86_64-pc-windows-msvc aarch64-apple-darwin` makes the real `windows` and `objc2` crates — with the genuine WinRT metadata and Objective-C class definitions — available to the type checker on a Linux host. So all four backends are now written and every one of them compiles against the actual frameworks: | Backend | Compiles for | Executed | |---|---|---| | Linux | `x86_64-unknown-linux-gnu` | **yes**, against a live `dbus-daemon` | | Android | `aarch64-linux-android` | no device | | macOS | `aarch64-apple-darwin` | no machine | | Windows | `x86_64-pc-windows-msvc` | no machine | | iOS | `aarch64-apple-ios` | no device | Compile-checking is not a formality here. It immediately caught `ToastNotificationManager::CreateToastNotifier(&HSTRING)`, which does not exist — the AUMID overload is `CreateToastNotifierWithId`. Nothing short of a compiler holding the real metadata would have found that, and it would have shipped as plausible-looking text. Because a `cfg`-gated module can silently compile to nothing, each backend was verified to be genuinely reachable by injecting a type error and confirming the target build failed. Both macOS and Windows were checked this way. Two platform decisions are worth recording: **macOS uses `NSUserNotification`, not `UNUserNotificationCenter`.** The modern API raises an Objective-C exception when the process has no bundle identifier, and that unwinds through Rust frames and aborts the host — so a non-bundled `cargo run` binary would crash rather than report unavailable. `NSUserNotification` is deprecated; aborting the host is worse than deprecated. **Windows requires the host to supply an AppUserModelID.** It comes from an MSIX manifest or a Start Menu shortcut, which is packaging a library cannot invent, and a fabricated one yields a notifier that constructs and then fails at `Show`. `set_app_user_model_id` is therefore part of the public API, a no-op off Windows so portable hosts call it unconditionally, and `is_available()` is false with an explanatory reason until it is called. **iOS uses `UNUserNotificationCenter`** — the only option there, since every iOS process is bundled so the exception that rules it out on macOS cannot occur. Its `is_available()` returns a constant `false`, which is a limit worth naming rather than hiding: the real answer comes from `getNotificationSettingsWithCompletionHandler:`, which is asynchronous, and a synchronous `is_available()` could only produce it by blocking on a completion handler — a deadlock on the main thread. `false` errs toward telling the user delivery is unverified, and the alternative is claiming availability the platform never confirmed. A correct answer needs an async entry point, which is an API change rather than a bug fix. **The pure logic is out of the `cfg` blocks.** XML escaping and tag clamping now live in `src/payload.rs`, which compiles and is tested everywhere. While they sat inside `#[cfg(target_os = "windows")]` they were unreachable by `cargo test` on the only machine available, and they are exactly the kind of rule a compiler cannot check: an unescaped `&` in a merchant name makes the toast XML malformed and Windows discards the notification silently, and a byte-wise truncation of a 64-character tag limit panics on the full-width names the Binance book actually contains. Eleven tests cover them. What remains unproven is behaviour: whether a toast actually appears, whether the macOS centre accepts the notification. Only a device settles that, and the support table keeps "compiles" and "executed" in separate columns so nobody reads one as the other. ## Consequences - p2p-intel's fourth limit is closed **on Linux**, narrowed on Android, and unchanged on Apple and Windows. The README table says exactly that. - `SystemNotifications` replaces `UnavailableNotifications` as the dashboard's sink. `UnavailableNotifications` stays: on platforms with no backend it is still the truthful answer, and a test needs something that reliably cannot deliver. - The dashboard already displays `unavailable_reason()`, so on a machine with no notification daemon the user is told, rather than assuming they are covered. - Alerts are tagged per fiat, so a market's alert replaces its own previous one instead of stacking. A 30-second poll would otherwise fill the shade, and a full shade is what makes someone disable notifications for the app. ## Alternatives rejected **Taking `zbus` for a quiet life.** 169 crates and an async runtime in every consumer of a crate family that has been deliberately kept lean. The hand- rolled client is more code *here* and far less code *everywhere else*. **Leaving Apple and Windows as stubs.** That was the earlier decision and it was over-cautious: it assumed compile verification was unavailable when a `rustup target add` would have provided it. Type-checked code against real framework metadata is a materially different artefact from untested prose, and the distinction is now carried by the support table rather than by refusing to write the code. **Claiming the four backends are equally done.** They are not, and the table says so in two columns. Only Linux has posted a notification.