Push notifications for Tauri v2 mobile apps -- iOS (APNs) and Android (FCM).
A Tauri v2 plugin that provides native remote push notification support using Apple Push Notification service (APNs) on iOS and Firebase Cloud Messaging (FCM) on Android. Unlike tauri-plugin-notification which only handles local notifications, this plugin handles server-sent remote push notifications -- the kind you need for chat apps, alerts, and real-time engagement.
Zero-config on iOS. No AppDelegate file, no method swizzling, no manual setup. The plugin automatically injects APNs handlers into the Tao AppDelegate at runtime using ObjC runtime APIs. Just enable the Push Notifications capability in Xcode and you are ready to go.
- APNs on iOS -- native device token registration and push delivery
- FCM on Android -- Firebase Cloud Messaging integration with automatic token management
- Zero iOS configuration -- no AppDelegate.swift file needed; APNs methods are injected automatically at runtime
- No method swizzling -- uses direct
@_cdeclFFI andclass_addMethodinjection, which is transparent and future-proof - Foreground notifications -- receive and display pushes while the app is open
- Notification tap handling -- deep-link into your app when users tap a notification
- Token refresh events -- stay in sync when the OS rotates device tokens
- Desktop no-op -- compiles on macOS/Windows/Linux without error; commands return stub values so you can gate push logic behind platform checks
- TypeScript API -- fully typed async functions and event listeners
| Platform | Push Token | Foreground Notifications | Notification Tap | Token Refresh |
|---|---|---|---|---|
| iOS 13+ | APNs device token (hex) | Yes | Yes | Yes |
| Android 7+ (API 24) | FCM registration token | Yes | Yes | Yes |
| Desktop | No-op (stub values) | N/A | N/A | N/A |
The official tauri-plugin-notification only supports local notifications. It cannot receive server-sent pushes. If you need to send notifications from your backend to your users' devices -- the standard push notification flow for any chat app, messaging service, or alert system -- you need a remote push plugin.
Third-party alternatives use method swizzling, which intercepts Objective-C method calls at runtime. This technique is fragile: it breaks when multiple plugins swizzle the same methods, produces difficult-to-debug failures, and Apple has been deprecating the APIs that enable it.
This plugin takes a different approach. On iOS, it uses direct @_cdecl FFI between Rust and Swift, bypassing Tauri's standard run_mobile_plugin dispatch entirely. APNs delegate methods are injected into the Tao-generated AppDelegate at runtime using imp_implementationWithBlock and class_addMethod. This means:
- Zero configuration -- no AppDelegate.swift file to create or maintain
- Reliable -- uses the same FFI mechanism (
@_cdecl) that Tauri uses internally forinit_plugin_<name>() - Debuggable -- all operations are logged via
NSLogwith the[mobile-push]prefix - Composable -- does not conflict with other Tauri plugins or native code
Add to src-tauri/Cargo.toml:
[dependencies]
tauri-plugin-mobile-push = "0.1"Or track the latest from git:
[dependencies]
tauri-plugin-mobile-push = { git = "https://github.com/yanqianglu/tauri-plugin-mobile-push" }npm install tauri-plugin-mobile-push-api
# or
pnpm add tauri-plugin-mobile-push-api
# or
bun add tauri-plugin-mobile-push-apiRequires @tauri-apps/api >= 2.0.0 as a peer dependency.
Add to your capabilities file (e.g., src-tauri/capabilities/mobile.json):
{
"permissions": ["mobile-push:default"]
}This grants both allow-request-permission and allow-get-token.
In src-tauri/src/lib.rs:
tauri::Builder::default()
.plugin(tauri_plugin_mobile_push::init())
// ... other plugins
.run(tauri::generate_context!())
.expect("error while running tauri application");When a push arrives while the app is foreground, iOS asks the delegate
what to present. The plugin's default is banner + list + sound + badge,
which is right for reminder/alert-style apps. Chat-style apps where the
notification content is already visible in-app usually want to suppress
the banner instead:
use tauri_plugin_mobile_push::{Builder, ForegroundPresentationOptions};
tauri::Builder::default()
.plugin(
Builder::new()
.ios_foreground_presentation(ForegroundPresentationOptions::silent())
.build(),
)
// ... other plugins
.run(tauri::generate_context!())
.expect("error while running tauri application");Presets on ForegroundPresentationOptions:
default()--banner + list + sound + badge(preserves pre-0.1.4 behavior)silent()--list + badgeonly (recommended for chat / messaging apps)none()-- fully invisible (data-only push)
Or set individual flags:
ForegroundPresentationOptions {
banner: false,
list: true,
sound: false,
badge: true,
}This only affects foreground behavior on iOS. When the app is backgrounded or the phone is locked, iOS bypasses the delegate entirely and shows the notification natively. Android follows FCM's standard behavior (banner when backgrounded, silent in-app when foreground).
In Xcode, select your target, go to Signing & Capabilities, and add the Push Notifications capability. This adds the aps-environment entitlement automatically.
Ensure your .entitlements file includes:
<key>aps-environment</key>
<string>development</string>Change to production for App Store / TestFlight builds. If you added the Push Notifications capability via Xcode, this is handled automatically.
That is the complete iOS setup. No AppDelegate.swift file is needed -- the plugin handles all APNs delegate methods automatically via runtime injection.
- Create a Firebase project at console.firebase.google.com and add your Android app.
- Download
google-services.jsonand place it insrc-tauri/gen/android/app/. - Configure your Gradle files:
// project-level build.gradle.kts
plugins {
id("com.google.gms.google-services") version "4.4.2" apply false
}
// app-level build.gradle.kts
plugins {
id("com.google.gms.google-services")
}
dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.8.0"))
implementation("com.google.firebase:firebase-messaging")
}Add to your AndroidManifest.xml inside the <application> tag:
<service
android:name="app.tauri.mobilepush.FCMService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>This registers the plugin's FCMService which forwards incoming messages and token refreshes to the Tauri event system.
Shows the system permission dialog on iOS. On Android 13+ (API 33), requests the POST_NOTIFICATIONS runtime permission. Earlier Android versions return { granted: true } immediately.
import { requestPermission } from "tauri-plugin-mobile-push-api";
const { granted } = await requestPermission();
if (!granted) {
console.warn("Push notification permission denied");
}Returns the APNs device token (hex string) on iOS or the FCM registration token on Android. On iOS, this triggers registerForRemoteNotifications() and resolves when the OS delivers the token.
import { getToken } from "tauri-plugin-mobile-push-api";
const token = await getToken();
console.log("Device push token:", token);
// Send this token to your backend for server-side push deliveryThe typical integration: request permission, get the token, and register it with your backend.
import {
requestPermission,
getToken,
onNotificationReceived,
onNotificationTapped,
onTokenRefresh,
} from "tauri-plugin-mobile-push-api";
// 1. Request permission
const { granted } = await requestPermission();
if (!granted) {
console.warn("Push permission denied");
return;
}
// 2. Get the device push token
const token = await getToken();
// 3. Send token to your backend
await fetch("https://your-api.com/push/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, platform: "ios" }),
});
// 4. Listen for foreground notifications
const unsubReceived = await onNotificationReceived((notification) => {
console.log("Received:", notification.title, notification.body);
console.log("Custom data:", notification.data);
});
// 5. Listen for notification taps (user opened app from notification)
const unsubTapped = await onNotificationTapped((notification) => {
console.log("Tapped:", notification.data);
// Navigate to the relevant screen based on notification.data
});
// 6. Listen for token refreshes (re-register with your backend)
const unsubToken = await onTokenRefresh(({ token }) => {
console.log("Token refreshed:", token);
// Send new token to your backend
});
// Cleanup when your component unmounts
unsubReceived.unregister();
unsubTapped.unregister();
unsubToken.unregister();function requestPermission(): Promise<{ granted: boolean }>;Request push notification permission from the user.
- iOS: Triggers the system permission dialog requesting
.alert,.badge, and.sound. Blocks until the user responds (30s timeout). - Android 13+: Requests the
POST_NOTIFICATIONSruntime permission. - Android < 13: Returns
{ granted: true }immediately (no runtime permission needed). - Desktop: Returns
{ granted: false }.
function getToken(): Promise<string>;Get the current device push token.
- iOS: Calls
registerForRemoteNotifications(), waits for the APNs callback, and returns the device token as a hex string. Times out after 15 seconds. - Android: Calls
FirebaseMessaging.getInstance().tokenand returns the FCM registration token. - Desktop: Returns an empty string.
All event listeners return Promise<PluginListener>. Call .unregister() on the returned listener to stop receiving events.
function onNotificationReceived(
handler: (notification: PushNotification) => void,
): Promise<PluginListener>;Fires when a push notification arrives while the app is in the foreground. On iOS, the notification is also displayed as a banner (with sound and badge).
function onNotificationTapped(
handler: (notification: PushNotification) => void,
): Promise<PluginListener>;Fires when the user taps a push notification to open the app. Use this for deep linking.
function onTokenRefresh(
handler: (payload: { token: string }) => void,
): Promise<PluginListener>;Fires when the OS issues a new push token (APNs token refresh on iOS, FCM token rotation on Android). Send the new token to your backend whenever this fires.
/** Payload delivered with push notification events. */
interface PushNotification {
title?: string;
body?: string;
data: Record<string, unknown>;
badge?: number;
sound?: string;
}Once you have the device token, send pushes from your backend:
- iOS (APNs): Use the APNs HTTP/2 API with a
.p8signing key or.p12certificate. The token fromgetToken()is a hex-encoded APNs device token. - Android (FCM): Use the FCM HTTP v1 API with a service account. The token from
getToken()is an FCM registration token.
The notification payload should include title, body, and any custom data fields your app needs. These will be delivered to your onNotificationReceived and onNotificationTapped handlers.
The plugin uses different strategies per platform to work around limitations in Tauri v2's mobile plugin dispatch.
Tauri v2's swift-rs compilation model creates duplicate PluginManager singletons when multiple plugins include Swift code. This causes run_mobile_plugin calls to hang indefinitely -- register_plugin() stores the plugin in one singleton, but run_plugin_command() dispatches through a different one.
This plugin bypasses that system entirely using @_cdecl FFI functions, which is the same mechanism Tauri uses for init_plugin_<name>() and is proven to work reliably:
request_permission: Rust spawns a thread that callsextern "C" mobile_push_request_permission()in Swift. The Swift function callsUNUserNotificationCenter.requestAuthorization()and blocks with aDispatchSemaphoreuntil the user responds. Returns 1 (granted) or 0 (denied) to Rust.get_token: Rust spawns a thread that callsextern "C" mobile_push_get_device_token()in Swift. On first call, the Swift function lazily injects APNs delegate methods (didRegisterForRemoteNotificationsWithDeviceToken,didFailToRegisterForRemoteNotificationsWithError) into Tao's dynamically-created AppDelegate usingimp_implementationWithBlock+class_addMethod. It then callsregisterForRemoteNotifications()and blocks until the APNs callback fires, writing the hex token to a C buffer.register_listener: Handled as a no-op in Rust'sgenerate_handler!to prevent fallthrough to the brokenrun_mobile_pluginpath.
On Android, the standard Tauri plugin dispatch works correctly. The Kotlin MobilePushPlugin handles commands directly, and FCMService (a FirebaseMessagingService) forwards incoming messages and token refreshes to the plugin's event system.
Thin async wrappers over invoke() and addPluginListener() from @tauri-apps/api/core. Published as tauri-plugin-mobile-push-api on npm.
- iOS event listeners are not yet functional.
onNotificationReceived,onNotificationTapped, andonTokenRefreshregister successfully but do not deliver events on iOS. This is because the TauriPluginManagerdispatch issue also affects the plugin'strigger()method for emitting events to the webview. The commands (requestPermission,getToken) work correctly via the direct FFI path. A future release will route iOS events throughAppHandle.emit()to bypass thePluginManager. - Android event listeners work as expected. The standard Tauri plugin dispatch functions correctly on Android.
Licensed under either of Apache License, Version 2.0 or MIT License at your option.