Repository navigation
feat(apple): background push through APNs - #2000
ArnabChatterjee20k wants to merge 7 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 A · Mergeable after minor fixes
Adds APNs target and topic-subscriber registration for Apple background push subscriptions, with payload handling, MQTT/APNs deduplication, notification taps, and silent-wake reconnects. It also adds Apple push unit tests for payload parsing, delivery tracking, IDs, and delegate proxy behavior. Latest changes: The newest commits install the delegate proxy before subscription network work, forward notification and fetch handling to the app's delegate, refresh target metadata on updates, and guard topic links against concurrent unsubscriptions.
Note Not approving while a bug finding is open: Coordinate re-linking with an in-flight unlink
Fix with agent prompt### Issue 1
templates/apple/Sources/Services/Push.swift.twig:1479-1480
**Coordinate re-linking with an in-flight unlink**
If a background subscription is re-enabled while this unlink request is in flight, the new `link` sees the topic in `apnsLinked` and returns; this delete then removes the subscriber and clears the marker, leaving the still-enabled topic without APNs delivery until another registration event. Coordinate link/unlink requests or recheck the current background topics after the delete completes.
### Issue 2
templates/apple/Sources/Services/Push.swift.twig:1294-1295
**Reconcile persistent subscribers after app relaunch**
`apnsLinked` exists only in memory, while messaging subscribers remain on the server across launches. If a topic is removed from the app's background subscriptions in a later launch, the new process has no record of the old link to delete, so that target can keep receiving APNs pushes for a topic the app no longer subscribes to.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.📂 Walkthrough · 3
⏳ Still open from earlier reviews · 1
✅ Fixed since the last review · 5
Reviewed the commits since |
| private static var apnsPushes: [ObjectIdentifier: WeakPush] = [:] | ||
| private static var apnsLinked: [String: Set<String>] = [:] |
There was a problem hiding this comment.
Reconcile persistent subscribers after app relaunch
apnsLinked exists only in memory, while messaging subscribers remain on the server across launches. If a topic is removed from the app's background subscriptions in a later launch, the new process has no record of the old link to delete, so that target can keep receiving APNs pushes for a topic the app no longer subscribes to.
Prompt To Fix With AI
This is a comment left during a code review.
Path: templates/apple/Sources/Services/Push.swift.twig
Line: 1294-1295
Comment:
**Reconcile persistent subscribers after app relaunch**
`apnsLinked` exists only in memory, while messaging subscribers remain on the server across launches. If a topic is removed from the app's background subscriptions in a later launch, the new process has no record of the old link to delete, so that target can keep receiving APNs pushes for a topic the app no longer subscribes to.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.🟡 Minor · bug · Reply if this doesn't apply.
There was a problem hiding this comment.
Leaving this as designed. Server subscribers outliving the process is the point of background delivery: after a kill there are no in-memory subscriptions, yet pushes must keep arriving. So a relaunch without a subscription can't be read as an unsubscribe. Reconciling on relaunch would need a guess at when the app has finished subscribing (apps often subscribe after login or lazily), and a wrong guess drops a topic the user still wants. Stopping a topic is explicit: unsubscribe() or update(background: false). Both work in any later launch, because subscribing links the topic again (409 → recorded) before it's removed. The target is also per user, so logging out moves to a different target.
There was a problem hiding this comment.
I agree that an absent subscription at launch alone should not imply unsubscribe. But the current API doesn’t provide a reliable way to explicitly remove a prior-launch topic: the unsubscribe closure captures the current subscription IDs, which are in memory only (lines 587–625). And apnsDisable deletes only topics in the process-local apnsLinked set (lines 1360–1370).
Re-subscribing to an old topic just to unsubscribe it is also racy: APNs registration runs in an unawaited task (lines 1348–1357). If unsubscribe runs before that task records the link, deletion can be skipped; registration then only links topics still wanted (lines 1420–1425).
So this isn’t about treating no subscription at launch as an unsubscribe; it’s that a topic removed from the app’s desired subscriptions can remain registered on the server with no reliable removal path. This finding still needs addressing.
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.
| if !push.backgroundTopics().contains(topic) { | ||
| await unlink(push, targetId: targetId, topic: topic) |
There was a problem hiding this comment.
Coordinate re-linking with an in-flight unlink
If a background subscription is re-enabled while this unlink request is in flight, the new link sees the topic in apnsLinked and returns; this delete then removes the subscriber and clears the marker, leaving the still-enabled topic without APNs delivery until another registration event. Coordinate link/unlink requests or recheck the current background topics after the delete completes.
Prompt To Fix With AI
This is a comment left during a code review.
Path: templates/apple/Sources/Services/Push.swift.twig
Line: 1479-1480
Comment:
**Coordinate re-linking with an in-flight unlink**
If a background subscription is re-enabled while this unlink request is in flight, the new `link` sees the topic in `apnsLinked` and returns; this delete then removes the subscriber and clears the marker, leaving the still-enabled topic without APNs delivery until another registration event. Coordinate link/unlink requests or recheck the current background topics after the delete completes.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.🟡 Minor · concurrency · Reply if this doesn't apply.
What does this PR do?
On iOS, background push now also goes through APNs, so messages reach an app that is suspended or killed. Everything lives in
Push; there's no new type for apps to learn.Usage
Nothing changes for the app. A subscription with
background: true(the default) now also registers with APNs:When the user taps a notification, it arrives through the existing tap API (
push.onNotificationOpened,push.getInitialNotification()).What the app needs:
appwrite init apns)No config files are needed.
Pushinstalls a proxy on the app delegate to receive the device token and remote notifications, and it still calls the app's own methods.Opting out of the delegate proxy
Set
AppwritePushAutoRegistration = NOin Info.plist, then forward these calls yourself:How it works
PushcallsregisterForRemoteNotifications()and receives the token through the delegate proxy. The proxy is retried briefly so it also works for SwiftUI@UIApplicationDelegateAdaptordelegates.409) the push targetPOST /account/targets/pushwith the token, plus:environment:sandboxorproduction, read fromembedded.mobileprovision(aps-environment)bundleId:Bundle.main.bundleIdentifierusers/<id>needs none, and filters with/,+or#are skipped. Turning background off, or unsubscribing, removes the subscriber once no background subscription still uses the topic. When the token changes, the target is updated and the topics are linked again.messageId, using a bounded store of 500 ids kept across launches.Server dependencies
createPushTargetwithenvironmentandbundleId, so the server can pick the APNs provider whoseoptions.sandboxandcredentials.bundleIdmatch. Until then the parameters are ignored and the server uses its default push provider, which works for a project with one APNs provider.appwrite: { messageId, topic, data }. The wake-up shape the server sends today (aps.data) is already understood.Not in this PR
subscribe: the existing authorization request is reused.Test Plan
PushTests.swift(13 Swift Testing tests), all passing locally. They cover:handleRemoteNotificationfor duplicatesswift buildon macOSswift format lint --strictUIApplicationregistration, thefetchCompletionHandlerproxy and theUIBackgroundFetchResultoverload.To run the tests with only the Command Line Tools, move aside the existing XCTest
Tests.swiftand point SwiftPM at Swift Testing:With Xcode installed,
swift testworks without the flags.Related PRs and Issues
appwrite init apnssets up the provider this uses)