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
3 changes: 2 additions & 1 deletion apps/web/src/lib/tauri/PushNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,8 @@ export function MaybePushNotificationRegistration(props: {
},
getCurrentPermission: async () => {
const sysPerm = await push.checkPermissions();
if (sysPerm.status === 'prompt') {
// Quiet (provisional) delivery keeps the in-app prompt for full alerts.
if (sysPerm.status === 'prompt' || sysPerm.provisional) {
return 'default';
}
const appNotification = await getCurrentPermission();
Expand Down
16 changes: 11 additions & 5 deletions apps/web/src/lib/tauri/push-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ declare global {
}
}

/** iOS reports provisional (quiet) delivery as granted plus this flag;
* Android and older iOS builds omit it. */
export type PushPermissionStatus = ios.NotificationPermissionStatus & {
provisional?: boolean;
};

export function supportsNotificationLaunch(): boolean {
return window.__MACRO_IOS_NOTIFICATION_LAUNCH__ === true;
}
Expand All @@ -34,11 +40,11 @@ export function createPushApi(platform: 'android' | 'ios') {
const android = platform === 'android';
return {
checkPermissions: () =>
android
? invoke<ios.NotificationPermissionStatus>(
'plugin:android-push|check_permissions'
)
: ios.checkPermissions(),
invoke<PushPermissionStatus>(
android
? 'plugin:android-push|check_permissions'
: 'plugin:notifications|check_permissions'
),
requestPermissions: () =>
android
? invoke<ios.NotificationPermissionStatus>(
Expand Down
10 changes: 10 additions & 0 deletions apps/web/tauri/ios_push_plugin/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ and owns durable notification-tap delivery across cold launches/WebView reloads.
consuming the intent. A hidden bootstrap must never wait for foregrounding.
Failed tap delivery releases the cover only after frontend bootstrap; keep
the intent available for a replacement listener.
- Request provisional authorization at launch only while undecided, and never
prompt from registration. `check_permissions` waits for that request, so a
signed-in launch never skips registration on a stale `prompt`. It reports
provisional as `granted` with `provisional: true`: the frontend registers
the token and still offers the full-alert prompt. Older web bundles ignore
the flag and treat quiet delivery as fully enabled. `.authorized` reports
`granted` even with banners off; **Keep → Deliver Immediately** authorizes
without turning them on.
- Gate new frontend commands on `__MACRO_IOS_NOTIFICATION_LAUNCH__`; older native
binaries retain the existing push behavior when receiving an OTA web bundle.
Pass `launchAware: true` when watching. Older web bundles omit that flag and
Expand All @@ -40,6 +48,8 @@ and owns durable notification-tap delivery across cold launches/WebView reloads.
- A full iOS simulator build validates the Swift/UIKit bridge and iOS Rust
commands; exercise warm resume, terminated launch, WebContent eviction, rapid
successive taps, offline/auth failures, and ordinary icon resume on a device.
- A `--no-sign` simulator build gets no APNs token ("Push registration with a
nil environment"); build without it so Xcode signs to run locally.

## Related

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import UIKit
import UserNotifications
import WebKit

private struct PermissionStatus: Encodable { let status: String }
private struct PermissionStatus: Encodable { let status: String; var provisional = false }
private struct RegistrationStatus: Encodable { let isRegistered: Bool; let token: String? }
private struct RegistrationResult: Encodable { let success: Bool; let token: String?; let error: String? }
private struct WatchArgs: Decodable { let channel: Channel; let launchAware: Bool? }
Expand All @@ -30,6 +30,9 @@ final class NotificationsPlugin: Plugin, UNUserNotificationCenterDelegate {
private var frontendReady = false
private var launchAware = false
private var suppressPendingCover = false
/// Held until the launch-time provisional request settles, so a permission
/// check never reports the undecided status that request is replacing.
private let launchAuthorization = DispatchGroup()

override init() {
super.init()
Expand All @@ -45,7 +48,25 @@ final class NotificationsPlugin: Plugin, UNUserNotificationCenterDelegate {
pendingStoreError = error
Logger.error("[notification-open] phase=store_init outcome=failed")
}
UNUserNotificationCenter.current().delegate = self
let center = UNUserNotificationCenter.current()
center.delegate = self
// Provisional (quiet) delivery needs no prompt and lists Macro under
// iOS Settings → Notifications, where users can turn on full alerts.
launchAuthorization.enter()
center.getNotificationSettings { [launchAuthorization] settings in
guard settings.authorizationStatus == .notDetermined else {
launchAuthorization.leave()
return
}
center.requestAuthorization(options: [.alert, .sound, .badge, .provisional]) { granted, error in
if error != nil {
Logger.error("[notification-permission] phase=provisional_request outcome=failed")
} else {
Logger.info("[notification-permission] phase=provisional_request granted=\(granted)")
}
launchAuthorization.leave()
}
}
}

@objc override public func load(webview: WKWebView) {
Expand Down Expand Up @@ -331,15 +352,21 @@ final class NotificationsPlugin: Plugin, UNUserNotificationCenterDelegate {
}

@objc override public func checkPermissions(_ invoke: Invoke) {
UNUserNotificationCenter.current().getNotificationSettings { settings in
let status: String
switch settings.authorizationStatus {
case .notDetermined: status = "prompt"
case .denied: status = "denied"
case .authorized, .provisional, .ephemeral: status = "granted"
@unknown default: status = "prompt"
launchAuthorization.notify(queue: .global()) {
UNUserNotificationCenter.current().getNotificationSettings { settings in
let status: String
switch settings.authorizationStatus {
case .notDetermined: status = "prompt"
case .denied: status = "denied"
case .authorized, .provisional, .ephemeral: status = "granted"
@unknown default: status = "prompt"
}
// Provisional delivery registers for push like any grant; the flag
// lets the frontend still offer the full-alert prompt.
invoke.resolve(PermissionStatus(
status: status, provisional: settings.authorizationStatus == .provisional
))
}
invoke.resolve(PermissionStatus(status: status))
}
}

Expand All @@ -359,10 +386,12 @@ final class NotificationsPlugin: Plugin, UNUserNotificationCenterDelegate {
}

@objc public func registerForRemoteNotifications(_ invoke: Invoke) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
// Registration never prompts: requesting full authorization here would
// interrupt users who only have provisional (quiet) delivery.
UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async {
guard granted else {
invoke.resolve(RegistrationResult(success: false, token: nil, error: error?.localizedDescription ?? "Push notifications are not authorized"))
guard [.authorized, .provisional, .ephemeral].contains(settings.authorizationStatus) else {
invoke.resolve(RegistrationResult(success: false, token: nil, error: "Push notifications are not authorized"))
return
}
if let token = UserDefaults.standard.string(forKey: "deviceToken") {
Expand Down
15 changes: 15 additions & 0 deletions docs/agent-guide/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ both listener attachment and pending-intent reads, including a tap delivered
before the pending read stalls. Receiving a push without tapping it must not
navigate.

On iOS, Macro requests provisional authorization at launch, so
**Settings → Apps → Macro → Notifications** exists before any prompt, and a
signed-in device registers for pushes that arrive quietly in Notification
Center. A quiet push counts as delivered, so those users get no email digest
for it. The in-app **Mobile notifications** switch and enable prompt stay off
while delivery is provisional; turning either on shows the system prompt.
A quiet notification's **Keep → Deliver Immediately** authorizes Macro but
leaves banners and the Lock Screen off: the switch shows on, iOS never prompts
again, and only iOS Settings turns banners on.
Verify on a fresh install: the Settings row appears without a prompt, the in-app
prompt still appears, and upgrading from Settings or from a quiet notification's
**Keep → Deliver Immediately** turns the switch on when Macro returns from the
background. Notification Center pulled down over Macro does not background it,
so the switch updates only after the next return.

On native Android, enable notifications in Settings while signed in. Android 13+
also asks for system permission; the system's **Activity** notification channel
must be enabled. Remote push owns system notification display after registration,
Expand Down
Loading