tauri-apps / tauri-apps/plugins-workspace
[notification] iOS: actionPerformed is delivered only to the webview, so an action tapped on a cold launch is silently lost
- Dominant language
- Rust
- Stars
- 1.8k
- Forks
- 602
- Avg merge
- 4d 14h
- Merged PRs (30d)
- 9
Description
### Describe the bug
On iOS, `didReceive(response:)` forwards a notification action to the app **only** through the webview:
https://github.com/tauri-apps/plugins-workspace/blob/v2/plugins/notification/ios/Sources/NotificationHandler.swift
```swift
try? self.plugin?.trigger("actionPerformed", data: ReceivedNotification(...))
```
iOS calls `didReceive(response:)` within milliseconds of launching the app to handle the response. The only consumer is a listener registered from JS via `onAction`, which cannot exist until the webview has loaded and app code has run. Tauri webview events are not buffered and not replayed, so the payload is emitted into a webview with no listener and discarded.
The result: **tapping a notification action on a cold or background launch silently does nothing.** No crash, no error — the app opens and the action is simply lost. That is the normal case for a scheduled reminder, since the app is usually not running when it fires.
This is not a race that careful app code can win. Registering `onAction` as early as possible still requires the webview to have booted, which is orders of magnitude later than `didFinishLaunchingWithOptions`.
### Reproduction
1. Register an action type with `registerActionTypes`
2. `sendNotification` with `schedule: Schedule.at(...)` and that `actionTypeId`
3. Force-quit the app
4. When the notification fires, tap one of its action buttons
The app launches; `onAction` never fires.
Verified on device with the OS-level evidence that the action *was* delivered:
```
[pro.example.app] Received response to … for action keep (7)
[pro.example.app] backgroundLaunchApplication: response:
Bootstrapping app with intent background
```
…followed by no `actionPerformed` reaching JS.
### Why the Rust side cannot compensate
`PluginHandle` exposes no `add_plugin_listener` in Tauri 2.8/2.9, so the Rust half of the plugin cannot observe `trigger` either. The webview is the only consumer, which is what makes this unwinnable from outside the plugin.
### Suggested fix
Persist responses that arrive with no consumer, and let the frontend drain them once its listener exists. Two shapes that both work:
1. **Native queue + drain command** — retain responses in the plugin and expose a command the JS side calls immediately after `onAction` is attached. Smallest change, but an in-memory queue dies if iOS suspends the process before the webview boots, which is common on a background launch.
2. **Persist to disk** — append each response to a file in the app's data directory before triggering, and drain it on startup. Survives the process never becoming interactive.
I am running (2) as a vendored patch: the handler appends a JSON line to `Application Support//…` before calling `trigger`, and the app drains it once its listener is up. On-device this now applies the action about two seconds after the tap, from a fully terminated start.
Treating the live `actionPerformed` event as a wake-up that prompts a drain — rather than as the payload — is what keeps it exactly-once when both paths are available.
### Full `tauri info` output
Plugin version 2.3.3; also present on `v2` HEAD as of today. Reproduced on an iOS 26 simulator with `tauri-cli` 2.9.5.
### Additional context
Related but distinct: #3533 (the same handler force-unwraps `notificationsMap`, which crashes before this code path is reached) and #3256 (scheduled notifications fire at the wrong time outside UTC). All three have to be fixed for iOS notification actions to work end to end — that was my experience getting there.
Happy to open a PR if a maintainer has a preference between the two shapes above.
Contributor guide
Research direction
Start with plugins/notification/ios/Sources/NotificationHandler.swift and reproduce the cold-launch flow using a scheduled notification action. Trace how actionPerformed reaches the onAction listener, then evaluate the queue or disk-persistence approaches described in the issue. Done means an action tapped after force-quitting the app is delivered to the frontend once after startup.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, rust, swift
- Domain
- frontend, mobile
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100