From 1b075bc8e3b9c3cd6e387d15cbc99f3b1ddab285 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Thu, 8 Oct 2026 05:39:33 +0200 Subject: [PATCH 1/4] Run the dx-built example on an emulator and in Chrome, and the tests on Windows --- .github/workflows/ci.yml | 70 ++++++++++++++++++++- ci/android/example.sh | 63 +++++++++++++++++++ ci/android/fcm_push.py | 29 +++++++++ ci/web/example.mjs | 132 +++++++++++++++++++++++++++++++++++++++ tests/windows.rs | 32 ++++++++++ 5 files changed, 324 insertions(+), 2 deletions(-) create mode 100755 ci/android/example.sh create mode 100644 ci/android/fcm_push.py create mode 100644 ci/web/example.mjs create mode 100644 tests/windows.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6966a9..1f9ccdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,16 @@ jobs: - run: cargo +stable test --workspace - run: cargo +stable test --workspace --all-features + test-windows: + # No Windows App SDK runtime on the runner, so this runs the Windows backend as an app shipped without it. + name: Test on Windows + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - run: rustup toolchain install stable --profile minimal + - run: cargo +stable test --workspace --all-features + msrv: name: MSRV runs-on: ubuntu-latest @@ -264,8 +274,54 @@ jobs: # The runner image exports its NDK path as a shell variable, which `env` cannot read. run: ANDROID_NDK_HOME="$ANDROID_NDK_LATEST_HOME" dx build --android --target aarch64-linux-android + example-android-emulator: + name: Example app on an Android emulator + runs-on: ubuntu-latest + timeout-minutes: 60 + # Neither a fork's pull request nor a Dependabot run can read the Firebase secrets. + if: >- + github.event_name == 'push' || + (github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]') + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "17" + - run: | + rustup toolchain install stable --profile minimal + rustup target add --toolchain stable x86_64-linux-android + - uses: taiki-e/install-action@v2 + with: + tool: dioxus-cli@0.7.10 + # The emulator needs KVM, which the runner's user cannot open by default. + - run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Write the Firebase configuration and the FCM key + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON }} + FCM_SERVICE_ACCOUNT: ${{ secrets.FCM_SERVICE_ACCOUNT }} + run: | + printf '%s' "$GOOGLE_SERVICES_JSON" > examples/dioxus/google-services.json + printf '%s' "$FCM_SERVICE_ACCOUNT" > "$RUNNER_TEMP/fcm-service-account.json" + - name: Build the APK for the emulator + working-directory: examples/dioxus + # The runner image exports its NDK path as a shell variable, which `env` cannot read. + run: ANDROID_NDK_HOME="$ANDROID_NDK_LATEST_HOME" dx build --android --target x86_64-linux-android + - name: Pushes to the open app and to one whose process was killed + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + target: google_apis + arch: x86_64 + disable-animations: true + script: ci/android/example.sh examples/dioxus/target/dx/pushups-dioxus-example/debug/android/app/app/build/outputs/apk/debug/app-debug.apk "$RUNNER_TEMP/fcm-service-account.json" + example-web: - name: Example web build + name: Example web app in Chrome runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -277,13 +333,23 @@ jobs: with: tool: dioxus-cli@0.7.10 - run: cp tests/fixtures/google-services.json examples/dioxus/google-services.json + - 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 web app in both worker modes working-directory: examples/dioxus run: | dx build --web --release --no-default-features --features web - PUSHUPS_WEB_WORKER=static dx build --web --release --no-default-features --features web + key=$(node ../../ci/web/example.mjs keys "$RUNNER_TEMP/vapid.json") + PUSHUPS_WEB_WORKER=static PUSHUPS_VAPID_PUBLIC_KEY="$key" dx build --web --release --no-default-features --features web # The `dioxus` feature has dx copy the worker unchanged into the assets. cmp target/dx/pushups-dioxus-example/release/web/public/assets/pushups-sw.js ../../js/pushups-sw.js + - name: The static-worker build in Chrome, with its manifest and real pushes + run: node ci/web/example.mjs check examples/dioxus/target/dx/pushups-dioxus-example/release/web/public "$RUNNER_TEMP/vapid.json" example-apple: name: Example apps for iOS and macOS diff --git a/ci/android/example.sh b/ci/android/example.sh new file mode 100755 index 0000000..90c0155 --- /dev/null +++ b/ci/android/example.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Runs the dx-built examples/dioxus APK on the device adb selects with real FCM pushes: one to the +# open app, one that starts the app after its process is killed, and the drain when the app opens. +# +# Usage: ci/android/example.sh +set -euo pipefail + +apk=$1 +key=$(realpath "$2") +ci=$(cd "$(dirname "$0")" && pwd) +package=rs.pushups.example +activity=$package/dev.dioxus.main.MainActivity +STEP_S=180 + +log() { adb logcat -d -s pushups-example:I; } +fail() { + echo "example: $1" >&2 + log | tail -40 >&2 + exit 1 +} +# Prints the first pushups-example line matching the extended regex $1, waiting up to STEP_S for it. +await() { + local deadline=$((SECONDS + STEP_S)) + while [ $SECONDS -lt $deadline ]; do + if log | grep -qE "$1"; then + log | grep -E "$1" | head -1 + return 0 + fi + sleep 1 + done + fail "no line matching '$1' within $STEP_S s" +} +open_app() { + adb shell input keyevent KEYCODE_WAKEUP + adb shell wm dismiss-keyguard + adb shell am start -W -n "$activity" >/dev/null +} + +adb uninstall "$package" >/dev/null 2>&1 || true +adb install "$apk" >/dev/null +# Before Android 13 there is no permission to grant. +adb shell pm grant "$package" android.permission.POST_NOTIFICATIONS 2>/dev/null || true +adb logcat -c +open_app +token=$(await 'ui token at_ms=[0-9]+ Fcm\(' | sed -E 's/.*Fcm\("([^"]+)"\).*/\1/') +[ -n "$token" ] || fail "no FCM token" + +python3 "$ci/fcm_push.py" "$key" "$token" 1 +await 'ui message .*started_app=false seq=1 ' +await 'handler started_app=false .*seq=1 ' + +adb shell input keyevent KEYCODE_HOME +sleep 2 +# Killed as the system kills a background app, which FCM still delivers to, unlike a force stop. +adb shell am kill "$package" +sleep 2 +[ -z "$(adb shell pidof "$package" || true)" ] || fail "the process outlived am kill" +python3 "$ci/fcm_push.py" "$key" "$token" 2 +await 'handler started_app=true .*seq=2 ' + +open_app +await 'ui message .*started_app=true seq=2 ' +echo "example: every step passed" diff --git a/ci/android/fcm_push.py b/ci/android/fcm_push.py new file mode 100644 index 0000000..9e5f69f --- /dev/null +++ b/ci/android/fcm_push.py @@ -0,0 +1,29 @@ +"""Sends one high-priority FCM data message carrying `seq` and `sent_at_ms` to a device token. + +Usage: python3 fcm_push.py +""" + +import json +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +import send # noqa: E402 + + +def main() -> None: + key = json.loads(Path(sys.argv[1]).read_text()) + message = { + "token": sys.argv[2], + "android": {"priority": "high"}, + "data": {"seq": sys.argv[3], "sent_at_ms": str(int(time.time() * 1000))}, + } + status = send.send(key, send.access_token(key), message) + print(status.splitlines()[0][:200]) + if not status.startswith("200"): + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/ci/web/example.mjs b/ci/web/example.mjs new file mode 100644 index 0000000..e5dd4e7 --- /dev/null +++ b/ci/web/example.mjs @@ -0,0 +1,132 @@ +// Checks the dx-built `examples/dioxus` in headless Chrome: the worker the `dioxus` feature bundles, +// the web app manifest, installability, and real pushes to an open and a closed page. +// +// node ci/web/example.mjs keys writes a VAPID pair and prints its public key +// node ci/web/example.mjs check +import { readFileSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { extname, join, normalize, relative } from "node:path"; +import { setTimeout as sleep } from "node:timers/promises"; +import puppeteer from "puppeteer-core"; +import webpush from "web-push"; + +const [command, ...args] = process.argv.slice(2); +if (command === "keys") { + const pair = webpush.generateVAPIDKeys(); + writeFileSync(args[0], JSON.stringify(pair)); + console.log(pair.publicKey); + process.exit(0); +} +if (command !== "check") { + console.error("usage: example.mjs keys | check "); + process.exit(2); +} + +const [site, keys] = args; +const vapid = JSON.parse(readFileSync(keys, "utf8")); +webpush.setVapidDetails("mailto:pushups-ci@users.noreply.github.com", vapid.publicKey, vapid.privateKey); +const STEP_MS = 60_000; +const types = { + ".html": "text/html", + ".js": "text/javascript", + ".wasm": "application/wasm", + ".png": "image/png", + ".webmanifest": "application/manifest+json", +}; +const server = createServer((request, response) => { + const path = new URL(request.url, "http://x").pathname; + const file = normalize(join(site, path === "/" ? "index.html" : path)); + try { + if (relative(site, file).startsWith("..")) throw new Error("outside the site"); + const body = readFileSync(file); + response.writeHead(200, { "content-type": types[extname(file)] ?? "application/octet-stream" }); + response.end(body); + } catch { + response.writeHead(404).end(); + } +}); +await new Promise((done) => server.listen(0, "127.0.0.1", done)); +const origin = `http://127.0.0.1:${server.address().port}`; + +const lines = []; +const expect = (ok, what) => { + if (!ok) throw new Error(what); + console.log(`ok: ${what}`); +}; +const awaitLine = async (what, test) => { + const end = performance.now() + STEP_MS; + while (performance.now() < end) { + const hit = lines.find(test); + if (hit) return hit; + await sleep(250); + } + throw new Error(`no ${what} within ${STEP_MS / 1000} s`); +}; + +const browser = await puppeteer.launch({ headless: true, channel: "chrome", args: ["--no-first-run"] }); +try { + await browser.setPermission(origin, { permission: { name: "notifications" }, state: "granted" }); + const open = async () => { + const page = await browser.newPage(); + page.on("console", (message) => lines.push(message.text())); + await page.goto(origin); + await page.waitForSelector("button", { timeout: STEP_MS }); + return page; + }; + let page = await open(); + + const cdp = await page.createCDPSession(); + const manifest = await cdp.send("Page.getAppManifest"); + expect(manifest.url === `${origin}/manifest.webmanifest`, "the page links its manifest"); + expect(manifest.errors.length === 0, `the manifest parses (${JSON.stringify(manifest.errors)})`); + for (const icon of JSON.parse(manifest.data).icons) { + const status = await page.evaluate(async (src) => (await fetch(src)).status, icon.src); + expect(status === 200, `icon ${icon.src} (${icon.purpose}) loads`); + } + + await page.$$eval("button", (buttons) => buttons.find((b) => b.textContent.includes("Allow"))?.click()); + const token = await awaitLine("token", (l) => l.includes("ui token") && l.includes("subscription=")); + const subscription = JSON.parse(token.split("subscription=")[1]); + const registrations = await page.evaluate(async () => + (await navigator.serviceWorker.getRegistrations()).map((r) => ({ + scope: r.scope, + script: (r.active ?? r.waiting ?? r.installing)?.scriptURL, + })), + ); + expect( + registrations.length === 1 && + registrations[0].scope === `${origin}/assets/` && + registrations[0].script === `${origin}/assets/pushups-sw.js?pushups=static`, + `the bundled worker is registered (${JSON.stringify(registrations)})`, + ); + + // Chrome rechecks installability once the worker settles, so it is asked until it answers or the step ends. + let errors = []; + const settled = performance.now() + STEP_MS; + do { + errors = (await cdp.send("Page.getInstallabilityErrors")).installabilityErrors; + if (errors.length === 0) break; + await sleep(500); + } while (performance.now() < settled); + expect(errors.length === 0, `Chrome finds the page installable (${JSON.stringify(errors)})`); + + const send = async (seq) => { + const payload = JSON.stringify({ seq: String(seq), sent_at_ms: String(Date.now()) }); + const sent = await webpush.sendNotification(subscription, payload, { TTL: 300, timeout: STEP_MS }); + expect(sent.statusCode === 201 || sent.statusCode === 200, `push ${seq} is accepted (HTTP ${sent.statusCode})`); + }; + await send(1); + await awaitLine("message on the open page", (l) => l.includes("ui message") && l.includes("seq=1 ")); + console.log("ok: a push reaches the open page"); + + await page.close(); + await send(2); + // The worker keeps the push for the next page, which drains it on load. + await sleep(5_000); + page = await open(); + await awaitLine("drained message", (l) => l.includes("ui message") && l.includes("seq=2 ")); + console.log("ok: a push to a closed page reaches the next page"); +} finally { + await browser.close(); + server.close(); +} diff --git a/tests/windows.rs b/tests/windows.rs new file mode 100644 index 0000000..c0088fc --- /dev/null +++ b/tests/windows.rs @@ -0,0 +1,32 @@ +//! `install` and `register` on a Windows machine without the Windows App SDK runtime beside the +//! test binary, as an app shipped without `Microsoft.WindowsAppRuntime.Bootstrap.dll` finds them. +#![cfg(target_os = "windows")] + +use pushups::{Config, Error, WnsConfig}; + +const REMOTE_ID: &str = "6f9619ff-8b86-d011-b42d-00c04fc964ff"; + +#[test] +fn install_without_a_windows_part_is_not_configured() { + assert_eq!(pushups::install(Config::new()), Err(Error::NotConfigured)); +} + +#[test] +fn install_names_a_remote_id_that_is_not_a_guid() { + let error = pushups::install(Config::new().windows(WnsConfig::new("not-a-guid"))).unwrap_err(); + assert!( + matches!(&error, Error::Platform(message) if message.contains("not-a-guid")), + "{error:?}" + ); +} + +#[test] +fn install_without_the_bootstrapper_names_it_and_the_app_still_runs() { + let error = pushups::install(Config::new().windows(WnsConfig::new(REMOTE_ID))).unwrap_err(); + assert!( + matches!(&error, Error::Platform(message) if message.contains("Microsoft.WindowsAppRuntime.Bootstrap.dll")), + "{error:?}" + ); + // Nothing was set up, so a registration is refused rather than sent. + assert_eq!(pushups::register(), Err(Error::NotConfigured)); +} From d4ebf2247e409e0ef4f22dac2c695bc9dac53f12 Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Thu, 8 Oct 2026 06:08:59 +0200 Subject: [PATCH 2/4] Register the example for UnifiedPush and Linux --- examples/dioxus/src/main.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/examples/dioxus/src/main.rs b/examples/dioxus/src/main.rs index 2fb6f50..9a05f0d 100644 --- a/examples/dioxus/src/main.rs +++ b/examples/dioxus/src/main.rs @@ -2,8 +2,9 @@ //! logcat tag `pushups-example` on Android and in the console on the web, so a device proof can //! time send to delivery. //! -//! On the web, `PUSHUPS_VAPID_PUBLIC_KEY` (base64url) at build time sets the VAPID key, and -//! `PUSHUPS_WEB_WORKER=static` registers the static worker in place of the Rust handler. +//! `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. use std::io::{Read, Write}; use std::net::TcpStream; @@ -13,7 +14,10 @@ use dioxus::prelude::*; use futures_util::StreamExt; use base64::Engine as _; use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; -use pushups::{Config, Context, Event, Message, Notification, Permission, WebPushConfig}; +use pushups::{ + Config, Context, Event, LinuxConfig, Message, Notification, Permission, UnifiedPushConfig, + WebPushConfig, +}; pushups::firebase_config!("google-services.json"); @@ -36,17 +40,21 @@ 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 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(); + return config; }; let web = WebPushConfig::new(key); - Config::new().web(match option_env!("PUSHUPS_WEB_WORKER") { - Some("static") => web, - _ => web.rust_handler(), - }) + config + .unified_push(UnifiedPushConfig::new(key)) + .web(match option_env!("PUSHUPS_WEB_WORKER") { + Some("static") => web, + _ => web.rust_handler(), + }) } /// Runs in the service worker for every push, and decides what the user sees. From 8e03da46a8c5d5b7d29a4e8406e3da499495721d Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 9 Oct 2026 03:40:10 +0200 Subject: [PATCH 3/4] Resend the example's first pushes on FCM's 410 for a fresh subscription --- ci/web/example.mjs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/ci/web/example.mjs b/ci/web/example.mjs index e5dd4e7..646b7a8 100644 --- a/ci/web/example.mjs +++ b/ci/web/example.mjs @@ -26,6 +26,7 @@ const [site, keys] = args; const vapid = JSON.parse(readFileSync(keys, "utf8")); webpush.setVapidDetails("mailto:pushups-ci@users.noreply.github.com", vapid.publicKey, vapid.privateKey); const STEP_MS = 60_000; +const PUSH_GONE_RETRY_MS = 30_000; const types = { ".html": "text/html", ".js": "text/javascript", @@ -110,10 +111,21 @@ try { } while (performance.now() < settled); expect(errors.length === 0, `Chrome finds the page installable (${JSON.stringify(errors)})`); + // FCM answers 410 to a subscription it has not propagated yet, so a 410 is resent for a while, as the harness does. const send = async (seq) => { const payload = JSON.stringify({ seq: String(seq), sent_at_ms: String(Date.now()) }); - const sent = await webpush.sendNotification(subscription, payload, { TTL: 300, timeout: STEP_MS }); - expect(sent.statusCode === 201 || sent.statusCode === 200, `push ${seq} is accepted (HTTP ${sent.statusCode})`); + const deadline = performance.now() + PUSH_GONE_RETRY_MS; + for (;;) { + try { + const sent = await webpush.sendNotification(subscription, payload, { TTL: 300, timeout: STEP_MS }); + expect(sent.statusCode === 201 || sent.statusCode === 200, `push ${seq} is accepted (HTTP ${sent.statusCode})`); + return; + } catch (error) { + if (error.statusCode !== 410 || performance.now() > deadline) throw error; + console.log(`push ${seq}: HTTP 410, retrying`); + await sleep(1000); + } + } }; await send(1); await awaitLine("message on the open page", (l) => l.includes("ui message") && l.includes("seq=1 ")); From 5822c3ba4249d9420dd747550ca7421174b8eb1e Mon Sep 17 00:00:00 2001 From: LucaCappelletti94 Date: Fri, 9 Oct 2026 04:41:51 +0200 Subject: [PATCH 4/4] Reopen the example when FCM refuses its first registration on a fresh emulator --- ci/android/example.sh | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/ci/android/example.sh b/ci/android/example.sh index 90c0155..dd49824 100755 --- a/ci/android/example.sh +++ b/ci/android/example.sh @@ -35,14 +35,30 @@ open_app() { adb shell wm dismiss-keyguard adb shell am start -W -n "$activity" >/dev/null } +# Prints the FCM token, reopening the app when its registration fails, as FCM's first one on a fresh emulator sometimes does. +register() { + local attempt line + for attempt in 1 2 3; do + adb logcat -c + open_app >&2 + line=$(await 'ui token at_ms=[0-9]+ Fcm\(|ui registration failed') + case "$line" in + *"ui token"*) + echo "$line" | sed -E 's/.*Fcm\("([^"]+)"\).*/\1/' + return 0 + ;; + esac + echo "example: registration attempt $attempt failed, reopening the app" >&2 + adb shell am force-stop "$package" + done + fail "FCM registration failed three times" +} adb uninstall "$package" >/dev/null 2>&1 || true adb install "$apk" >/dev/null # Before Android 13 there is no permission to grant. adb shell pm grant "$package" android.permission.POST_NOTIFICATIONS 2>/dev/null || true -adb logcat -c -open_app -token=$(await 'ui token at_ms=[0-9]+ Fcm\(' | sed -E 's/.*Fcm\("([^"]+)"\).*/\1/') +token=$(register) [ -n "$token" ] || fail "no FCM token" python3 "$ci/fcm_push.py" "$key" "$token" 1