Skip to content

feat(apple): background push through APNs - #2000

Open
ArnabChatterjee20k wants to merge 27 commits into
mainfrom
feat/apple-push-apns
Open

ArnabChatterjee20k wants to merge 27 commits into
mainfrom
feat/apple-push-apns

Conversation

@ArnabChatterjee20k

@ArnabChatterjee20k ArnabChatterjee20k commented Oct 9, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Delivers Appwrite Push to an iOS app that is in the background, terminated, or swiped away, with the message content still going over MQTT, never through Apple. It includes #2012 (the Notification Service Extension), merged into this branch.

message sent → app connected: delivered over MQTT, once
             → app not connected: the server sends APNs a placeholder
               { aps: { alert: { title: "New message" }, mutable-content: 1 } }   no content, no id
               → iOS runs the app's Notification Service Extension, even after a swipe-away
               → the extension connects over MQTT with the app's session, fetches the message,
                 and shows it in the placeholder's place
               → on the next start, the app's callbacks get those messages, without second banners

How to use it

1. Set up the project

appwrite init apns

This uploads the APNs key, creates the APNs providers (sandbox and production), registers the App ID with Push Notifications, and sets aps-environment in the app's entitlements. The project also needs the Appwrite push provider (MQTT) enabled.

2. Subscribe in the app

Nothing new to call. A subscription with background: true (the default) also registers the device with APNs:

let push = Push(client)
let subscription = try await push.subscribe(["news"]) { message in
    print(message.data)   // over MQTT while connected, handed over from the extension after a wake-up; once either way
}

Taps arrive through the existing API: push.onNotificationOpened { … } and push.getInitialNotification().

3. Add a Notification Service Extension

This is what lets messages reach an app that isn't running. Without it, the user sees the placeholder ("New message") and gets the content when the app opens.

  1. In Xcode: File → New → Target → Notification Service Extension. Add the Appwrite package to the new target.
  2. Replace the generated class with:
    import Appwrite
    
    class NotificationService: AppwriteNotificationService {}
  3. Add the App Groups capability to both the app and the extension, with the same group.
  4. Name that group in both Info.plists:
    <key>AppwritePushAppGroup</key>
    <string>group.com.example.app</string>

4. Sign out

Call close() before ending the session:

push.close()
try await account.deleteSession(sessionId: "current")

Once a user has no background subscription left, Push deletes the device's push target and clears the session it shares with the extension. The server stops pushing to the device, and the extension can't fetch as that user. Signing in as someone else and subscribing registers the device for the new user.

Optional: handle the delegate yourself

Push proxies the app delegate to get the device token and remote notifications, and still calls the app's own methods. To turn that off, set AppwritePushAutoRegistration = NO in Info.plist and forward the calls:

func application(_ app: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
    Push.setDeviceToken(token)
}

func application(_ app: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
                 fetchCompletionHandler completion: @escaping (UIBackgroundFetchResult) -> Void) {
    if !Push.handleRemoteNotification(userInfo, completion: completion) { completion(.noData) }
}

// With your own UNUserNotificationCenterDelegate:
func userNotificationCenter(_ c: UNUserNotificationCenter, willPresent n: UNNotification,
                            withCompletionHandler done: @escaping (UNNotificationPresentationOptions) -> Void) {
    done(Push.isDelivered(n) ? [] : [.banner, .sound])
}
func userNotificationCenter(_ c: UNUserNotificationCenter, didReceive r: UNNotificationResponse,
                            withCompletionHandler done: @escaping () -> Void) {
    _ = Push.handleNotificationResponse(r); done()
}

