Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
79 changes: 79 additions & 0 deletions ci/android/example.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/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 <apk> <FCM service account JSON>
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
}
# 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
token=$(register)
[ -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"
29 changes: 29 additions & 0 deletions ci/android/fcm_push.py
Original file line number Diff line number Diff line change
@@ -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 <service account json> <device token> <seq>
"""

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()
144 changes: 144 additions & 0 deletions ci/web/example.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// 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 <vapid.json> writes a VAPID pair and prints its public key
// node ci/web/example.mjs check <site> <vapid.json>
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 <vapid.json> | check <site> <vapid.json>");
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 PUSH_GONE_RETRY_MS = 30_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)})`);

// 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 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 "));
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();
}
Loading
Loading