Skip to content

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

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

ArnabChatterjee20k wants to merge 45 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.
  • Leftovers from an earlier launch (feat(apple): unlink background topics a later launch no longer subscribes to #2022).
    • The linked topics and the registered target are saved across launches.
    • Once per launch, 30 s after the first subscribe, Push unlinks the saved topics no background subscription claims. With no background subscription at all, it deletes the target, as close() does.
    • So an app version that stops using a topic no longer gets placeholders for it. A topic subscribed later is linked again.
  • 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).

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 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.

Verdict New comments Fixed Still open
💬 Commented 0 0 0

Note

Not approving while a bug finding is open: Gate the token-registration reconciliation path too

📂 Walkthrough · 6
File Change
src/SDK/Language/Apple.php Registers the Notification Service Extension source in generated Apple SDK packages.
templates/apple/Sources/Client.swift.twig Requests gzip responses when decompression is enabled.
templates/apple/Sources/Services/Push.swift.twig Implements APNs target and topic management, shared delivery state, reconciliation, and MQTT message deduplication.
templates/apple/Sources/Services/PushServiceExtension.swift.twig Adds the shared-state Notification Service Extension that fetches MQTT messages and fills APNs placeholders.
templates/apple/Tests/PushTests.swift.twig Tests push payload parsing, shared state, delivery handoff, deduplication, and delegate proxy behavior.
templates/swift/Sources/Client.swift.twig Requests gzip responses for Swift API requests to avoid incompatible deflate responses.

Merged main (3427633); no changes to this PR's code since c23ea4a · 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
@ArnabChatterjee20k ArnabChatterjee20k mentioned this pull request Oct 13, 2026
3 of 4 tasks
…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.
ArnabChatterjee20k and others added 2 commits October 13, 2026 19:04
…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

@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
…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.

@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 S · Looks good to merge. Summary

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.
@ArnabChatterjee20k

Copy link
Copy Markdown
Member Author

Follow-up fixes from a review of this PR's edge cases are in #2024, stacked on this branch:

  • the extension no longer takes a live app's MQTT session over;
  • placeholders for topics no subscription claims are cleared;
  • a token change doesn't re-create a deleted target;
  • a missing AppwritePushAppGroup is reported instead of leaving the placeholder silently.

…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 abnegate left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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}/status to disabled did not stop delivery on the open connection.
  • Session revoked: DELETE /users/{id}/sessions did not stop delivery; the next message still reached the callback.
  • Sign-out: close() stops live callbacks, but the same setPushClientId reconnects 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))."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This string is attached to every upsertTarget failure, and on a real device it was wrong for each of the three errors we hit:

  1. NIOHTTPCompression.NIOHTTPDecompression.DecompressionError — Client HTTP decompression against the local Appwrite HTTP port. init apns is irrelevant; Client.setCompression(false) made the call reach the API.
  2. Invalid Origin. Register your new client (io.appwrite.pushe2e) as a new iOS platform… — the project had no Apple platform. appwrite init apns creates 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.
  3. 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.

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.

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:

  1. DecompressionError: a client bug, fixed in fix(swift): ask for gzip so compressed responses decode #2025. NIO asks for deflate, gzip, and Appwrite answers deflate with a raw deflate stream rather than zlib, so every compressed response failed with inflationError(-3). The clients now ask for gzip. Against a local Appwrite: 60/60 account.get() calls failed before, 0 after.
  2. Invalid Origin: now reported with that hint. Having init apns also add the Apple platform is next in feat(cli): appwrite init apns #2015.
  3. 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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

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.

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.

  • register now takes the target id from the server's reply.
  • On 409 followed by 404, it finds the target holding the token in GET /account and 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-xyz with 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)."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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.

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.

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.
@ArnabChatterjee20k

Copy link
Copy Markdown
Member Author

@abnegate thanks for the device run. Registration should now complete:

On the rest of your device notes:

  • Blocked user and session revoked keep receiving on the open connection. The broker decides that, so it's server-side: your fix(mqtt): disconnect blocked or deleted users from open connections appwrite#14411 covers the open connection. Revoking a user's sessions through the Users API also queues no target cleanup, unlike DELETE /account/sessions, which deletes the session's targets.
  • Replay after signing back in: the broker keeps a replay cursor per user and client id, so messages sent while that user was signed out replay when the same user signs in on the same client id. The SDK can't clear it, because the cursor lives in the broker and the client id is derived per user, or set by the app. If messages from a signed-out period shouldn't replay, the broker would need to drop the user's cursors when the session ends. Nothing leaks across users: the cursor is per user.

The killed-app and NSE cases should be reachable on the device now. A rerun would be great once you have time.

ArnabChatterjee20k and others added 4 commits October 14, 2026 13:48
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

@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
…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.

@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
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.
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.

2 participants