How it works

  • Device. On the first background subscription, Push registers for remote notifications and creates the push target, POST /account/targets/push. It updates the target on 409, and sends environment (sandbox for Simulator and development builds) and bundleId. The target ID is stable per installation and user.
  • Topics. Each messaging topic subscribed in the background gets a subscriber on the device's target, through direct API calls, so Push doesn't need a generated Messaging service. users/<id> needs none. Subscribers are removed once no background subscription still uses the topic.
  • Shared state (App Group).
    • The app keeps the endpoint, project, client id, background topics and their titles there. The session secret or JWT goes in the Keychain under the App Group's access group.
    • Shared writes take an NSLock and a file lock in the group container, because the app and the extension are separate processes.
  • The extension.
    • App live: it doesn't connect while the app is live (heartbeat under 10 s), because the session allows one connection. It replaces the app's banner instead, so one banner shows. When there's no app banner to replace, for example because the app was killed a moment ago, it fetches.
    • Fetch: otherwise it connects with the shared session, takes the backlog the broker replays, shows one message and queues the rest for the placeholders that follow. Queued messages expire after 30 s.
    • Not filled: if it can't fetch (broker down, session gone), the placeholder stays, marked with the topics it was for. The app removes it once it's subscribed to those topics and the broker has replayed the message.
  • Once only. The broker delivers each message once per session (user and client id). Messages seen over APNs are also recorded by messageId, up to 500, kept across launches and shared with the extension.
  • Data-only messages (no notification block) reach the callbacks with no banner.

