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
10 changes: 10 additions & 0 deletions ci/apple/harness/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>rs.pushups.ci.harness</string>
<key>PushupsNotificationFiltering</key>
<true/>
</dict>
</plist>
16 changes: 16 additions & 0 deletions ci/apple/harness/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,22 @@ for (kind, marker) in [("error", "failed"), ("panic", "panicked"), ("icon", "fai
expect(fallback.snapshot.content?.userInfo["original"] as? String == "ciphertext", "\(kind) preserves sender payload")
}

// The harness embeds an Info.plist declaring `PushupsNotificationFiltering`, which the default initializer reads.
let suppressed = receive(service, ["kind": "no-alert", "original": "ciphertext"])
wait(suppressed)
if let content = suppressed.snapshot.content {
expect(content.title.isEmpty && content.subtitle.isEmpty && content.body.isEmpty, "declared no-alert clears every alert text")
expect(content.sound == nil && content.attachments.isEmpty, "declared no-alert has no sound or attachments")
expect(content.userInfo.isEmpty, "declared no-alert completes with fresh content, without the original payload or a marker")
}

let undeclaredService = PushupsNotificationService(filteringDeclared: false)
let undeclared = receive(undeclaredService, ["kind": "no-alert", "original": "ciphertext"])
wait(undeclared)
expect(outcome(undeclared) == "suppression-undeclared", "undeclared no-alert outcome")
expect(undeclared.snapshot.content?.title == "Safe title" && undeclared.snapshot.content?.body == "Safe body", "undeclared no-alert keeps the safe alert")
expect(undeclared.snapshot.content?.userInfo["original"] as? String == "ciphertext", "undeclared no-alert keeps the sender payload")

let expired = receive(service, ["sleep": 300])
service.serviceExtensionTimeWillExpire()
service.serviceExtensionTimeWillExpire()
Expand Down
16 changes: 10 additions & 6 deletions ci/apple/probe/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
//! The consumer transform linked into the native completion harness and iOS extension.

use pushups::{Error, Message, Notification, RenderingContext};
use pushups::{Error, Message, Notification, Rendering, RenderingContext};

#[pushups::notification_service]
fn render(_context: RenderingContext, message: Message) -> Result<Notification, Error> {
fn render(_context: RenderingContext, message: Message) -> Result<Rendering, Error> {
let payload: serde_json::Value = serde_json::from_slice(&message.payload)
.map_err(|error| Error::Platform(error.to_string()))?;
match payload["kind"].as_str().unwrap_or_default() {
"error" => Err(Error::Platform("an error".to_owned())),
"panic" => panic!("a panic"),
"icon" => Ok(Notification::new("Icon title").icon("https://example.com/icon.png")),
"clear" => Ok(Notification::new("").tag("").data("").navigate("")),
"minimal" => Ok(Notification::new("Minimal")),
"no-alert" => Ok(Rendering::NoAlert),
"icon" => Ok(Notification::new("Icon title")
.icon("https://example.com/icon.png")
.into()),
"clear" => Ok(Notification::new("").tag("").data("").navigate("").into()),
"minimal" => Ok(Notification::new("Minimal").into()),
_ => {
if let Some(sleep) = payload["sleep"].as_u64() {
std::thread::sleep(std::time::Duration::from_millis(sleep));
Expand All @@ -20,7 +23,8 @@ fn render(_context: RenderingContext, message: Message) -> Result<Notification,
.body("Decrypted body")
.tag("thread-1")
.data(payload["original"].as_str().unwrap_or("tap-data"))
.navigate("https://example.com"))
.navigate("https://example.com")
.into())
}
}
}
34 changes: 33 additions & 1 deletion ci/apple/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ xcrun --sdk macosx swiftc \
"$CI/harness/main.swift" \
"$ROOT/ios/PushupsNotificationService.swift" \
"$BUILD/rust/debug/$LIB" \
-framework Foundation -framework UserNotifications -framework AppKit
-framework Foundation -framework UserNotifications -framework AppKit \
-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __info_plist -Xlinker "$CI/harness/Info.plist"
"$BUILD/harness"

build_extension() {
Expand Down Expand Up @@ -51,3 +52,34 @@ build_extension() {

build_extension iphonesimulator arm64-apple-ios16.0-simulator aarch64-apple-ios-sim
build_extension iphoneos arm64-apple-ios16.0 aarch64-apple-ios

# The release checker against an iOS extension signed with and without the entitlement and the declaration.
FIXTURE="$BUILD/check-filtering"
ENTITLEMENTS="$FIXTURE/filtering.entitlements"
mkdir -p "$FIXTURE"
plutil -create xml1 "$ENTITLEMENTS"
/usr/libexec/PlistBuddy -c 'Add :com.apple.developer.usernotifications.filtering bool true' "$ENTITLEMENTS"
check_row() {
local declared="$1" entitled="$2" expected="$3"
local bundle="$FIXTURE/$declared-$entitled/NotificationService.appex"
mkdir -p "$(dirname "$bundle")"
cp -R "$BUILD/iphoneos/NotificationService.appex" "$bundle"
if [ "$declared" = declared ]; then
plutil -insert PushupsNotificationFiltering -bool true "$bundle/Info.plist"
fi
if [ "$entitled" = entitled ]; then
codesign --force --sign - --entitlements "$ENTITLEMENTS" "$bundle"
else
codesign --force --sign - "$bundle"
fi
local status=0
"$ROOT/ios/check-filtering.sh" "$bundle" || status=$?
if [ "$status" != "$expected" ]; then
echo "check-filtering: $declared and $entitled exited $status, expected $expected" >&2
exit 1
fi
}
check_row undeclared unentitled 0
check_row declared entitled 0
check_row declared unentitled 1
check_row undeclared entitled 1
20 changes: 14 additions & 6 deletions docs/mobile-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,38 +7,46 @@ An iOS notification service extension runs in its own process before an eligible
The extension's Rust crate declares `crate-type = ["staticlib"]` and one callback, shown here for fixed-text rendering.

```rust
use pushups::{Error, Message, Notification, RenderingContext};
use pushups::{Error, Message, Notification, Rendering, RenderingContext};

#[pushups::notification_service]
fn render(context: RenderingContext, message: Message) -> Result<Notification, Error> {
fn render(context: RenderingContext, message: Message) -> Result<Rendering, Error> {
if context.is_cancelled() {
return Err(Error::Platform("notification rendering expired".into()));
}
let data = String::from_utf8(message.payload)
.map_err(|error| Error::Platform(error.to_string()))?;
Ok(Notification::new("New activity").data(data))
Ok(Notification::new("New activity").data(data).into())
}
```

Application-produced and encrypted rendering supply application-owned interpretation or decryption in that callback. `pushups` supplies no encryption, key storage, payload schema, network client or async runtime. The callback receives owned bytes containing the complete original APNs user-info dictionary as `JSON`, and `Message.started_app` is `false` because the extension does not launch the application.

The callback runs on an extension-owned worker. `RenderingContext` is `Clone + Send + Sync` and contains an owned request identifier, a monotonic deadline and shared completion state, with `request_id()`, `deadline()`, `is_cancelled()` and `outcome()` accessors. Consumer async work must run on its own executor and obey the deadline and cancellation signal, without retaining native references.
The callback runs on an extension-owned worker. `RenderingContext` is `Clone + Send + Sync` and contains an owned request identifier, a monotonic deadline and shared completion state, with `request_id()`, `deadline()`, `is_cancelled()`, `suppression()` and `outcome()` accessors. Consumer async work must run on its own executor and obey the deadline and cancellation signal, without retaining native references.

Completion selects one terminal outcome exactly once. The bound is 25 seconds from receipt or the earlier platform expiration callback. Cancellation is cooperative, and work that returns after completion cannot change the notification.

| Outcome | Presented content |
|---|---|
| `Rendered` | Returned title and body |
| `SuppressionRequested` | Empty content, which hides the alert only under Apple's approved entitlement |
| `SuppressionUndeclared` | Original safe alert |
| `Failed` | Original safe alert |
| `Panicked` | Original safe alert after an unwinding panic |
| `Expired` | Original safe alert |
| `WorkerUnavailable` | Original safe alert |
| `Superseded` | Original safe alert for the displaced request |

The final outcome is stored in the `pushups.rendering-outcome` user-info string. Abort-mode panics and process termination cannot run Rust cleanup, and iOS retains its original-alert fallback. The original alert must therefore remain safe to show without decryption.
The final outcome is stored in the `pushups.rendering-outcome` user-info string, except for `SuppressionRequested`, whose empty content carries none and which `RenderingContext::outcome()` reports. Abort-mode panics and process termination cannot run Rust cleanup, and iOS retains its original-alert fallback. The original alert must therefore remain safe to show without decryption.

A successful `Notification` replaces title and body, with an absent body clearing the body. `tag` maps to the native thread identifier, and `data` and `navigate` occupy reserved user-info keys while unrelated user info, attachments, badge, sound and category remain intact. Icon URLs are unsupported on mobile and select failure rather than fetching media.

### Suppressing an alert

`Rendering::NoAlert` asks for no alert, and an error or an empty title never means that. The extension's `Info.plist` declares the policy with `PushupsNotificationFiltering` set to `true`, and `RenderingContext::suppression()` reports `Declared` or `Undeclared`. Without the declaration, `NoAlert` settles `SuppressionUndeclared` on the original alert. With it, `NoAlert` completes with an empty `UNNotificationContent`.

iOS cannot read an extension's signed entitlements at runtime, so `Declared` is the app's own assertion and `SuppressionRequested` only says the empty content went to the system. The alert disappears only when Apple has granted [`com.apple.developer.usernotifications.filtering`](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.usernotifications.filtering), the extension is signed with it, and the sender uses `apns-push-type: alert`. Before a release, `ios/check-filtering.sh <NotificationService.appex>` on macOS fails when the declaration and the signed entitlement disagree. Seeing the alert disappear on a device remains the release owner's check.

### Bundle and signing

Compile the shipped `ios/PushupsNotificationService.swift` into the extension and link the consumer's Rust static library. The Swift adapter calls the Rust receive and expiration symbols generated by `#[pushups::notification_service]`. Its module-qualified `PushupsNotificationService` class is the extension's principal class.
Expand Down Expand Up @@ -67,7 +75,7 @@ The `Context` passed to `#[pushups::background_handler]` borrows the native call

## Acceptance boundary

`ci/apple/run.sh` exercises native transformation and completion on macOS, then links and verifies Rust entry symbols in ad-hoc-signed iOS device and simulator extension bundles. The harness does not execute an iOS extension in the simulator or establish locked-iPhone alert delivery, background scheduling, real keychain accessibility or visible locked-device rendering, which require device acceptance.
`ci/apple/run.sh` exercises native transformation and completion on macOS, including the empty content of a declared `NoAlert` and the safe alert of an undeclared one, then links and verifies Rust entry symbols in ad-hoc-signed iOS device and simulator extension bundles and runs `ios/check-filtering.sh` against them signed with and without the entitlement and the declaration. The harness does not execute an iOS extension in the simulator or establish Apple's filtering approval, locked-iPhone alert delivery, background scheduling, real keychain accessibility, visible locked-device rendering or a suppressed alert on a device, which require device acceptance.

`ci/android/rendering.sh` exercises headless presentation, refusal cases, tagged replacement, launcher tap delivery and the context `with_jni` lends on a booted Android device or emulator.

Expand Down
19 changes: 17 additions & 2 deletions ios/PushupsNotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ private final class PushupsCompletion {
data: String?,
navigate: String?
) {
// Apple's filtering contract is a fresh content with no alert fields, so no marker rides on it.
if outcome == 6 {
handler(UNNotificationContent())
return
}
if outcome == 0, let title {
content.title = title
content.body = body ?? ""
Expand Down Expand Up @@ -85,7 +90,7 @@ private func pushupsFinish(
}

@_silgen_name("__pushups_notification_service_create")
private func pushupsCreate() -> UnsafeMutableRawPointer
private func pushupsCreate(_ filteringDeclared: Bool) -> UnsafeMutableRawPointer

@_silgen_name("__pushups_notification_service_receive")
private func pushupsReceive(
Expand All @@ -105,7 +110,17 @@ private func pushupsDeallocated(_ service: UnsafeMutableRawPointer)

/// Transforms an alert through the Rust `notification_service` callback.
public final class PushupsNotificationService: UNNotificationServiceExtension {
private let service = pushupsCreate()
private let service: UnsafeMutableRawPointer

/// Reads the extension's `PushupsNotificationFiltering` declaration from its `Info.plist`.
public override convenience init() {
self.init(filteringDeclared: Bundle.main.object(forInfoDictionaryKey: "PushupsNotificationFiltering") as? Bool ?? false)
}

init(filteringDeclared: Bool) {
service = pushupsCreate(filteringDeclared)
super.init()
}

public override func didReceive(
_ request: UNNotificationRequest,
Expand Down
36 changes: 36 additions & 0 deletions ios/check-filtering.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Compares a signed notification service extension's `PushupsNotificationFiltering` declaration with its signed filtering entitlement.
#
# Usage: ios/check-filtering.sh <NotificationService.appex>
set -euo pipefail

appex=${1:?usage: check-filtering.sh <NotificationService.appex>}
codesign --verify --strict "$appex"

entitlements=$(mktemp)
trap 'rm -f "$entitlements"' EXIT
codesign -d --entitlements - --xml "$appex" > "$entitlements" 2>/dev/null

declared=$(/usr/libexec/PlistBuddy -c 'Print :PushupsNotificationFiltering' "$appex/Info.plist" 2>/dev/null) || declared=false
entitled=$(/usr/libexec/PlistBuddy -c 'Print :com.apple.developer.usernotifications.filtering' "$entitlements" 2>/dev/null) || entitled=false

case "$declared/$entitled" in
false/false)
echo "check-filtering: no declaration and no entitlement, so Rendering::NoAlert keeps the original alert"
;;
true/true)
echo "check-filtering: declared and entitled. Apple's approval of the entitlement, apns-push-type alert and suppression on a device still need checking"
;;
true/false)
echo "check-filtering: PushupsNotificationFiltering is declared but the signature lacks com.apple.developer.usernotifications.filtering, so iOS would show alerts the app asks to suppress" >&2
exit 1
;;
false/true)
echo "check-filtering: the signature carries com.apple.developer.usernotifications.filtering but PushupsNotificationFiltering is not declared, so Rendering::NoAlert keeps the original alert" >&2
exit 1
;;
*)
echo "check-filtering: unexpected values, declaration '$declared' and entitlement '$entitled'" >&2
exit 2
;;
esac
12 changes: 7 additions & 5 deletions pushups-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ pub fn background_handler(args: TokenStream, item: TokenStream) -> TokenStream {
.into()
}

/// Exports a synchronous `fn(RenderingContext, Message) -> Result<Notification, Error>` for the [iOS service extension](https://github.com/LucaCappelletti94/pushups/blob/main/docs/mobile-notifications.md).
/// Exports a synchronous `fn(RenderingContext, Message) -> Result<Rendering, Error>` for the [iOS service extension](https://github.com/LucaCappelletti94/pushups/blob/main/docs/mobile-notifications.md).
#[proc_macro_attribute]
pub fn notification_service(args: TokenStream, item: TokenStream) -> TokenStream {
let item: TokenStream2 = item.into();
Expand All @@ -208,12 +208,14 @@ pub fn notification_service(args: TokenStream, item: TokenStream) -> TokenStream
const _: fn(
#pushups::RenderingContext,
#pushups::Message,
) -> core::result::Result<#pushups::Notification, #pushups::Error> = #name;
) -> core::result::Result<#pushups::Rendering, #pushups::Error> = #name;

#[cfg(any(target_os = "ios", target_os = "macos"))]
#[unsafe(no_mangle)]
pub extern "C" fn __pushups_notification_service_create() -> *mut ::core::ffi::c_void {
#pushups::__private::ios::create()
pub extern "C" fn __pushups_notification_service_create(
filtering_declared: bool,
) -> *mut ::core::ffi::c_void {
#pushups::__private::ios::create(filtering_declared)
}

/// # Safety
Expand Down Expand Up @@ -275,7 +277,7 @@ pub fn notification_service(args: TokenStream, item: TokenStream) -> TokenStream
#[cfg(any(target_os = "ios", target_os = "macos"))]
#[used]
static __PUSHUPS_NOTIFICATION_SERVICE_KEPT: (
extern "C" fn() -> *mut ::core::ffi::c_void,
extern "C" fn(bool) -> *mut ::core::ffi::c_void,
unsafe extern "C" fn(
*const ::core::ffi::c_void,
*const u8,
Expand Down
4 changes: 2 additions & 2 deletions pushups-macros/tests/notification_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ fn notification_service_expands_under_a_renamed_dependency() {
consumer.path().join("src/lib.rs"),
"#[notifications::notification_service]\n\
fn on_render(\n _context: notifications::RenderingContext,\n\
message: notifications::Message,\n) -> Result<notifications::Notification, notifications::Error> {\n\
Ok(notifications::Notification::new(\"Rendered\").body(message.payload.len().to_string()))\n}\n",
message: notifications::Message,\n) -> Result<notifications::Rendering, notifications::Error> {\n\
Ok(notifications::Notification::new(\"Rendered\").body(message.payload.len().to_string()).into())\n}\n",
)
.expect("the consumer's source");
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
Expand Down
Loading
Loading