From 22a075d1fe36952837ed7c9d4426fd5c42bc4ea1 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Mon, 12 Oct 2026 12:29:29 +0200 Subject: [PATCH 1/3] Trim the README to a support table and one section per platform --- README.md | 165 +++++++++++++----------------------------------------- 1 file changed, 38 insertions(+), 127 deletions(-) diff --git a/README.md b/README.md index 7f758e6..ac046b5 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,14 @@ [![CI](https://github.com/LucaCappelletti94/pushups/actions/workflows/ci.yml/badge.svg)](https://github.com/LucaCappelletti94/pushups/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/LucaCappelletti94/pushups/graph/badge.svg)](https://codecov.io/gh/LucaCappelletti94/pushups) +[![docs.rs](https://docs.rs/pushups/badge.svg)](https://docs.rs/pushups) +[![crates.io](https://img.shields.io/crates/v/pushups.svg)](https://crates.io/crates/pushups) [![MSRV](https://img.shields.io/badge/MSRV-1.85-blue)](https://blog.rust-lang.org/2025/02/20/Rust-1.85.0/) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](https://github.com/LucaCappelletti94/pushups/blob/main/LICENSE) -Push notifications in Rust, one rep per platform. APNs on iOS and macOS, FCM on Android, WNS on Windows, UnifiedPush on Linux and Web Push in the browser, behind one API that works with tao, winit or any other toolkit. +Not on crates.io yet, so the docs.rs and crates.io badges resolve after the first release. -The app installs the crate before its toolkit's event loop starts, sets one handler, and asks for a token. The handler receives the token to send to the app's server, and every push, including the one that started the app before the handler existed. +Push notifications for Rust apps through each platform's own service, APNs on iOS and macOS, FCM or UnifiedPush on Android, WNS on Windows, UnifiedPush on Linux and Web Push in the browser. The app calls `install` in `main` before its toolkit starts, sets one handler and calls `register`. The handler gets the token for the app's server and every push, including the one that started the app. ```rust use pushups::{Config, Event}; @@ -20,98 +22,32 @@ if let Err(error) = pushups::install(Config::new()) { pushups::set_handler(|event| match event { Event::Token(token) => println!("send {token:?} to the server"), Event::RegistrationFailed(error) => eprintln!("no push token: {error}"), - Event::Message(message) => println!( - "{} payload bytes, started the app: {}", - message.payload.len(), - message.started_app, - ), + Event::Message(message) => println!("{} bytes, started the app: {}", message.payload.len(), message.started_app), Event::MessagesDropped => println!("fetch what was missed from the server"), }); let _ = pushups::register(); ``` -The handler runs on the thread the platform delivers on, so waking the toolkit's event loop is its job, through a tao or winit `EventLoopProxy` or a Dioxus signal. The `stream` feature adds `pushups::events()`, the same events as a `futures` stream. `pushups::request_permission().await` asks the user to let pushes show notifications. +The handler runs on the platform's delivery thread, so waking the toolkit is its job. The `stream` feature adds `pushups::events()`, the same events as a stream. -Tokens come in the form push servers take. An APNs token becomes the hex string `a2` sends to, and a Web Push subscription becomes the base64url keys of the `web-push` crate's `SubscriptionInfo`. +## Support -```rust -use pushups::Token; - -let apns = Token::Apns(vec![0xde, 0xad, 0xbe, 0xef]); -assert_eq!(apns.apns_device_token().as_deref(), Some("deadbeef")); - -let web = Token::WebPush { - endpoint: "https://push.example.net/subscription".to_owned(), - p256dh: [4; 65], - auth: [7; 16], - expires: None, -}; -let keys = web.web_push_keys().unwrap(); -assert_eq!(keys.auth, "BwcHBwcHBwcHBwcHBwcHBw"); -``` - -## Dioxus - -The `dioxus` feature lets `dx` bundle what the crate ships, the Android module and the web's static service worker, with no path into the crate in the app. - -```toml -[dependencies] -pushups = { version = "0.1", features = ["dioxus", "stream"] } -``` +"yes" means proven on a device or in CI with the framework's current release. A linked PR is upstream work the cell waits on. -The app installs the crate in `main` before `dioxus::launch`, and a `use_future` in its root component reads `pushups::events()` into its signals, as `examples/dioxus` does. Apple's push entitlement and background mode go in `Dioxus.toml`. The feature builds with the Rust version Dioxus 0.7 needs, newer than the crate's own. +| | Android | iOS | macOS | Windows | Linux | Web | +|---|---|---|---|---|---|---| +| Dioxus 0.7 | yes, though a tap into the running app restarts it until [tao#1365](https://github.com/tauri-apps/tao/pull/1365) | yes up to iOS 26, iOS 27 needs [dioxus#5893](https://github.com/DioxusLabs/dioxus/pull/5893) | yes | untested | yes | yes | +| Tauri 2 | yes | yes | untested | untested | untested | n/a | +| tao 0.37 | untested | yes | yes | yes | untested | n/a | +| winit 0.31 | needs [winit#4752](https://github.com/rust-windowing/winit/pull/4752), which `examples/winit` pins | yes | yes | untested | untested | untested | +| Leptos 0.8 | n/a | n/a | n/a | n/a | n/a | yes | +| Yew 0.23 | n/a | n/a | n/a | n/a | n/a | yes | -## iOS and macOS - -`install` runs in `main`, on the main thread, before the toolkit starts. The crate adds its push methods to whatever app delegate the toolkit installs, and installs its own when there is none, so tao and winit need no change. It also handles the notification center's delegate, so a push arriving while the app is in front shows as a banner and a tap on a notification reaches the handler. Every push reaches the handler once, including one whose notification is tapped later. - -The app bundle needs the push entitlement and, on iOS, the `remote-notification` background mode. A `dx` app sets both in `Dioxus.toml`. - -```toml -[ios.entitlements] -aps-environment = "development" - -[macos.entitlements] -"com.apple.developer.aps-environment" = "development" - -[background] -remote-notifications = true -``` - -A push that wakes the app in the background runs the background handler below off the main thread, and the system gets its answer when the handler returns, or after 25 seconds, within the 30 seconds Apple allows. The push also waits on disk for the app's handler, as on Android. +The [examples](https://github.com/LucaCappelletti94/pushups/tree/main/examples) are the proofs. Without the winit fix, a fresh install's first launch can get no FCM token until the app is reopened. ## Android -The crate ships a Gradle module in `android/` holding the FCM and UnifiedPush services. A `dx` app bundles it through the `dioxus` feature. A Tauri app adds [`tauri-plugin-pushups`](https://github.com/LucaCappelletti94/pushups/tree/main/tauri-plugin-pushups), whose build hands Tauri a copy of the module and adds the iOS push entitlement, as [`examples/tauri`](https://github.com/LucaCappelletti94/pushups/tree/main/examples/tauri) does. Any other app with a Gradle project, such as a winit, Slint, Bevy or egui app on android-activity, includes the module from its `settings.gradle.kts`, which asks `cargo metadata` where cargo put the `pushups` its Rust links, and adds `implementation(project(":pushups"))` to its app module. [`examples/winit`](https://github.com/LucaCappelletti94/pushups/tree/main/examples/winit) shows it in `android/settings.gradle.kts`. cargo-apk, cargo-apk2, xbuild and `cargo makepad` build no Gradle project and cannot ship the module. - -```kotlin -val pushupsDir = run { - val metadata = providers.exec { - commandLine("cargo", "metadata", "--format-version", "1", "--manifest-path", file("../Cargo.toml").path) - }.standardOutput.asText.get() - @Suppress("UNCHECKED_CAST") - val packages = (groovy.json.JsonSlurper().parseText(metadata) as Map)["packages"] as List> - File(packages.single { it["name"] == "pushups" }["manifest_path"] as String).parentFile -} -include(":pushups") -project(":pushups").projectDir = pushupsDir.resolve("android") -// Keeps the module's build output out of the crate's directory, which cargo's registry shares. -gradle.beforeProject { - if (path == ":pushups") layout.buildDirectory.set(rootDir.resolve("build/pushups")) -} -``` - -The module is also on Maven Central as `io.github.lucacappelletti94:pushups-android`, released with every version of the crate, for a Gradle project that would rather add one dependency than include the sources. Its version must be the version of the `pushups` crate the app links, since the Kotlin calls into that crate's Rust. - -```kotlin -dependencies { - implementation("io.github.lucacappelletti94:pushups-android:0.1.0") -} -``` - -In every route the module checks at start that the app's `pushups` crate is its own version, and a mismatch, such as an AAR of one release beside a crate of another, leaves push off and makes `install` return `Error::AndroidModuleMismatch` naming both versions. - -The app compiles its Firebase configuration in, from the `google-services.json` the Firebase console gives, with no Google services Gradle plugin. A process the push started with no UI can run Rust at once through a background handler. The push also waits on disk for the app's handler, which gets it when the UI opens. +The crate's Gradle module holds the FCM and UnifiedPush services. `dx` bundles it through the `dioxus` feature, a Tauri app adds [`tauri-plugin-pushups`](https://github.com/LucaCappelletti94/pushups/tree/main/tauri-plugin-pushups), and any other Gradle project depends on `io.github.lucacappelletti94:pushups-android` at the crate's version or includes `android/` through `cargo metadata` as [`examples/winit`](https://github.com/LucaCappelletti94/pushups/tree/main/examples/winit) does. A `UnifiedPushConfig` sends pushes through the device's UnifiedPush distributor when it has one. ```rust use pushups::{Context, Message}; @@ -120,69 +56,52 @@ pushups::firebase_config!("tests/fixtures/google-services.json"); #[pushups::background_handler] fn on_push(context: Context, message: Message) { - #[cfg(target_os = "android")] - let _ = (context.android_vm(), context.android_context()); let _ = (context, message.payload); } ``` -The payload is the FCM `data` map as a JSON object. A notification message that FCM shows itself reaches the app when the user taps it. A tap into a running `singleTop` or `singleTask` Activity reaches it through that Activity's `onNewIntent`, which an androidx `ComponentActivity` reports by itself, so a plain `android.app.Activity` with such a launch mode calls `setIntent(intent)` in its `onNewIntent`. - -An app whose `Config` has a `UnifiedPushConfig` gets its pushes through a [UnifiedPush](https://unifiedpush.org) distributor, such as ntfy, when the device has one, and through FCM otherwise. The first `register` with several distributors installed asks the user to pick one. The app holds one token at a time, so on UnifiedPush it is a Web Push subscription, and the payload is the message body the server sent. - -## Web - -The web target needs the app's VAPID public key. Every push shows a notification, as browsers require, and a payload in the [Declarative Web Push](https://webkit.org/blog/16535/meet-declarative-web-push/) shape (`"web_push": 8030`) is shown as it is. Pushes that arrive with no page open wait in `IndexedDB` for the next page's handler. Each `install` asks the browser to check the service worker for a change, so a new `pushups-sw.js` or app build takes over at the next page load, even where the worker's scope leaves the page out, as `dx`'s `/assets/` does. +## iOS and macOS -By default the app serves the static service worker at `/pushups-sw.js`, a worker with no wasm in it. With the `dioxus` feature `dx` serves it from the app's assets. Any other app serves `pushups::SERVICE_WORKER`, the worker's source, as JavaScript, from a route of its own server or as a file its build writes. An app that needs Rust to build the notification, to decrypt an end-to-end encrypted payload for instance, sets `rust_handler`. Its own wasm then runs as the service worker, with no second build, and its `main` serves the handler there. Under `dx` the page registers the app's glue, which starts itself. Under Trunk, cargo-leptos or wasm-pack the page starts the glue, so the app also serves a worker entry, a module that imports its glue and calls `init()`, and passes its path to `service_worker_path`. +The crate adds its push methods to whatever app delegate the toolkit installs. The bundle needs the push entitlement and, on iOS, the `remote-notification` background mode, which a `dx` app sets in `Dioxus.toml`. -```rust -use pushups::{Config, Message, Notification, WebPushConfig}; +```toml +[ios.entitlements] +aps-environment = "development" -async fn notification_for(message: Message) -> Notification { - Notification::new("New message") - .body(format!("{} bytes", message.payload.len())) - .navigate("/inbox") -} +[macos.entitlements] +"com.apple.developer.aps-environment" = "development" -fn main() { - if pushups::in_service_worker() { - pushups::serve_service_worker(notification_for).expect("in the service worker"); - return; - } - let vapid_public_key = [4; 65]; - let config = Config::new().web(WebPushConfig::new(vapid_public_key).rust_handler()); - if let Err(error) = pushups::install(config) { - eprintln!("no push on this target: {error}"); - } -} +[background] +remote-notifications = true ``` -A Trunk app, such as one built with Yew, writes the static worker from a `build.rs` with `pushups` as a build dependency, and copies it into the site from a `post_build` hook, as [`examples/yew`](https://github.com/LucaCappelletti94/pushups/tree/main/examples/yew) does. Its `Trunk.toml` turns file hashes off, so that the worker entry `sw.js` can import the glue by name. +## Web -A Leptos app built with cargo-leptos serves `pushups::SERVICE_WORKER` from a route of its axum server, as [`examples/leptos`](https://github.com/LucaCappelletti94/pushups/tree/main/examples/leptos) does. Its worker entry for `rust_handler` passes the wasm's URL to `init()`, since cargo-leptos renames the file, and the library's `#[wasm_bindgen(start)]` function serves the handler when `pushups::in_service_worker()` is true. +The app gives its VAPID public key and serves `pushups::SERVICE_WORKER` at `/pushups-sw.js`, which the `dioxus` feature has `dx` do, or sets `rust_handler` to run its own wasm as the worker, as [`examples/yew`](https://github.com/LucaCappelletti94/pushups/tree/main/examples/yew) and [`examples/leptos`](https://github.com/LucaCappelletti94/pushups/tree/main/examples/leptos) show. On iOS only home-screen web apps get pushes, and they reach the page when it is next shown ([WebKit bug 268797](https://bugs.webkit.org/show_bug.cgi?id=268797)). -Web Push needs HTTPS, or `localhost` while developing. On iOS it works only in web apps added to the home screen, and there a push reaches the handler when the app is next shown or started, even if it is open when the push arrives, since iOS gives the service worker no way to reach the open page ([WebKit bug 268797](https://bugs.webkit.org/show_bug.cgi?id=268797)). Safari never fires `pushsubscriptionchange`, so the page should call `register` on every load, which also moves a returning user to the new VAPID key after the app changes it. Webviews expose no Push API, so an app in a webview uses its platform's native backend. +```rust +use pushups::{Config, WebPushConfig}; + +let config = Config::new().web(WebPushConfig::new([4; 65])); +# let _ = config; +``` ## Linux -Linux has no push service of its own, so the crate speaks [UnifiedPush](https://unifiedpush.org) over the D-Bus session bus, through whichever distributor the user runs, such as KDE's. The app gives its reverse-DNS id, its name on the bus, and the VAPID public key of its server. The token is a Web Push subscription, so the server sends with any Web Push library. +The crate speaks UnifiedPush over D-Bus to the user's distributor, such as KDE's, and a push to a closed app starts it through D-Bus activation for the background handler. ```rust use pushups::{Config, LinuxConfig, UnifiedPushConfig}; -let vapid_public_key = [4; 65]; let config = Config::new() .linux(LinuxConfig::new("org.example.App")) - .unified_push(UnifiedPushConfig::new(vapid_public_key)); + .unified_push(UnifiedPushConfig::new([4; 65])); # let _ = config; ``` -`install` writes a D-Bus activation file to `~/.local/share/dbus-1/services`, unless the system already ships one for the app, so a push to a closed app starts it. A process started that way never returns from `install`. It hands the push to the background handler, keeps it on disk for the next window's handler, and exits once idle, so the app's window never opens for a push. There is no permission prompt, and `request_permission` answers whether a distributor runs. `register` returns at once, and a distributor that is offline, as KDE's is when the machine has no network, answers once it reconnects, so the token can come much later. - ## Windows -The Windows backend goes through the Windows App SDK 2.5 push notifications, whose runtime the app's installer puts on the machine. The app gives the Object ID of its Entra ID app registration's service principal, and ships `Microsoft.WindowsAppRuntime.Bootstrap.dll` beside its executable, where `install` loads it from. A push reaches a running app as `Event::Message`. A push to a closed app starts its executable as usual, and `install` hands the push to the background handler with `started_app` set before it returns. CI sends real WNS pushes to an unpackaged app on Windows Server 2025 to prove both, and a Windows 11 desktop is not checked yet. +The app's installer puts the Windows App SDK 2.5 runtime on the machine, and the app ships `Microsoft.WindowsAppRuntime.Bootstrap.dll` beside its executable. It gives the Object ID of its Entra ID app registration's service principal, and a push to a closed app starts it for the background handler. ```rust use pushups::{Config, WnsConfig}; @@ -190,11 +109,3 @@ use pushups::{Config, WnsConfig}; let config = Config::new().windows(WnsConfig::new("00000000-0000-0000-0000-000000000000")); # let _ = config; ``` - -| Platform | Push service | Backend | -|---|---|---| -| Android | FCM or UnifiedPush | available | -| iOS, macOS | APNs | in development | -| Linux | UnifiedPush over D-Bus | in development | -| Windows | WNS | available | -| Web | Web Push | available | From ba255cf826844be44e76bd0f07e25e78624b1f1a Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Mon, 12 Oct 2026 14:53:26 +0200 Subject: [PATCH 2/3] Sort the keys of an Apple push's JSON so both delegate paths match --- src/apple/runtime.rs | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/apple/runtime.rs b/src/apple/runtime.rs index 0714482..0fa1ff5 100644 --- a/src/apple/runtime.rs +++ b/src/apple/runtime.rs @@ -333,7 +333,7 @@ fn original_with(this: &AnyObject, cmd: Sel) -> Option<&'static AnyClass> { .filter(|original| original.instance_method(cmd).is_some()) } -/// The JSON encoding of a property-list dictionary such as a push's `userInfo`. +/// The JSON encoding of a property-list dictionary such as a push's `userInfo`, keys sorted so both delegate paths give the duplicate check the same bytes. fn json(object: &AnyObject) -> Option> { // SAFETY: `isValidJSONObject` accepts any object and never throws. if !unsafe { NSJSONSerialization::isValidJSONObject(object) } { @@ -341,7 +341,10 @@ fn json(object: &AnyObject) -> Option> { } // SAFETY: `isValidJSONObject` confirmed the object converts, so this cannot throw. let data: Result, Retained> = unsafe { - NSJSONSerialization::dataWithJSONObject_options_error(object, NSJSONWritingOptions::empty()) + NSJSONSerialization::dataWithJSONObject_options_error( + object, + NSJSONWritingOptions::SortedKeys, + ) }; data.ok().map(|data| data.to_vec()) } @@ -570,3 +573,31 @@ unsafe extern "C-unwind" fn delegate_changed( let center = UNUserNotificationCenter::currentNotificationCenter(); adopt_notification_delegate(¢er); } + +#[cfg(test)] +mod tests { + use objc2_foundation::{NSDictionary, NSString}; + + #[test] + fn the_json_of_a_push_lists_keys_in_sorted_order_whatever_the_dictionary_order() { + let keys: Vec<_> = (0..32) + .rev() + .map(|n| NSString::from_str(&format!("k{n:02}"))) + .collect(); + let values: Vec<_> = (0..32) + .rev() + .map(|n| NSString::from_str(&format!("v{n:02}"))) + .collect(); + let keys: Vec<&NSString> = keys.iter().map(|key| &**key).collect(); + let values: Vec<&NSString> = values.iter().map(|value| &**value).collect(); + let info = NSDictionary::from_slices(&keys, &values); + let entries: Vec = (0..32) + .map(|n| format!("\"k{n:02}\":\"v{n:02}\"")) + .collect(); + let json = super::json(&info).expect("a dictionary of strings is valid JSON"); + assert_eq!( + String::from_utf8(json).unwrap(), + format!("{{{}}}", entries.join(",")) + ); + } +} From d2aa5ce5ddbeef08674f2f09dd3afba41b3ef6e6 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Mon, 12 Oct 2026 14:53:27 +0200 Subject: [PATCH 3/3] Prove every framework cell with real pushes and CI jobs --- .github/workflows/ci.yml | 74 ++++++++++++ .github/workflows/windows-push.yml | 46 ++++++++ README.md | 11 +- assets/brand/icon-macos.ico | Bin 0 -> 22458 bytes assets/brand/render.sh | 7 ++ ci/linux/Dockerfile | 11 +- ci/linux/example-host.sh | 29 +++++ ci/linux/example.sh | 122 ++++++++++++++++++++ ci/windows/example.ps1 | 177 +++++++++++++++++++++++++++++ examples/dioxus/src/main.rs | 9 +- examples/tauri/.gitignore | 1 + examples/tauri/Cargo.lock | 1 + examples/tauri/Cargo.toml | 1 + examples/tauri/entitlements.plist | 8 ++ examples/tauri/src/lib.rs | 30 ++++- examples/tauri/tauri.conf.json | 9 +- examples/winit/.gitignore | 2 + examples/winit/Cargo.lock | 10 ++ examples/winit/Cargo.toml | 10 ++ examples/winit/Trunk.toml | 13 +++ examples/winit/build.rs | 11 ++ examples/winit/index.html | 20 ++++ examples/winit/src/app.rs | 105 ++++++++++++++++- examples/winit/src/main.rs | 27 ++++- examples/winit/sw.js | 5 + 25 files changed, 718 insertions(+), 21 deletions(-) create mode 100644 assets/brand/icon-macos.ico create mode 100755 ci/linux/example-host.sh create mode 100755 ci/linux/example.sh create mode 100644 ci/windows/example.ps1 create mode 100644 examples/tauri/entitlements.plist create mode 100644 examples/winit/Trunk.toml create mode 100644 examples/winit/build.rs create mode 100644 examples/winit/index.html create mode 100644 examples/winit/sw.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e69adb6..1bda9a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -250,6 +250,33 @@ jobs: path: target/linux-rust.lcov if-no-files-found: error + example-linux: + name: Example apps on Linux through a UnifiedPush distributor + runs-on: ubuntu-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + example: [winit, tauri] + # A fork's pull request gets no secrets, so it pulls anonymously. + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + steps: + - uses: actions/checkout@v7 + - run: rustup toolchain install stable --profile minimal + # Tauri builds on Linux against GTK and WebKitGTK. + - if: matrix.example == 'tauri' + run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev + # GitHub's runners share Docker Hub's anonymous pull limit, which the image pull otherwise hits. + - if: env.DOCKERHUB_USERNAME != '' + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + # The app in a window on Xvfb, KDE's distributor in a Debian testing container, real pushes through ntfy.sh. + - name: Token, a push to the open app, a push to the closed app that the bus starts headless, the next window draining it + run: ci/linux/example-host.sh ${{ matrix.example }} + coverage: name: Coverage and Sonar needs: [android-coverage, linux-coverage, web-coverage] @@ -525,6 +552,38 @@ jobs: - name: The static-worker build in Chrome, with real pushes run: node ci/web/example.mjs check examples/yew/dist-static "$RUNNER_TEMP/vapid.json" '/pushups-sw\.js\?pushups=static' + example-winit-web: + name: Example winit app on Trunk in Chrome + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - run: | + rustup toolchain install stable --profile minimal + rustup target add --toolchain stable wasm32-unknown-unknown + - uses: taiki-e/install-action@v2 + with: + tool: trunk@0.21.14 + - uses: actions/setup-node@v7 + with: + node-version: "24" + cache: npm + cache-dependency-path: ci/web/package-lock.json + - run: npm ci + working-directory: ci/web + - name: Build the winit app in both worker modes + working-directory: examples/winit + run: | + key=$(node ../../ci/web/example.mjs keys "$RUNNER_TEMP/vapid.json") + PUSHUPS_VAPID_PUBLIC_KEY="$key" trunk build --release --dist dist-rust + PUSHUPS_WEB_WORKER=static PUSHUPS_VAPID_PUBLIC_KEY="$key" trunk build --release --dist dist-static + # `build.rs` wrote `pushups::SERVICE_WORKER`, which the Trunk hook copied. + cmp dist-static/pushups-sw.js ../../js/pushups-sw.js + - name: The Rust-handler build in Chrome, with real pushes + run: node ci/web/example.mjs check examples/winit/dist-rust "$RUNNER_TEMP/vapid.json" '/sw\.js\?pushups=rust' --rust + - name: The static-worker build in Chrome, with real pushes + run: node ci/web/example.mjs check examples/winit/dist-static "$RUNNER_TEMP/vapid.json" '/pushups-sw\.js\?pushups=static' + example-leptos: name: Example Leptos SSR app in Chrome runs-on: ubuntu-latest @@ -573,3 +632,18 @@ jobs: nm -gU "$binary" | grep -q ___pushups_background_handler || { echo "no background handler in $binary"; exit 1; } done done + # The Tauri build needs its CLI, and CI has no signing identity, so the app comes out unsigned. + - name: Build the Tauri app, unsigned, and check the handler and the aps-environment entitlement + run: | + cargo install tauri-cli --locked --version 2.12.1 + (cd examples/tauri && cargo tauri build --debug --bundles app) + app=$(find examples/tauri/target/debug/bundle/macos -maxdepth 1 -name "*.app" | head -n1) + binary="$app/Contents/MacOS/pushups-tauri-example" + nm -gU "$binary" | grep -q ___pushups_background_handler || { echo "no background handler in $binary"; exit 1; } + entitlements=$(codesign -d --entitlements - "$binary" 2>/dev/null || true) + if [ -n "$entitlements" ]; then + echo "$entitlements" | grep -q "com.apple.developer.aps-environment" || { echo "no aps-environment in the Tauri app entitlements"; exit 1; } + else + # An unsigned build applies no entitlements, so check the file the signed build applies. + grep -q "com.apple.developer.aps-environment" examples/tauri/entitlements.plist || { echo "no aps-environment in examples/tauri/entitlements.plist"; exit 1; } + fi diff --git a/.github/workflows/windows-push.yml b/.github/workflows/windows-push.yml index 6e3946d..2097218 100644 --- a/.github/workflows/windows-push.yml +++ b/.github/workflows/windows-push.yml @@ -43,3 +43,49 @@ jobs: WNS_OBJECT_ID: ${{ secrets.WNS_OBJECT_ID }} WNS_CLIENT_SECRET: ${{ secrets.WNS_CLIENT_SECRET }} run: ./ci/windows/w0.ps1 + example: + name: Example (${{ matrix.example }}) + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: windows-latest + timeout-minutes: 90 + strategy: + matrix: + example: [dioxus, tauri, winit] + steps: + - uses: actions/checkout@v7 + - run: rustup toolchain install stable --profile minimal + - name: Install the Windows App SDK 2.5 runtime + shell: pwsh + run: | + $installer = Join-Path $env:RUNNER_TEMP 'WindowsAppRuntimeInstall-x64.exe' + Invoke-WebRequest -TimeoutSec 600 -OutFile $installer 'https://aka.ms/windowsappsdk/2.5/latest/windowsappruntimeinstall-x64.exe' + $install = Start-Process $installer -ArgumentList '--quiet' -NoNewWindow -PassThru + # The handle is taken now so the exit code stays readable. + $null = $install.Handle + if (-not $install.WaitForExit(600000)) { $install.Kill(); throw 'the runtime installer did not finish within 10 minutes' } + if ($install.ExitCode -ne 0) { throw "the runtime installer exited with $($install.ExitCode)" } + - name: Build the ${{ matrix.example }} example + shell: pwsh + env: + EXAMPLE: ${{ matrix.example }} + PUSHUPS_WNS_REMOTE_ID: ${{ secrets.WNS_OBJECT_ID }} + run: | + # The Dioxus example's `firebase_config!` reads this file on every target. + Copy-Item tests/fixtures/google-services.json "examples/$env:EXAMPLE/google-services.json" + $map = @{ + dioxus = @{ Manifest = 'examples/dioxus/Cargo.toml'; Args = @('--no-default-features', '--features', 'desktop') } + tauri = @{ Manifest = 'examples/tauri/Cargo.toml'; Args = @() } + winit = @{ Manifest = 'examples/winit/Cargo.toml'; Args = @() } + } + $entry = $map[$env:EXAMPLE] + $buildArgs = $entry.Args + cargo +stable build --release --manifest-path $entry.Manifest @buildArgs + - name: Run the ${{ matrix.example }} example with WNS pushes + shell: pwsh + env: + WNS_TENANT_ID: ${{ secrets.WNS_TENANT_ID }} + WNS_APP_ID: ${{ secrets.WNS_APP_ID }} + WNS_OBJECT_ID: ${{ secrets.WNS_OBJECT_ID }} + WNS_CLIENT_SECRET: ${{ secrets.WNS_CLIENT_SECRET }} + run: | + ./ci/windows/example.ps1 "$env:GITHUB_WORKSPACE\examples\${{ matrix.example }}\target\release\pushups-${{ matrix.example }}-example.exe" diff --git a/README.md b/README.md index ac046b5..02cdaf0 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,13 @@ The handler runs on the platform's delivery thread, so waking the toolkit is its | | Android | iOS | macOS | Windows | Linux | Web | |---|---|---|---|---|---|---| -| Dioxus 0.7 | yes, though a tap into the running app restarts it until [tao#1365](https://github.com/tauri-apps/tao/pull/1365) | yes up to iOS 26, iOS 27 needs [dioxus#5893](https://github.com/DioxusLabs/dioxus/pull/5893) | yes | untested | yes | yes | -| Tauri 2 | yes | yes | untested | untested | untested | n/a | -| tao 0.37 | untested | yes | yes | yes | untested | n/a | -| winit 0.31 | needs [winit#4752](https://github.com/rust-windowing/winit/pull/4752), which `examples/winit` pins | yes | yes | untested | untested | untested | +| Dioxus 0.7 | yes, though a tap into the running app restarts it until [tao#1365](https://github.com/tauri-apps/tao/pull/1365) | yes up to iOS 26, iOS 27 needs [dioxus#5893](https://github.com/DioxusLabs/dioxus/pull/5893) | yes | panics when its window cannot take focus, as on a locked screen, until Dioxus takes [wry#1799](https://github.com/tauri-apps/wry/pull/1799) | yes | yes | +| Tauri 2 | yes | yes | yes | yes | yes | n/a | +| winit 0.31 | needs [winit#4752](https://github.com/rust-windowing/winit/pull/4752), which `examples/winit` pins | yes | yes | yes | yes | yes | | Leptos 0.8 | n/a | n/a | n/a | n/a | n/a | yes | | Yew 0.23 | n/a | n/a | n/a | n/a | n/a | yes | -The [examples](https://github.com/LucaCappelletti94/pushups/tree/main/examples) are the proofs. Without the winit fix, a fresh install's first launch can get no FCM token until the app is reopened. +The [examples](https://github.com/LucaCappelletti94/pushups/tree/main/examples) are the proofs. Dioxus and Tauri run on tao, so their cells cover a tao app. Without the winit fix, a fresh install's first launch can get no FCM token until the app is reopened. ## Android @@ -62,7 +61,7 @@ fn on_push(context: Context, message: Message) { ## iOS and macOS -The crate adds its push methods to whatever app delegate the toolkit installs. The bundle needs the push entitlement and, on iOS, the `remote-notification` background mode, which a `dx` app sets in `Dioxus.toml`. +The crate adds its push methods to whatever app delegate the toolkit installs. The bundle needs the push entitlement and, on iOS, the `remote-notification` background mode, which a `dx` app sets in `Dioxus.toml`. A Tauri app gets the iOS entitlement from `tauri-plugin-pushups` and names a macOS entitlements file in `tauri.conf.json`, as [`examples/tauri`](https://github.com/LucaCappelletti94/pushups/tree/main/examples/tauri) does. ```toml [ios.entitlements] diff --git a/assets/brand/icon-macos.ico b/assets/brand/icon-macos.ico new file mode 100644 index 0000000000000000000000000000000000000000..a46b8bfa96f2d67204f44f0be0603b239d5ec8af GIT binary patch literal 22458 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 V;13kJSinj`n!EO>=BQYn`G3?Z(P#hw literal 0 HcmV?d00001 diff --git a/assets/brand/render.sh b/assets/brand/render.sh index e6bfc8b..54ea6a0 100755 --- a/assets/brand/render.sh +++ b/assets/brand/render.sh @@ -9,6 +9,13 @@ mkdir -p "$brand/png" for size in 32 128 256 512 1024; do rsvg-convert --width "$size" --height "$size" "$brand/icon-macos.svg" --output "$brand/png/icon-macos-$size.png" done +# The Windows resource icon `tauri-build` compiles into the Tauri example, which it reads only from an `.ico`. +ico=$(mktemp -d) +for size in 16 32 48 256; do + rsvg-convert --width "$size" --height "$size" "$brand/icon-macos.svg" --output "$ico/$size.png" +done +convert "$ico/16.png" "$ico/32.png" "$ico/48.png" "$ico/256.png" "$brand/icon-macos.ico" +rm -r "$ico" # The repository's social preview, uploaded in the GitHub settings. rsvg-convert --width 1280 --height 640 "$brand/social.svg" --output "$brand/png/social.png" diff --git a/ci/linux/Dockerfile b/ci/linux/Dockerfile index d9740a3..3ab8ad9 100644 --- a/ci/linux/Dockerfile +++ b/ci/linux/Dockerfile @@ -6,15 +6,24 @@ ENV DEBIAN_FRONTEND=noninteractive ENV QT_QPA_PLATFORM=offscreen ENV LANG=C.UTF-8 -# dbus provides dbus-daemon, dbus-run-session and dbus-send. systemd provides busctl, procps pgrep. +# The session bus, KDE's distributor, and Xvfb with the X and WebKitGTK libraries the example apps open their windows with. RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ dbus \ kunifiedpush \ + libgtk-3-0t64 \ + libx11-6 \ + libxcomposite1 \ + libxdamage1 \ + libxfixes3 \ + libxkbcommon0 \ + libxrandr2 \ + libwebkit2gtk-4.1-0 \ procps \ systemd \ + xvfb \ && rm -rf /var/lib/apt/lists/* RUN useradd -m -u 1000 -s /bin/bash testuser \ diff --git a/ci/linux/example-host.sh b/ci/linux/example-host.sh new file mode 100755 index 0000000..d1addcd --- /dev/null +++ b/ci/linux/example-host.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Builds the named example, winit or tauri, with a fresh VAPID key pair, builds the probe's send +# binary, and runs ci/linux/example.sh in the UnifiedPush test bed. Needs Docker and, for tauri, +# the GTK and WebKitGTK development packages. +set -euo pipefail + +example=${1:?usage: example-host.sh winit|tauri} +case "$example" in winit | tauri) ;; *) exit 2 ;; esac + +ci=$(cd "$(dirname "$0")" && pwd) +root=$(cd "$ci/../.." && pwd) +mkdir -p "$root/target" +out=$(mktemp -d "$root/target/linux-example.XXXXXX") +trap 'rm -rf "$out"' EXIT + +# A fresh VAPID key pair for the app's server, the private PEM and the base64url public point. +openssl ecparam -name prime256v1 -genkey -noout -out "$out/vapid.pem" +openssl ec -in "$out/vapid.pem" -pubout -outform DER 2>/dev/null | tail -c 65 \ + | base64 -w0 | tr '+/' '-_' | tr -d '=' > "$out/vapid.b64url" + +(cd "$ci/probe" && cargo build --quiet --release --bin send) +( + cd "$root/examples/$example" + PUSHUPS_VAPID_PUBLIC_KEY="$(cat "$out/vapid.b64url")" cargo build --quiet --release +) + +# The host's user, whose uid need not be the container's, must be able to delete what lands here. +chmod -R a+rwX "$out" +PUSHUPS_TESTBED_OUT="$out" "$ci/testbed.sh" bash /work/ci/linux/example.sh "$example" diff --git a/ci/linux/example.sh b/ci/linux/example.sh new file mode 100755 index 0000000..6bf1ebf --- /dev/null +++ b/ci/linux/example.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# Walks one example app, winit or tauri, through the plan's Linux delivery table with real pushes +# through ntfy.sh, inside the test bed (ci/linux/testbed.sh). The app is built on the host with +# PUSHUPS_VAPID_PUBLIC_KEY and runs from /work. Reads /out/vapid.pem and writes the app's log to +# /out/run. +set -euo pipefail + +example=${1:?usage: example.sh winit|tauri} +case "$example" in +winit) bin=/work/examples/winit/target/release/pushups-winit-example ;; +tauri) bin=/work/examples/tauri/target/release/pushups-tauri-example ;; +*) echo "example.sh: expected winit or tauri, got $example" >&2; exit 2 ;; +esac + +out=/out/run +send=/work/ci/linux/probe/target/release/send +app_id=rs.pushups.example + +umask 0000 +mkdir -p "$out" +rm -f "$out/pushups-example.log" "$out/subscription.json" + +fail() { + echo "example: $*" >&2 + echo "--- pushups-example.log" >&2 + cat "$out/pushups-example.log" >&2 2>/dev/null || true + echo "--- distributor" >&2 + cat /tmp/pw-testbed-distributor.log >&2 || true + exit 1 +} + +# A virtual display for the window, since the container has no one to show it in. +Xvfb :99 -screen 0 1024x768x24 & +xvfb=$! +trap 'kill "$xvfb" 2>/dev/null || true' EXIT +deadline=$((SECONDS + 30)) +while [ ! -e /tmp/.X11-unix/X99 ] && [ "$SECONDS" -lt "$deadline" ]; do sleep 0.2; done +[ -e /tmp/.X11-unix/X99 ] || fail "Xvfb did not start within 30 s" + +export TMPDIR="$out" +export DISPLAY=:99 +unset WAYLAND_DISPLAY +# Xvfb has no GPU, so WebKit renders with software GL and no compositor. +export LIBGL_ALWAYS_SOFTWARE=1 +export WEBKIT_DISABLE_COMPOSITING_MODE=1 + +# A fresh queue, so an earlier run of the same app id leaves nothing behind. +rm -rf "${XDG_DATA_HOME:?}/$app_id" + +# A process the bus starts for a push gets the bus's activation environment, which this sets. +busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus \ + UpdateActivationEnvironment 'a{ss}' 6 \ + HOME "$HOME" XDG_DATA_HOME "$XDG_DATA_HOME" TMPDIR "$TMPDIR" \ + DISPLAY "$DISPLAY" LIBGL_ALWAYS_SOFTWARE "$LIBGL_ALWAYS_SOFTWARE" \ + WEBKIT_DISABLE_COMPOSITING_MODE "$WEBKIT_DISABLE_COMPOSITING_MODE" + +now_ms() { date +%s%3N; } + +push() { + "$send" "$out/subscription.json" /out/vapid.pem "$1" || fail "the push service refused $1" +} + +app_running() { + pgrep -f "$bin" >/dev/null +} + +# Waits up to $2 seconds for a line of the app log containing $1 and prints it. +await_line() { + local deadline=$((SECONDS + $2)) + while [ "$SECONDS" -lt "$deadline" ]; do + if grep -qF -- "$1" "$out/pushups-example.log" 2>/dev/null; then + grep -F -- "$1" "$out/pushups-example.log" | tail -1 + return 0 + fi + sleep 0.2 + done + fail "no line with '$1' in the app log within $2 s" +} + +stop_ui() { + kill "$1" 2>/dev/null || true + deadline=$((SECONDS + 30)) + while app_running && [ "$SECONDS" -lt "$deadline" ]; do sleep 0.5; done + if app_running; then fail "the UI app did not exit within 30 s"; fi +} + +echo "== a UI process registers and receives at once" +"$bin" & +ui=$! +line=$(await_line "ui token at_ms=" 90) +[[ $line == *"subscription={"* ]] || fail "the token line carried no subscription: $line" +[ -f "$XDG_DATA_HOME/dbus-1/services/$app_id.service" ] || fail "install wrote no activation file" +printf '%s\n' "${line#*subscription=}" > "$out/subscription.json" + +echo "== a push to the running app reaches the UI and the background handler" +push "{\"seq\":\"1\",\"sent_at_ms\":\"$(now_ms)\"}" +line=$(await_line "started_app=false seq=1 " 60) +[[ $line == *"ui message"* ]] || fail "the seq=1 UI line is not a message: $line" +line=$(await_line "handler started_app=false" 30) +[[ $line == *"seq=1 "* ]] || fail "the background handler line is not seq=1: $line" + +echo "== a push to a closed app starts it headless, which persists it and exits" +stop_ui "$ui" +push "{\"seq\":\"2\",\"sent_at_ms\":\"$(now_ms)\"}" +line=$(await_line "handler started_app=true" 90) +[[ $line == *"seq=2 "* ]] || fail "the headless handler line is not seq=2: $line" +# Only the next UI session may see seq=2 as a message. +if grep -qF "started_app=true seq=2 " "$out/pushups-example.log"; then + fail "the headless process emitted seq=2 to a UI handler" +fi +deadline=$((SECONDS + 60)) +while app_running && [ "$SECONDS" -lt "$deadline" ]; do sleep 0.5; done +if app_running; then fail "the headless process did not exit when idle"; fi + +echo "== the next UI process drains the queue" +"$bin" & +ui=$! +line=$(await_line "started_app=true seq=2 " 60) +[[ $line == *"ui message"* ]] || fail "the drained line is not a UI message: $line" +stop_ui "$ui" + +echo "== every step passed" diff --git a/ci/windows/example.ps1 b/ci/windows/example.ps1 new file mode 100644 index 0000000..18cf5e4 --- /dev/null +++ b/ci/windows/example.ps1 @@ -0,0 +1,177 @@ +# Runs one pushups example exe (Dioxus, Tauri or winit) with real WNS pushes, for the X1 proofs. +# The exe must have been built with PUSHUPS_WNS_REMOTE_ID set, so a process Windows starts for a +# push knows the remote id without any file to read. +# Run from the repository root after building the example, with WNS_TENANT_ID, WNS_APP_ID, +# WNS_OBJECT_ID and WNS_CLIENT_SECRET set. Takes the built exe's path. +param([Parameter(Mandatory)][string] $Exe) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +# The Foundation package metapackage 2.5.1 depends on, the release ci/windows/bindings.sh pins. +$Foundation = '2.3.12' +# WNS may take minutes to answer a channel request. +$ChannelBound = [TimeSpan]::FromSeconds(240) +$PushBound = [TimeSpan]::FromSeconds(90) +# A push-started process also starts a window and a web view environment. +$ActivationBound = [TimeSpan]::FromSeconds(180) +$ExitBound = [TimeSpan]::FromSeconds(20) + +foreach ($name in 'WNS_TENANT_ID', 'WNS_APP_ID', 'WNS_OBJECT_ID', 'WNS_CLIENT_SECRET') { + if (-not [Environment]::GetEnvironmentVariable($name)) { throw "$name is not set" } +} + +$exe = (Resolve-Path $Exe).Path +$app = [IO.Path]::GetFileNameWithoutExtension($exe) +$dir = Split-Path $exe +$log = Join-Path $env:TEMP 'pushups-example.log' +$work = Join-Path ($env:RUNNER_TEMP ?? [IO.Path]::GetTempPath()) "example-$app" +$verdict = [Collections.Generic.List[string]]::new() +$passed = $true + +function Note([string] $line) { + Write-Host $line + $verdict.Add($line) +} + +# Runs `$done` until it returns something or `$bound` passes on a monotonic clock. +function Wait-Until([TimeSpan] $bound, [scriptblock] $done) { + $clock = [Diagnostics.Stopwatch]::StartNew() + while ($clock.Elapsed -lt $bound) { + $result = & $done + if ($result) { return $result } + Start-Sleep -Milliseconds 250 + } + return $null +} + +function First-Line([string] $file, [string] $pattern) { + Get-Content $file -ErrorAction SilentlyContinue | Where-Object { $_ -match $pattern } | Select-Object -First 1 +} + +function Send-Push([string] $uri, [string] $token, [string] $payload) { + $response = Invoke-WebRequest -Method Post -Uri $uri -TimeoutSec 60 -SkipHttpErrorCheck ` + -Headers @{ Authorization = "Bearer $token"; 'X-WNS-Type' = 'wns/raw' } ` + -ContentType 'application/octet-stream' -Body ([Text.Encoding]::UTF8.GetBytes($payload)) + $wns = foreach ($header in 'X-WNS-Status', 'X-WNS-NotificationStatus', 'X-WNS-Error-Description') { + if ($response.Headers.ContainsKey($header)) { "$header=$($response.Headers[$header] -join ',')" } + } + "HTTP $($response.StatusCode) $($wns -join ' ')" +} + +# Ends the app's process and reports whether it left within the bound. +function Stop-App([string] $label, [System.Diagnostics.Process] $p) { + $p.Kill() | Out-Null + $exited = $p.WaitForExit([int] $ExitBound.TotalMilliseconds) + if (-not $exited) { Note "- the $label app did not exit within $ExitBound" } + $exited +} + +Note "## X1 on $((Get-CimInstance Win32_OperatingSystem).Caption) $([Environment]::OSVersion.Version), $app" + +# The bootstrapper the 2.5 channel install loads, which a desktop machine ships nowhere, fetched as w0.ps1 does. +$bootstrap = Join-Path $dir 'Microsoft.WindowsAppRuntime.Bootstrap.dll' +if (Test-Path $bootstrap) { + Note '- the bootstrapper already beside the exe' +} else { + New-Item -ItemType Directory -Force $work | Out-Null + $package = Join-Path $work 'foundation.zip' + Invoke-WebRequest -TimeoutSec 600 -OutFile $package ` + "https://api.nuget.org/v3-flatcontainer/microsoft.windowsappsdk.foundation/$Foundation/microsoft.windowsappsdk.foundation.$Foundation.nupkg" + Expand-Archive -Force $package (Join-Path $work 'foundation') + Copy-Item (Join-Path $work 'foundation/runtimes/win-x64/native/Microsoft.WindowsAppRuntime.Bootstrap.dll') $bootstrap + Note '- the bootstrapper copied beside the exe' +} + +# The temp log a process started with nothing reading its output writes to. +Remove-Item $log -ErrorAction SilentlyContinue + +$process = Start-Process -FilePath $exe -WorkingDirectory $dir -NoNewWindow -PassThru + +$uri = $null +$tokenLine = Wait-Until $ChannelBound { First-Line $log 'ui token at_ms=\d+ Wns \{ channel_uri: "([^"]+)"' } +if (-not $tokenLine) { + Note "- no token within $ChannelBound" + $passed = $false +} elseif ($tokenLine -match 'channel_uri: "([^"]+)"') { + $uri = $matches[1] + Write-Host "::add-mask::$uri" + Note "- channel created on $(([Uri] $uri).Host), $($tokenLine -replace '^.*ui token at_ms=\d+ ', '')" +} else { + Note "- the token line has no channel uri: $tokenLine" + $passed = $false +} + +$token = $null +if ($uri) { + try { + $token = (Invoke-RestMethod -Method Post -TimeoutSec 60 ` + -Uri "https://login.microsoftonline.com/$env:WNS_TENANT_ID/oauth2/v2.0/token" ` + -Body @{ + grant_type = 'client_credentials' + client_id = $env:WNS_APP_ID + client_secret = $env:WNS_CLIENT_SECRET + scope = 'https://wns.windows.com/.default' + }).access_token + Write-Host "::add-mask::$token" + Note '- access token issued for https://wns.windows.com/.default' + } catch { + $reason = if ($_.ErrorDetails) { $_.ErrorDetails.Message } else { $_.Exception.Message } + Note "- access token refused: $reason" + $token = $null + $passed = $false + } +} + +if ($uri -and $token) { + $sent = [DateTimeOffset]::Now.ToUnixTimeMilliseconds() + $payload = '{"seq":"1","sent_at_ms":"' + $sent + '"}' + try { + $result = Send-Push $uri $token $payload + } catch { + $result = "failed: $($_.Exception.Message)" + } + Note "- push 1 to the running app: $result" + $got = Wait-Until $PushBound { + $ui = First-Line $log 'ui message at_ms=\d+ started_app=false seq=1 ' + $handler = First-Line $log 'handler started_app=false .*seq=1 ' + if ($ui -and $handler) { [pscustomobject]@{ Ui = $ui; Handler = $handler } } + } + if ($got) { + Note "- running: $($got.Ui)" + Note "- running: $($got.Handler)" + } else { + Note "- the running app did not get seq=1 within $PushBound" + $passed = $false + } + if (-not (Stop-App 'running' $process)) { $passed = $false } + + # The same channel still reaches the app once the process is gone. + $sent = [DateTimeOffset]::Now.ToUnixTimeMilliseconds() + $payload = '{"seq":"2","sent_at_ms":"' + $sent + '"}' + try { + $result = Send-Push $uri $token $payload + } catch { + $result = "failed: $($_.Exception.Message)" + } + Note "- push 2 with the app stopped: $result" + $got2 = Wait-Until $ActivationBound { First-Line $log 'handler started_app=true .*seq=2 ' } + if ($got2) { + Note "- push-started: $got2" + $started = Wait-Until $ExitBound { Get-Process -Name $app -ErrorAction SilentlyContinue | Select-Object -First 1 } + if ($started) { + if (-not (Stop-App 'push-started' $started)) { $passed = $false } + } else { + Note '- the push-started process was already gone when it was looked for' + } + } else { + Note "- the stopped app was not started with seq=2 within $ActivationBound" + $passed = $false + } +} + +Write-Host "--- $app pushups-example.log" +Get-Content $log -ErrorAction SilentlyContinue | Write-Host + +if ($env:GITHUB_STEP_SUMMARY) { $verdict | Add-Content $env:GITHUB_STEP_SUMMARY } +exit ([int] (-not $passed)) diff --git a/examples/dioxus/src/main.rs b/examples/dioxus/src/main.rs index 9a05f0d..318bd3d 100644 --- a/examples/dioxus/src/main.rs +++ b/examples/dioxus/src/main.rs @@ -4,7 +4,7 @@ //! //! `PUSHUPS_VAPID_PUBLIC_KEY` (base64url) at build time sets the app server's VAPID key, for Web Push //! on the web and UnifiedPush on Android and Linux. `PUSHUPS_WEB_WORKER=static` registers the static -//! worker in place of the Rust handler. +//! worker in place of the Rust handler. `PUSHUPS_WNS_REMOTE_ID` sets the Windows remote id. use std::io::{Read, Write}; use std::net::TcpStream; @@ -16,7 +16,7 @@ use base64::Engine as _; use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use pushups::{ Config, Context, Event, LinuxConfig, Message, Notification, Permission, UnifiedPushConfig, - WebPushConfig, + WebPushConfig, WnsConfig, }; pushups::firebase_config!("google-services.json"); @@ -41,7 +41,10 @@ fn main() { fn config() -> Config { // The bundle identifier, which also names the app on the Linux session bus. - let config = Config::new().linux(LinuxConfig::new("rs.pushups.example")); + let mut config = Config::new().linux(LinuxConfig::new("rs.pushups.example")); + if let Some(remote_id) = option_env!("PUSHUPS_WNS_REMOTE_ID") { + config = config.windows(WnsConfig::new(remote_id)); + } let key = option_env!("PUSHUPS_VAPID_PUBLIC_KEY") .and_then(|key| URL_SAFE_NO_PAD.decode(key).ok()) .and_then(|key| <[u8; 65]>::try_from(key).ok()); diff --git a/examples/tauri/.gitignore b/examples/tauri/.gitignore index d57a05f..af8350e 100644 --- a/examples/tauri/.gitignore +++ b/examples/tauri/.gitignore @@ -1,3 +1,4 @@ /target /gen /google-services.json +/embedded.provisionprofile diff --git a/examples/tauri/Cargo.lock b/examples/tauri/Cargo.lock index 4019c93..a43725e 100644 --- a/examples/tauri/Cargo.lock +++ b/examples/tauri/Cargo.lock @@ -3062,6 +3062,7 @@ dependencies = [ name = "pushups-tauri-example" version = "0.0.0" dependencies = [ + "base64 0.22.1", "pushups", "serde_json", "tauri", diff --git a/examples/tauri/Cargo.toml b/examples/tauri/Cargo.toml index a106d26..277260b 100644 --- a/examples/tauri/Cargo.toml +++ b/examples/tauri/Cargo.toml @@ -15,6 +15,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2.7", features = [] } [dependencies] +base64 = "0.22" pushups = { path = "../.." } serde_json = "1" tauri = { version = "2.12", features = [] } diff --git a/examples/tauri/entitlements.plist b/examples/tauri/entitlements.plist new file mode 100644 index 0000000..1da788e --- /dev/null +++ b/examples/tauri/entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.developer.aps-environment + development + + diff --git a/examples/tauri/src/lib.rs b/examples/tauri/src/lib.rs index f80e483..fad2f9a 100644 --- a/examples/tauri/src/lib.rs +++ b/examples/tauri/src/lib.rs @@ -3,7 +3,9 @@ //! wall-clock time, to logcat under `pushups-example` on Android, so `ci/android/example.sh` can //! time send to delivery. -use pushups::{Config, Context, Event, Message}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use pushups::{Config, Context, Event, LinuxConfig, Message, UnifiedPushConfig, WnsConfig}; #[cfg(target_os = "android")] pushups::firebase_config!("google-services.json"); @@ -11,7 +13,7 @@ pushups::firebase_config!("google-services.json"); /// Installs `pushups`, sets its handler and registers, then runs the Tauri app. #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - match pushups::install(Config::new()) { + match pushups::install(config()) { Ok(()) => log(&format!("installed at_ms={}", now_ms())), Err(error) => log(&format!("install failed: {error}")), } @@ -25,9 +27,33 @@ pub fn run() { .expect("the Tauri app runs"); } +/// The app's configuration from build-time keys, `PUSHUPS_VAPID_PUBLIC_KEY` (base64url) for UnifiedPush on Linux and Android and `PUSHUPS_WNS_REMOTE_ID` for Windows. +fn config() -> Config { + // The bundle identifier, which also names the app on the Linux session bus. + let mut config = Config::new().linux(LinuxConfig::new("rs.pushups.example")); + if let Some(remote_id) = option_env!("PUSHUPS_WNS_REMOTE_ID") { + config = config.windows(WnsConfig::new(remote_id)); + } + let key = option_env!("PUSHUPS_VAPID_PUBLIC_KEY") + .and_then(|key| URL_SAFE_NO_PAD.decode(key).ok()) + .and_then(|key| <[u8; 65]>::try_from(key).ok()); + if let Some(key) = key { + config = config.unified_push(UnifiedPushConfig::new(key)); + } + config +} + fn describe(event: &Event) -> String { let at = now_ms(); match event { + Event::Token(token @ pushups::Token::WebPush { endpoint, .. }) => { + let keys = token.web_push_keys().expect("a Web Push token has keys"); + let subscription = serde_json::json!({ + "endpoint": endpoint, + "keys": { "p256dh": keys.p256dh, "auth": keys.auth }, + }); + format!("ui token at_ms={at} subscription={subscription}") + } Event::Token(token) => format!("ui token at_ms={at} {token:?}"), Event::RegistrationFailed(error) => format!("ui registration failed at_ms={at} {error}"), Event::Message(message) => format!( diff --git a/examples/tauri/tauri.conf.json b/examples/tauri/tauri.conf.json index bad4533..e6804d1 100644 --- a/examples/tauri/tauri.conf.json +++ b/examples/tauri/tauri.conf.json @@ -18,7 +18,12 @@ "icon": [ "../../assets/brand/png/icon-macos-512.png", "../../assets/brand/png/icon-macos-128.png", - "../../assets/brand/png/icon-macos-32.png" - ] + "../../assets/brand/png/icon-macos-32.png", + "../../assets/brand/icon-macos.ico" + ], + "macOS": { + "entitlements": "entitlements.plist", + "hardenedRuntime": false + } } } diff --git a/examples/winit/.gitignore b/examples/winit/.gitignore index 949be0c..ffc490a 100644 --- a/examples/winit/.gitignore +++ b/examples/winit/.gitignore @@ -4,3 +4,5 @@ /android/app/build /android/app/src/main/jniLibs /android/local.properties +/dist* +/pushups-sw.js diff --git a/examples/winit/Cargo.lock b/examples/winit/Cargo.lock index 7895898..662bbe5 100644 --- a/examples/winit/Cargo.lock +++ b/examples/winit/Cargo.lock @@ -245,6 +245,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -1449,9 +1455,13 @@ dependencies = [ name = "pushups-winit-example" version = "0.0.0" dependencies = [ + "base64", "futures-executor", "pushups", "serde_json", + "wasm-bindgen-futures", + "web-sys", + "web-time", "winit", ] diff --git a/examples/winit/Cargo.toml b/examples/winit/Cargo.toml index 036fcf9..bed315d 100644 --- a/examples/winit/Cargo.toml +++ b/examples/winit/Cargo.toml @@ -12,6 +12,7 @@ name = "main" crate-type = ["cdylib"] [dependencies] +base64 = "0.22" futures-executor = "0.3" pushups = { path = "../.." } serde_json = "1" @@ -19,9 +20,18 @@ winit = "=0.31.0-beta.3" [target.'cfg(target_os = "android")'.dependencies] winit = { version = "=0.31.0-beta.3", features = ["android-native-activity"] } +# The web build, which `Trunk` starts from `index.html` and which `build.rs` writes its worker beside. +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["console"] } +web-time = "1" [workspace] +# The build script writes the static worker from `pushups::SERVICE_WORKER` for Trunk to copy. +[build-dependencies] +pushups = { path = "../.." } + # winit's Android loop ignores `Destroy` and allows one loop per process, so a relaunched Activity # freezes its main thread (winit#4303). The fix's PR head, until a winit release carries it. [patch.crates-io] diff --git a/examples/winit/Trunk.toml b/examples/winit/Trunk.toml new file mode 100644 index 0000000..7e6ff56 --- /dev/null +++ b/examples/winit/Trunk.toml @@ -0,0 +1,13 @@ +[build] +# `sw.js` imports the glue by name, so its file name carries no hash. +filehash = false + +[watch] +# The build script writes it, so watching it would rebuild in a loop. +ignore = ["pushups-sw.js"] + +# Runs after the build script wrote the worker, into the directory Trunk turns into `dist`. +[[hooks]] +stage = "post_build" +command = "sh" +command_arguments = ["-c", "cp pushups-sw.js \"$TRUNK_STAGING_DIR/pushups-sw.js\""] diff --git a/examples/winit/build.rs b/examples/winit/build.rs new file mode 100644 index 0000000..43dc8db --- /dev/null +++ b/examples/winit/build.rs @@ -0,0 +1,11 @@ +//! Writes the static service worker beside the manifest, where the Trunk hook in `Trunk.toml` +//! copies it into the site. + +fn main() { + let dir = std::env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"); + let path = std::path::Path::new(&dir).join("pushups-sw.js"); + // Rewriting an unchanged file would wake `trunk serve`'s watcher for nothing. + if std::fs::read_to_string(&path).ok().as_deref() != Some(pushups::SERVICE_WORKER) { + std::fs::write(&path, pushups::SERVICE_WORKER).expect("the worker is written"); + } +} diff --git a/examples/winit/index.html b/examples/winit/index.html new file mode 100644 index 0000000..85a322c --- /dev/null +++ b/examples/winit/index.html @@ -0,0 +1,20 @@ + + + + pushups winit example + + + + + + + +

pushups winit example

+ + + + diff --git a/examples/winit/src/app.rs b/examples/winit/src/app.rs index f18c606..464c4c3 100644 --- a/examples/winit/src/app.rs +++ b/examples/winit/src/app.rs @@ -1,8 +1,19 @@ -//! The app both entry points run, `main` on desktop and iOS and `android_main` on Android. +//! The app both entry points run, `main` on desktop, iOS and the web and `android_main` on Android. use std::sync::mpsc::{Receiver, channel}; +#[cfg(not(target_arch = "wasm32"))] +use std::time::{SystemTime, UNIX_EPOCH}; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use pushups::{Config, Context, Event, Message}; + +// The Linux and Windows sub-configs, unused on the web, which takes its key for `WebPushConfig` instead. +#[cfg(not(target_arch = "wasm32"))] +use pushups::{LinuxConfig, UnifiedPushConfig, WnsConfig}; +// `std`'s clock panics on `wasm32-unknown-unknown`, where `web-time` reads `Date.now()` instead. +#[cfg(target_arch = "wasm32")] +use web_time::{SystemTime, UNIX_EPOCH}; use winit::application::ApplicationHandler; use winit::event::WindowEvent; use winit::event_loop::{ActiveEventLoop, EventLoop}; @@ -10,12 +21,46 @@ use winit::window::{Window, WindowAttributes, WindowId}; /// Installs `pushups`, before the event loop exists. pub(crate) fn install() { - match pushups::install(Config::new()) { + match pushups::install(config()) { Ok(()) => log(&format!("installed at_ms={}", now_ms())), Err(error) => log(&format!("install failed: {error}")), } } +#[cfg(not(target_arch = "wasm32"))] +/// The app's configuration from build-time keys, `PUSHUPS_VAPID_PUBLIC_KEY` (base64url) for UnifiedPush on Linux and Android and `PUSHUPS_WNS_REMOTE_ID` for Windows. +fn config() -> Config { + // The bundle identifier, which also names the app on the Linux session bus. + let mut config = Config::new().linux(LinuxConfig::new("rs.pushups.example")); + if let Some(remote_id) = option_env!("PUSHUPS_WNS_REMOTE_ID") { + config = config.windows(WnsConfig::new(remote_id)); + } + let key = option_env!("PUSHUPS_VAPID_PUBLIC_KEY") + .and_then(|key| URL_SAFE_NO_PAD.decode(key).ok()) + .and_then(|key| <[u8; 65]>::try_from(key).ok()); + if let Some(key) = key { + config = config.unified_push(UnifiedPushConfig::new(key)); + } + config +} + +/// The web configuration from `PUSHUPS_VAPID_PUBLIC_KEY` (base64url). `PUSHUPS_WEB_WORKER=static` registers the static worker, which `build.rs` writes from `pushups::SERVICE_WORKER`, in place of the Rust handler, which runs from the `sw.js` entry. +#[cfg(target_arch = "wasm32")] +fn config() -> Config { + let key = option_env!("PUSHUPS_VAPID_PUBLIC_KEY") + .and_then(|key| URL_SAFE_NO_PAD.decode(key).ok()) + .and_then(|key| <[u8; 65]>::try_from(key).ok()); + let Some(key) = key else { + return Config::new(); + }; + let web = pushups::WebPushConfig::new(key); + Config::new().web(match option_env!("PUSHUPS_WEB_WORKER") { + Some("static") => web, + _ => web.rust_handler().service_worker_path("/sw.js"), + }) +} + +#[cfg(not(target_arch = "wasm32"))] /// Registers, asks for permission, and runs the event loop until the app exits. pub(crate) fn run(event_loop: EventLoop) -> Result<(), Box> { let proxy = event_loop.create_proxy(); @@ -38,6 +83,29 @@ pub(crate) fn run(event_loop: EventLoop) -> Result<(), Box Result<(), Box> { + let proxy = event_loop.create_proxy(); + let (sender, events) = channel(); + pushups::set_handler(move |event| { + // A closed channel means the app is exiting. + let _ = sender.send(event); + proxy.wake_up(); + }); + if let Err(error) = pushups::register() { + log(&format!("register failed: {error}")); + } + wasm_bindgen_futures::spawn_local(async move { + let answer = pushups::request_permission().await; + log(&format!("permission {answer:?}")); + }); + Ok(event_loop.run_app(App { + window: None, + events, + })?) +} + struct App { window: Option>, events: Receiver, @@ -78,6 +146,14 @@ fn describe(event: &Event) -> String { let hex: String = token.iter().map(|byte| format!("{byte:02x}")).collect(); format!("ui token at_ms={at} apns={hex}") } + Event::Token(token @ pushups::Token::WebPush { endpoint, .. }) => { + let keys = token.web_push_keys().expect("a Web Push token has keys"); + let subscription = serde_json::json!({ + "endpoint": endpoint, + "keys": { "p256dh": keys.p256dh, "auth": keys.auth }, + }); + format!("ui token at_ms={at} subscription={subscription}") + } Event::Token(token) => format!("ui token at_ms={at} {token:?}"), Event::RegistrationFailed(error) => format!("ui registration failed at_ms={at} {error}"), Event::Message(message) => format!( @@ -102,6 +178,17 @@ fn on_push(_context: Context, message: Message) { )); } +/// Runs in the service worker for every push, and decides what the user sees. +#[cfg(target_arch = "wasm32")] +pub(crate) async fn notification_for(message: Message) -> pushups::Notification { + let at = now_ms(); + let line = timing(&message, at); + log(&format!("worker push at_ms={at} {line}")); + pushups::Notification::new("pushups from Rust") + .body(format!("pushups winit example, {line}")) + .navigate("/") +} + /// The push's `seq` and its latency from the sender's `sent_at_ms` to `at`, for a JSON payload /// such as an FCM data map. fn timing(message: &Message, at: u128) -> String { @@ -120,14 +207,14 @@ fn timing(message: &Message, at: u128) -> String { } fn now_ms() -> u128 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + SystemTime::now() + .duration_since(UNIX_EPOCH) .map_or(0, |since| since.as_millis()) } /// Prints the line, and appends it to `pushups-example.log` in the temporary directory, which outlives a process the system launched with nothing reading its output. -#[cfg(not(target_os = "android"))] -fn log(line: &str) { +#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))] +pub(crate) fn log(line: &str) { use std::io::Write; let line = format!("pushups-winit-example {line}"); @@ -143,6 +230,12 @@ fn log(line: &str) { } } +/// Logs the line to the console, which `ci/web/example.mjs` reads with the page's lines. +#[cfg(target_arch = "wasm32")] +pub(crate) fn log(line: &str) { + web_sys::console::log_1(&format!("pushups-winit-example {line}").into()); +} + /// Writes the line to logcat under the tag `pushups-example`, which `ci/android/example.sh` reads. #[cfg(target_os = "android")] fn log(line: &str) { diff --git a/examples/winit/src/main.rs b/examples/winit/src/main.rs index 7ee3159..4175dfd 100644 --- a/examples/winit/src/main.rs +++ b/examples/winit/src/main.rs @@ -1,8 +1,33 @@ -//! Receives pushes through `pushups` under winit 0.31, which installs no app delegate on Apple, and logs every step with its wall-clock time to standard output and to `pushups-example.log` in the temporary directory. +//! Receives pushes through `pushups` under winit 0.31, which installs no app delegate on Apple, +//! and logs every step with its wall-clock time to standard output and to `pushups-example.log` +//! in the temporary directory, or to the console on the web. mod app; +#[cfg(not(target_arch = "wasm32"))] fn main() -> Result<(), Box> { app::install(); app::run(winit::event_loop::EventLoop::new()?) } + +/// The web entry, which Trunk builds from this target. In the service worker it serves the Rust +/// handler, and in the page it installs pushups and runs the winit event loop. +#[cfg(target_arch = "wasm32")] +fn main() { + if pushups::in_service_worker() { + if let Err(error) = pushups::serve_service_worker(app::notification_for) { + app::log(&format!("serving the service worker failed: {error}")); + } + return; + } + app::install(); + match winit::event_loop::EventLoop::new() { + // The event loop does not block on the web, and keeps running after `main` returns. + Ok(event_loop) => { + if let Err(error) = app::run(event_loop) { + app::log(&format!("event loop failed: {error}")); + } + } + Err(error) => app::log(&format!("event loop failed: {error}")), + } +} diff --git a/examples/winit/sw.js b/examples/winit/sw.js new file mode 100644 index 0000000..84a4cd2 --- /dev/null +++ b/examples/winit/sw.js @@ -0,0 +1,5 @@ +// The service worker under `rust_handler`. Trunk starts the wasm only in the page, so this module +// starts it in the worker, where the app's `main` serves its Rust handler. +import init from "./pushups-winit-example.js"; + +init();