Skip to content

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

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

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

Conversation

@ArnabChatterjee20k

Copy link
Copy Markdown
Member

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:

let push = Push(client)
let subscription = try await push.subscribe(["news"]) { message in
    print(message.data)   // MQTT while connected, APNs when the app was asleep, delivered once
}

When the user taps a notification, it arrives through the existing tap API (push.onNotificationOpened, push.getInitialNotification()).

What the app needs:

  • the Push Notifications capability
  • an APNs provider in Appwrite (appwrite init apns)

No config files are needed. Push installs 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 = NO in Info.plist, then forward these calls yourself:

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

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

// If you have 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 token. On the first background subscription, Push calls registerForRemoteNotifications() and receives the token through the delegate proxy. The proxy is retried briefly so it also works for SwiftUI @UIApplicationDelegateAdaptor delegates.
  • Target. Creates (or updates, on 409) the push target POST /account/targets/push with the token, plus:
    • environment: sandbox or production, read from embedded.mobileprovision (aps-environment)
    • bundleId: Bundle.main.bundleIdentifier
    • The target ID is stable per installation and user. The installation ID is kept in the Keychain.
  • Topics. Each messaging topic subscribed in the background gets a subscriber. users/<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.
  • Delivery once.
    • A visible APNs push is passed to the subscription callback without posting a second local notification.
    • When the same message arrives over MQTT, it's skipped by messageId, using a bounded store of 500 ids kept across launches.
    • I chose this over turning MQTT's notifications off whenever APNs is registered, so silent wake-ups still work.
  • Wake. A silent wake push (#14302) reconnects MQTT with the persistent session and drains it.

Server dependencies

  • createPushTarget with environment and bundleId, so the server can pick the APNs provider whose options.sandbox and credentials.bundleId match. Until then the parameters are ignored and the server uses its default push provider, which works for a project with one APNs provider.
  • feat(messaging): wake APNS/FCM devices for Appwrite (MQTT) push appwrite#14302 sending visible alerts with a top-level appwrite: { messageId, topic, data }. The wake-up shape the server sends today (aps.data) is already understood.

Not in this PR

  • Android/FCM, Flutter and React Native. They're iOS-only for now; they'll follow.
  • No permission option on subscribe: the existing authorization request is reused.
  • The push target is not deleted on logout.

Test Plan

  • New PushTests.swift (13 Swift Testing tests), all passing locally. They cover:
    • environment detection from a provisioning profile
    • deduplication (once only, size cap, kept across launches)
    • each payload shape, including non-Appwrite pushes
    • target and subscriber IDs
    • the delegate proxy swizzling a real ObjC class: the app's method still runs, and missing methods are added
    • handleRemoteNotification for duplicates
  • swift build on macOS
  • swift format lint --strict
  • Rector, the Twig lint and the Generation suite
  • iOS-only lines are not compiled locally (no Xcode here): the UIApplication registration, the fetchCompletionHandler proxy and the UIBackgroundFetchResult overload.
  • Device test against a server with the dependencies above.

To run the tests with only the Command Line Tools, move aside the existing XCTest Tests.swift and point SwiftPM at Swift Testing:

F=/Library/Developer/CommandLineTools/Library/Developer/Frameworks
swift test -Xswiftc -F -Xswiftc $F -Xswiftc -Xfrontend -Xswiftc -disable-cross-import-overlays \
  -Xlinker -F -Xlinker $F -Xlinker -rpath -Xlinker $F

With Xcode installed, swift test works without the flags.

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 A · Mergeable after minor fixes

A minor race can leave an opted-in topic without its APNs subscriber after concurrent subscription changes.

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.

Verdict New comments Fixed Still open
💬 Commented 1 5 1

Note

Not approving while a bug finding is open: Coordinate re-linking with an in-flight unlink

Finding Where
🟡 Coordinate re-linking with an in-flight unlink templates/apple/Sources/Services/Push.swift.twig:1479
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
File Change
src/SDK/Language/Apple.php Registers the generated Apple push unit-test file.
templates/apple/Sources/Services/Push.swift.twig Adds APNs registration, target and subscriber management, delivery handling, deduplication, wake behavior, and delegate proxying.
templates/apple/Tests/PushTests.swift.twig Tests APNs environment detection, payload parsing, delivery persistence, IDs, and delegate proxy behavior.
⏳ Still open from earlier reviews · 1
✅ Fixed since the last review · 5
  • Install the remote-notification handler before awaiting MQTT setup · templates/apple/Sources/Services/Push.swift.twig:572
  • Forward handled pushes to the app's fetch delegate · templates/apple/Sources/Services/Push.swift.twig:1661
  • Forward foreground pushes to the replaced notification delegate · templates/apple/Sources/Services/Push.swift.twig:168
  • Recheck topics before linking after the target request · templates/apple/Sources/Services/Push.swift.twig:1403
  • Refresh target environment metadata on the 409 update path · templates/apple/Sources/Services/Push.swift.twig:1426

Reviewed the commits since 82270a3 · 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 on lines +1294 to +1295
private static var apnsPushes: [ObjectIdentifier: WeakPush] = [:]
private static var apnsLinked: [String: Set<String>] = [:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.
@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 on lines +1479 to +1480
if !push.backgroundTopics().contains(topic) {
await unlink(push, targetId: targetId, topic: topic)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

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