Server dependencies (appwrite/appwrite#14302)

The placeholder push is in #14302. Still needed there:

  • Reach over MQTT. Treat a user as reachable over MQTT when the Appwrite push provider is enabled. Today it takes a target on the appwrite provider, which the SDK never creates, so the message goes out as a full APNs alert.
  • Provider choice. Pick the APNs provider from the target's bundleId and environment. Today the target uses the default push provider, so Simulator and development builds land on production.
  • Wait for the acknowledgement. Wait ~2–3 s for the device's session to acknowledge the message before sending the placeholder, so a foreground app doesn't get a second banner pop.
  • Data-only messages. No placeholder for them. An extension can't drop a notification without Apple's filtering entitlement.
  • Revoked sessions. No placeholder to a device whose session was revoked.

Not in this PR

  • Android/FCM, Flutter and React Native background delivery through the platform push service.
  • appwrite init apns adding the extension target and App Group (step 3 is manual for now).
  • Reconciling topic subscribers left over from an earlier app version that no longer subscribes to them.

Test Plan

  • 21 Swift Testing tests (PushTests.swift) covering:

    • environment detection
    • dedup
    • payload shapes
    • IDs
    • the delegate proxy
    • the shared session, heartbeat, hand-off, queue order and expiry
    • two delivered-message stores seeing each other's writes
  • swift build on macOS, the iOS Simulator SDK, and Linux (swift:6.1-jammy, the CI image, from the e2e fixture spec); swift format lint --strict; Rector, Twig lint and the Generation suite.

  • iOS Simulator against a real local Appwrite (#14302 branch), the real MQTT broker and the real APNs sandbox:

    Case Result
    App in foreground Delivered once over MQTT; the extension replaces the app's banner
    App in background / terminated The extension fetched the real message in ~1.7 s
    Relaunch Messages the extension fetched reach the callbacks once, in order, with no banners
    3 messages right after a kill Each placeholder shows its own message
    20 messages with the app open, 25 in the background All delivered once, in order; every banner has its own content
    Unicode title and body, 256-character title with a 60 KB body Shown exactly as sent, no extension crash
    Same title twice Two notifications
    Data-only Reaches the callback, no banner, no raw JSON
    Broker restart with the app open Reconnects and receives
    Broker down while closed Placeholder stays; removed when the app reconnects and gets the message
    Sign out Target deleted; nothing reaches the device, alive or killed
    Switch user Only the new user's messages arrive
    Session revoked No content shown, no reconnect loop
  • Swipe-away from the App Switcher, against the mock broker.

  • Swipe-away against the real stack, tapping a banner, and notifications turned off.

  • Real device: the extension's memory and time limits.

Push edge-case runbook

The Android runbook's cases (27 device scenarios), run on iOS against a real local Appwrite, its MQTT broker and the APNs sandbox.

SDK findings from the runbook

  • 3. A data-only push posted a notification with the raw JSON as its body. The same bug was in the Apple SDK. Fixed in 275a593 with the rule fix(push): post no notification for data-only pushes #2013 uses on the other SDKs:

    • a payload that isn't a JSON object is shown with itself as the body;
    • an envelope is shown only when its notification has a title or body;
    • data-only, empty and image-only messages reach the callbacks with no banner.

    Tested on the Simulator with the app open and terminated.

  • 15. Battery "Restricted" not reported by backgroundStatus() (fix(push): report restricted background usage as best effort #2014). Not applicable: it's an Android setting, and the Apple SDK has no backgroundStatus().

Server findings: 11 (stuck replay cursor), 21 (expired messages replayed) and 24 (open connection of a blocked user). They affect iOS the same way, but nothing in the SDK can fix them. They're fixed by appwrite/appwrite#14410, #14412 and #14411.

Runbook cases run on iOS

  • 1. Unicode content: title and body exactly as sent
  • 2. Oversized content (256-character title, 60 KB body): shown, no extension crash
  • 3. Data-only push: reaches the callback, no banner (fixed above)
  • 4. Same title twice: two notifications
  • 5. Tap the right one: needs a manual tap
  • 6. No redelivery on open: messages handed over from the extension reach the callbacks once, with no second banner
  • 7. Foreground burst (20): all in order, none twice
  • 8. Background burst (25): all 25 banners show their own message; all handed off once on relaunch
  • 9. Replay beyond depth: the newest 5 replay, the older ones are lost (by design, as on Android)
  • 10. Messages right after a kill: each placeholder shows its own message
  • 11. Redelivery after restart: server-side (fix(mqtt): stop a truncated replay from pinning the replay cursor appwrite#14410)
  • 12. App update while closed: reinstalled while terminated, then sent; the extension filled the banner, and the app got the message once on launch
  • 13. Exact alarms revoked (no iOS equivalent: Android wakes the app with alarms; iOS delivers through APNs and the extension)
  • 14. Notifications revoked: needs a manual toggle in Settings
  • 15. Battery "Restricted" (no iOS equivalent: an Android battery setting; iOS Low Power Mode doesn't hold visible APNs pushes)
  • 16. Data Saver (no iOS equivalent: Android's Data Saver blocks background data; iOS Low Data Mode doesn't hold APNs pushes, and the extension may still fetch)
  • 17. Restricted standby bucket (no iOS equivalent: Android app standby buckets delay background work; iOS has no such buckets for pushes)
  • 18. Wi-Fi to cellular: can't run on the Simulator, which only has the Mac's network; needs a device
  • 19. Broker restart: reconnects and receives
  • 20. Broker down while closed: the placeholder stays, and is removed once the app reconnects and gets the message
  • 21. Expired message: server-side (fix(mqtt): drop expired messages from offline replay appwrite#14412)
  • 22. Scheduled message: sent 60 s ahead with the app terminated; delivered and filled 66 s after sending
  • 23. Reboot: the Simulator was shut down and booted, then a message sent without opening the app; the extension filled the banner. Being offline during the reboot can't be reproduced on the Simulator
  • 24. Blocked user: server-side (fix(mqtt): disconnect blocked or deleted users from open connections appwrite#14411)
  • 25. Sign out: the device target is deleted; nothing reaches the device, alive or killed
  • 26. Switch user: only the new user's messages arrive, alive or killed
  • 27. Session revoked: no content shown, no reconnect loop

Related PRs and Issues

Subscribing with background: true now also registers the app with APNs
and creates the device's push target and topic subscribers, so messages
reach a suspended or killed app as visible notifications. Push installs
a proxy on the app delegate to receive the device token and remote
notifications; apps that opt out with AppwritePushAutoRegistration = NO
call Push.setDeviceToken and Push.handleRemoteNotification themselves.

A message that arrives through both APNs and MQTT is delivered once,
deduplicated by messageId, and Push.isDelivered tells a custom
notification delegate whether one was already shown.
@hansi-codes

hansi-codes Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

🟢 Tier S · Ready to merge

This PR adds APNs-backed background delivery to the generated Apple Push SDK, including push-target and topic-subscriber management, shared app/extension state, and MQTT message handoff with deduplication. It also generates a notification service extension and tests for push handling, delegate behavior, and shared delivery state.

Latest changes: The newest commits display raw non-object payloads as notification bodies, limit envelope banners to nonempty notification titles or bodies, and add tests for the visibility classification.

Verdict New comments Fixed Still open
💬 Commented 0 0 0

Note

Not approving while a bug finding is open: Reconcile persistent subscribers after app relaunch

📂 Walkthrough · 4
File Change
src/SDK/Language/Apple.php Registers the generated notification service extension source in the Apple SDK output.
templates/apple/Sources/Services/Push.swift.twig Adds APNs registration and target/subscriber lifecycle, shared delivery state, notification handling, and MQTT message deduplication.
templates/apple/Sources/Services/PushServiceExtension.swift.twig Adds the app-group-backed notification service extension that fetches and presents visible MQTT messages.
templates/apple/Tests/PushTests.swift.twig Tests push payload handling, delegate proxying, shared extension state, message handoff, and notification visibility classification.

Reviewed the commits since 337f44b · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

hansi-codes[bot]
hansi-codes Bot previously requested changes Oct 9, 2026

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tier B · 1 blocking finding to address. Summary

Comment thread templates/apple/Sources/Services/Push.swift.twig
Comment thread templates/apple/Sources/Services/Push.swift.twig Outdated
Comment thread templates/apple/Sources/Services/Push.swift.twig Outdated
Comment thread templates/apple/Sources/Services/Push.swift.twig
Comment thread templates/apple/Sources/Services/Push.swift.twig Outdated
Comment thread templates/apple/Sources/Services/Push.swift.twig
The delegate proxy was installed, and the push registered for wake-ups, only after the SUBSCRIBE finished. A wake push arriving on a fresh launch while it was in flight went to no one. Both now happen before the subscription's network work; asking APNs for a token still waits for a successful subscribe.
willPresent answered Appwrite's pushes itself, so an app's own userNotificationCenter(_:willPresent:) stopped running for them. It is now called for those pushes too, and its options are used unless MQTT already delivered the message.
…shes too

The delegate proxy returned early for Appwrite's pushes, so an app's own application(_:didReceiveRemoteNotification:fetchCompletionHandler:) stopped seeing them. Both now run, and iOS gets a single completion once both finish: new data when either had some.
register linked the topics it started with after awaiting the target request. A topic unsubscribed meanwhile got a subscriber anyway, since apnsDisable found nothing linked yet to remove. Topics are checked again before each link, and a subscriber created for a topic that was unsubscribed while the request was in flight is removed.
… target

The target id is stable per installation and user, so the same install moving from a debug build to TestFlight hits the 409 path. That path only updated the token, leaving the sandbox environment on the target. The update now sends the environment and bundle id as well.
@hansi-codes
hansi-codes Bot dismissed their stale review October 9, 2026 13:23

The findings that requested changes are resolved. See the summary comment for what is still open.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · See the inline comments. Summary

Comment thread templates/apple/Sources/Services/Push.swift.twig
SwiftUI's app delegate implements none of the remote-notification methods; it forwards them to the @UIApplicationDelegateAdaptor class. The proxy added its methods to SwiftUI's class, so the app's own didRegisterForRemoteNotifications and didReceiveRemoteNotification stopped running. A method the delegate forwards is now proxied on the class it forwards to. Found running a SwiftUI app in the iOS Simulator; covered by a test with a forwarding delegate.
The SDK is also built on Linux, where ObjectiveC, CryptoKit, Security and UIKit do not exist, and import ObjectiveC failed the apple and AppleSwift61 jobs. The import is now guarded, and the app delegate proxy, which needs the Objective-C runtime, moves to an extension that exists only where the runtime does; elsewhere its two entry points do nothing, as there is no APNs there. The delegate proxy tests are guarded the same way.
A topic put back into a background subscription while its subscriber delete was in flight was skipped by link, which still saw it linked; the delete then removed the subscriber and the topic lost APNs delivery. After the delete, unlink links the topic again when a background subscription still has it.
… Simulator builds as sandbox

appwrite/appwrite#14302 puts the wake marker under a top-level data key beside aps, which Push did not read, so it treated the wake as another service's push. It now reads messageId and type under data as well as under appwrite, aps.data or at the top level; a data key without a messageId is still not Appwrite's.

A Simulator build has no provisioning profile, so its environment fell back to production, but Simulator device tokens are always sandbox ones and the target pointed the server at the wrong APNs provider. Simulator builds now register as sandbox.
A silent push never reaches an app the user swiped away from recents, but a visible push with mutable-content always runs the app's Notification Service Extension. AppwriteNotificationService is that extension's base class: for a placeholder push it connects over MQTT with the app's session, takes the backlog the broker replays, and shows one message in the placeholder's place, queueing the rest for the pushes that follow. No content passes through APNs.

The app and the extension share, through the App Group named by AppwritePushAppGroup: the push session and its credential, the delivered-message store, a heartbeat while the app is connected, and the messages the extension fetched. While the app is live the extension does not connect; it replaces the app's latest banner instead, which Push now posts under the message id. On its next start Push delivers what the extension fetched to the subscription callbacks without banners.
ArnabChatterjee20k and others added 11 commits October 13, 2026 12:27
The project header is added by each generated service call, not kept on
the client, so the raw target requests reached the server without it and
were refused as an unknown origin.
The project header is added by each generated service call, not kept on
the client, so the raw target requests reached the server without it and
were refused as an unknown origin.
A killed app keeps a fresh heartbeat for a few seconds, so the extension
took it as live and left the placeholder in place without fetching. It
now fetches whenever there is no recent app banner to replace.
The extension filled later placeholders from messages it had queued
even after the app had taken them over, showing stale content in place
of the new message. The app clears the queue when it takes the hand-off,
and entries older than 30 seconds are dropped.
…keep queued banners

An NSLock only serializes one process, so the app and its extension could
overwrite each other's hand-offs and delivered ids. Shared state now also
takes a file lock in the App Group container.

Clearing the queue on hand-off left the placeholders that follow without
their content when the app opened first. Queued messages are kept, used
before the live-app check, and dropped after 30 seconds.
feat(apple): deliver push through a Notification Service Extension
Push called Messaging(client), which isn't generated when the spec has
no client Messaging service, as in the e2e fixture, so the Apple SDK
failed to compile there. Topic links now call the API directly with the
project header, like target registration.
…ption is left

close() and dropping the last background subscription left the device's
target and the session shared with the extension in place, so after a
sign-out the server kept pushing and the extension fetched the old
user's messages. Push now deletes the target and clears the shared
session; switching user registers the new user's target.
A message without a notification block posted a banner with its raw
JSON as the body. It now only reaches the callbacks, and the extension
shows a message that has content.
With the broker down, the session gone or only data-only messages, the
extension leaves the placeholder in place. It marks it, and the app
removes the marked placeholders once it is connected and has the
messages.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Tier B · See the inline comments. Summary

Comment thread templates/apple/Sources/Services/Push.swift.twig Outdated
Comment thread templates/apple/Sources/Services/Push.swift.twig Outdated
… needs it

The target is shared by every Push of the user, so deleting it on one
Push's last background subscription could stop delivery for another, or
for a subscription added meanwhile. The delete now checks every Push of
the user right before it runs, and the shared session is kept from one
that still has background subscriptions.
The app cleared every unfilled placeholder on its first subscribe, even
when the topic the placeholder was for was not subscribed yet. The
extension now records the topics it fetched for, and the app clears a
placeholder once all of them are subscribed, so the broker replays its
message to a callback.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · See the inline comments. Summary

Comment thread templates/apple/Sources/Services/PushServiceExtension.swift.twig Outdated
…ssion

Without a session the extension fetched for no topic, so the empty mark
let the app clear the placeholder on any subscribe, with no message
behind it reaching a callback.
…ext only

Matches #2013 on the other SDKs: a payload that is not a JSON object is
shown with itself as the body, and an envelope is shown only when its
notification has a title or body. An empty or image-only notification
block no longer posts a title-only banner, and a raw MQTT payload gets
one again.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant