nigig-org/REVIEWS/adr/0036-robius-notification.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.4 KiB

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.