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

182 lines
8.1 KiB
Markdown

# 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
<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.