Repository navigation
feat(apple): background push through APNs - #2000
ArnabChatterjee20k wants to merge 45 commits into
Conversation
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.
🟢 Tier S · Ready to mergeThis pull request adds APNs-backed background delivery to the Apple SDK while keeping message content on MQTT, with a Notification Service Extension that fetches messages and fills placeholder notifications. It also manages device targets and topic subscribers across launches, shares session and delivery state with the extension, and adds gzip handling and push tests for the Swift clients. Latest changes: The latest commit moves reconciliation scheduling to after the MQTT broker acknowledges a subscription, avoiding starting the launch timer before that subscription succeeds.
Note Not approving while a bug finding is open: Gate the token-registration reconciliation path too 📂 Walkthrough · 6
Merged |
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.
The findings that requested changes are resolved. See the summary comment for what is still open.
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.
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.
…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.
…ibes to Topic subscribers outlive the process so APNs can wake a killed app, but a launch that stops subscribing to a topic, such as after an app update, left its subscriber on the server, and every message on it woke the device with a placeholder the extension could not fill. Push now keeps the topics it linked per target in the shared store. 30 seconds after the device registers, once per launch, it unlinks the saved topics no background subscription of the user claims. A topic subscribed later is linked again.
…recheck every Push after an unlink A launch that dropped every background subscription never reconciled, so the target and its subscribers stayed and kept waking the device. Every subscribe now schedules the launch's reconcile, the store records the targets this install registered, and with no background subscription left the reconcile deletes the target, as close() does. An unlink re-linked the topic only when the Push that unlinked it still claimed it; a sibling Push of the user claiming it during the delete lost its link. The check now covers every live Push of the user.
feat(apple): unlink background topics a later launch no longer subscribes to
…w background subscription The reconcile and close() delete the target after deciding no background subscription needs it; one registered while the DELETE was in flight lost its target until the next registration. After the delete, a Push of the user with background subscriptions now registers the device and links its topics again.
With the app live but its banner not posted yet, as for a data-only message, the extension fetched with the same session and took the app's connection over. It now waits up to a heartbeat for the app's banner or its next heartbeat, either of which means the app is running and has the message, and fetches only when neither comes, as after a kill. The running app clears the placeholders it has the messages for on each heartbeat.
A placeholder was cleared only once every topic it was for was subscribed again, so one for a topic a later launch dropped, or whose filter changed, stayed. The launch's reconcile now clears those too.
…ound subscription A token change registered every Push that had ever subscribed in the background, re-creating a target that had been deleted for having none.
Without AppwritePushAppGroup the extension has no session and leaves the placeholder, silently. A background subscription without it now warns once, and so does the extension.
|
Follow-up fixes from a review of this PR's edge cases are in #2024, stacked on this branch:
|
…d subscription The reconcile cleared placeholders only after finding a Push with background topics, so a launch that dropped them all kept every marked placeholder. It now clears them before deciding, with nothing claimed.
abnegate
left a comment
There was a problem hiding this comment.
Device test (iPhone 14 Pro Max, iOS 26.5.2)
Reran the Android 27-case push runbook against this branch on a physical device, with subscribe(..., background: true) and a local pushdev + APNs sandbox provider (init apns from #2015, bundle io.appwrite.pushe2e).
MQTT while the app is open works. Unicode, oversized payload, 20-message foreground burst (in order), and broker restart (disconnect → reconnect → next message) all passed.
APNs never registered, so the cases this PR left for a real device are still unchecked: swipe-away, banner tap, notifications off, NSE memory/time, Wi-Fi↔cellular, and anything that needs a killed or backgrounded app (kill loop, update, scheduled, expired, broker-down-while-closed). After a kill, a send produced no banner.
APNs upsertTarget failed in three different ways, and each one was reported as “run appwrite init apns” (see inline). After origin was fixed, the API error was The target could not be found — so the device token never landed, and the NSE path could not be exercised.
Also seen on device (may be server / MQTT, but it is what this SDK does today)
- Blocked user:
PATCH /users/{id}/statusto disabled did not stop delivery on the open connection. - Session revoked:
DELETE /users/{id}/sessionsdid not stop delivery; the next message still reached the callback. - Sign-out:
close()stops live callbacks, but the samesetPushClientIdreconnects the broker session and replays messages sent while signed out.
Happy to re-run the killed-app / NSE cases once a token actually upserts.
| push.surface( | ||
| {{ spec.info.title | caseUcfirst }}Error( | ||
| message: | ||
| "Could not register this device for APNs: \(error.localizedDescription). Run `appwrite init apns` so the project has an APNs provider for \(Bundle.main.bundleIdentifier ?? "this app") (\(Environment.current.rawValue))." |
There was a problem hiding this comment.
This string is attached to every upsertTarget failure, and on a real device it was wrong for each of the three errors we hit:
NIOHTTPCompression.NIOHTTPDecompression.DecompressionError— Client HTTP decompression against the local Appwrite HTTP port.init apnsis irrelevant;Client.setCompression(false)made the call reach the API.Invalid Origin. Register your new client (io.appwrite.pushe2e) as a new iOS platform…— the project had no Apple platform.appwrite init apnscreates the APNs provider and the Apple Developer App ID; it does not add an Appwrite project platform, so the hint sends people to the wrong fix.The target could not be found— after (1) and (2) were cleared, the token still never upserted (see the 409 → PUT comment below).
Please surface the underlying status/type, and only mention init apns when the failure is actually a missing APNs provider / aps-environment.
There was a problem hiding this comment.
Fixed in 79e9b4b. A failed registration now reports the server's message, status and type. The only hint is for general_unknown_origin: add an Apple platform with the app's bundle ID. init apns isn't mentioned any more. Your three errors:
DecompressionError: a client bug, fixed in fix(swift): ask for gzip so compressed responses decode #2025. NIO asks fordeflate, gzip, and Appwrite answersdeflatewith a raw deflate stream rather than zlib, so every compressed response failed withinflationError(-3). The clients now ask forgzip. Against a local Appwrite: 60/60account.get()calls failed before, 0 after.- Invalid Origin: now reported with that hint. Having
init apnsalso add the Apple platform is next in feat(cli): appwrite init apns #2015. - The target could not be found: see the 409 thread.
| // This install already has a target for the user: point it at the current token, and | ||
| // the build's environment, which changes when the same install moves from a debug to | ||
| // a TestFlight build. | ||
| let _: Any = try await push.client.call( |
There was a problem hiding this comment.
On the device this PUT is what produced The target could not be found, so APNs registration never completed and no killed-app / NSE case could run.
The 409 path assumes “this targetId already exists.” A 409 from POST /account/targets/push can also mean the user already has a different push target (we had created one via the API while debugging). The fallback then PUTs the hashed installation id, which 404s, and register bails out — the device token is never stored.
If 409 is “user already has a push target”, this needs to GET/list the existing target and update that id (or POST with the server-assigned id), not PUT a target that was never created.
There was a problem hiding this comment.
Fixed in 79e9b4b. You were right: the server can keep the token in another of the user's targets. On create it reuses the session's existing push target, and it answers 409 when another target already holds the token.
registernow takes the target id from the server's reply.- On 409 followed by 404, it finds the target holding the token in
GET /accountand updates that one. - The id it ends up with is saved, and links, unlinks, the reconcile and the sign-out delete all use it.
Reproduced your case on the Simulator against a real Appwrite. I created manual-xyz through the Users API with the device's token, then launched the app.
- Registration updated
manual-xyzwith no error, and created no second target. - A push with the app killed was filled by the extension with the real message.
- Sign-out deleted
manual-xyz.
| let pushes = apnsLocked { apnsPushes.values.compactMap { $0.push } } | ||
| let described = {{ spec.info.title | caseUcfirst }}Error( | ||
| message: | ||
| "APNs registration failed: \(error.localizedDescription). Run `appwrite init apns` to add the Push Notifications capability (aps-environment entitlement)." |
There was a problem hiding this comment.
Same problem as the upsertTarget wrapper: this blames a missing Push Notifications entitlement for any didFailToRegisterForRemoteNotifications error (network, user denied, provisioning, etc.). On device we never reached a clean token-failure because upsertTarget failed first, but the hint will misdirect anyone who does.
There was a problem hiding this comment.
Fixed in 99a3bfb. The entitlement hint is given only for Cocoa error 3000, iOS's "no valid aps-environment entitlement". Any other failure reports its own description.
Any didFailToRegisterForRemoteNotifications error, from the network to the user's choice, was reported as a missing Push Notifications capability. The hint is now given only for the missing aps-environment entitlement (Cocoa error 3000).
… real failure POST /account/targets/push can keep the token in another of the user's targets: the session's existing push target, or the one already holding the token, which answers 409. Push then updated or linked its own id, which did not exist, failed with "The target could not be found", and never registered the device. It now takes the id from the server's reply, finds the target holding the token on 409 then 404, and keeps that id for links, unlinks, the reconcile and the target delete. A failed registration said to run appwrite init apns whatever went wrong. It now gives the server's message, status and type, with a fix only for an unknown origin: add an Apple platform for the bundle ID.
|
@abnegate thanks for the device run. Registration should now complete:
On the rest of your device notes:
The killed-app and NSE cases should be reachable on the device now. A rerun would be great once you have time. |
With no Accept-Encoding of its own, NIO's decompressor asks for "deflate, gzip". Appwrite answers deflate with a raw deflate stream, not the zlib stream HTTP's deflate names, so every compressed response failed with DecompressionError.inflationError(-3). The Swift and Apple clients now ask for gzip unless the app sets Accept-Encoding.
fix(swift): ask for gzip so compressed responses decode
fix(apple): push edge cases from review
…tion The reconcile's timer started when subscribe() was called, before the connection and the SUBSCRIBE. A connection slower than the 30 seconds left it seeing no claimed topics, so it cleared placeholders before their messages were replayed, and with no background subscription recorded yet it could delete the target. It now starts after the broker accepts the SUBSCRIBE; a launch that never subscribes doesn't reconcile.
register() also started the reconcile, and a token callback can run it while a SUBSCRIBE is still pending. If that SUBSCRIBE then failed after the timer fired, the reconcile had treated its topic as claimed and the launch counted as reconciled. register() no longer starts it: every Push that registers has subscribed, and subscribe() starts it once the broker accepts the SUBSCRIBE.
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.
How to use it
1. Set up the project
This uploads the APNs key, creates the APNs providers (sandbox and production), registers the App ID with Push Notifications, and sets
aps-environmentin 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:Taps arrive through the existing API:
push.onNotificationOpened { … }andpush.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.
4. Sign out
Call
close()before ending the session:Once a user has no background subscription left,
Pushdeletes 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
Pushproxies the app delegate to get the device token and remote notifications, and still calls the app's own methods. To turn that off, setAppwritePushAutoRegistration = NOin Info.plist and forward the calls:How it works
Pushregisters for remote notifications and creates the push target,POST /account/targets/push. It updates the target on409, and sendsenvironment(sandboxfor Simulator and development builds) andbundleId. The target ID is stable per installation and user.Pushdoesn't need a generated Messaging service.users/<id>needs none. Subscribers are removed once no background subscription still uses the topic.Pushunlinks the saved topics no background subscription claims. With no background subscription at all, it deletes the target, asclose()does.NSLockand a file lock in the group container, because the app and the extension are separate processes.messageId, up to 500, kept across launches and shared with the extension.notificationblock) reach the callbacks with no banner.Server dependencies (appwrite/appwrite#14302)
The placeholder push is in #14302. Still needed there:
appwriteprovider, which the SDK never creates, so the message goes out as a full APNs alert.bundleIdandenvironment. Today the target uses the default push provider, so Simulator and development builds land on production.Not in this PR
appwrite init apnsadding the extension target and App Group (step 3 is manual for now).Test Plan
21 Swift Testing tests (
PushTests.swift) covering:swift buildon 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:
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:
notificationhas a title or body;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 nobackgroundStatus().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
Related PRs and Issues
appwrite init apns)