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
190 changes: 189 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,30 @@ jobs:
- run: cargo +1.85 check --workspace --features stream --target aarch64-apple-ios
- run: cargo +1.85 check --workspace --features stream --target aarch64-apple-darwin

tauri-plugin:
name: tauri-plugin-pushups
runs-on: ubuntu-latest
timeout-minutes: 30
defaults:
run:
working-directory: tauri-plugin-pushups
steps:
- uses: actions/checkout@v7
- run: |
rustup toolchain install stable --profile minimal --component clippy,rustfmt
rustup toolchain install 1.90 --profile minimal
rustup target add --toolchain stable aarch64-linux-android
# Tauri builds on Linux against GTK and WebKitGTK.
- run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev librsvg2-dev
- run: cargo +stable fmt --check
- run: cargo +stable clippy --all-targets -- -D warnings
# On Android the build script copies the crate's module, which this runs.
- run: cargo +stable clippy --target aarch64-linux-android -- -D warnings
- run: cargo +stable test --doc
- run: cargo +1.90 check
- name: The plugin is released at the crate's version
run: test "$(cargo pkgid | sed 's/.*[#@]//')" = "$(cd .. && cargo pkgid -p pushups | sed 's/.*[#@]//')"

docs:
name: Docs
runs-on: ubuntu-latest
Expand Down Expand Up @@ -199,6 +223,9 @@ jobs:
name: Linux through a UnifiedPush distributor
runs-on: ubuntu-latest
timeout-minutes: 30
# 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 --component llvm-tools,clippy,rustfmt
Expand All @@ -207,6 +234,12 @@ jobs:
run: |
cargo fmt --check
cargo clippy -- -D warnings
# 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 }}
# KDE's distributor in a Debian testing container, with real pushes through ntfy.sh.
- run: ci/linux/run.sh
- uses: actions/upload-artifact@v7
Expand Down Expand Up @@ -320,6 +353,107 @@ jobs:
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-winit-android:
name: Example winit app through Gradle 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: cargo-ndk
# 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/winit/google-services.json
printf '%s' "$FCM_SERVICE_ACCOUNT" > "$RUNNER_TEMP/fcm-service-account.json"
- name: Build the library with cargo-ndk and the APK with Gradle, the module found through cargo metadata
working-directory: examples/winit
# The runner image exports its NDK path as a shell variable, which `env` cannot read.
run: |
ANDROID_NDK_HOME="$ANDROID_NDK_LATEST_HOME" cargo ndk -t x86_64 -o android/app/src/main/jniLibs build
(cd android && ./gradlew --no-daemon assembleDebug)
# The module's build output stays out of the crate.
test ! -e ../../android/build
- 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/winit/android/app/build/outputs/apk/debug/app-debug.apk "$RUNNER_TEMP/fcm-service-account.json" android.app.NativeActivity

example-tauri-android:
name: Example Tauri app through tauri-plugin-pushups 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: tauri-cli@2.12.1
# 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/tauri/google-services.json
printf '%s' "$FCM_SERVICE_ACCOUNT" > "$RUNNER_TEMP/fcm-service-account.json"
- name: Generate the Android project and build the APK, the module coming from the plugin
working-directory: examples/tauri
# The runner image exports its NDK path as a shell variable, which `env` cannot read.
run: |
export NDK_HOME="$ANDROID_NDK_LATEST_HOME"
cargo tauri android init --ci
cargo tauri android build --apk --debug --target x86_64
# Tauri's own files land in the plugin's copy of the module, never in the crate.
test ! -e ../../android/.tauri
- 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 "$(find examples/tauri/gen/android/app/build/outputs/apk -name '*.apk' | head -1)" "$RUNNER_TEMP/fcm-service-account.json" .MainActivity

example-web:
name: Example web app in Chrome
runs-on: ubuntu-latest
Expand Down Expand Up @@ -349,7 +483,61 @@ jobs:
# 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"
run: node ci/web/example.mjs check examples/dioxus/target/dx/pushups-dioxus-example/release/web/public "$RUNNER_TEMP/vapid.json" '/assets/pushups-sw\.js\?pushups=static' --manifest

example-yew:
name: Example Yew 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 Yew app in both worker modes
working-directory: examples/yew
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/yew/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/yew/dist-static "$RUNNER_TEMP/vapid.json" '/pushups-sw\.js\?pushups=static'

example-leptos:
name: Example Leptos SSR app in Chrome
runs-on: ubuntu-latest
timeout-minutes: 45
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: cargo-leptos@0.3.11
- 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: Both worker modes, served by the app's own server, with real pushes
run: ci/web/leptos.sh

example-apple:
name: Example apps for iOS and macOS
Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ readme = "README.md"
keywords = ["push", "notifications", "apns", "fcm", "webpush"]
categories = ["api-bindings", "os", "wasm"]
exclude = ["/assets"]
# Unique in a build, so `build.rs` can hand the Android module's path to direct dependents.
links = "pushups"

[features]
stream = ["dep:futures-channel", "dep:futures-core"]
Expand All @@ -18,6 +20,8 @@ dioxus = ["dep:manganis"]

[workspace]
members = ["pushups-macros"]
# Built in its own workspace, since `tauri` needs GTK on Linux, Rust 1.90 and no wasm32.
exclude = ["tauri-plugin-pushups"]

[dependencies]
futures-channel = { version = "0.3", optional = true, default-features = false, features = ["std"] }
Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,24 @@ A push that wakes the app in the background runs the background handler below of

## 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 includes it as a Gradle project. cargo-apk and xbuild have no way to bundle it yet.
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<String, Any>)["packages"] as List<Map<String, Any>>
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 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.

Expand All @@ -107,7 +124,7 @@ An app whose `Config` has a `UnifiedPushConfig` gets its pushes through a [Unifi

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.

By default the app serves `js/pushups-sw.js` from this crate at `/pushups-sw.js`, a service worker with no wasm in it, and with the `dioxus` feature `dx` serves it from the app's assets. 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.
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`.

```rust
use pushups::{Config, Message, Notification, WebPushConfig};
Expand All @@ -131,6 +148,10 @@ fn main() {
}
```

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.

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.

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.

## Linux
Expand Down
9 changes: 9 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//! Hands the path of the crate's Android module to the build scripts of the crates that depend
//! on `pushups` directly, as `DEP_PUSHUPS_ANDROID`, which `tauri-plugin-pushups` copies.

fn main() {
let manifest = std::env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR");
let android = std::path::Path::new(&manifest).join("android");
println!("cargo::metadata=android={}", android.display());
println!("cargo::rerun-if-changed=build.rs");
}
10 changes: 6 additions & 4 deletions ci/android/example.sh
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
#!/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.
# Runs an example APK of package `rs.pushups.example` 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. The app logs under the logcat tag `pushups-example`.
#
# Usage: ci/android/example.sh <apk> <FCM service account JSON>
# Usage: ci/android/example.sh <apk> <FCM service account JSON> [launch Activity]
# The Activity defaults to the one `dx` generates for examples/dioxus.
set -euo pipefail

apk=$1
key=$(realpath "$2")
ci=$(cd "$(dirname "$0")" && pwd)
package=rs.pushups.example
activity=$package/dev.dioxus.main.MainActivity
activity=$package/${3:-dev.dioxus.main.MainActivity}
STEP_S=180

log() { adb logcat -d -s pushups-example:I; }
Expand Down
Loading
Loading