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
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.
182 lines
8.1 KiB
Markdown
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.
|