From b94142f94082cac735e48b0dbc783e9cf7d689ef Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 18:39:51 +0530 Subject: [PATCH 01/40] feat(apple): deliver background push through APNs from Push 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. --- .../apple/Sources/Services/Push.swift.twig | 725 +++++++++++++++++- 1 file changed, 715 insertions(+), 10 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 5d7eadcd7a..85ea69f842 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1,10 +1,22 @@ import Foundation import MQTTNIO import NIOCore +import ObjectiveC +#if canImport(CryptoKit) + import CryptoKit +#endif +#if canImport(Security) + import Security +#endif #if canImport(UserNotifications) import UserNotifications #endif +#if os(iOS) || os(tvOS) + import UIKit +#elseif os(macOS) + import AppKit +#endif /// A message delivered on a subscribed topic — the MQTT push analog of a realtime event. public struct PushMessage { @@ -37,6 +49,11 @@ public struct PushNotificationOpened { let object = (try? JSONSerialization.jsonObject(with: Data(payload.utf8))) as? [String: Any] self.data = object?["data"] as? [String: Any] ?? [:] } + + init(topic: String, data: [String: Any]) { + self.topic = topic + self.data = data + } } #if canImport(UserNotifications) @@ -68,16 +85,21 @@ public struct PushNotificationOpened { center.delegate = self } - /// Report [response] when it is a tap on a notification `Push` posted; false otherwise. + /// Report [response] when it is a tap on a notification `Push` posted, or on an APNs push + /// Appwrite sent; false otherwise. func handle(_ response: UNNotificationResponse) -> Bool { let info = response.notification.request.content.userInfo - guard response.actionIdentifier == UNNotificationDefaultActionIdentifier, - let topic = info["topic"] as? String, - let payload = info["payload"] as? String - else { + guard response.actionIdentifier == UNNotificationDefaultActionIdentifier else { + return false + } + let opened: PushNotificationOpened + if let topic = info["topic"] as? String, let payload = info["payload"] as? String { + opened = PushNotificationOpened(topic: topic, payload: payload) + } else if let remote = Push.RemoteMessage(userInfo: info), !remote.wake { + opened = PushNotificationOpened(topic: remote.topic ?? "", data: remote.data) + } else { return false } - let opened = PushNotificationOpened(topic: topic, payload: payload) lock.lock() let callbacks = Array(listeners.values) if callbacks.isEmpty { @@ -142,7 +164,14 @@ public struct PushNotificationOpened { willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { - if let forwarded, forwarded.responds(to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:))) { + let info = notification.request.content.userInfo + if Push.RemoteMessage(userInfo: info) != nil { + // An APNs push Appwrite sent: shows nothing when MQTT already delivered it, and is + // recorded otherwise, so MQTT does not deliver it again. + let delivered = Push.isDelivered(notification) + _ = Push.handleRemoteNotification(info) + completionHandler(delivered ? [] : [.banner, .list, .sound]) + } else if let forwarded, forwarded.responds(to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:))) { forwarded.userNotificationCenter?(center, willPresent: notification, withCompletionHandler: completionHandler) } else { completionHandler([]) @@ -352,11 +381,55 @@ open class Push: Service { #if canImport(UserNotifications) /// For apps that set their own `UNUserNotificationCenter` delegate after creating `Push`: /// pass each response from `userNotificationCenter(_:didReceive:withCompletionHandler:)`. - /// Returns true when it was a tap on a notification `Push` posted, which `Push` reports. + /// Returns true when it was a tap on a notification `Push` posted, or on an APNs push + /// Appwrite sent, which `Push` reports. @discardableResult public static func handleNotificationResponse(_ response: UNNotificationResponse) -> Bool { PushNotificationTaps.shared.handle(response) } + + /// Whether this APNs push's message was already delivered over MQTT, so the app can skip + /// showing it again. False for anything else, including pushes that are not Appwrite's. + /// Only needed with `AppwritePushAutoRegistration = NO`, in + /// `userNotificationCenter(_:willPresent:withCompletionHandler:)`: + /// + /// done(Push.isDelivered(notification) ? [] : [.banner, .sound]) + public static func isDelivered(_ notification: UNNotification) -> Bool { + guard let id = RemoteMessage(userInfo: notification.request.content.userInfo)?.id else { + return false + } + return apnsDeliveries.contains(id) + } + #endif + + /// Give `Push` the APNs device token. Required with `AppwritePushAutoRegistration = NO` in + /// Info.plist, from `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`; + /// otherwise `Push` catches the token itself. Registers this device with Appwrite for the + /// signed-in user and links it to each background subscription's topic. + public static func setDeviceToken(_ token: Data) { + apnsSetDeviceToken(token) + } + + /// Take in a remote notification. Only needed with `AppwritePushAutoRegistration = NO`. + /// Returns true when the push was Appwrite's: its message reaches the subscriptions' + /// callbacks once. Returns false for any other push. + @discardableResult + public static func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) -> Bool { + apnsHandle(userInfo, completion: nil) + } + + #if os(iOS) || os(tvOS) + /// Take in a remote notification from + /// `application(_:didReceiveRemoteNotification:fetchCompletionHandler:)`. Only needed with + /// `AppwritePushAutoRegistration = NO`. Returns true when the push was Appwrite's: its + /// message reaches the subscriptions' callbacks once, and `completion` is called when + /// `Push` is done with it. Returns false, without calling `completion`, for any other push. + /// + /// if Push.handleRemoteNotification(userInfo, completion: completionHandler) { return } + @discardableResult + public static func handleRemoteNotification(_ userInfo: [AnyHashable: Any], completion: @escaping (UIBackgroundFetchResult) -> Void) -> Bool { + apnsHandle(userInfo) { delivered in completion(delivered ? .newData : .noData) } + } #endif /// Subscribe to the signed-in user's own topic, `users/`. Returns a `PushSubscription` handle. @@ -496,8 +569,13 @@ open class Push: Service { throw described } + if background { + Push.apnsEnable(self, topics: topics) + } + let unsubscribe: () -> Void = { [weak self] in guard let self else { return } + let backgroundTopics = Set(self.sync.sync { ids.compactMap { self.subscriptions[$0] }.filter { $0.background }.map { $0.filter } }) let (removed, downgrade) = self.sync.sync { () -> ([String], [MQTTSubscribeInfoV5]) in var removedFilters: [String] = [] var maybeDowngrade = Set() @@ -528,6 +606,12 @@ open class Push: Service { if let client, !downgrade.isEmpty { self.inBackground { try await self.brokerSubscribe(client, downgrade) } } + // A topic leaves APNs once no background subscription is left on it. + let stillBackground = Set(self.backgroundTopics()) + let unlinked = backgroundTopics.subtracting(stillBackground) + if !unlinked.isEmpty { + Push.apnsDisable(self, topics: Array(unlinked)) + } if self.sync.sync(execute: { self.subscriptions.isEmpty }) { self.close() } @@ -556,6 +640,14 @@ open class Push: Service { } if newBackground == true { self.requestNotificationAuthorization() + Push.apnsEnable(self, topics: self.sync.sync { ids.compactMap { self.subscriptions[$0]?.filter } }) + } else if newBackground == false { + let topics = self.sync.sync { ids.compactMap { self.subscriptions[$0]?.filter } } + let stillBackground = Set(self.backgroundTopics()) + let unlinked = topics.filter { !stillBackground.contains($0) } + if !unlinked.isEmpty { + Push.apnsDisable(self, topics: unlinked) + } } // Re-SUBSCRIBE the affected filters at their new effective QoS. if let client, !changed.isEmpty { @@ -1018,17 +1110,27 @@ open class Push: Service { payload: [UInt8](publish.payload.readableBytesView), qos: Int(publish.qos.rawValue) ) + // A message APNs already delivered (or a replay of one MQTT delivered) reaches the app + // once: skip it here. + if let id = RemoteMessage.id(payload: pushMessage.payload), !Push.apnsDeliveries.record(id) { + return + } + deliver(pushMessage) + } + + // `notifyLocally` is false for a message APNs delivered: iOS already showed it. + private func deliver(_ pushMessage: PushMessage, notifyLocally: Bool = true) { // Notification is per-subscription: only subs that opted in post one, each title once, as // their callbacks return. A title the server sent replaces theirs, so it is posted once. let matched = sync.sync { - subscriptions.values.filter { matches(filter: $0.filter, topic: publish.topicName) } + subscriptions.values.filter { matches(filter: $0.filter, topic: pushMessage.topic) } } let notification = PushNotificationContent(message: pushMessage) var postedTitles = Set() for entry in matched { entry.callback(pushMessage) let title = notification.title ?? entry.title ?? pushMessage.topic - if entry.background && postedTitles.insert(title).inserted { + if notifyLocally && entry.background && postedTitles.insert(title).inserted { notify(pushMessage, title: title, notification: notification) } } @@ -1172,3 +1274,606 @@ private func matches(filter: String, topic: String) -> Bool { } private struct SupersededError: Swift.Error {} + +// MARK: - APNs +// +// With `background: true`, `Push` also registers the device for APNs: it asks iOS for a device +// token (caught by proxying the app delegate, as Firebase does), creates the push target for the +// signed-in user, and a subscriber for each subscribed topic. APNs then delivers while the app is +// in the background or not running; MQTT keeps delivering while it runs. Each message reaches the +// app once, matched by its `messageId`. +// +// Set `AppwritePushAutoRegistration` to `NO` in Info.plist to turn the proxy off. The app then +// passes the token with `Push.setDeviceToken(_:)` (required), and optionally pushes with +// `Push.handleRemoteNotification(_:completion:)` and foreground checks with `Push.isDelivered(_:)`. +extension Push { + static let autoRegistrationKey = "AppwritePushAutoRegistration" + + private static let apnsLock = NSLock() + private static var apnsToken: String? + private static var apnsPushes: [ObjectIdentifier: WeakPush] = [:] + private static var apnsLinked: [String: Set] = [:] + private static var apnsProxyInstalled = false + private static var apnsRegistrationRequested = false + static var apnsDeliveries = Deliveries(defaults: .standard) + + private final class WeakPush { + weak var push: Push? + init(_ push: Push) { self.push = push } + } + + /// Whether the app delegate proxy is on (Info.plist `AppwritePushAutoRegistration`, default YES). + static var autoRegistration: Bool { + (Bundle.main.object(forInfoDictionaryKey: autoRegistrationKey) as? Bool) ?? true + } + + private static func apnsLocked(_ body: () throws -> T) rethrows -> T { + apnsLock.lock() + defer { apnsLock.unlock() } + return try body() + } + + /// The token as hex, the form APNs and Appwrite use. + static func hex(_ token: Data) -> String { + token.map { String(format: "%02x", $0) }.joined() + } + + /// The device token `Push` last received, as hex. + static var apnsCurrentToken: String? { + apnsLocked { apnsToken } + } + + // MARK: Lifecycle + + /// Start APNs for a push with background subscriptions on `topics`. + static func apnsEnable(_ push: Push, topics: [String]) { + let token = apnsLocked { () -> String? in + apnsPushes[ObjectIdentifier(push)] = WeakPush(push) + return apnsToken + } + if autoRegistration { + installProxy() + requestRegistration() + } + if let token { + Task { await register(push, token: token, topics: topics) } + } + } + + /// Unlink `topics` from APNs once they have no background subscription left. + static func apnsDisable(_ push: Push, topics: [String]) { + guard let userId = push.remoteUserId() else { + return + } + let targetId = Ids.target(installation: Installation.id, userId: userId) + let linked = apnsLocked { apnsLinked[targetId] ?? [] } + Task { + for topic in topics where linked.contains(topic) { + await unlink(push, targetId: targetId, topic: topic) + } + } + } + + static func apnsSetDeviceToken(_ token: Data) { + let hex = hex(token) + let (changed, pushes) = apnsLocked { () -> (Bool, [Push]) in + let changed = apnsToken != hex + apnsToken = hex + return (changed, apnsPushes.values.compactMap { $0.push }) + } + guard changed else { + return + } + for push in pushes { + Task { await register(push, token: hex, topics: push.backgroundTopics()) } + } + } + + static func apnsRegistrationFailed(_ error: Swift.Error) { + 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)." + ) + for push in pushes { + push.surface(described) + } + } + + // MARK: Target and subscribers + + /// Create or update this device's push target for the push's user, then a subscriber for each + /// messaging topic. Failures go to `onError` (or the log) and are never thrown: MQTT keeps + /// delivering while the app runs. + static func register(_ push: Push, token: String, topics: [String]) async { + guard let userId = push.remoteUserId() else { + return + } + let targetId = Ids.target(installation: Installation.id, userId: userId) + do { + try await upsertTarget(push, targetId: targetId, token: token) + } catch { + 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))." + )) + return + } + apnsLocked { apnsLinked[targetId] = apnsLinked[targetId] ?? [] } + for topic in topics where Ids.isMessagingTopic(topic) { + await link(push, targetId: targetId, topic: topic) + } + } + + private static func upsertTarget(_ push: Push, targetId: String, token: String) async throws { + // `environment` and `bundleId` let the server pick the APNs provider for this app and build. + // A server without that support ignores them. + let params: [String: Any?] = [ + "targetId": targetId, + "identifier": token, + "environment": Environment.current.rawValue, + "bundleId": Bundle.main.bundleIdentifier, + ] + do { + let _: Any = try await push.client.call( + method: "POST", + path: "/account/targets/push", + headers: ["content-type": "application/json"], + params: params, + converter: { $0 } + ) + } catch let error as {{ spec.info.title | caseUcfirst }}Error where error.code == 409 { + // This install already has a target for the user: point it at the current token. + _ = try await Account(push.client).updatePushTarget(targetId: targetId, identifier: token) + } + } + + private static func link(_ push: Push, targetId: String, topic: String) async { + let already = apnsLocked { apnsLinked[targetId]?.contains(topic) ?? false } + guard !already else { + return + } + do { + _ = try await Messaging(push.client).createSubscriber( + topicId: topic, + subscriberId: Ids.subscriber(targetId: targetId, topic: topic), + targetId: targetId + ) + } catch let error as {{ spec.info.title | caseUcfirst }}Error where error.code == 409 { + // Subscribed already. + } catch { + push.surface(error) + return + } + apnsLocked { _ = apnsLinked[targetId, default: []].insert(topic) } + } + + private static func unlink(_ push: Push, targetId: String, topic: String) async { + do { + _ = try await Messaging(push.client).deleteSubscriber( + topicId: topic, + subscriberId: Ids.subscriber(targetId: targetId, topic: topic) + ) + } catch let error as {{ spec.info.title | caseUcfirst }}Error where error.code == 404 { + // Gone already. + } catch { + push.surface(error) + } + apnsLocked { _ = apnsLinked[targetId]?.remove(topic) } + } + + // MARK: Delivery + + /// Take in a push APNs delivered; false when it is not Appwrite's. A visible push is recorded, + /// so MQTT does not deliver it again, and reaches the subscriptions' callbacks once. A silent + /// wake reconnects MQTT so its messages are delivered. + static func apnsHandle(_ userInfo: [AnyHashable: Any], completion: ((Bool) -> Void)?) -> Bool { + guard let message = RemoteMessage(userInfo: userInfo) else { + return false + } + let pushes = apnsLocked { apnsPushes.values.compactMap { $0.push } } + if message.wake { + Task { + var woke = false + for push in pushes { + if await push.wake() { + woke = true + } + } + completion?(woke) + } + return true + } + if let id = message.id, !apnsDeliveries.record(id) { + completion?(false) + return true + } + for push in pushes { + push.deliverRemote(message) + } + completion?(true) + return true + } + + /// The topics this push has background subscriptions on. + func backgroundTopics() -> [String] { + sync.sync { Array(Set(subscriptions.values.filter { $0.background }.map { $0.filter })) } + } + + /// The signed-in user's id, or nil without a credential. + func remoteUserId() -> String? { + try? currentUserId() + } + + /// Deliver a message APNs brought to the subscriptions on its topic, without a local + /// notification: iOS already showed it. + func deliverRemote(_ message: RemoteMessage) { + guard let topic = message.topic else { + return + } + var body: [String: Any] = ["data": message.data] + if let id = message.id { + body["messageId"] = id + } + let payload = (try? JSONSerialization.data(withJSONObject: body)) ?? Data() + deliver(PushMessage(topic: topic, payload: [UInt8](payload), qos: 1), notifyLocally: false) + } + + /// Reconnect MQTT for a silent wake, then give the broker a moment to replay the backlog. + /// True when it reconnected. + func wake() async -> Bool { + let (client, hasSubscriptions) = sync.sync { (mqtt, !subscriptions.isEmpty) } + guard hasSubscriptions, !(client?.isActive() ?? false) else { + return false + } + do { + let connected: MQTTClient + if let client { + _ = try await client.v5.connect(cleanStart: false, properties: try connectProperties()) + connected = client + } else { + connected = try await connect() + } + let infos = sync.sync { + Set(subscriptions.values.map { $0.filter }).map { MQTTSubscribeInfoV5(topicFilter: $0, qos: effectiveQos($0)) } + } + if !infos.isEmpty { + try await brokerSubscribe(connected, infos) + } + try? await Task.sleep(nanoseconds: 3_000_000_000) + return true + } catch { + surface(Push.describe(error)) + return false + } + } + + // MARK: App delegate proxy + + private static func requestRegistration() { + let first = apnsLocked { () -> Bool in + defer { apnsRegistrationRequested = true } + return !apnsRegistrationRequested + } + guard first, Bundle.main.bundleIdentifier != nil else { + return + } + DispatchQueue.main.async { + #if os(iOS) || os(tvOS) + UIApplication.shared.registerForRemoteNotifications() + #elseif os(macOS) + NSApplication.shared.registerForRemoteNotifications() + #endif + } + } + + /// Proxy the app delegate's remote-notification methods. SwiftUI apps set their delegate after + /// launch, so this waits for one to exist. + private static func installProxy(attempt: Int = 0) { + guard Bundle.main.bundleIdentifier != nil else { + return + } + DispatchQueue.main.async { + guard let delegate = applicationDelegate() else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { installProxy(attempt: attempt + 1) } + } + return + } + let install = apnsLocked { () -> Bool in + defer { apnsProxyInstalled = true } + return !apnsProxyInstalled + } + if install { + DelegateProxy.install(on: type(of: delegate)) + } + } + } + + private static func applicationDelegate() -> NSObject? { + #if os(iOS) || os(tvOS) + return UIApplication.shared.delegate as? NSObject + #elseif os(macOS) + return NSApplication.shared.delegate as? NSObject + #else + return nil + #endif + } + + /// Replaces the app delegate's remote-notification methods with versions that tell `Push` first + /// and then call the app's own. Methods the app does not implement are added. + enum DelegateProxy { + static let didRegister = NSSelectorFromString("application:didRegisterForRemoteNotificationsWithDeviceToken:") + static let didFail = NSSelectorFromString("application:didFailToRegisterForRemoteNotificationsWithError:") + #if os(macOS) + static let didReceive = NSSelectorFromString("application:didReceiveRemoteNotification:") + #else + static let didReceive = NSSelectorFromString("application:didReceiveRemoteNotification:fetchCompletionHandler:") + #endif + + private typealias TokenFunction = @convention(c) (AnyObject, Selector, AnyObject, NSData) -> Void + private typealias FailFunction = @convention(c) (AnyObject, Selector, AnyObject, NSError) -> Void + #if os(macOS) + private typealias ReceiveFunction = @convention(c) (AnyObject, Selector, AnyObject, NSDictionary) -> Void + #else + private typealias FetchCompletion = @convention(block) (UInt) -> Void + private typealias ReceiveFunction = @convention(c) (AnyObject, Selector, AnyObject, NSDictionary, FetchCompletion) -> Void + #endif + + static func install(on cls: AnyClass) { + swizzle(cls, didRegister, types: "v@:@@") { original in + let block: @convention(block) (AnyObject, AnyObject, NSData) -> Void = { this, application, token in + Push.apnsSetDeviceToken(token as Data) + if let original { + unsafeBitCast(original, to: TokenFunction.self)(this, didRegister, application, token) + } + } + return imp_implementationWithBlock(block) + } + swizzle(cls, didFail, types: "v@:@@") { original in + let block: @convention(block) (AnyObject, AnyObject, NSError) -> Void = { this, application, error in + Push.apnsRegistrationFailed(error) + if let original { + unsafeBitCast(original, to: FailFunction.self)(this, didFail, application, error) + } + } + return imp_implementationWithBlock(block) + } + #if os(macOS) + swizzle(cls, didReceive, types: "v@:@@") { original in + let block: @convention(block) (AnyObject, AnyObject, NSDictionary) -> Void = { this, application, userInfo in + let handled = Push.apnsHandle(userInfo as? [AnyHashable: Any] ?? [:], completion: nil) + if let original, !handled { + unsafeBitCast(original, to: ReceiveFunction.self)(this, didReceive, application, userInfo) + } + } + return imp_implementationWithBlock(block) + } + #else + swizzle(cls, didReceive, types: "v@:@@@?") { original in + let block: @convention(block) (AnyObject, AnyObject, NSDictionary, @escaping FetchCompletion) -> Void = { + this, application, userInfo, completion in + // UIBackgroundFetchResult raw values: 0 newData, 1 noData. + let handled = Push.apnsHandle(userInfo as? [AnyHashable: Any] ?? [:]) { delivered in + completion(delivered ? 0 : 1) + } + if handled { + return + } + if let original { + unsafeBitCast(original, to: ReceiveFunction.self)(this, didReceive, application, userInfo, completion) + } else { + completion(1) + } + } + return imp_implementationWithBlock(block) + } + #endif + } + + /// Point `selector` on `cls` at the implementation `make` builds from the original, which is + /// nil when the class did not implement it. + static func swizzle(_ cls: AnyClass, _ selector: Selector, types: String, make: (IMP?) -> IMP) { + if let method = class_getInstanceMethod(cls, selector) { + method_setImplementation(method, make(method_getImplementation(method))) + } else { + class_addMethod(cls, selector, make(nil), types) + } + } + } + + // MARK: Helpers + + /// The APNs server a device token belongs to. Debug builds get sandbox tokens and App Store or + /// TestFlight builds production ones, and Apple rejects a token sent to the other server. + enum Environment: String { + case sandbox + case production + + /// Read off the app's provisioning profile: `aps-environment = development` means sandbox. + /// App Store builds carry no profile and always use production. + static var current: Environment { + for (name, ext) in [("embedded", "mobileprovision"), ("embedded", "provisionprofile")] { + if let url = Bundle.main.url(forResource: name, withExtension: ext), let data = try? Data(contentsOf: url) { + return detect(profile: data) + } + } + return .production + } + + /// The environment a provisioning profile grants. The profile is a signed container with its + /// plist inside as plain text, cut out between the XML header and the end tag. + static func detect(profile: Data) -> Environment { + let text = String(decoding: profile, as: UTF8.self) + guard let start = text.range(of: "", range: start.upperBound.. + + init(defaults: UserDefaults) { + self.defaults = defaults + let saved = defaults.stringArray(forKey: Deliveries.key) ?? [] + ids = saved + lookup = Set(saved) + } + + /// Whether `id` was delivered before. + func contains(_ id: String) -> Bool { + lock.lock() + defer { lock.unlock() } + return lookup.contains(id) + } + + /// Record `id`: true when it is new, false when it was delivered before. + @discardableResult + func record(_ id: String) -> Bool { + lock.lock() + defer { lock.unlock() } + guard lookup.insert(id).inserted else { + return false + } + ids.append(id) + if ids.count > Deliveries.capacity { + ids.removeFirst(ids.count - Deliveries.capacity) + lookup = Set(ids) + } + defaults.set(ids, forKey: Deliveries.key) + return true + } + } + + /// Appwrite's part of a message, read the same way from an APNs push and an MQTT payload. + struct RemoteMessage { + let id: String? + let topic: String? + let data: [String: Any] + /// A silent push sent only to wake the app; its content follows over MQTT. + let wake: Bool + + /// From an APNs `userInfo`: Appwrite's data under the top-level `appwrite` key, or as top-level + /// (or `aps.data`) `messageId`/`type` keys from servers before that. Nil when it is not Appwrite's. + init?(userInfo: [AnyHashable: Any]) { + if let appwrite = userInfo["appwrite"] as? [String: Any] { + id = appwrite["messageId"] as? String + topic = appwrite["topic"] as? String + data = appwrite["data"] as? [String: Any] ?? [:] + wake = (appwrite["type"] as? String) == "wake" + return + } + let aps = userInfo["aps"] as? [String: Any] + let fields = (aps?["data"] as? [String: Any]) ?? (userInfo as? [String: Any] ?? [:]) + guard let messageId = fields["messageId"] as? String, !messageId.isEmpty else { + return nil + } + id = messageId + topic = fields["topic"] as? String + data = fields["data"] as? [String: Any] ?? [:] + wake = (fields["type"] as? String) == "wake" + } + + /// The id in an MQTT payload, when the server sent one (`messageId` or `appwrite.messageId`). + static func id(payload: [UInt8]) -> String? { + guard let json = (try? JSONSerialization.jsonObject(with: Data(payload))) as? [String: Any] else { + return nil + } + if let id = json["messageId"] as? String, !id.isEmpty { + return id + } + return (json["appwrite"] as? [String: Any])?["messageId"] as? String + } + } + + /// A stable id for this app install, kept in the Keychain so it survives app updates and, on + /// iOS, reinstalls. Falls back to `UserDefaults` where the Keychain is not available. + enum Installation { + private static let service = "{{ spec.info.title | caseLower }}.push" + private static let account = "installation" + + static var id: String { + if let saved = read() { + return saved + } + save(UUID().uuidString.lowercased()) + return read() ?? UUID().uuidString.lowercased() + } + + private static func read() -> String? { + #if canImport(Security) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + if SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data { + return String(data: data, encoding: .utf8) + } + #endif + return UserDefaults.standard.string(forKey: "\(service).\(account)") + } + + private static func save(_ id: String) { + #if canImport(Security) + let item: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: Data(id.utf8), + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + if SecItemAdd(item as CFDictionary, nil) == errSecSuccess { + return + } + #endif + UserDefaults.standard.set(id, forKey: "\(service).\(account)") + } + } + + /// Ids derived from the install, the user and the topic: 36 lowercase hex characters, which + /// Appwrite accepts as a document id. + enum Ids { + static func target(installation: String, userId: String) -> String { + hash("target:\(installation):\(userId)") + } + + static func subscriber(targetId: String, topic: String) -> String { + hash("subscriber:\(targetId):\(topic)") + } + + /// Topics a subscriber can point at: a messaging topic id. `users/` reaches the user's + /// targets without one, and wildcard filters match no single topic. + static func isMessagingTopic(_ topic: String) -> Bool { + !topic.isEmpty && !topic.contains("/") && !topic.contains("+") && !topic.contains("#") + } + + private static func hash(_ value: String) -> String { + #if canImport(CryptoKit) + return String(SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined().prefix(36)) + #else + return String(value.utf8.map { String(format: "%02x", $0) }.joined().suffix(36)) + #endif + } + } +} From 82270a39b53de43d497d13ab53dac44566a66a55 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 18:39:51 +0530 Subject: [PATCH 02/40] test(apple): cover APNs environment, deduplication, payloads and the delegate proxy --- src/SDK/Language/Apple.php | 5 + templates/apple/Tests/PushTests.swift.twig | 179 +++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 templates/apple/Tests/PushTests.swift.twig diff --git a/src/SDK/Language/Apple.php b/src/SDK/Language/Apple.php index 1145688d5b..36ac976ce1 100644 --- a/src/SDK/Language/Apple.php +++ b/src/SDK/Language/Apple.php @@ -55,6 +55,11 @@ public function getFiles(): array 'destination' => '/Tests/{{ spec.info.title | caseUcfirst}}Tests/Tests.swift', 'template' => 'swift/Tests/Tests.swift.twig', ], + [ + 'scope' => 'default', + 'destination' => '/Tests/{{ spec.info.title | caseUcfirst}}Tests/PushTests.swift', + 'template' => 'apple/Tests/PushTests.swift.twig', + ], [ 'scope' => 'default', 'destination' => '/Sources/{{ spec.info.title | caseUcfirst}}/Models/{{ spec.info.title | caseUcfirst}}Error.swift', diff --git a/templates/apple/Tests/PushTests.swift.twig b/templates/apple/Tests/PushTests.swift.twig new file mode 100644 index 0000000000..57f6f618c1 --- /dev/null +++ b/templates/apple/Tests/PushTests.swift.twig @@ -0,0 +1,179 @@ +import Foundation +import ObjectiveC +import Testing + +@testable import {{ spec.info.title | caseUcfirst }} + +@Suite(.serialized) +final class PushTests { + private var suites: [String] = [] + + deinit { + for name in suites { + UserDefaults.standard.removePersistentDomain(forName: name) + } + } + + // MARK: Environment + + private func profile(_ entitlements: [String: Any]?) -> Data { + var plist: [String: Any] = ["Name": "Test"] + if let entitlements { + plist["Entitlements"] = entitlements + } + let xml = try! PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) + // A provisioning profile wraps the plist in a signed container. + return Data([0x30, 0x82, 0x01, 0x00]) + xml + Data([0x00, 0xA0, 0x82]) + } + + @Test func environmentFromTheProvisioningProfile() { + #expect(Push.Environment.detect(profile: profile(["aps-environment": "development"])) == .sandbox) + #expect(Push.Environment.detect(profile: profile(["aps-environment": "production"])) == .production) + #expect(Push.Environment.detect(profile: profile(["com.apple.developer.aps-environment": "development"])) == .sandbox) + #expect(Push.Environment.detect(profile: profile([:])) == .production) + #expect(Push.Environment.detect(profile: profile(nil)) == .production) + #expect(Push.Environment.detect(profile: Data("not a profile".utf8)) == .production) + } + + // MARK: Deliveries + + private func defaults() -> UserDefaults { + let name = "push-tests-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + suites.append(name) + return defaults + } + + @Test func eachMessageIsDeliveredOnce() { + let deliveries = Push.Deliveries(defaults: defaults()) + #expect(deliveries.record("message-1")) + #expect(!deliveries.record("message-1")) + #expect(deliveries.contains("message-1")) + #expect(!deliveries.contains("message-2")) + } + + @Test func deliveriesAreKeptAcrossLaunchesAndBounded() { + let store = defaults() + let first = Push.Deliveries(defaults: store) + for index in 0...Push.Deliveries.capacity { + first.record("message-\(index)") + } + let relaunched = Push.Deliveries(defaults: store) + #expect(!relaunched.contains("message-0"), "the oldest id is dropped past the capacity") + #expect(relaunched.contains("message-1")) + #expect(relaunched.contains("message-\(Push.Deliveries.capacity)")) + #expect(!relaunched.record("message-\(Push.Deliveries.capacity)")) + } + + // MARK: Payloads + + @Test func appwriteDataUnderTheAppwriteKey() { + let message = Push.RemoteMessage(userInfo: [ + "aps": ["alert": ["title": "Sale", "body": "50% off"]], + "appwrite": ["messageId": "m1", "topic": "news", "data": ["saleId": "42"]], + ]) + #expect(message?.id == "m1") + #expect(message?.topic == "news") + #expect(message?.data["saleId"] as? String == "42") + #expect(message?.wake == false) + } + + @Test func wakeFromServersBeforeTheAppwriteKey() { + // appwrite/appwrite#14302 sends the wake inside `aps.data`. + let message = Push.RemoteMessage(userInfo: [ + "aps": ["content-available": 1, "data": ["type": "wake", "messageId": "m2"]] + ]) + #expect(message?.id == "m2") + #expect(message?.wake == true) + + // FCM-style top-level keys. + let flat = Push.RemoteMessage(userInfo: ["type": "wake", "messageId": "m3"]) + #expect(flat?.id == "m3") + #expect(flat?.wake == true) + } + + @Test func otherPushesAreNotAppwrites() { + #expect(Push.RemoteMessage(userInfo: ["aps": ["alert": "Hello"], "orderId": "7"]) == nil) + #expect(Push.RemoteMessage(userInfo: ["aps": ["alert": "Hello"], "messageId": ""]) == nil) + } + + @Test func messageIdInAnMqttPayload() { + #expect(Push.RemoteMessage.id(payload: Array(#"{"messageId":"m4","data":{}}"#.utf8)) == "m4") + #expect(Push.RemoteMessage.id(payload: Array(#"{"appwrite":{"messageId":"m5"}}"#.utf8)) == "m5") + #expect(Push.RemoteMessage.id(payload: Array(#"{"data":{}}"#.utf8)) == nil) + #expect(Push.RemoteMessage.id(payload: Array("plain text".utf8)) == nil) + } + + // MARK: Ids + + @Test func targetAndSubscriberIds() { + let target = Push.Ids.target(installation: "install-1", userId: "user-1") + #expect(target.count == 36) + #expect(target.allSatisfy { "0123456789abcdef".contains($0) }) + #expect(target == Push.Ids.target(installation: "install-1", userId: "user-1"), "stable for the same install and user") + #expect(target != Push.Ids.target(installation: "install-1", userId: "user-2"), "one target per user") + #expect(Push.Ids.subscriber(targetId: target, topic: "news") != Push.Ids.subscriber(targetId: target, topic: "sales")) + #expect(Push.Ids.subscriber(targetId: target, topic: "news").count == 36) + } + + @Test func onlyMessagingTopicsGetSubscribers() { + #expect(Push.Ids.isMessagingTopic("news")) + #expect(!Push.Ids.isMessagingTopic("users/123")) + #expect(!Push.Ids.isMessagingTopic("news/+")) + #expect(!Push.Ids.isMessagingTopic("news/#")) + #expect(!Push.Ids.isMessagingTopic("")) + } + + @Test func theInstallationIdIsStable() { + #expect(Push.Installation.id == Push.Installation.id) + } + + // MARK: Delegate proxy + + @Test func theProxyTellsPushAndKeepsTheAppsOwnMethod() { + let cls: AnyClass = objc_allocateClassPair(NSObject.self, "PushTestsDelegate\(UUID().uuidString.prefix(8))", 0)! + var calls = 0 + let original: @convention(block) (AnyObject, AnyObject, NSData) -> Void = { _, _, _ in calls += 1 } + class_addMethod(cls, Push.DelegateProxy.didRegister, imp_implementationWithBlock(original), "v@:@@") + objc_registerClassPair(cls) + Push.DelegateProxy.install(on: cls) + + let delegate = (cls as! NSObject.Type).init() + let token = Data([0xde, 0xad, 0xbe, 0xef]) + _ = delegate.perform(Push.DelegateProxy.didRegister, with: NSObject(), with: token as NSData) + + #expect(calls == 1, "the app's own method still runs") + #expect(Push.apnsCurrentToken == "deadbeef") + } + + @Test func theProxyAddsMethodsTheAppDoesNotHave() { + let cls: AnyClass = objc_allocateClassPair(NSObject.self, "PushTestsBareDelegate\(UUID().uuidString.prefix(8))", 0)! + objc_registerClassPair(cls) + #expect(!class_respondsToSelector(cls, Push.DelegateProxy.didRegister)) + Push.DelegateProxy.install(on: cls) + #expect(class_respondsToSelector(cls, Push.DelegateProxy.didRegister)) + #expect(class_respondsToSelector(cls, Push.DelegateProxy.didFail)) + #expect(class_respondsToSelector(cls, Push.DelegateProxy.didReceive)) + + let delegate = (cls as! NSObject.Type).init() + _ = delegate.perform(Push.DelegateProxy.didRegister, with: NSObject(), with: Data([0x01, 0x02]) as NSData) + #expect(Push.apnsCurrentToken == "0102") + } + + // MARK: Remote notifications + + @Test func remoteNotifications() { + let previous = Push.apnsDeliveries + Push.apnsDeliveries = Push.Deliveries(defaults: defaults()) + defer { Push.apnsDeliveries = previous } + + #expect(!Push.handleRemoteNotification(["aps": ["alert": "Not Appwrite's"]])) + + let push: [AnyHashable: Any] = ["appwrite": ["messageId": "m6", "topic": "news"]] + var results: [Bool] = [] + #expect(Push.apnsHandle(push) { results.append($0) }) + #expect(Push.apnsHandle(push) { results.append($0) }, "still Appwrite's the second time") + #expect(results == [true, false], "delivered once") + #expect(Push.apnsDeliveries.contains("m6")) + } +} From 1cdf99d1d89eb7030b1c0ba667fd2ba663d38b52 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 18:52:29 +0530 Subject: [PATCH 03/40] fix(apple): receive APNs pushes before the subscription connects 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. --- .../apple/Sources/Services/Push.swift.twig | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 85ea69f842..5bf4217eff 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -498,6 +498,9 @@ open class Push: Service { if background { requestNotificationAuthorization() + // Before any network work: a wake push that arrives while the SUBSCRIBE is in flight + // must already reach this push. + Push.apnsPrepare(self) } let generation = sync.sync { closeGeneration } @@ -1325,14 +1328,20 @@ extension Push { // MARK: Lifecycle + /// Let `push` receive APNs pushes and install the app delegate proxy. Runs before the + /// subscription's network work, so nothing APNs delivers meanwhile is missed. + static func apnsPrepare(_ push: Push) { + apnsLocked { apnsPushes[ObjectIdentifier(push)] = WeakPush(push) } + if autoRegistration { + installProxy() + } + } + /// Start APNs for a push with background subscriptions on `topics`. static func apnsEnable(_ push: Push, topics: [String]) { - let token = apnsLocked { () -> String? in - apnsPushes[ObjectIdentifier(push)] = WeakPush(push) - return apnsToken - } + apnsPrepare(push) + let token = apnsLocked { apnsToken } if autoRegistration { - installProxy() requestRegistration() } if let token { From d40d835a1b77d73f6e78dfff1391697e24e82865 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 18:52:29 +0530 Subject: [PATCH 04/40] fix(apple): let the app's notification delegate present Appwrite pushes 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. --- templates/apple/Sources/Services/Push.swift.twig | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 5bf4217eff..20f3cc013f 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -165,13 +165,21 @@ public struct PushNotificationOpened { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { let info = notification.request.content.userInfo + let forwards = forwarded?.responds(to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:))) ?? false if Push.RemoteMessage(userInfo: info) != nil { // An APNs push Appwrite sent: shows nothing when MQTT already delivered it, and is - // recorded otherwise, so MQTT does not deliver it again. + // recorded otherwise, so MQTT does not deliver it again. The app's own delegate + // still decides how a new one is presented. let delivered = Push.isDelivered(notification) _ = Push.handleRemoteNotification(info) - completionHandler(delivered ? [] : [.banner, .list, .sound]) - } else if let forwarded, forwarded.responds(to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:))) { + if let forwarded, forwards { + forwarded.userNotificationCenter?(center, willPresent: notification) { options in + completionHandler(delivered ? [] : options) + } + } else { + completionHandler(delivered ? [] : [.banner, .list, .sound]) + } + } else if let forwarded, forwards { forwarded.userNotificationCenter?(center, willPresent: notification, withCompletionHandler: completionHandler) } else { completionHandler([]) From 7cf442fc56f40c90fd5309d5afbfe8cb723d289d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 18:52:29 +0530 Subject: [PATCH 05/40] fix(apple): call the app's remote-notification method for Appwrite pushes 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. --- .../apple/Sources/Services/Push.swift.twig | 58 ++++++++++++++++--- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 20f3cc013f..8cc831a82b 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1660,8 +1660,8 @@ extension Push { #if os(macOS) swizzle(cls, didReceive, types: "v@:@@") { original in let block: @convention(block) (AnyObject, AnyObject, NSDictionary) -> Void = { this, application, userInfo in - let handled = Push.apnsHandle(userInfo as? [AnyHashable: Any] ?? [:], completion: nil) - if let original, !handled { + _ = Push.apnsHandle(userInfo as? [AnyHashable: Any] ?? [:], completion: nil) + if let original { unsafeBitCast(original, to: ReceiveFunction.self)(this, didReceive, application, userInfo) } } @@ -1671,17 +1671,18 @@ extension Push { swizzle(cls, didReceive, types: "v@:@@@?") { original in let block: @convention(block) (AnyObject, AnyObject, NSDictionary, @escaping FetchCompletion) -> Void = { this, application, userInfo, completion in - // UIBackgroundFetchResult raw values: 0 newData, 1 noData. + // The app's own method still runs for Appwrite's pushes. iOS gets one + // completion, once both are done: Push's result, then the app's. + let fetch = FetchResults(parts: original == nil ? 1 : 2, completion: completion) let handled = Push.apnsHandle(userInfo as? [AnyHashable: Any] ?? [:]) { delivered in - completion(delivered ? 0 : 1) + fetch.finish(delivered ? FetchResults.newData : FetchResults.noData) } - if handled { - return + if !handled { + fetch.finish(FetchResults.noData) } if let original { - unsafeBitCast(original, to: ReceiveFunction.self)(this, didReceive, application, userInfo, completion) - } else { - completion(1) + let appCompletion: FetchCompletion = { result in fetch.finish(result) } + unsafeBitCast(original, to: ReceiveFunction.self)(this, didReceive, application, userInfo, appCompletion) } } return imp_implementationWithBlock(block) @@ -1689,6 +1690,45 @@ extension Push { #endif } + #if !os(macOS) + /// Joins the fetch completions of `Push` and the app into the one iOS expects: new data + /// when either had some, else failed when either failed, else no data. + private final class FetchResults { + // UIBackgroundFetchResult raw values. + static let newData: UInt = 0 + static let noData: UInt = 1 + static let failed: UInt = 2 + + private let lock = NSLock() + private var remaining: Int + private var results: [UInt] = [] + private let completion: FetchCompletion + + init(parts: Int, completion: @escaping FetchCompletion) { + remaining = parts + self.completion = completion + } + + func finish(_ result: UInt) { + lock.lock() + guard remaining > 0 else { + lock.unlock() + return + } + results.append(result) + remaining -= 1 + let done = remaining == 0 + let combined = + results.contains(FetchResults.newData) + ? FetchResults.newData : results.contains(FetchResults.failed) ? FetchResults.failed : FetchResults.noData + lock.unlock() + if done { + completion(combined) + } + } + } + #endif + /// Point `selector` on `cls` at the implementation `make` builds from the original, which is /// nil when the class did not implement it. static func swizzle(_ cls: AnyClass, _ selector: Selector, types: String, make: (IMP?) -> IMP) { From bb5a08c261fcf242693e233ec10f60ff22074ede Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 18:52:30 +0530 Subject: [PATCH 06/40] fix(apple): skip subscribers for topics unsubscribed during registration 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. --- templates/apple/Sources/Services/Push.swift.twig | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 8cc831a82b..1a4eb8eaf4 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1418,7 +1418,9 @@ extension Push { return } apnsLocked { apnsLinked[targetId] = apnsLinked[targetId] ?? [] } - for topic in topics where Ids.isMessagingTopic(topic) { + // A topic unsubscribed while the target request was in flight is not linked. + let wanted = Set(push.backgroundTopics()) + for topic in topics where Ids.isMessagingTopic(topic) && wanted.contains(topic) { await link(push, targetId: targetId, topic: topic) } } @@ -1464,6 +1466,11 @@ extension Push { return } apnsLocked { _ = apnsLinked[targetId, default: []].insert(topic) } + // Unsubscribed while the subscriber was being created: `apnsDisable` found nothing to + // unlink then, so undo it here. + if !push.backgroundTopics().contains(topic) { + await unlink(push, targetId: targetId, topic: topic) + } } private static func unlink(_ push: Push, targetId: String, topic: String) async { From fa2071243c0978db0d367fae932ff62d28189df8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Fri, 9 Oct 2026 18:52:30 +0530 Subject: [PATCH 07/40] fix(apple): send the environment and bundle id when updating the push 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. --- templates/apple/Sources/Services/Push.swift.twig | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 1a4eb8eaf4..bee47a5ad2 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1443,8 +1443,16 @@ extension Push { converter: { $0 } ) } catch let error as {{ spec.info.title | caseUcfirst }}Error where error.code == 409 { - // This install already has a target for the user: point it at the current token. - _ = try await Account(push.client).updatePushTarget(targetId: targetId, identifier: token) + // 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( + method: "PUT", + path: "/account/targets/\(targetId)/push", + headers: ["content-type": "application/json"], + params: params.filter { $0.key != "targetId" }, + converter: { $0 } + ) } } From 082617d6014fd4bbd9c2360adb93c41ad90fad32 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Oct 2026 12:51:14 +0530 Subject: [PATCH 08/40] fix(apple): proxy the class a SwiftUI app delegate forwards to 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. --- .../apple/Sources/Services/Push.swift.twig | 35 +++++++++++++--- templates/apple/Tests/PushTests.swift.twig | 40 +++++++++++++++++++ 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index bee47a5ad2..eabc3d0743 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1618,7 +1618,7 @@ extension Push { return !apnsProxyInstalled } if install { - DelegateProxy.install(on: type(of: delegate)) + DelegateProxy.install(on: delegate) } } } @@ -1653,8 +1653,31 @@ extension Push { private typealias ReceiveFunction = @convention(c) (AnyObject, Selector, AnyObject, NSDictionary, FetchCompletion) -> Void #endif - static func install(on cls: AnyClass) { - swizzle(cls, didRegister, types: "v@:@@") { original in + /// Install on the app delegate. A method it does not implement but forwards goes on the class it + /// forwards to: SwiftUI's delegate forwards to the `@UIApplicationDelegateAdaptor` class, and a + /// method added to SwiftUI's would stop the app's own from running. + static func install(on delegate: NSObject) { + install(on: type(of: delegate)) { selector in + owner(of: delegate, selector) + } + } + + /// The class whose `selector` the delegate runs: its own, or the one it forwards it to. + static func owner(of delegate: NSObject, _ selector: Selector) -> AnyClass { + let cls: AnyClass = type(of: delegate) + if class_getInstanceMethod(cls, selector) == nil, + let target = delegate.forwardingTarget(for: selector) as? NSObject, + target !== delegate, + class_getInstanceMethod(type(of: target), selector) != nil + { + return type(of: target) + } + return cls + } + + /// Install on `cls`, or for each method on the class `owner` names. + static func install(on cls: AnyClass, owner: ((Selector) -> AnyClass)? = nil) { + swizzle(owner?(didRegister) ?? cls, didRegister, types: "v@:@@") { original in let block: @convention(block) (AnyObject, AnyObject, NSData) -> Void = { this, application, token in Push.apnsSetDeviceToken(token as Data) if let original { @@ -1663,7 +1686,7 @@ extension Push { } return imp_implementationWithBlock(block) } - swizzle(cls, didFail, types: "v@:@@") { original in + swizzle(owner?(didFail) ?? cls, didFail, types: "v@:@@") { original in let block: @convention(block) (AnyObject, AnyObject, NSError) -> Void = { this, application, error in Push.apnsRegistrationFailed(error) if let original { @@ -1673,7 +1696,7 @@ extension Push { return imp_implementationWithBlock(block) } #if os(macOS) - swizzle(cls, didReceive, types: "v@:@@") { original in + swizzle(owner?(didReceive) ?? cls, didReceive, types: "v@:@@") { original in let block: @convention(block) (AnyObject, AnyObject, NSDictionary) -> Void = { this, application, userInfo in _ = Push.apnsHandle(userInfo as? [AnyHashable: Any] ?? [:], completion: nil) if let original { @@ -1683,7 +1706,7 @@ extension Push { return imp_implementationWithBlock(block) } #else - swizzle(cls, didReceive, types: "v@:@@@?") { original in + swizzle(owner?(didReceive) ?? cls, didReceive, types: "v@:@@@?") { original in let block: @convention(block) (AnyObject, AnyObject, NSDictionary, @escaping FetchCompletion) -> Void = { this, application, userInfo, completion in // The app's own method still runs for Appwrite's pushes. iOS gets one diff --git a/templates/apple/Tests/PushTests.swift.twig b/templates/apple/Tests/PushTests.swift.twig index 57f6f618c1..bc1c02c9af 100644 --- a/templates/apple/Tests/PushTests.swift.twig +++ b/templates/apple/Tests/PushTests.swift.twig @@ -160,6 +160,19 @@ final class PushTests { #expect(Push.apnsCurrentToken == "0102") } + @Test func theProxyFollowsAForwardingDelegate() { + // SwiftUI's app delegate forwards to the `@UIApplicationDelegateAdaptor` class rather than + // implementing the methods. The proxy must go on that class, or the app's method stops running. + let target = ForwardedDelegate() + let delegate = ForwardingDelegate(target: target) + Push.DelegateProxy.install(on: delegate) + + #expect(class_getInstanceMethod(ForwardingDelegate.self, Push.DelegateProxy.didRegister) == nil, "nothing added to the forwarding class") + _ = delegate.perform(Push.DelegateProxy.didRegister, with: NSObject(), with: Data([0xab, 0xcd]) as NSData) + #expect(target.tokens == 1, "the app's own method still runs") + #expect(Push.apnsCurrentToken == "abcd") + } + // MARK: Remote notifications @Test func remoteNotifications() { @@ -177,3 +190,30 @@ final class PushTests { #expect(Push.apnsDeliveries.contains("m6")) } } + +/// Stands in for SwiftUI's app delegate: implements nothing, forwards to the app's class. +private final class ForwardingDelegate: NSObject { + let target: ForwardedDelegate + + init(target: ForwardedDelegate) { + self.target = target + } + + override func forwardingTarget(for selector: Selector!) -> Any? { + target.responds(to: selector) ? target : nil + } + + override func responds(to selector: Selector!) -> Bool { + super.responds(to: selector) || target.responds(to: selector) + } +} + +/// Stands in for the app's `@UIApplicationDelegateAdaptor` class. +private final class ForwardedDelegate: NSObject { + var tokens = 0 + + @objc(application:didRegisterForRemoteNotificationsWithDeviceToken:) + func didRegister(_ application: AnyObject, token: NSData) { + tokens += 1 + } +} From c6eb525b4dd181e15ad4f08d67fd97395a8a52ee Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Oct 2026 18:24:23 +0530 Subject: [PATCH 09/40] fix(apple): build the Push APNs code on Linux 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. --- .../apple/Sources/Services/Push.swift.twig | 410 +++++++++--------- templates/apple/Tests/PushTests.swift.twig | 136 +++--- 2 files changed, 284 insertions(+), 262 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index eabc3d0743..a16d6e5793 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1,8 +1,10 @@ import Foundation import MQTTNIO import NIOCore -import ObjectiveC +#if canImport(ObjectiveC) + import ObjectiveC +#endif #if canImport(CryptoKit) import CryptoKit #endif @@ -1581,203 +1583,6 @@ extension Push { } } - // MARK: App delegate proxy - - private static func requestRegistration() { - let first = apnsLocked { () -> Bool in - defer { apnsRegistrationRequested = true } - return !apnsRegistrationRequested - } - guard first, Bundle.main.bundleIdentifier != nil else { - return - } - DispatchQueue.main.async { - #if os(iOS) || os(tvOS) - UIApplication.shared.registerForRemoteNotifications() - #elseif os(macOS) - NSApplication.shared.registerForRemoteNotifications() - #endif - } - } - - /// Proxy the app delegate's remote-notification methods. SwiftUI apps set their delegate after - /// launch, so this waits for one to exist. - private static func installProxy(attempt: Int = 0) { - guard Bundle.main.bundleIdentifier != nil else { - return - } - DispatchQueue.main.async { - guard let delegate = applicationDelegate() else { - if attempt < 20 { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { installProxy(attempt: attempt + 1) } - } - return - } - let install = apnsLocked { () -> Bool in - defer { apnsProxyInstalled = true } - return !apnsProxyInstalled - } - if install { - DelegateProxy.install(on: delegate) - } - } - } - - private static func applicationDelegate() -> NSObject? { - #if os(iOS) || os(tvOS) - return UIApplication.shared.delegate as? NSObject - #elseif os(macOS) - return NSApplication.shared.delegate as? NSObject - #else - return nil - #endif - } - - /// Replaces the app delegate's remote-notification methods with versions that tell `Push` first - /// and then call the app's own. Methods the app does not implement are added. - enum DelegateProxy { - static let didRegister = NSSelectorFromString("application:didRegisterForRemoteNotificationsWithDeviceToken:") - static let didFail = NSSelectorFromString("application:didFailToRegisterForRemoteNotificationsWithError:") - #if os(macOS) - static let didReceive = NSSelectorFromString("application:didReceiveRemoteNotification:") - #else - static let didReceive = NSSelectorFromString("application:didReceiveRemoteNotification:fetchCompletionHandler:") - #endif - - private typealias TokenFunction = @convention(c) (AnyObject, Selector, AnyObject, NSData) -> Void - private typealias FailFunction = @convention(c) (AnyObject, Selector, AnyObject, NSError) -> Void - #if os(macOS) - private typealias ReceiveFunction = @convention(c) (AnyObject, Selector, AnyObject, NSDictionary) -> Void - #else - private typealias FetchCompletion = @convention(block) (UInt) -> Void - private typealias ReceiveFunction = @convention(c) (AnyObject, Selector, AnyObject, NSDictionary, FetchCompletion) -> Void - #endif - - /// Install on the app delegate. A method it does not implement but forwards goes on the class it - /// forwards to: SwiftUI's delegate forwards to the `@UIApplicationDelegateAdaptor` class, and a - /// method added to SwiftUI's would stop the app's own from running. - static func install(on delegate: NSObject) { - install(on: type(of: delegate)) { selector in - owner(of: delegate, selector) - } - } - - /// The class whose `selector` the delegate runs: its own, or the one it forwards it to. - static func owner(of delegate: NSObject, _ selector: Selector) -> AnyClass { - let cls: AnyClass = type(of: delegate) - if class_getInstanceMethod(cls, selector) == nil, - let target = delegate.forwardingTarget(for: selector) as? NSObject, - target !== delegate, - class_getInstanceMethod(type(of: target), selector) != nil - { - return type(of: target) - } - return cls - } - - /// Install on `cls`, or for each method on the class `owner` names. - static func install(on cls: AnyClass, owner: ((Selector) -> AnyClass)? = nil) { - swizzle(owner?(didRegister) ?? cls, didRegister, types: "v@:@@") { original in - let block: @convention(block) (AnyObject, AnyObject, NSData) -> Void = { this, application, token in - Push.apnsSetDeviceToken(token as Data) - if let original { - unsafeBitCast(original, to: TokenFunction.self)(this, didRegister, application, token) - } - } - return imp_implementationWithBlock(block) - } - swizzle(owner?(didFail) ?? cls, didFail, types: "v@:@@") { original in - let block: @convention(block) (AnyObject, AnyObject, NSError) -> Void = { this, application, error in - Push.apnsRegistrationFailed(error) - if let original { - unsafeBitCast(original, to: FailFunction.self)(this, didFail, application, error) - } - } - return imp_implementationWithBlock(block) - } - #if os(macOS) - swizzle(owner?(didReceive) ?? cls, didReceive, types: "v@:@@") { original in - let block: @convention(block) (AnyObject, AnyObject, NSDictionary) -> Void = { this, application, userInfo in - _ = Push.apnsHandle(userInfo as? [AnyHashable: Any] ?? [:], completion: nil) - if let original { - unsafeBitCast(original, to: ReceiveFunction.self)(this, didReceive, application, userInfo) - } - } - return imp_implementationWithBlock(block) - } - #else - swizzle(owner?(didReceive) ?? cls, didReceive, types: "v@:@@@?") { original in - let block: @convention(block) (AnyObject, AnyObject, NSDictionary, @escaping FetchCompletion) -> Void = { - this, application, userInfo, completion in - // The app's own method still runs for Appwrite's pushes. iOS gets one - // completion, once both are done: Push's result, then the app's. - let fetch = FetchResults(parts: original == nil ? 1 : 2, completion: completion) - let handled = Push.apnsHandle(userInfo as? [AnyHashable: Any] ?? [:]) { delivered in - fetch.finish(delivered ? FetchResults.newData : FetchResults.noData) - } - if !handled { - fetch.finish(FetchResults.noData) - } - if let original { - let appCompletion: FetchCompletion = { result in fetch.finish(result) } - unsafeBitCast(original, to: ReceiveFunction.self)(this, didReceive, application, userInfo, appCompletion) - } - } - return imp_implementationWithBlock(block) - } - #endif - } - - #if !os(macOS) - /// Joins the fetch completions of `Push` and the app into the one iOS expects: new data - /// when either had some, else failed when either failed, else no data. - private final class FetchResults { - // UIBackgroundFetchResult raw values. - static let newData: UInt = 0 - static let noData: UInt = 1 - static let failed: UInt = 2 - - private let lock = NSLock() - private var remaining: Int - private var results: [UInt] = [] - private let completion: FetchCompletion - - init(parts: Int, completion: @escaping FetchCompletion) { - remaining = parts - self.completion = completion - } - - func finish(_ result: UInt) { - lock.lock() - guard remaining > 0 else { - lock.unlock() - return - } - results.append(result) - remaining -= 1 - let done = remaining == 0 - let combined = - results.contains(FetchResults.newData) - ? FetchResults.newData : results.contains(FetchResults.failed) ? FetchResults.failed : FetchResults.noData - lock.unlock() - if done { - completion(combined) - } - } - } - #endif - - /// Point `selector` on `cls` at the implementation `make` builds from the original, which is - /// nil when the class did not implement it. - static func swizzle(_ cls: AnyClass, _ selector: Selector, types: String, make: (IMP?) -> IMP) { - if let method = class_getInstanceMethod(cls, selector) { - method_setImplementation(method, make(method_getImplementation(method))) - } else { - class_addMethod(cls, selector, make(nil), types) - } - } - } - // MARK: Helpers /// The APNs server a device token belongs to. Debug builds get sandbox tokens and App Store or @@ -1972,3 +1777,212 @@ extension Push { } } } + +// The app delegate proxy needs the Objective-C runtime, so it exists on Apple platforms only, +// where APNs does. +#if canImport(ObjectiveC) + extension Push { + // MARK: App delegate proxy + + private static func requestRegistration() { + let first = apnsLocked { () -> Bool in + defer { apnsRegistrationRequested = true } + return !apnsRegistrationRequested + } + guard first, Bundle.main.bundleIdentifier != nil else { + return + } + DispatchQueue.main.async { + #if os(iOS) || os(tvOS) + UIApplication.shared.registerForRemoteNotifications() + #elseif os(macOS) + NSApplication.shared.registerForRemoteNotifications() + #endif + } + } + + /// Proxy the app delegate's remote-notification methods. SwiftUI apps set their delegate after + /// launch, so this waits for one to exist. + private static func installProxy(attempt: Int = 0) { + guard Bundle.main.bundleIdentifier != nil else { + return + } + DispatchQueue.main.async { + guard let delegate = applicationDelegate() else { + if attempt < 20 { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { installProxy(attempt: attempt + 1) } + } + return + } + let install = apnsLocked { () -> Bool in + defer { apnsProxyInstalled = true } + return !apnsProxyInstalled + } + if install { + DelegateProxy.install(on: delegate) + } + } + } + + private static func applicationDelegate() -> NSObject? { + #if os(iOS) || os(tvOS) + return UIApplication.shared.delegate as? NSObject + #elseif os(macOS) + return NSApplication.shared.delegate as? NSObject + #else + return nil + #endif + } + + /// Replaces the app delegate's remote-notification methods with versions that tell `Push` first + /// and then call the app's own. Methods the app does not implement are added. + enum DelegateProxy { + static let didRegister = NSSelectorFromString("application:didRegisterForRemoteNotificationsWithDeviceToken:") + static let didFail = NSSelectorFromString("application:didFailToRegisterForRemoteNotificationsWithError:") + #if os(macOS) + static let didReceive = NSSelectorFromString("application:didReceiveRemoteNotification:") + #else + static let didReceive = NSSelectorFromString("application:didReceiveRemoteNotification:fetchCompletionHandler:") + #endif + + private typealias TokenFunction = @convention(c) (AnyObject, Selector, AnyObject, NSData) -> Void + private typealias FailFunction = @convention(c) (AnyObject, Selector, AnyObject, NSError) -> Void + #if os(macOS) + private typealias ReceiveFunction = @convention(c) (AnyObject, Selector, AnyObject, NSDictionary) -> Void + #else + private typealias FetchCompletion = @convention(block) (UInt) -> Void + private typealias ReceiveFunction = @convention(c) (AnyObject, Selector, AnyObject, NSDictionary, FetchCompletion) -> Void + #endif + + /// Install on the app delegate. A method it does not implement but forwards goes on the class it + /// forwards to: SwiftUI's delegate forwards to the `@UIApplicationDelegateAdaptor` class, and a + /// method added to SwiftUI's would stop the app's own from running. + static func install(on delegate: NSObject) { + install(on: type(of: delegate)) { selector in + owner(of: delegate, selector) + } + } + + /// The class whose `selector` the delegate runs: its own, or the one it forwards it to. + static func owner(of delegate: NSObject, _ selector: Selector) -> AnyClass { + let cls: AnyClass = type(of: delegate) + if class_getInstanceMethod(cls, selector) == nil, + let target = delegate.forwardingTarget(for: selector) as? NSObject, + target !== delegate, + class_getInstanceMethod(type(of: target), selector) != nil + { + return type(of: target) + } + return cls + } + + /// Install on `cls`, or for each method on the class `owner` names. + static func install(on cls: AnyClass, owner: ((Selector) -> AnyClass)? = nil) { + swizzle(owner?(didRegister) ?? cls, didRegister, types: "v@:@@") { original in + let block: @convention(block) (AnyObject, AnyObject, NSData) -> Void = { this, application, token in + Push.apnsSetDeviceToken(token as Data) + if let original { + unsafeBitCast(original, to: TokenFunction.self)(this, didRegister, application, token) + } + } + return imp_implementationWithBlock(block) + } + swizzle(owner?(didFail) ?? cls, didFail, types: "v@:@@") { original in + let block: @convention(block) (AnyObject, AnyObject, NSError) -> Void = { this, application, error in + Push.apnsRegistrationFailed(error) + if let original { + unsafeBitCast(original, to: FailFunction.self)(this, didFail, application, error) + } + } + return imp_implementationWithBlock(block) + } + #if os(macOS) + swizzle(owner?(didReceive) ?? cls, didReceive, types: "v@:@@") { original in + let block: @convention(block) (AnyObject, AnyObject, NSDictionary) -> Void = { this, application, userInfo in + _ = Push.apnsHandle(userInfo as? [AnyHashable: Any] ?? [:], completion: nil) + if let original { + unsafeBitCast(original, to: ReceiveFunction.self)(this, didReceive, application, userInfo) + } + } + return imp_implementationWithBlock(block) + } + #else + swizzle(owner?(didReceive) ?? cls, didReceive, types: "v@:@@@?") { original in + let block: @convention(block) (AnyObject, AnyObject, NSDictionary, @escaping FetchCompletion) -> Void = { + this, application, userInfo, completion in + // The app's own method still runs for Appwrite's pushes. iOS gets one + // completion, once both are done: Push's result, then the app's. + let fetch = FetchResults(parts: original == nil ? 1 : 2, completion: completion) + let handled = Push.apnsHandle(userInfo as? [AnyHashable: Any] ?? [:]) { delivered in + fetch.finish(delivered ? FetchResults.newData : FetchResults.noData) + } + if !handled { + fetch.finish(FetchResults.noData) + } + if let original { + let appCompletion: FetchCompletion = { result in fetch.finish(result) } + unsafeBitCast(original, to: ReceiveFunction.self)(this, didReceive, application, userInfo, appCompletion) + } + } + return imp_implementationWithBlock(block) + } + #endif + } + + #if !os(macOS) + /// Joins the fetch completions of `Push` and the app into the one iOS expects: new data + /// when either had some, else failed when either failed, else no data. + private final class FetchResults { + // UIBackgroundFetchResult raw values. + static let newData: UInt = 0 + static let noData: UInt = 1 + static let failed: UInt = 2 + + private let lock = NSLock() + private var remaining: Int + private var results: [UInt] = [] + private let completion: FetchCompletion + + init(parts: Int, completion: @escaping FetchCompletion) { + remaining = parts + self.completion = completion + } + + func finish(_ result: UInt) { + lock.lock() + guard remaining > 0 else { + lock.unlock() + return + } + results.append(result) + remaining -= 1 + let done = remaining == 0 + let combined = + results.contains(FetchResults.newData) + ? FetchResults.newData : results.contains(FetchResults.failed) ? FetchResults.failed : FetchResults.noData + lock.unlock() + if done { + completion(combined) + } + } + } + #endif + + /// Point `selector` on `cls` at the implementation `make` builds from the original, which is + /// nil when the class did not implement it. + static func swizzle(_ cls: AnyClass, _ selector: Selector, types: String, make: (IMP?) -> IMP) { + if let method = class_getInstanceMethod(cls, selector) { + method_setImplementation(method, make(method_getImplementation(method))) + } else { + class_addMethod(cls, selector, make(nil), types) + } + } + } + } +#else + extension Push { + private static func requestRegistration() {} + + private static func installProxy() {} + } +#endif diff --git a/templates/apple/Tests/PushTests.swift.twig b/templates/apple/Tests/PushTests.swift.twig index bc1c02c9af..4ea253c9fa 100644 --- a/templates/apple/Tests/PushTests.swift.twig +++ b/templates/apple/Tests/PushTests.swift.twig @@ -1,9 +1,12 @@ import Foundation -import ObjectiveC import Testing @testable import {{ spec.info.title | caseUcfirst }} +#if canImport(ObjectiveC) + import ObjectiveC +#endif + @Suite(.serialized) final class PushTests { private var suites: [String] = [] @@ -128,51 +131,6 @@ final class PushTests { #expect(Push.Installation.id == Push.Installation.id) } - // MARK: Delegate proxy - - @Test func theProxyTellsPushAndKeepsTheAppsOwnMethod() { - let cls: AnyClass = objc_allocateClassPair(NSObject.self, "PushTestsDelegate\(UUID().uuidString.prefix(8))", 0)! - var calls = 0 - let original: @convention(block) (AnyObject, AnyObject, NSData) -> Void = { _, _, _ in calls += 1 } - class_addMethod(cls, Push.DelegateProxy.didRegister, imp_implementationWithBlock(original), "v@:@@") - objc_registerClassPair(cls) - Push.DelegateProxy.install(on: cls) - - let delegate = (cls as! NSObject.Type).init() - let token = Data([0xde, 0xad, 0xbe, 0xef]) - _ = delegate.perform(Push.DelegateProxy.didRegister, with: NSObject(), with: token as NSData) - - #expect(calls == 1, "the app's own method still runs") - #expect(Push.apnsCurrentToken == "deadbeef") - } - - @Test func theProxyAddsMethodsTheAppDoesNotHave() { - let cls: AnyClass = objc_allocateClassPair(NSObject.self, "PushTestsBareDelegate\(UUID().uuidString.prefix(8))", 0)! - objc_registerClassPair(cls) - #expect(!class_respondsToSelector(cls, Push.DelegateProxy.didRegister)) - Push.DelegateProxy.install(on: cls) - #expect(class_respondsToSelector(cls, Push.DelegateProxy.didRegister)) - #expect(class_respondsToSelector(cls, Push.DelegateProxy.didFail)) - #expect(class_respondsToSelector(cls, Push.DelegateProxy.didReceive)) - - let delegate = (cls as! NSObject.Type).init() - _ = delegate.perform(Push.DelegateProxy.didRegister, with: NSObject(), with: Data([0x01, 0x02]) as NSData) - #expect(Push.apnsCurrentToken == "0102") - } - - @Test func theProxyFollowsAForwardingDelegate() { - // SwiftUI's app delegate forwards to the `@UIApplicationDelegateAdaptor` class rather than - // implementing the methods. The proxy must go on that class, or the app's method stops running. - let target = ForwardedDelegate() - let delegate = ForwardingDelegate(target: target) - Push.DelegateProxy.install(on: delegate) - - #expect(class_getInstanceMethod(ForwardingDelegate.self, Push.DelegateProxy.didRegister) == nil, "nothing added to the forwarding class") - _ = delegate.perform(Push.DelegateProxy.didRegister, with: NSObject(), with: Data([0xab, 0xcd]) as NSData) - #expect(target.tokens == 1, "the app's own method still runs") - #expect(Push.apnsCurrentToken == "abcd") - } - // MARK: Remote notifications @Test func remoteNotifications() { @@ -191,29 +149,79 @@ final class PushTests { } } -/// Stands in for SwiftUI's app delegate: implements nothing, forwards to the app's class. -private final class ForwardingDelegate: NSObject { - let target: ForwardedDelegate +// The delegate proxy needs the Objective-C runtime. +#if canImport(ObjectiveC) + extension PushTests { + // MARK: Delegate proxy + + @Test func theProxyTellsPushAndKeepsTheAppsOwnMethod() { + let cls: AnyClass = objc_allocateClassPair(NSObject.self, "PushTestsDelegate\(UUID().uuidString.prefix(8))", 0)! + var calls = 0 + let original: @convention(block) (AnyObject, AnyObject, NSData) -> Void = { _, _, _ in calls += 1 } + class_addMethod(cls, Push.DelegateProxy.didRegister, imp_implementationWithBlock(original), "v@:@@") + objc_registerClassPair(cls) + Push.DelegateProxy.install(on: cls) + + let delegate = (cls as! NSObject.Type).init() + let token = Data([0xde, 0xad, 0xbe, 0xef]) + _ = delegate.perform(Push.DelegateProxy.didRegister, with: NSObject(), with: token as NSData) + + #expect(calls == 1, "the app's own method still runs") + #expect(Push.apnsCurrentToken == "deadbeef") + } - init(target: ForwardedDelegate) { - self.target = target - } + @Test func theProxyAddsMethodsTheAppDoesNotHave() { + let cls: AnyClass = objc_allocateClassPair(NSObject.self, "PushTestsBareDelegate\(UUID().uuidString.prefix(8))", 0)! + objc_registerClassPair(cls) + #expect(!class_respondsToSelector(cls, Push.DelegateProxy.didRegister)) + Push.DelegateProxy.install(on: cls) + #expect(class_respondsToSelector(cls, Push.DelegateProxy.didRegister)) + #expect(class_respondsToSelector(cls, Push.DelegateProxy.didFail)) + #expect(class_respondsToSelector(cls, Push.DelegateProxy.didReceive)) + + let delegate = (cls as! NSObject.Type).init() + _ = delegate.perform(Push.DelegateProxy.didRegister, with: NSObject(), with: Data([0x01, 0x02]) as NSData) + #expect(Push.apnsCurrentToken == "0102") + } - override func forwardingTarget(for selector: Selector!) -> Any? { - target.responds(to: selector) ? target : nil + @Test func theProxyFollowsAForwardingDelegate() { + // SwiftUI's app delegate forwards to the `@UIApplicationDelegateAdaptor` class rather than + // implementing the methods. The proxy must go on that class, or the app's method stops running. + let target = ForwardedDelegate() + let delegate = ForwardingDelegate(target: target) + Push.DelegateProxy.install(on: delegate) + + #expect(class_getInstanceMethod(ForwardingDelegate.self, Push.DelegateProxy.didRegister) == nil, "nothing added to the forwarding class") + _ = delegate.perform(Push.DelegateProxy.didRegister, with: NSObject(), with: Data([0xab, 0xcd]) as NSData) + #expect(target.tokens == 1, "the app's own method still runs") + #expect(Push.apnsCurrentToken == "abcd") + } } - override func responds(to selector: Selector!) -> Bool { - super.responds(to: selector) || target.responds(to: selector) + /// Stands in for SwiftUI's app delegate: implements nothing, forwards to the app's class. + private final class ForwardingDelegate: NSObject { + let target: ForwardedDelegate + + init(target: ForwardedDelegate) { + self.target = target + } + + override func forwardingTarget(for selector: Selector!) -> Any? { + target.responds(to: selector) ? target : nil + } + + override func responds(to selector: Selector!) -> Bool { + super.responds(to: selector) || target.responds(to: selector) + } } -} -/// Stands in for the app's `@UIApplicationDelegateAdaptor` class. -private final class ForwardedDelegate: NSObject { - var tokens = 0 + /// Stands in for the app's `@UIApplicationDelegateAdaptor` class. + private final class ForwardedDelegate: NSObject { + var tokens = 0 - @objc(application:didRegisterForRemoteNotificationsWithDeviceToken:) - func didRegister(_ application: AnyObject, token: NSData) { - tokens += 1 + @objc(application:didRegisterForRemoteNotificationsWithDeviceToken:) + func didRegister(_ application: AnyObject, token: NSData) { + tokens += 1 + } } -} +#endif From 695e1baed2094c59e933ae831feaa8cc4fa6e2cc Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Oct 2026 18:24:23 +0530 Subject: [PATCH 10/40] fix(apple): link a topic again when it returns during its unlink 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. --- templates/apple/Sources/Services/Push.swift.twig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index a16d6e5793..492af2e53d 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1495,6 +1495,11 @@ extension Push { push.surface(error) } apnsLocked { _ = apnsLinked[targetId]?.remove(topic) } + // Back in a background subscription while the delete was in flight: `link` saw the topic + // still linked then and skipped it, so link it again now. + if push.backgroundTopics().contains(topic) { + await link(push, targetId: targetId, topic: topic) + } } // MARK: Delivery From c040a8a0c8c705918cb972f17896721ec4788c91 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 12 Oct 2026 19:52:00 +0530 Subject: [PATCH 11/40] fix(apple): read the wake appwrite/appwrite#14302 sends, and register 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. --- .../apple/Sources/Services/Push.swift.twig | 23 +++++++++++-------- templates/apple/Tests/PushTests.swift.twig | 12 +++++++++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 492af2e53d..1043d77bd7 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1599,12 +1599,16 @@ extension Push { /// Read off the app's provisioning profile: `aps-environment = development` means sandbox. /// App Store builds carry no profile and always use production. static var current: Environment { - for (name, ext) in [("embedded", "mobileprovision"), ("embedded", "provisionprofile")] { - if let url = Bundle.main.url(forResource: name, withExtension: ext), let data = try? Data(contentsOf: url) { - return detect(profile: data) + #if targetEnvironment(simulator) + return .sandbox + #else + for (name, ext) in [("embedded", "mobileprovision"), ("embedded", "provisionprofile")] { + if let url = Bundle.main.url(forResource: name, withExtension: ext), let data = try? Data(contentsOf: url) { + return detect(profile: data) + } } - } - return .production + return .production + #endif } /// The environment a provisioning profile grants. The profile is a signed container with its @@ -1675,8 +1679,7 @@ extension Push { /// A silent push sent only to wake the app; its content follows over MQTT. let wake: Bool - /// From an APNs `userInfo`: Appwrite's data under the top-level `appwrite` key, or as top-level - /// (or `aps.data`) `messageId`/`type` keys from servers before that. Nil when it is not Appwrite's. + /// From an APNs `userInfo`: Appwrite's data under `appwrite`, `data`, `aps.data` or at the top level. Nil when it is not Appwrite's. init?(userInfo: [AnyHashable: Any]) { if let appwrite = userInfo["appwrite"] as? [String: Any] { id = appwrite["messageId"] as? String @@ -1686,8 +1689,10 @@ extension Push { return } let aps = userInfo["aps"] as? [String: Any] - let fields = (aps?["data"] as? [String: Any]) ?? (userInfo as? [String: Any] ?? [:]) - guard let messageId = fields["messageId"] as? String, !messageId.isEmpty else { + let places = [userInfo["data"], aps?["data"], userInfo as? [String: Any]].compactMap { $0 as? [String: Any] } + guard let fields = places.first(where: { ($0["messageId"] as? String)?.isEmpty == false }), + let messageId = fields["messageId"] as? String + else { return nil } id = messageId diff --git a/templates/apple/Tests/PushTests.swift.twig b/templates/apple/Tests/PushTests.swift.twig index 4ea253c9fa..b9a11d10f9 100644 --- a/templates/apple/Tests/PushTests.swift.twig +++ b/templates/apple/Tests/PushTests.swift.twig @@ -81,8 +81,18 @@ final class PushTests { #expect(message?.wake == false) } + @Test func wakeAsAppwriteSendsIt() { + let message = Push.RemoteMessage(userInfo: [ + "aps": ["content-available": 1], + "data": ["type": "wake", "messageId": "m7"], + ]) + #expect(message?.id == "m7") + #expect(message?.wake == true) + + #expect(Push.RemoteMessage(userInfo: ["aps": ["alert": "Hello"], "data": ["orderId": "7"]]) == nil) + } + @Test func wakeFromServersBeforeTheAppwriteKey() { - // appwrite/appwrite#14302 sends the wake inside `aps.data`. let message = Push.RemoteMessage(userInfo: [ "aps": ["content-available": 1, "data": ["type": "wake", "messageId": "m2"]] ]) From 1e324ed9cdda9afdd87352919ca29fb80034f1ef Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 10:48:42 +0530 Subject: [PATCH 12/40] feat(apple): deliver push through a Notification Service Extension 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. --- src/SDK/Language/Apple.php | 5 + .../apple/Sources/Services/Push.swift.twig | 90 ++++- .../Services/PushServiceExtension.swift.twig | 374 ++++++++++++++++++ templates/apple/Tests/PushTests.swift.twig | 63 +++ 4 files changed, 523 insertions(+), 9 deletions(-) create mode 100644 templates/apple/Sources/Services/PushServiceExtension.swift.twig diff --git a/src/SDK/Language/Apple.php b/src/SDK/Language/Apple.php index 36ac976ce1..f18e9ecf8c 100644 --- a/src/SDK/Language/Apple.php +++ b/src/SDK/Language/Apple.php @@ -261,6 +261,11 @@ public function getFiles(): array 'destination' => '/Sources/{{ spec.info.title | caseUcfirst}}/Services/Push.swift', 'template' => '/apple/Sources/Services/Push.swift.twig', ], + [ + 'scope' => 'default', + 'destination' => '/Sources/{{ spec.info.title | caseUcfirst}}/Services/PushServiceExtension.swift', + 'template' => '/apple/Sources/Services/PushServiceExtension.swift.twig', + ], [ 'scope' => 'default', 'destination' => '/Sources/{{ spec.info.title | caseUcfirst}}/WebSockets/HTTPHandler.swift', diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 1043d77bd7..fbac0dffee 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -275,6 +275,8 @@ open class Push: Service { private var closedClients = Set() private var closeGeneration = 0 + private var heartbeat: DispatchSourceTimer? + private var onOpenCb: (() -> Void)? private var onCloseCb: (() -> Void)? private var onErrorCb: ((Swift.Error) -> Void)? @@ -585,6 +587,7 @@ open class Push: Service { if background { Push.apnsEnable(self, topics: topics) } + deliverHandedOff() let unsubscribe: () -> Void = { [weak self] in guard let self else { return } @@ -697,6 +700,7 @@ open class Push: Service { } return existing } + stopHeartbeat() guard let client else { return } DispatchQueue.global().async { [weak self] in do { @@ -788,7 +792,7 @@ open class Push: Service { /// The session set on the client (`Client.setSession`), else the one the app signed in with /// through it (e.g. `Account.createEmailPasswordSession`), read from the cookies it saved. - private func session() -> String { + func session() -> String { if let session = client.config["session"], !session.isEmpty { return session } @@ -931,9 +935,11 @@ open class Push: Service { return } if let self, let mqtt, self.sync.sync(execute: { self.closedClients.remove(ObjectIdentifier(mqtt)) != nil }) { + self.stopHeartbeat() self.onCloseCb?() return } + self?.stopHeartbeat() if case .failure(let error) = result { self?.surface(Push.describe(error)) } else if let self, !self.sync.sync(execute: { self.closing }) { @@ -966,6 +972,7 @@ open class Push: Service { throw described } + startHeartbeat() onOpenCb?() return mqtt } @@ -1024,6 +1031,7 @@ open class Push: Service { if !infos.isEmpty { try await brokerSubscribe(client, infos) } + startHeartbeat() onOpenCb?() return } catch { @@ -1155,16 +1163,17 @@ open class Push: Service { content.title = title content.body = notification.body ?? (notification.present ? "" : message.data) content.sound = .default - content.userInfo = ["topic": message.topic, "payload": message.data] + content.userInfo = ["topic": message.topic, "payload": message.data, Shared.bannerKey: true] + let identifier = RemoteMessage.id(payload: message.payload) ?? UUID().uuidString guard let image = notification.image.flatMap(URL.init(string:)) else { - post(content) + post(content, identifier: identifier) return } Push.downloadAttachment(image) { [weak self] attachment in if let attachment { content.attachments = [attachment] } - self?.post(content) + self?.post(content, identifier: identifier) } #endif } @@ -1180,8 +1189,8 @@ open class Push: Service { "image/heic": "heic", ] - private func post(_ content: UNNotificationContent) { - let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) + private func post(_ content: UNNotificationContent, identifier: String) { + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) UNUserNotificationCenter.current().add(request) { [weak self] error in if let error { self?.surface(error) @@ -1191,7 +1200,7 @@ open class Push: Service { /// Download a notification image into a file a notification can attach, or nil when it cannot be fetched or is /// not an image type notifications show. - private static func downloadAttachment(_ url: URL, completion: @escaping (UNNotificationAttachment?) -> Void) { + static func downloadAttachment(_ url: URL, completion: @escaping (UNNotificationAttachment?) -> Void) { URLSession.shared.downloadTask(with: URLRequest(url: url, timeoutInterval: 10)) { location, response, _ in guard let location, let fileExtension = attachmentExtension(url, mimeType: response?.mimeType) else { completion(nil) @@ -1269,7 +1278,7 @@ private func jsonString(base64: String, key: String) -> String? { } /// MQTT topic-filter match with `+` (single level) and `#` (multi level). -private func matches(filter: String, topic: String) -> Bool { +func matches(filter: String, topic: String) -> Bool { let filterParts = filter.split(separator: "/", omittingEmptySubsequences: false) let topicParts = topic.split(separator: "/", omittingEmptySubsequences: false) for (index, part) in filterParts.enumerated() { @@ -1308,7 +1317,7 @@ extension Push { private static var apnsLinked: [String: Set] = [:] private static var apnsProxyInstalled = false private static var apnsRegistrationRequested = false - static var apnsDeliveries = Deliveries(defaults: .standard) + static var apnsDeliveries = Deliveries(defaults: Shared.defaults) private final class WeakPush { weak var push: Push? @@ -1349,6 +1358,7 @@ extension Push { /// Start APNs for a push with background subscriptions on `topics`. static func apnsEnable(_ push: Push, topics: [String]) { + Shared.share(push) apnsPrepare(push) let token = apnsLocked { apnsToken } if autoRegistration { @@ -1361,6 +1371,7 @@ extension Push { /// Unlink `topics` from APNs once they have no background subscription left. static func apnsDisable(_ push: Push, topics: [String]) { + Shared.share(push) guard let userId = push.remoteUserId() else { return } @@ -1540,6 +1551,56 @@ extension Push { sync.sync { Array(Set(subscriptions.values.filter { $0.background }.map { $0.filter })) } } + /// The notification title of each background topic. + func backgroundTitles() -> [String: String] { + sync.sync { + var titles: [String: String] = [:] + for entry in subscriptions.values where entry.background { + titles[entry.filter] = entry.title ?? titles[entry.filter] ?? entry.filter + } + return titles + } + } + + /// Deliver what the Notification Service Extension fetched, without banners: iOS already showed them. + func deliverHandedOff() { + guard !Shared.inServiceExtension else { + return + } + let filters = sync.sync { subscriptions.values.map { $0.filter } } + let messages = Shared.takeHandedOff { topic in + filters.contains { matches(filter: $0, topic: topic) } + } + for message in messages { + deliver(message, notifyLocally: false) + } + } + + private func startHeartbeat() { + guard Shared.appGroup != nil, !Shared.inServiceExtension else { + return + } + let timer = DispatchSource.makeTimerSource(queue: .global()) + timer.schedule(deadline: .now(), repeating: Shared.heartbeatInterval) + timer.setEventHandler { Shared.markLive() } + let previous = sync.sync { () -> DispatchSourceTimer? in + let previous = heartbeat + heartbeat = timer + return previous + } + previous?.cancel() + timer.resume() + } + + private func stopHeartbeat() { + let timer = sync.sync { () -> DispatchSourceTimer? in + let timer = heartbeat + heartbeat = nil + return timer + } + timer?.cancel() + } + /// The signed-in user's id, or nil without a credential. func remoteUserId() -> String? { try? currentUserId() @@ -1570,6 +1631,7 @@ extension Push { let connected: MQTTClient if let client { _ = try await client.v5.connect(cleanStart: false, properties: try connectProperties()) + startHeartbeat() connected = client } else { connected = try await connect() @@ -1650,6 +1712,7 @@ extension Push { func contains(_ id: String) -> Bool { lock.lock() defer { lock.unlock() } + reload() return lookup.contains(id) } @@ -1658,6 +1721,7 @@ extension Push { func record(_ id: String) -> Bool { lock.lock() defer { lock.unlock() } + reload() guard lookup.insert(id).inserted else { return false } @@ -1669,6 +1733,14 @@ extension Push { defaults.set(ids, forKey: Deliveries.key) return true } + + private func reload() { + let saved = defaults.stringArray(forKey: Deliveries.key) ?? [] + if saved != ids { + ids = saved + lookup = Set(saved) + } + } } /// Appwrite's part of a message, read the same way from an APNs push and an MQTT payload. diff --git a/templates/apple/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig new file mode 100644 index 0000000000..2aae1aef66 --- /dev/null +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -0,0 +1,374 @@ +import Foundation + +#if canImport(Security) + import Security +#endif +#if canImport(UserNotifications) + import UserNotifications +#endif + +extension Push { + /// State the app and its Notification Service Extension share through their App Group. + enum Shared { + static let appGroupKey = "AppwritePushAppGroup" + static let bannerKey = "{{ spec.info.title | caseLower }}.push.banner" + static let heartbeatInterval = DispatchTimeInterval.seconds(3) + static let liveWindow: TimeInterval = 10 + static var inServiceExtension = false + + private static let prefix = "{{ spec.info.title | caseLower }}.push.shared" + private static let lock = NSLock() + + static var appGroup: String? { + guard let group = Bundle.main.object(forInfoDictionaryKey: appGroupKey) as? String, !group.isEmpty else { + return nil + } + return group + } + + static var defaults: UserDefaults = appGroup.flatMap { UserDefaults(suiteName: $0) } ?? .standard + + // MARK: Session + + struct Session: Codable, Equatable { + var endpoint: String + var pushEndpoint: String? + var project: String + var clientId: String + /// Background topic filters and their notification titles. + var topics: [String: String] + var jwt: Bool + } + + /// What the extension needs to connect as this app, kept while `push` has background subscriptions. + static func share(_ push: Push) { + guard appGroup != nil, !inServiceExtension else { + return + } + let topics = push.backgroundTitles() + guard !topics.isEmpty else { + save(nil) + saveCredential(nil) + return + } + let jwt = push.client.config["jwt"] ?? "" + save( + Session( + endpoint: push.client.endPoint, + pushEndpoint: push.client.endPointPush, + project: push.client.config["project"] ?? "", + clientId: push.client.pushClientId ?? "", + topics: topics, + jwt: !jwt.isEmpty + )) + saveCredential(jwt.isEmpty ? push.session() : jwt) + } + + static func save(_ session: Session?) { + let key = "\(prefix).session" + if let session, let data = try? JSONEncoder().encode(session) { + defaults.set(data, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + static func loadSession() -> Session? { + defaults.data(forKey: "\(prefix).session").flatMap { try? JSONDecoder().decode(Session.self, from: $0) } + } + + // MARK: Heartbeat + + static func markLive(at date: Date = Date()) { + defaults.set(date.timeIntervalSince1970, forKey: "\(prefix).live") + } + + static func isAppLive(at date: Date = Date()) -> Bool { + let live = defaults.double(forKey: "\(prefix).live") + return live > 0 && date.timeIntervalSince1970 - live < liveWindow + } + + // MARK: Messages + + private struct Stored: Codable { + let topic: String + let payload: Data + let qos: Int + + init(_ message: PushMessage) { + topic = message.topic + payload = Data(message.payload) + qos = message.qos + } + + var message: PushMessage { + PushMessage(topic: topic, payload: [UInt8](payload), qos: qos) + } + } + + static func handOff(_ messages: [PushMessage]) { + append(messages, to: "\(prefix).handedOff") + } + + static func takeHandedOff(matching: (String) -> Bool) -> [PushMessage] { + lock.lock() + defer { lock.unlock() } + let key = "\(prefix).handedOff" + let stored = read(key) + let taken = stored.filter { matching($0.topic) } + guard !taken.isEmpty else { + return [] + } + write(stored.filter { !matching($0.topic) }, to: key) + return taken.map(\.message) + } + + static func enqueue(_ messages: [PushMessage]) { + append(messages, to: "\(prefix).pending") + } + + static func dequeue() -> PushMessage? { + lock.lock() + defer { lock.unlock() } + let key = "\(prefix).pending" + var stored = read(key) + guard !stored.isEmpty else { + return nil + } + let first = stored.removeFirst() + write(stored, to: key) + return first.message + } + + private static func append(_ messages: [PushMessage], to key: String) { + guard !messages.isEmpty else { + return + } + lock.lock() + defer { lock.unlock() } + write(read(key) + messages.map(Stored.init), to: key) + } + + private static func read(_ key: String) -> [Stored] { + defaults.data(forKey: key).flatMap { try? JSONDecoder().decode([Stored].self, from: $0) } ?? [] + } + + private static func write(_ stored: [Stored], to key: String) { + if stored.isEmpty { + defaults.removeObject(forKey: key) + } else if let data = try? JSONEncoder().encode(stored) { + defaults.set(data, forKey: key) + } + } + + // MARK: Credential + + static func loadCredential() -> String? { + #if canImport(Security) + var query = credentialQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var result: AnyObject? + guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + #else + return nil + #endif + } + + private static func saveCredential(_ credential: String?) { + #if canImport(Security) + let query = credentialQuery() + SecItemDelete(query as CFDictionary) + guard let credential, !credential.isEmpty else { + return + } + var item = query + item[kSecValueData as String] = Data(credential.utf8) + item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(item as CFDictionary, nil) + #endif + } + + #if canImport(Security) + private static func credentialQuery() -> [String: Any] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: prefix, + kSecAttrAccount as String: "credential", + ] + if let appGroup { + query[kSecAttrAccessGroup as String] = appGroup + } + return query + } + #endif + } +} + +#if os(iOS) && canImport(UserNotifications) + /// The base class of an app's Notification Service Extension, which is all of its code: + /// + /// import {{ spec.info.title | caseUcfirst }} + /// + /// class NotificationService: {{ spec.info.title | caseUcfirst }}NotificationService {} + /// + /// iOS runs it for each visible push with `mutable-content`, even when the app was swiped away. + /// It fetches the messages waiting for the app over MQTT, with the app's session, and shows one in + /// place of the push's placeholder. The app and the extension must share an App Group, named by + /// `AppwritePushAppGroup` in both Info.plists. + open class {{ spec.info.title | caseUcfirst }}NotificationService: UNNotificationServiceExtension { + private let lock = NSLock() + private var contentHandler: ((UNNotificationContent) -> Void)? + private var content: UNMutableNotificationContent? + private var push: Push? + + open override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + let content = (request.content.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent() + lock.lock() + self.contentHandler = contentHandler + self.content = content + lock.unlock() + Task { + await self.fill(content) + self.finish() + } + } + + open override func serviceExtensionTimeWillExpire() { + lock.lock() + let push = self.push + lock.unlock() + push?.close() + finish() + } + + private func finish() { + lock.lock() + let handler = contentHandler + contentHandler = nil + let content = self.content + lock.unlock() + if let handler, let content { + handler(content) + } + } + + private func fill(_ content: UNMutableNotificationContent) async { + Push.Shared.inServiceExtension = true + guard let session = Push.Shared.loadSession(), let credential = Push.Shared.loadCredential() else { + return + } + // The session allows one connection, and the live app already has the message. + if Push.Shared.isAppLive() { + await replaceAppBanner(in: content) + return + } + var message = Push.Shared.dequeue() + if message == nil { + let fetched = await fetch(session, credential: credential) + Push.Shared.handOff(fetched) + message = fetched.first + Push.Shared.enqueue(Array(fetched.dropFirst())) + } + if let message { + await show(message, titles: session.topics, in: content) + } + } + + private func fetch(_ session: Push.Shared.Session, credential: String) async -> [PushMessage] { + let client = Client().setEndpoint(session.endpoint).setProject(session.project) + if let pushEndpoint = session.pushEndpoint { + _ = client.setPushEndpoint(pushEndpoint) + } + if !session.clientId.isEmpty { + _ = client.setPushClientId(session.clientId) + } + _ = session.jwt ? client.setJWT(credential) : client.setSession(credential) + let push = Push(client) + lock.lock() + self.push = push + lock.unlock() + + let received = Received() + do { + _ = try await push.subscribe(Array(session.topics.keys), retry: true) { received.add($0) } + } catch { + push.close() + return [] + } + let started = Date() + while Date().timeIntervalSince(started) < 20 { + try? await Task.sleep(nanoseconds: 300_000_000) + if let last = received.lastAt { + if Date().timeIntervalSince(last) > 1.5 { + break + } + } else if Date().timeIntervalSince(started) > 5 { + break + } + } + push.close() + return received.messages + } + + private func show(_ message: PushMessage, titles: [String: String], in content: UNMutableNotificationContent) async { + let notification = PushNotificationContent(message: message) + let title = titles.first { matches(filter: $0.key, topic: message.topic) }?.value + content.title = notification.title ?? title ?? message.topic + content.body = notification.body ?? (notification.present ? "" : message.data) + content.userInfo = ["topic": message.topic, "payload": message.data] + if let image = notification.image.flatMap(URL.init(string:)) { + let attachment = await withCheckedContinuation { continuation in + Push.downloadAttachment(image) { continuation.resume(returning: $0) } + } + if let attachment { + content.attachments = [attachment] + } + } + } + + private func replaceAppBanner(in content: UNMutableNotificationContent) async { + let center = UNUserNotificationCenter.current() + let delivered = await center.deliveredNotifications() + guard + let latest = delivered.filter({ $0.request.content.userInfo[Push.Shared.bannerKey] as? Bool == true }) + .max(by: { $0.date < $1.date }), + Date().timeIntervalSince(latest.date) < 60 + else { + return + } + content.title = latest.request.content.title + content.body = latest.request.content.body + content.userInfo = latest.request.content.userInfo + center.removeDeliveredNotifications(withIdentifiers: [latest.request.identifier]) + } + } + + private final class Received: @unchecked Sendable { + private let lock = NSLock() + private var stored: [PushMessage] = [] + private var last: Date? + + var messages: [PushMessage] { + lock.lock() + defer { lock.unlock() } + return stored + } + + var lastAt: Date? { + lock.lock() + defer { lock.unlock() } + return last + } + + func add(_ message: PushMessage) { + lock.lock() + stored.append(message) + last = Date() + lock.unlock() + } + } +#endif diff --git a/templates/apple/Tests/PushTests.swift.twig b/templates/apple/Tests/PushTests.swift.twig index b9a11d10f9..984de684d9 100644 --- a/templates/apple/Tests/PushTests.swift.twig +++ b/templates/apple/Tests/PushTests.swift.twig @@ -157,6 +157,69 @@ final class PushTests { #expect(results == [true, false], "delivered once") #expect(Push.apnsDeliveries.contains("m6")) } + + // MARK: Shared with the Notification Service Extension + + private func withSharedDefaults(_ body: () -> Void) { + let previous = Push.Shared.defaults + Push.Shared.defaults = defaults() + defer { Push.Shared.defaults = previous } + body() + } + + private func message(_ topic: String, _ text: String) -> PushMessage { + PushMessage(topic: topic, payload: Array(text.utf8), qos: 1) + } + + @Test func theSessionIsKeptForTheExtension() { + withSharedDefaults { + let session = Push.Shared.Session( + endpoint: "https://cloud.appwrite.io/v1", pushEndpoint: "mqtts://push.cloud.appwrite.io:8883", project: "p1", + clientId: "", topics: ["news": "News"], jwt: false) + Push.Shared.save(session) + #expect(Push.Shared.loadSession() == session) + Push.Shared.save(nil) + #expect(Push.Shared.loadSession() == nil) + } + } + + @Test func theAppIsLiveOnlyWhileItKeepsMarkingIt() { + withSharedDefaults { + let now = Date() + #expect(!Push.Shared.isAppLive(at: now)) + Push.Shared.markLive(at: now) + #expect(Push.Shared.isAppLive(at: now.addingTimeInterval(5))) + #expect(!Push.Shared.isAppLive(at: now.addingTimeInterval(Push.Shared.liveWindow + 1))) + } + } + + @Test func handedOffMessagesGoToTheMatchingTopicsOnce() { + withSharedDefaults { + Push.Shared.handOff([message("news", "a"), message("sales", "b"), message("news", "c")]) + let news = Push.Shared.takeHandedOff { $0 == "news" } + #expect(news.map(\.data) == ["a", "c"]) + #expect(Push.Shared.takeHandedOff { $0 == "news" }.isEmpty) + #expect(Push.Shared.takeHandedOff { _ in true }.map(\.data) == ["b"]) + } + } + + @Test func queuedMessagesComeOutInOrder() { + withSharedDefaults { + Push.Shared.enqueue([message("news", "1"), message("news", "2")]) + #expect(Push.Shared.dequeue()?.data == "1") + #expect(Push.Shared.dequeue()?.data == "2") + #expect(Push.Shared.dequeue() == nil) + } + } + + @Test func theAppAndTheExtensionSeeEachOthersDeliveries() { + let shared = defaults() + let app = Push.Deliveries(defaults: shared) + let serviceExtension = Push.Deliveries(defaults: shared) + #expect(serviceExtension.record("m8")) + #expect(app.contains("m8")) + #expect(!app.record("m8")) + } } // The delegate proxy needs the Objective-C runtime. From 3a476bfba9aa72519cbf4593dfe6c1684ef8453c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 12:27:20 +0530 Subject: [PATCH 13/40] fix(apple): send the project header when registering the APNs device 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. --- templates/apple/Sources/Services/Push.swift.twig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 1043d77bd7..db1b925a5e 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1436,11 +1436,12 @@ extension Push { "environment": Environment.current.rawValue, "bundleId": Bundle.main.bundleIdentifier, ] + let headers = ["X-Appwrite-Project": push.client.config["project"] ?? "", "content-type": "application/json"] do { let _: Any = try await push.client.call( method: "POST", path: "/account/targets/push", - headers: ["content-type": "application/json"], + headers: headers, params: params, converter: { $0 } ) @@ -1451,7 +1452,7 @@ extension Push { let _: Any = try await push.client.call( method: "PUT", path: "/account/targets/\(targetId)/push", - headers: ["content-type": "application/json"], + headers: headers, params: params.filter { $0.key != "targetId" }, converter: { $0 } ) From 38edf625b4e0a93700cb59a247e8c4c8582d5a51 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 12:27:20 +0530 Subject: [PATCH 14/40] fix(apple): send the project header when registering the APNs device 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. --- templates/apple/Sources/Services/Push.swift.twig | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index fbac0dffee..ad341bf2ad 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1447,11 +1447,12 @@ extension Push { "environment": Environment.current.rawValue, "bundleId": Bundle.main.bundleIdentifier, ] + let headers = ["X-Appwrite-Project": push.client.config["project"] ?? "", "content-type": "application/json"] do { let _: Any = try await push.client.call( method: "POST", path: "/account/targets/push", - headers: ["content-type": "application/json"], + headers: headers, params: params, converter: { $0 } ) @@ -1462,7 +1463,7 @@ extension Push { let _: Any = try await push.client.call( method: "PUT", path: "/account/targets/\(targetId)/push", - headers: ["content-type": "application/json"], + headers: headers, params: params.filter { $0.key != "targetId" }, converter: { $0 } ) From af361e749b056807b3f338ba3106d338491e4b97 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 12:45:18 +0530 Subject: [PATCH 15/40] fix(apple): fetch when an app that just closed left no banner to replace 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. --- .../Services/PushServiceExtension.swift.twig | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/templates/apple/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig index 2aae1aef66..7e3ee0c54a 100644 --- a/templates/apple/Sources/Services/PushServiceExtension.swift.twig +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -261,9 +261,9 @@ extension Push { guard let session = Push.Shared.loadSession(), let credential = Push.Shared.loadCredential() else { return } - // The session allows one connection, and the live app already has the message. - if Push.Shared.isAppLive() { - await replaceAppBanner(in: content) + // The session allows one connection, and the live app already has the message. An app killed + // moments ago still looks live but posted no banner, so fetch then. + if Push.Shared.isAppLive(), await replaceAppBanner(in: content) { return } var message = Push.Shared.dequeue() @@ -330,20 +330,21 @@ extension Push { } } - private func replaceAppBanner(in content: UNMutableNotificationContent) async { + private func replaceAppBanner(in content: UNMutableNotificationContent) async -> Bool { let center = UNUserNotificationCenter.current() let delivered = await center.deliveredNotifications() guard let latest = delivered.filter({ $0.request.content.userInfo[Push.Shared.bannerKey] as? Bool == true }) .max(by: { $0.date < $1.date }), - Date().timeIntervalSince(latest.date) < 60 + Date().timeIntervalSince(latest.date) < Push.Shared.liveWindow else { - return + return false } content.title = latest.request.content.title content.body = latest.request.content.body content.userInfo = latest.request.content.userInfo center.removeDeliveredNotifications(withIdentifiers: [latest.request.identifier]) + return true } } From 8a4de0a01f6b0e13a329eda3318f71c5333653d8 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 12:45:18 +0530 Subject: [PATCH 16/40] fix(apple): drop queued messages the app already delivered 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. --- templates/apple/Sources/Services/Push.swift.twig | 1 + .../Sources/Services/PushServiceExtension.swift.twig | 11 ++++++++++- templates/apple/Tests/PushTests.swift.twig | 8 ++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index ad341bf2ad..69c561d9fb 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1568,6 +1568,7 @@ extension Push { guard !Shared.inServiceExtension else { return } + Shared.clearPending() let filters = sync.sync { subscriptions.values.map { $0.filter } } let messages = Shared.takeHandedOff { topic in filters.contains { matches(filter: $0, topic: topic) } diff --git a/templates/apple/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig index 7e3ee0c54a..1bbe332c17 100644 --- a/templates/apple/Sources/Services/PushServiceExtension.swift.twig +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -14,6 +14,7 @@ extension Push { static let bannerKey = "{{ spec.info.title | caseLower }}.push.banner" static let heartbeatInterval = DispatchTimeInterval.seconds(3) static let liveWindow: TimeInterval = 10 + static let pendingLifetime: TimeInterval = 30 static var inServiceExtension = false private static let prefix = "{{ spec.info.title | caseLower }}.push.shared" @@ -94,11 +95,13 @@ extension Push { let topic: String let payload: Data let qos: Int + let at: Date init(_ message: PushMessage) { topic = message.topic payload = Data(message.payload) qos = message.qos + at = Date() } var message: PushMessage { @@ -123,6 +126,12 @@ extension Push { return taken.map(\.message) } + static func clearPending() { + lock.lock() + defer { lock.unlock() } + write([], to: "\(prefix).pending") + } + static func enqueue(_ messages: [PushMessage]) { append(messages, to: "\(prefix).pending") } @@ -131,7 +140,7 @@ extension Push { lock.lock() defer { lock.unlock() } let key = "\(prefix).pending" - var stored = read(key) + var stored = read(key).filter { Date().timeIntervalSince($0.at) < pendingLifetime } guard !stored.isEmpty else { return nil } diff --git a/templates/apple/Tests/PushTests.swift.twig b/templates/apple/Tests/PushTests.swift.twig index 984de684d9..39f989ae42 100644 --- a/templates/apple/Tests/PushTests.swift.twig +++ b/templates/apple/Tests/PushTests.swift.twig @@ -212,6 +212,14 @@ final class PushTests { } } + @Test func clearedQueueShowsNothingStale() { + withSharedDefaults { + Push.Shared.enqueue([message("news", "1")]) + Push.Shared.clearPending() + #expect(Push.Shared.dequeue() == nil) + } + } + @Test func theAppAndTheExtensionSeeEachOthersDeliveries() { let shared = defaults() let app = Push.Deliveries(defaults: shared) From bba786a38531ac9a31c8600afbec4cb0e6563533 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 12:45:18 +0530 Subject: [PATCH 17/40] fix(apple): keep the extension's Push outside an async lock --- .../Sources/Services/PushServiceExtension.swift.twig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/templates/apple/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig index 1bbe332c17..9e1d93a2f1 100644 --- a/templates/apple/Sources/Services/PushServiceExtension.swift.twig +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -297,9 +297,7 @@ extension Push { } _ = session.jwt ? client.setJWT(credential) : client.setSession(credential) let push = Push(client) - lock.lock() - self.push = push - lock.unlock() + keep(push) let received = Received() do { @@ -323,6 +321,12 @@ extension Push { return received.messages } + private func keep(_ push: Push) { + lock.lock() + self.push = push + lock.unlock() + } + private func show(_ message: PushMessage, titles: [String: String], in content: UNMutableNotificationContent) async { let notification = PushNotificationContent(message: message) let title = titles.first { matches(filter: $0.key, topic: message.topic) }?.value From 0565e9abc8df5d55cc43b10fabc1c19cf9ff0210 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 13:23:02 +0530 Subject: [PATCH 18/40] fix(apple): lock shared push state across the app and extension, and 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. --- .../apple/Sources/Services/Push.swift.twig | 34 ++++---- .../Services/PushServiceExtension.swift.twig | 80 ++++++++++++------- templates/apple/Tests/PushTests.swift.twig | 4 +- 3 files changed, 68 insertions(+), 50 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 69c561d9fb..38047990a6 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1568,7 +1568,6 @@ extension Push { guard !Shared.inServiceExtension else { return } - Shared.clearPending() let filters = sync.sync { subscriptions.values.map { $0.filter } } let messages = Shared.takeHandedOff { topic in filters.contains { matches(filter: $0, topic: topic) } @@ -1698,7 +1697,6 @@ extension Push { static let capacity = 500 static let key = "{{ spec.info.title | caseLower }}.push.delivered" - private let lock = NSLock() private let defaults: UserDefaults private var ids: [String] private var lookup: Set @@ -1712,28 +1710,28 @@ extension Push { /// Whether `id` was delivered before. func contains(_ id: String) -> Bool { - lock.lock() - defer { lock.unlock() } - reload() - return lookup.contains(id) + Shared.locked { + reload() + return lookup.contains(id) + } } /// Record `id`: true when it is new, false when it was delivered before. @discardableResult func record(_ id: String) -> Bool { - lock.lock() - defer { lock.unlock() } - reload() - guard lookup.insert(id).inserted else { - return false - } - ids.append(id) - if ids.count > Deliveries.capacity { - ids.removeFirst(ids.count - Deliveries.capacity) - lookup = Set(ids) + Shared.locked { + reload() + guard lookup.insert(id).inserted else { + return false + } + ids.append(id) + if ids.count > Deliveries.capacity { + ids.removeFirst(ids.count - Deliveries.capacity) + lookup = Set(ids) + } + defaults.set(ids, forKey: Deliveries.key) + return true } - defaults.set(ids, forKey: Deliveries.key) - return true } private func reload() { diff --git a/templates/apple/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig index 9e1d93a2f1..257251923c 100644 --- a/templates/apple/Sources/Services/PushServiceExtension.swift.twig +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -19,6 +19,31 @@ extension Push { private static let prefix = "{{ spec.info.title | caseLower }}.push.shared" private static let lock = NSLock() + #if canImport(Darwin) + private static let lockFile: Int32 = { + guard let group = appGroup, let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: group) else { + return -1 + } + return open(url.appendingPathComponent("\(prefix).lock").path, O_RDWR | O_CREAT, 0o600) + }() + #endif + + /// Run `body` while holding the lock the app and its extension share, as both write this state. + static func locked(_ body: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + #if canImport(Darwin) + if lockFile >= 0 { + flock(lockFile, LOCK_EX) + } + defer { + if lockFile >= 0 { + flock(lockFile, LOCK_UN) + } + } + #endif + return try body() + } static var appGroup: String? { guard let group = Bundle.main.object(forInfoDictionaryKey: appGroupKey) as? String, !group.isEmpty else { @@ -114,48 +139,43 @@ extension Push { } static func takeHandedOff(matching: (String) -> Bool) -> [PushMessage] { - lock.lock() - defer { lock.unlock() } - let key = "\(prefix).handedOff" - let stored = read(key) - let taken = stored.filter { matching($0.topic) } - guard !taken.isEmpty else { - return [] + locked { + let key = "\(prefix).handedOff" + let stored = read(key) + let taken = stored.filter { matching($0.topic) } + guard !taken.isEmpty else { + return [] + } + write(stored.filter { !matching($0.topic) }, to: key) + return taken.map(\.message) } - write(stored.filter { !matching($0.topic) }, to: key) - return taken.map(\.message) - } - - static func clearPending() { - lock.lock() - defer { lock.unlock() } - write([], to: "\(prefix).pending") } static func enqueue(_ messages: [PushMessage]) { append(messages, to: "\(prefix).pending") } - static func dequeue() -> PushMessage? { - lock.lock() - defer { lock.unlock() } - let key = "\(prefix).pending" - var stored = read(key).filter { Date().timeIntervalSince($0.at) < pendingLifetime } - guard !stored.isEmpty else { - return nil + static func dequeue(at date: Date = Date()) -> PushMessage? { + locked { + let key = "\(prefix).pending" + var stored = read(key).filter { date.timeIntervalSince($0.at) < pendingLifetime } + guard !stored.isEmpty else { + write([], to: key) + return nil + } + let first = stored.removeFirst() + write(stored, to: key) + return first.message } - let first = stored.removeFirst() - write(stored, to: key) - return first.message } private static func append(_ messages: [PushMessage], to key: String) { guard !messages.isEmpty else { return } - lock.lock() - defer { lock.unlock() } - write(read(key) + messages.map(Stored.init), to: key) + locked { + write(read(key) + messages.map(Stored.init), to: key) + } } private static func read(_ key: String) -> [Stored] { @@ -270,12 +290,12 @@ extension Push { guard let session = Push.Shared.loadSession(), let credential = Push.Shared.loadCredential() else { return } + var message = Push.Shared.dequeue() // The session allows one connection, and the live app already has the message. An app killed // moments ago still looks live but posted no banner, so fetch then. - if Push.Shared.isAppLive(), await replaceAppBanner(in: content) { + if message == nil, Push.Shared.isAppLive(), await replaceAppBanner(in: content) { return } - var message = Push.Shared.dequeue() if message == nil { let fetched = await fetch(session, credential: credential) Push.Shared.handOff(fetched) diff --git a/templates/apple/Tests/PushTests.swift.twig b/templates/apple/Tests/PushTests.swift.twig index 39f989ae42..dde733653f 100644 --- a/templates/apple/Tests/PushTests.swift.twig +++ b/templates/apple/Tests/PushTests.swift.twig @@ -212,10 +212,10 @@ final class PushTests { } } - @Test func clearedQueueShowsNothingStale() { + @Test func expiredQueuedMessagesAreDropped() { withSharedDefaults { Push.Shared.enqueue([message("news", "1")]) - Push.Shared.clearPending() + #expect(Push.Shared.dequeue(at: Date().addingTimeInterval(Push.Shared.pendingLifetime + 1)) == nil) #expect(Push.Shared.dequeue() == nil) } } From 3850cf55217fc9c0730a9250d06cb38dae478db3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 13:36:49 +0530 Subject: [PATCH 19/40] fix(apple): link topics without the generated Messaging service 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. --- .../apple/Sources/Services/Push.swift.twig | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 38047990a6..01f022489f 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1447,7 +1447,7 @@ extension Push { "environment": Environment.current.rawValue, "bundleId": Bundle.main.bundleIdentifier, ] - let headers = ["X-Appwrite-Project": push.client.config["project"] ?? "", "content-type": "application/json"] + let headers = projectHeaders(push) do { let _: Any = try await push.client.call( method: "POST", @@ -1470,16 +1470,23 @@ extension Push { } } + // Push calls the API directly, so it doesn't depend on a Messaging service being generated. + private static func projectHeaders(_ push: Push) -> [String: String] { + ["X-Appwrite-Project": push.client.config["project"] ?? "", "content-type": "application/json"] + } + private static func link(_ push: Push, targetId: String, topic: String) async { let already = apnsLocked { apnsLinked[targetId]?.contains(topic) ?? false } guard !already else { return } do { - _ = try await Messaging(push.client).createSubscriber( - topicId: topic, - subscriberId: Ids.subscriber(targetId: targetId, topic: topic), - targetId: targetId + let _: Any = try await push.client.call( + method: "POST", + path: "/messaging/topics/\(topic)/subscribers", + headers: projectHeaders(push), + params: ["subscriberId": Ids.subscriber(targetId: targetId, topic: topic), "targetId": targetId], + converter: { $0 } ) } catch let error as {{ spec.info.title | caseUcfirst }}Error where error.code == 409 { // Subscribed already. @@ -1497,9 +1504,11 @@ extension Push { private static func unlink(_ push: Push, targetId: String, topic: String) async { do { - _ = try await Messaging(push.client).deleteSubscriber( - topicId: topic, - subscriberId: Ids.subscriber(targetId: targetId, topic: topic) + let _: Any = try await push.client.call( + method: "DELETE", + path: "/messaging/topics/\(topic)/subscribers/\(Ids.subscriber(targetId: targetId, topic: topic))", + headers: projectHeaders(push), + converter: { $0 } ) } catch let error as {{ spec.info.title | caseUcfirst }}Error where error.code == 404 { // Gone already. From 600c6fa76ab68ba8936cff627bae1f087f8aadd6 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 14:42:43 +0530 Subject: [PATCH 20/40] fix(apple): stop APNs pushes to the device once no background subscription 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. --- .../apple/Sources/Services/Push.swift.twig | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 01f022489f..ad2006343c 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -677,13 +677,15 @@ open class Push: Service { return PushSubscription(unsubscribe: unsubscribe, update: update) } - /// Tear down the connection and drop all active subscriptions. + /// Tear down the connection and drop all active subscriptions. APNs stops pushing to this device + /// for the user, so call it on sign-out. /// /// Returns at once: the DISCONNECT and the client shutdown run on a background queue, /// because a blocking wait is not allowed on the event loop that runs message callbacks, /// and those callbacks may call `close()` or drop the last subscription. A connection /// still being opened is abandoned, and any `subscribe` waiting on it throws. public func close() { + let hadBackground = sync.sync { subscriptions.values.contains { $0.background } } let client = sync.sync { () -> MQTTClient? in // Mark this a deliberate close so the close listener does not reconnect. closing = true @@ -701,6 +703,9 @@ open class Push: Service { return existing } stopHeartbeat() + if hadBackground { + Push.apnsDisable(self, topics: []) + } guard let client else { return } DispatchQueue.global().async { [weak self] in do { @@ -1377,10 +1382,31 @@ extension Push { } let targetId = Ids.target(installation: Installation.id, userId: userId) let linked = apnsLocked { apnsLinked[targetId] ?? [] } + let retire = !Shared.inServiceExtension && push.backgroundTopics().isEmpty Task { for topic in topics where linked.contains(topic) { await unlink(push, targetId: targetId, topic: topic) } + if retire { + await deleteTarget(push, targetId: targetId) + } + } + } + + /// With no background subscription left, the server stops pushing to this device for the user. + private static func deleteTarget(_ push: Push, targetId: String) async { + apnsLocked { apnsLinked[targetId] = nil } + do { + let _: Any = try await push.client.call( + method: "DELETE", + path: "/account/targets/\(targetId)/push", + headers: projectHeaders(push), + converter: { $0 } + ) + } catch let error as {{ spec.info.title | caseUcfirst }}Error where error.code == 404 || error.code == 401 { + // Gone already, or the session was ended first. + } catch { + push.surface(error) } } From 670778b476a38b16eb0cb18c6a998be76327dfbd Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 14:42:51 +0530 Subject: [PATCH 21/40] fix(apple): no banner for data-only messages 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. --- templates/apple/Sources/Services/Push.swift.twig | 4 ++-- .../apple/Sources/Services/PushServiceExtension.swift.twig | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index ad2006343c..c463780fb4 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1156,7 +1156,7 @@ open class Push: Service { for entry in matched { entry.callback(pushMessage) let title = notification.title ?? entry.title ?? pushMessage.topic - if notifyLocally && entry.background && postedTitles.insert(title).inserted { + if notifyLocally && entry.background && notification.present && postedTitles.insert(title).inserted { notify(pushMessage, title: title, notification: notification) } } @@ -1166,7 +1166,7 @@ open class Push: Service { #if canImport(UserNotifications) let content = UNMutableNotificationContent() content.title = title - content.body = notification.body ?? (notification.present ? "" : message.data) + content.body = notification.body ?? "" content.sound = .default content.userInfo = ["topic": message.topic, "payload": message.data, Shared.bannerKey: true] let identifier = RemoteMessage.id(payload: message.payload) ?? UUID().uuidString diff --git a/templates/apple/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig index 257251923c..aaf06f2938 100644 --- a/templates/apple/Sources/Services/PushServiceExtension.swift.twig +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -299,8 +299,9 @@ extension Push { if message == nil { let fetched = await fetch(session, credential: credential) Push.Shared.handOff(fetched) - message = fetched.first - Push.Shared.enqueue(Array(fetched.dropFirst())) + let shown = fetched.filter { PushNotificationContent(message: $0).present } + message = shown.first + Push.Shared.enqueue(Array(shown.dropFirst())) } if let message { await show(message, titles: session.topics, in: content) @@ -351,7 +352,7 @@ extension Push { let notification = PushNotificationContent(message: message) let title = titles.first { matches(filter: $0.key, topic: message.topic) }?.value content.title = notification.title ?? title ?? message.topic - content.body = notification.body ?? (notification.present ? "" : message.data) + content.body = notification.body ?? "" content.userInfo = ["topic": message.topic, "payload": message.data] if let image = notification.image.flatMap(URL.init(string:)) { let attachment = await withCheckedContinuation { continuation in From 3d4978bd652882de7cec40fd8a1aa50ab53d7113 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 14:42:51 +0530 Subject: [PATCH 22/40] fix(apple): clear placeholders the extension couldn't fill 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. --- templates/apple/Sources/Services/Push.swift.twig | 16 ++++++++++++++++ .../Services/PushServiceExtension.swift.twig | 3 +++ 2 files changed, 19 insertions(+) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index c463780fb4..8f979888fa 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1610,8 +1610,24 @@ extension Push { for message in messages { deliver(message, notifyLocally: false) } + #if canImport(UserNotifications) + Task { await Push.clearPlaceholders() } + #endif } + #if canImport(UserNotifications) + /// Remove the placeholders the extension couldn't fill: the connected app delivers their messages. + static func clearPlaceholders() async { + let center = UNUserNotificationCenter.current() + let stale = await center.deliveredNotifications() + .filter { $0.request.content.userInfo[Shared.placeholderKey] as? Bool == true } + .map(\.request.identifier) + if !stale.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: stale) + } + } + #endif + private func startHeartbeat() { guard Shared.appGroup != nil, !Shared.inServiceExtension else { return diff --git a/templates/apple/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig index aaf06f2938..e4246918ca 100644 --- a/templates/apple/Sources/Services/PushServiceExtension.swift.twig +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -12,6 +12,7 @@ extension Push { enum Shared { static let appGroupKey = "AppwritePushAppGroup" static let bannerKey = "{{ spec.info.title | caseLower }}.push.banner" + static let placeholderKey = "{{ spec.info.title | caseLower }}.push.placeholder" static let heartbeatInterval = DispatchTimeInterval.seconds(3) static let liveWindow: TimeInterval = 10 static let pendingLifetime: TimeInterval = 30 @@ -287,6 +288,8 @@ extension Push { private func fill(_ content: UNMutableNotificationContent) async { Push.Shared.inServiceExtension = true + // Unfilled, the placeholder shows; the app clears it once it has the messages. + content.userInfo[Push.Shared.placeholderKey] = true guard let session = Push.Shared.loadSession(), let credential = Push.Shared.loadCredential() else { return } From 4ffc977b72d3238a706bcc21d26648d3d802c8af Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 14:51:01 +0530 Subject: [PATCH 23/40] fix(apple): keep the APNs target while another Push of the user still needs it The target is shared by every Push of the user, so deleting it on one Push's last background subscription could stop delivery for another, or for a subscription added meanwhile. The delete now checks every Push of the user right before it runs, and the shared session is kept from one that still has background subscriptions. --- templates/apple/Sources/Services/Push.swift.twig | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 8f979888fa..9b34c29656 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1376,23 +1376,30 @@ extension Push { /// Unlink `topics` from APNs once they have no background subscription left. static func apnsDisable(_ push: Push, topics: [String]) { - Shared.share(push) guard let userId = push.remoteUserId() else { + Shared.share(push) return } + Shared.share(backgroundPush(userId: userId) ?? push) let targetId = Ids.target(installation: Installation.id, userId: userId) let linked = apnsLocked { apnsLinked[targetId] ?? [] } - let retire = !Shared.inServiceExtension && push.backgroundTopics().isEmpty Task { for topic in topics where linked.contains(topic) { await unlink(push, targetId: targetId, topic: topic) } - if retire { + // The target is shared by every Push of the user, so check again just before deleting it. + if !Shared.inServiceExtension, backgroundPush(userId: userId) == nil { await deleteTarget(push, targetId: targetId) } } } + /// A `Push` of `userId` that still has background subscriptions. + private static func backgroundPush(userId: String) -> Push? { + apnsLocked { apnsPushes.values.compactMap { $0.push } } + .first { $0.remoteUserId() == userId && !$0.backgroundTopics().isEmpty } + } + /// With no background subscription left, the server stops pushing to this device for the user. private static func deleteTarget(_ push: Push, targetId: String) async { apnsLocked { apnsLinked[targetId] = nil } From a97585648cf14ccf573c9c06f26c58eb2500fc9f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 14:51:01 +0530 Subject: [PATCH 24/40] fix(apple): clear a placeholder only once its topics are subscribed The app cleared every unfilled placeholder on its first subscribe, even when the topic the placeholder was for was not subscribed yet. The extension now records the topics it fetched for, and the app clears a placeholder once all of them are subscribed, so the broker replays its message to a callback. --- templates/apple/Sources/Services/Push.swift.twig | 9 +++++---- .../Sources/Services/PushServiceExtension.swift.twig | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 9b34c29656..69818f4098 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1618,16 +1618,17 @@ extension Push { deliver(message, notifyLocally: false) } #if canImport(UserNotifications) - Task { await Push.clearPlaceholders() } + Task { await Push.clearPlaceholders(subscribed: Set(filters)) } #endif } #if canImport(UserNotifications) - /// Remove the placeholders the extension couldn't fill: the connected app delivers their messages. - static func clearPlaceholders() async { + /// Remove the placeholders the extension couldn't fill once every topic it fetched for is + /// subscribed here: the broker replays their messages to these callbacks. + static func clearPlaceholders(subscribed: Set) async { let center = UNUserNotificationCenter.current() let stale = await center.deliveredNotifications() - .filter { $0.request.content.userInfo[Shared.placeholderKey] as? Bool == true } + .filter { ($0.request.content.userInfo[Shared.placeholderKey] as? [String])?.allSatisfy(subscribed.contains) == true } .map(\.request.identifier) if !stale.isEmpty { center.removeDeliveredNotifications(withIdentifiers: stale) diff --git a/templates/apple/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig index e4246918ca..ce45aadf17 100644 --- a/templates/apple/Sources/Services/PushServiceExtension.swift.twig +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -288,11 +288,12 @@ extension Push { private func fill(_ content: UNMutableNotificationContent) async { Push.Shared.inServiceExtension = true - // Unfilled, the placeholder shows; the app clears it once it has the messages. - content.userInfo[Push.Shared.placeholderKey] = true + // Unfilled, the placeholder shows until the app subscribes to these topics and gets the messages. + content.userInfo[Push.Shared.placeholderKey] = [String]() guard let session = Push.Shared.loadSession(), let credential = Push.Shared.loadCredential() else { return } + content.userInfo[Push.Shared.placeholderKey] = Array(session.topics.keys) var message = Push.Shared.dequeue() // The session allows one connection, and the live app already has the message. An app killed // moments ago still looks live but posted no banner, so fetch then. From 337f44be9d73e8c9817332fb63efb262dedf28a2 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 14:53:28 +0530 Subject: [PATCH 25/40] fix(apple): leave a placeholder unmarked when the extension has no session Without a session the extension fetched for no topic, so the empty mark let the app clear the placeholder on any subscribe, with no message behind it reaching a callback. --- .../apple/Sources/Services/PushServiceExtension.swift.twig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/templates/apple/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig index ce45aadf17..f9719f8aab 100644 --- a/templates/apple/Sources/Services/PushServiceExtension.swift.twig +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -288,11 +288,10 @@ extension Push { private func fill(_ content: UNMutableNotificationContent) async { Push.Shared.inServiceExtension = true - // Unfilled, the placeholder shows until the app subscribes to these topics and gets the messages. - content.userInfo[Push.Shared.placeholderKey] = [String]() guard let session = Push.Shared.loadSession(), let credential = Push.Shared.loadCredential() else { return } + // Unfilled, the placeholder shows until the app subscribes to these topics and gets the messages. content.userInfo[Push.Shared.placeholderKey] = Array(session.topics.keys) var message = Push.Shared.dequeue() // The session allows one connection, and the live app already has the message. An app killed From 275a5938866054bf49e19e5082ff1acfc3df3cc7 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 16:10:35 +0530 Subject: [PATCH 26/40] fix(apple): show a notification for raw payloads and envelopes with text 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. --- .../apple/Sources/Services/Push.swift.twig | 15 +++++++++------ .../Services/PushServiceExtension.swift.twig | 4 ++-- templates/apple/Tests/PushTests.swift.twig | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 69818f4098..b43af9aab0 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1156,7 +1156,7 @@ open class Push: Service { for entry in matched { entry.callback(pushMessage) let title = notification.title ?? entry.title ?? pushMessage.topic - if notifyLocally && entry.background && notification.present && postedTitles.insert(title).inserted { + if notifyLocally && entry.background && notification.visible && postedTitles.insert(title).inserted { notify(pushMessage, title: title, notification: notification) } } @@ -1166,7 +1166,7 @@ open class Push: Service { #if canImport(UserNotifications) let content = UNMutableNotificationContent() content.title = title - content.body = notification.body ?? "" + content.body = notification.body ?? (notification.raw ? message.data : "") content.sound = .default content.userInfo = ["topic": message.topic, "payload": message.data, Shared.bannerKey: true] let identifier = RemoteMessage.id(payload: message.payload) ?? UUID().uuidString @@ -1236,18 +1236,21 @@ open class Push: Service { } -/// What a message's `notification` block asks a background notification to show: nils when the -/// payload has none or is not JSON. +/// What a message asks a background notification to show. A `raw` payload, one that is not a JSON +/// object, is shown as the body under the subscription's title; an envelope is `visible` only with a +/// title or body in its `notification`. struct PushNotificationContent { - let present: Bool + let raw: Bool let title: String? let body: String? let image: String? + var visible: Bool { raw || title != nil || body != nil } + init(message: PushMessage) { let json = (try? JSONSerialization.jsonObject(with: Data(message.payload))) as? [String: Any] let notification = json?["notification"] as? [String: Any] - present = notification != nil + raw = json == nil func field(_ name: String) -> String? { guard let value = notification?[name] as? String, !value.isEmpty else { return nil } return value diff --git a/templates/apple/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig index f9719f8aab..7524f8b8e3 100644 --- a/templates/apple/Sources/Services/PushServiceExtension.swift.twig +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -302,7 +302,7 @@ extension Push { if message == nil { let fetched = await fetch(session, credential: credential) Push.Shared.handOff(fetched) - let shown = fetched.filter { PushNotificationContent(message: $0).present } + let shown = fetched.filter { PushNotificationContent(message: $0).visible } message = shown.first Push.Shared.enqueue(Array(shown.dropFirst())) } @@ -355,7 +355,7 @@ extension Push { let notification = PushNotificationContent(message: message) let title = titles.first { matches(filter: $0.key, topic: message.topic) }?.value content.title = notification.title ?? title ?? message.topic - content.body = notification.body ?? "" + content.body = notification.body ?? (notification.raw ? message.data : "") content.userInfo = ["topic": message.topic, "payload": message.data] if let image = notification.image.flatMap(URL.init(string:)) { let attachment = await withCheckedContinuation { continuation in diff --git a/templates/apple/Tests/PushTests.swift.twig b/templates/apple/Tests/PushTests.swift.twig index dde733653f..f938c62c82 100644 --- a/templates/apple/Tests/PushTests.swift.twig +++ b/templates/apple/Tests/PushTests.swift.twig @@ -171,6 +171,24 @@ final class PushTests { PushMessage(topic: topic, payload: Array(text.utf8), qos: 1) } + @Test func onlyRawPayloadsAndEnvelopesWithTextAreShown() { + let shown: [(String, Bool, Bool)] = [ + ("plain text", true, true), + ("[1, 2]", true, true), + (#"{"notification":{"title":"Sale"}}"#, false, true), + (#"{"notification":{"body":"50% off"}}"#, false, true), + (#"{"messageId":"m1","data":{"saleId":"1"}}"#, false, false), + (#"{"notification":{}}"#, false, false), + (#"{"notification":{"image":"https://example.com/a.png"}}"#, false, false), + (#"{"notification":{"title":""},"contentAvailable":true,"badge":1}"#, false, false), + ] + for (payload, raw, visible) in shown { + let content = PushNotificationContent(message: message("news", payload)) + #expect(content.raw == raw, "\(payload)") + #expect(content.visible == visible, "\(payload)") + } + } + @Test func theSessionIsKeptForTheExtension() { withSharedDefaults { let session = Push.Shared.Session( From c99a01af6cf45e74ae19e3d483244f42df373a8e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 18:37:30 +0530 Subject: [PATCH 27/40] feat(apple): unlink background topics a later launch no longer subscribes 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. --- .../apple/Sources/Services/Push.swift.twig | 86 +++++++++++++++++++ templates/apple/Tests/PushTests.swift.twig | 20 +++++ 2 files changed, 106 insertions(+) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index b43af9aab0..1e9bdbe0c2 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1326,6 +1326,10 @@ extension Push { private static var apnsProxyInstalled = false private static var apnsRegistrationRequested = false static var apnsDeliveries = Deliveries(defaults: Shared.defaults) + static var apnsLinkedTopics = LinkedTopics(defaults: Shared.defaults) + private static var apnsReconciled: Set = [] + /// How long a launch has to subscribe again before the topics it doesn't claim are unlinked. + static var reconcileDelay: TimeInterval = 30 private final class WeakPush { weak var push: Push? @@ -1417,6 +1421,32 @@ extension Push { // Gone already, or the session was ended first. } catch { push.surface(error) + return + } + apnsLinkedTopics.removeTarget(targetId) + } + + /// Once per launch, after the app has had time to subscribe again, unlink the topics an earlier + /// launch linked that no background subscription claims now, so the server stops waking the + /// device for them. A topic subscribed later is linked again. + private static func scheduleReconcile(userId: String, targetId: String) { + guard !Shared.inServiceExtension, apnsLocked({ apnsReconciled.insert(targetId).inserted }) else { + return + } + Task { + try? await Task.sleep(nanoseconds: UInt64(reconcileDelay * 1_000_000_000)) + await reconcile(userId: userId, targetId: targetId) + } + } + + static func reconcile(userId: String, targetId: String) async { + let pushes = apnsLocked { apnsPushes.values.compactMap { $0.push } }.filter { $0.remoteUserId() == userId } + guard let push = pushes.first(where: { !$0.backgroundTopics().isEmpty }) else { + return + } + let claimed = Set(pushes.flatMap { $0.backgroundTopics() }) + for topic in LinkedTopics.stale(saved: apnsLinkedTopics.topics(targetId), claimed: claimed) { + await unlink(push, targetId: targetId, topic: topic) } } @@ -1467,6 +1497,7 @@ extension Push { return } apnsLocked { apnsLinked[targetId] = apnsLinked[targetId] ?? [] } + scheduleReconcile(userId: userId, targetId: targetId) // A topic unsubscribed while the target request was in flight is not linked. let wanted = Set(push.backgroundTopics()) for topic in topics where Ids.isMessagingTopic(topic) && wanted.contains(topic) { @@ -1531,6 +1562,7 @@ extension Push { return } apnsLocked { _ = apnsLinked[targetId, default: []].insert(topic) } + apnsLinkedTopics.add(topic, to: targetId) // Unsubscribed while the subscriber was being created: `apnsDisable` found nothing to // unlink then, so undo it here. if !push.backgroundTopics().contains(topic) { @@ -1539,6 +1571,7 @@ extension Push { } private static func unlink(_ push: Push, targetId: String, topic: String) async { + var unlinked = true do { let _: Any = try await push.client.call( method: "DELETE", @@ -1550,8 +1583,12 @@ extension Push { // Gone already. } catch { push.surface(error) + unlinked = false } apnsLocked { _ = apnsLinked[targetId]?.remove(topic) } + if unlinked { + apnsLinkedTopics.remove(topic, from: targetId) + } // Back in a background subscription while the delete was in flight: `link` saw the topic // still linked then and skipped it, so link it again now. if push.backgroundTopics().contains(topic) { @@ -1755,6 +1792,55 @@ extension Push { /// Message ids already delivered, by MQTT or APNs, so each message reaches the app once. /// Bounded, and kept across launches. + /// The topics linked to each target, kept across launches and shared with the extension. + final class LinkedTopics { + static let key = "{{ spec.info.title | caseLower }}.push.linked" + + private let defaults: UserDefaults + + init(defaults: UserDefaults) { + self.defaults = defaults + } + + func topics(_ targetId: String) -> Set { + Shared.locked { Set(read()[targetId] ?? []) } + } + + func add(_ topic: String, to targetId: String) { + change { $0[targetId] = Array(Set($0[targetId] ?? []).union([topic])).sorted() } + } + + func remove(_ topic: String, from targetId: String) { + change { $0[targetId] = $0[targetId]?.filter { $0 != topic } } + } + + func removeTarget(_ targetId: String) { + change { $0[targetId] = nil } + } + + /// Saved topics no background subscription claims. + static func stale(saved: Set, claimed: Set) -> [String] { + saved.subtracting(claimed).sorted() + } + + private func read() -> [String: [String]] { + defaults.dictionary(forKey: LinkedTopics.key) as? [String: [String]] ?? [:] + } + + private func change(_ body: (inout [String: [String]]) -> Void) { + Shared.locked { + var links = read() + body(&links) + links = links.filter { !$0.value.isEmpty } + if links.isEmpty { + defaults.removeObject(forKey: LinkedTopics.key) + } else { + defaults.set(links, forKey: LinkedTopics.key) + } + } + } + } + final class Deliveries { static let capacity = 500 static let key = "{{ spec.info.title | caseLower }}.push.delivered" diff --git a/templates/apple/Tests/PushTests.swift.twig b/templates/apple/Tests/PushTests.swift.twig index f938c62c82..3766b72c30 100644 --- a/templates/apple/Tests/PushTests.swift.twig +++ b/templates/apple/Tests/PushTests.swift.twig @@ -171,6 +171,26 @@ final class PushTests { PushMessage(topic: topic, payload: Array(text.utf8), qos: 1) } + @Test func linkedTopicsAreKeptAcrossLaunches() { + let shared = defaults() + let launch = Push.LinkedTopics(defaults: shared) + launch.add("news", to: "t1") + launch.add("sales", to: "t1") + launch.add("news", to: "t2") + launch.remove("sales", from: "t1") + + let next = Push.LinkedTopics(defaults: shared) + #expect(next.topics("t1") == ["news"]) + #expect(next.topics("t2") == ["news"]) + next.removeTarget("t1") + #expect(Push.LinkedTopics(defaults: shared).topics("t1").isEmpty) + } + + @Test func topicsNoSubscriptionClaimsAreStale() { + #expect(Push.LinkedTopics.stale(saved: ["news", "sales", "users-1"], claimed: ["news"]) == ["sales", "users-1"]) + #expect(Push.LinkedTopics.stale(saved: ["news"], claimed: ["news", "deals"]).isEmpty) + } + @Test func onlyRawPayloadsAndEnvelopesWithTextAreShown() { let shown: [(String, Bool, Bool)] = [ ("plain text", true, true), From 76d71706a0d3cbab4453bb283cec705e23ecd049 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 19:04:23 +0530 Subject: [PATCH 28/40] fix(apple): reconcile launches without background subscriptions, and 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. --- .../apple/Sources/Services/Push.swift.twig | 45 ++++++++++++++----- templates/apple/Tests/PushTests.swift.twig | 11 +++++ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 1e9bdbe0c2..766df24554 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -515,6 +515,7 @@ open class Push: Service { Push.apnsPrepare(self) } + Push.scheduleReconcile(self) let generation = sync.sync { closeGeneration } let mqtt: MQTTClient do { @@ -1429,19 +1430,28 @@ extension Push { /// Once per launch, after the app has had time to subscribe again, unlink the topics an earlier /// launch linked that no background subscription claims now, so the server stops waking the /// device for them. A topic subscribed later is linked again. - private static func scheduleReconcile(userId: String, targetId: String) { - guard !Shared.inServiceExtension, apnsLocked({ apnsReconciled.insert(targetId).inserted }) else { + static func scheduleReconcile(_ push: Push) { + guard !Shared.inServiceExtension, let userId = push.remoteUserId() else { return } + let targetId = Ids.target(installation: Installation.id, userId: userId) + guard apnsLocked({ apnsReconciled.insert(targetId).inserted }) else { + return + } + let scheduler = WeakPush(push) Task { try? await Task.sleep(nanoseconds: UInt64(reconcileDelay * 1_000_000_000)) - await reconcile(userId: userId, targetId: targetId) + await reconcile(userId: userId, targetId: targetId, fallback: scheduler.push) } } - static func reconcile(userId: String, targetId: String) async { + static func reconcile(userId: String, targetId: String, fallback: Push?) async { let pushes = apnsLocked { apnsPushes.values.compactMap { $0.push } }.filter { $0.remoteUserId() == userId } guard let push = pushes.first(where: { !$0.backgroundTopics().isEmpty }) else { + // No background subscription this launch: the target and its subscribers go, as on close(). + if apnsLinkedTopics.isRegistered(targetId), let push = fallback ?? pushes.first { + await deleteTarget(push, targetId: targetId) + } return } let claimed = Set(pushes.flatMap { $0.backgroundTopics() }) @@ -1497,7 +1507,8 @@ extension Push { return } apnsLocked { apnsLinked[targetId] = apnsLinked[targetId] ?? [] } - scheduleReconcile(userId: userId, targetId: targetId) + apnsLinkedTopics.register(targetId) + scheduleReconcile(push) // A topic unsubscribed while the target request was in flight is not linked. let wanted = Set(push.backgroundTopics()) for topic in topics where Ids.isMessagingTopic(topic) && wanted.contains(topic) { @@ -1589,10 +1600,13 @@ extension Push { if unlinked { apnsLinkedTopics.remove(topic, from: targetId) } - // Back in a background subscription while the delete was in flight: `link` saw the topic - // still linked then and skipped it, so link it again now. - if push.backgroundTopics().contains(topic) { - await link(push, targetId: targetId, topic: topic) + // Back in a background subscription of any Push of the user while the delete was in flight: + // `link` saw the topic still linked then and skipped it, so link it again now. + let userId = push.remoteUserId() + let claimer = apnsLocked { apnsPushes.values.compactMap { $0.push } } + .first { $0.remoteUserId() == userId && $0.backgroundTopics().contains(topic) } + if let claimer { + await link(claimer, targetId: targetId, topic: topic) } } @@ -1792,7 +1806,8 @@ extension Push { /// Message ids already delivered, by MQTT or APNs, so each message reaches the app once. /// Bounded, and kept across launches. - /// The topics linked to each target, kept across launches and shared with the extension. + /// The targets this install registered and the topics linked to each, kept across launches and + /// shared with the extension. final class LinkedTopics { static let key = "{{ spec.info.title | caseLower }}.push.linked" @@ -1818,6 +1833,15 @@ extension Push { change { $0[targetId] = nil } } + /// Record a target this install registered, linked topics or not. + func register(_ targetId: String) { + change { $0[targetId] = $0[targetId] ?? [] } + } + + func isRegistered(_ targetId: String) -> Bool { + Shared.locked { read()[targetId] != nil } + } + /// Saved topics no background subscription claims. static func stale(saved: Set, claimed: Set) -> [String] { saved.subtracting(claimed).sorted() @@ -1831,7 +1855,6 @@ extension Push { Shared.locked { var links = read() body(&links) - links = links.filter { !$0.value.isEmpty } if links.isEmpty { defaults.removeObject(forKey: LinkedTopics.key) } else { diff --git a/templates/apple/Tests/PushTests.swift.twig b/templates/apple/Tests/PushTests.swift.twig index 3766b72c30..e149c74b0a 100644 --- a/templates/apple/Tests/PushTests.swift.twig +++ b/templates/apple/Tests/PushTests.swift.twig @@ -186,6 +186,17 @@ final class PushTests { #expect(Push.LinkedTopics(defaults: shared).topics("t1").isEmpty) } + @Test func aRegisteredTargetIsKeptUntilItIsDeleted() { + let links = Push.LinkedTopics(defaults: defaults()) + #expect(!links.isRegistered("t1")) + links.register("t1") + links.add("news", to: "t1") + links.remove("news", from: "t1") + #expect(links.isRegistered("t1")) + links.removeTarget("t1") + #expect(!links.isRegistered("t1")) + } + @Test func topicsNoSubscriptionClaimsAreStale() { #expect(Push.LinkedTopics.stale(saved: ["news", "sales", "users-1"], claimed: ["news"]) == ["sales", "users-1"]) #expect(Push.LinkedTopics.stale(saved: ["news"], claimed: ["news", "deals"]).isEmpty) From 966125e16fede604327921bc976b283c140a6d39 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 19:09:50 +0530 Subject: [PATCH 29/40] fix(apple): register the device again when a target delete races a new 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. --- templates/apple/Sources/Services/Push.swift.twig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 766df24554..350062f3fb 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1425,6 +1425,13 @@ extension Push { return } apnsLinkedTopics.removeTarget(targetId) + // A background subscription that registered while the DELETE was in flight lost its target: + // register the device again for it. + if !Shared.inServiceExtension, let userId = push.remoteUserId(), let claimer = backgroundPush(userId: userId), + let token = apnsLocked({ apnsToken }) + { + await register(claimer, token: token, topics: claimer.backgroundTopics()) + } } /// Once per launch, after the app has had time to subscribe again, unlink the topics an earlier From d1ea34c1556bc28c9eeb80758b5ce4306e1a71fd Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 10:29:11 +0530 Subject: [PATCH 30/40] fix(apple): don't take a live app's session over from the extension 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. --- .../apple/Sources/Services/Push.swift.twig | 10 ++++++- .../Services/PushServiceExtension.swift.twig | 28 +++++++++++++++++-- templates/apple/Tests/PushTests.swift.twig | 9 ++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 350062f3fb..bb988ee4c1 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1703,7 +1703,15 @@ extension Push { } let timer = DispatchSource.makeTimerSource(queue: .global()) timer.schedule(deadline: .now(), repeating: Shared.heartbeatInterval) - timer.setEventHandler { Shared.markLive() } + timer.setEventHandler { [weak self] in + Shared.markLive() + #if canImport(UserNotifications) + if let self { + let filters = Set(self.sync.sync { self.subscriptions.values.map { $0.filter } }) + Task { await Push.clearPlaceholders(subscribed: filters) } + } + #endif + } let previous = sync.sync { () -> DispatchSourceTimer? in let previous = heartbeat heartbeat = timer diff --git a/templates/apple/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig index 7524f8b8e3..a13d7fc9b9 100644 --- a/templates/apple/Sources/Services/PushServiceExtension.swift.twig +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -13,7 +13,8 @@ extension Push { static let appGroupKey = "AppwritePushAppGroup" static let bannerKey = "{{ spec.info.title | caseLower }}.push.banner" static let placeholderKey = "{{ spec.info.title | caseLower }}.push.placeholder" - static let heartbeatInterval = DispatchTimeInterval.seconds(3) + static let heartbeatSeconds: TimeInterval = 3 + static let heartbeatInterval = DispatchTimeInterval.milliseconds(Int(heartbeatSeconds * 1000)) static let liveWindow: TimeInterval = 10 static let pendingLifetime: TimeInterval = 30 static var inServiceExtension = false @@ -115,6 +116,12 @@ extension Push { return live > 0 && date.timeIntervalSince1970 - live < liveWindow } + /// When the app last marked itself live, or nil when it never did. + static func lastLive() -> Date? { + let live = defaults.double(forKey: "\(prefix).live") + return live > 0 ? Date(timeIntervalSince1970: live) : nil + } + // MARK: Messages private struct Stored: Codable { @@ -296,7 +303,7 @@ extension Push { var message = Push.Shared.dequeue() // The session allows one connection, and the live app already has the message. An app killed // moments ago still looks live but posted no banner, so fetch then. - if message == nil, Push.Shared.isAppLive(), await replaceAppBanner(in: content) { + if message == nil, Push.Shared.isAppLive(), await appHasIt(content) { return } if message == nil { @@ -367,6 +374,23 @@ extension Push { } } + /// Whether the live app has the message, so connecting would only take its session over. Its banner + /// can trail the push, and an app that marks itself live after the push arrived is running and + /// clears the placeholder itself. An app killed moments ago does neither, and the extension fetches. + private func appHasIt(_ content: UNMutableNotificationContent) async -> Bool { + let arrived = Date() + repeat { + if await replaceAppBanner(in: content) { + return true + } + if let live = Push.Shared.lastLive(), live > arrived { + return true + } + try? await Task.sleep(nanoseconds: 300_000_000) + } while Date().timeIntervalSince(arrived) < Push.Shared.heartbeatSeconds + 1 + return false + } + private func replaceAppBanner(in content: UNMutableNotificationContent) async -> Bool { let center = UNUserNotificationCenter.current() let delivered = await center.deliveredNotifications() diff --git a/templates/apple/Tests/PushTests.swift.twig b/templates/apple/Tests/PushTests.swift.twig index e149c74b0a..51841c7d9d 100644 --- a/templates/apple/Tests/PushTests.swift.twig +++ b/templates/apple/Tests/PushTests.swift.twig @@ -242,6 +242,15 @@ final class PushTests { } } + @Test func theLastHeartbeatIsKept() { + withSharedDefaults { + #expect(Push.Shared.lastLive() == nil) + let now = Date(timeIntervalSince1970: 1_800_000_000) + Push.Shared.markLive(at: now) + #expect(Push.Shared.lastLive() == now) + } + } + @Test func handedOffMessagesGoToTheMatchingTopicsOnce() { withSharedDefaults { Push.Shared.handOff([message("news", "a"), message("sales", "b"), message("news", "c")]) From e009314d731887588c33be48680660c29744362c Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 10:29:11 +0530 Subject: [PATCH 31/40] fix(apple): clear placeholders for topics no subscription claims 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. --- templates/apple/Sources/Services/Push.swift.twig | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index bb988ee4c1..123dba03b3 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1462,6 +1462,9 @@ extension Push { return } let claimed = Set(pushes.flatMap { $0.backgroundTopics() }) + #if canImport(UserNotifications) + await clearDroppedPlaceholders(claimed: claimed) + #endif for topic in LinkedTopics.stale(saved: apnsLinkedTopics.topics(targetId), claimed: claimed) { await unlink(push, targetId: targetId, topic: topic) } @@ -1687,9 +1690,19 @@ extension Push { /// Remove the placeholders the extension couldn't fill once every topic it fetched for is /// subscribed here: the broker replays their messages to these callbacks. static func clearPlaceholders(subscribed: Set) async { + await clearPlaceholders { $0.allSatisfy(subscribed.contains) } + } + + /// Remove the placeholders for topics no background subscription claims any more: their messages + /// will not be delivered here. + static func clearDroppedPlaceholders(claimed: Set) async { + await clearPlaceholders { !$0.allSatisfy(claimed.contains) } + } + + private static func clearPlaceholders(where isStale: ([String]) -> Bool) async { let center = UNUserNotificationCenter.current() let stale = await center.deliveredNotifications() - .filter { ($0.request.content.userInfo[Shared.placeholderKey] as? [String])?.allSatisfy(subscribed.contains) == true } + .filter { ($0.request.content.userInfo[Shared.placeholderKey] as? [String]).map(isStale) == true } .map(\.request.identifier) if !stale.isEmpty { center.removeDeliveredNotifications(withIdentifiers: stale) From 0004316dba0c9470cf820c0bed0be5052fa2c623 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 10:29:11 +0530 Subject: [PATCH 32/40] fix(apple): don't register the device again for a Push with no background 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. --- templates/apple/Sources/Services/Push.swift.twig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 123dba03b3..c9ec1d0951 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1480,7 +1480,7 @@ extension Push { guard changed else { return } - for push in pushes { + for push in pushes where !push.backgroundTopics().isEmpty { Task { await register(push, token: hex, topics: push.backgroundTopics()) } } } From 0d9a67f9444a44634ec655d3a16d18ada5baa6e1 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 10:29:11 +0530 Subject: [PATCH 33/40] fix(apple): say why a placeholder stays when the App Group is missing 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. --- .../apple/Sources/Services/Push.swift.twig | 18 ++++++++++++++++++ .../Services/PushServiceExtension.swift.twig | 2 ++ 2 files changed, 20 insertions(+) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index c9ec1d0951..3158c28b86 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1369,9 +1369,27 @@ extension Push { } } + private static var appGroupWarned = false + + /// Without the App Group the Notification Service Extension can't fetch, and the placeholder stays. + private static func warnWithoutAppGroup() { + let first = apnsLocked { () -> Bool in + defer { appGroupWarned = true } + return !appGroupWarned + } + guard Shared.appGroup == nil, first else { + return + } + FileHandle.standardError.write( + Data( + "{{ spec.info.title | caseUcfirst }} Push: AppwritePushAppGroup is not set, so a Notification Service Extension can't fetch messages and their banners stay as placeholders. Set it to the App Group shared with the extension, in both Info.plists.\n" + .utf8)) + } + /// Start APNs for a push with background subscriptions on `topics`. static func apnsEnable(_ push: Push, topics: [String]) { Shared.share(push) + warnWithoutAppGroup() apnsPrepare(push) let token = apnsLocked { apnsToken } if autoRegistration { diff --git a/templates/apple/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig index a13d7fc9b9..ac1a2a38fb 100644 --- a/templates/apple/Sources/Services/PushServiceExtension.swift.twig +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -296,6 +296,8 @@ extension Push { private func fill(_ content: UNMutableNotificationContent) async { Push.Shared.inServiceExtension = true guard let session = Push.Shared.loadSession(), let credential = Push.Shared.loadCredential() else { + FileHandle.standardError.write( + Data("{{ spec.info.title | caseUcfirst }} Push: no shared session, so the placeholder stays. Set AppwritePushAppGroup in both Info.plists and subscribe with background: true.\n".utf8)) return } // Unfilled, the placeholder shows until the app subscribes to these topics and gets the messages. From cb2f91cdcc06fa4ab5bfbb7b0369e4510bfe8767 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 10:29:11 +0530 Subject: [PATCH 34/40] docs(apple): put Deliveries' doc comment back on Deliveries --- templates/apple/Sources/Services/Push.swift.twig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 3158c28b86..c3e0470629 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1850,8 +1850,6 @@ extension Push { } } - /// Message ids already delivered, by MQTT or APNs, so each message reaches the app once. - /// Bounded, and kept across launches. /// The targets this install registered and the topics linked to each, kept across launches and /// shared with the extension. final class LinkedTopics { @@ -1910,6 +1908,8 @@ extension Push { } } + /// Message ids already delivered, by MQTT or APNs, so each message reaches the app once. + /// Bounded, and kept across launches. final class Deliveries { static let capacity = 500 static let key = "{{ spec.info.title | caseLower }}.push.delivered" From 2e4e62a846030f9bd0d0cbd82bbc1c283ba0a2bb Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 10:32:37 +0530 Subject: [PATCH 35/40] fix(apple): clear dropped placeholders when a launch has no background 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. --- templates/apple/Sources/Services/Push.swift.twig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index c3e0470629..6cedd24414 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1472,6 +1472,10 @@ extension Push { static func reconcile(userId: String, targetId: String, fallback: Push?) async { let pushes = apnsLocked { apnsPushes.values.compactMap { $0.push } }.filter { $0.remoteUserId() == userId } + let claimed = Set(pushes.flatMap { $0.backgroundTopics() }) + #if canImport(UserNotifications) + await clearDroppedPlaceholders(claimed: claimed) + #endif guard let push = pushes.first(where: { !$0.backgroundTopics().isEmpty }) else { // No background subscription this launch: the target and its subscribers go, as on close(). if apnsLinkedTopics.isRegistered(targetId), let push = fallback ?? pushes.first { @@ -1479,10 +1483,6 @@ extension Push { } return } - let claimed = Set(pushes.flatMap { $0.backgroundTopics() }) - #if canImport(UserNotifications) - await clearDroppedPlaceholders(claimed: claimed) - #endif for topic in LinkedTopics.stale(saved: apnsLinkedTopics.topics(targetId), claimed: claimed) { await unlink(push, targetId: targetId, topic: topic) } From 99a3bfb2e4320c7087677aab7a2ef4b9335d0d5d Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 13:47:44 +0530 Subject: [PATCH 36/40] fix(apple): blame the entitlement only when APNs registration says so 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). --- templates/apple/Sources/Services/Push.swift.twig | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 350062f3fb..c9a1200028 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1484,9 +1484,14 @@ extension Push { static func apnsRegistrationFailed(_ error: Swift.Error) { let pushes = apnsLocked { apnsPushes.values.compactMap { $0.push } } + let nsError = error as NSError + // iOS reports a missing aps-environment entitlement as Cocoa error 3000. + let entitlement = nsError.domain == NSCocoaErrorDomain && nsError.code == 3000 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)." + message: "APNs registration failed: \(error.localizedDescription)" + + (entitlement + ? ". Add the Push Notifications capability so the app has an aps-environment entitlement; `appwrite init apns` sets it in the Xcode project." + : "") ) for push in pushes { push.surface(described) From 79e9b4ba534f6bb754583b28e774a2b6772ed143 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 13:47:44 +0530 Subject: [PATCH 37/40] fix(apple): register into the target the server keeps, and report the 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. --- .../apple/Sources/Services/Push.swift.twig | 90 ++++++++++++++----- 1 file changed, 68 insertions(+), 22 deletions(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index c9a1200028..d1b5a38c20 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1389,7 +1389,7 @@ extension Push { return } Shared.share(backgroundPush(userId: userId) ?? push) - let targetId = Ids.target(installation: Installation.id, userId: userId) + let targetId = Push.targetId(for: userId) let linked = apnsLocked { apnsLinked[targetId] ?? [] } Task { for topic in topics where linked.contains(topic) { @@ -1425,6 +1425,9 @@ extension Push { return } apnsLinkedTopics.removeTarget(targetId) + if let userId = push.remoteUserId(), Push.targetId(for: userId) == targetId { + saveTargetId(nil, for: userId) + } // A background subscription that registered while the DELETE was in flight lost its target: // register the device again for it. if !Shared.inServiceExtension, let userId = push.remoteUserId(), let claimer = backgroundPush(userId: userId), @@ -1441,7 +1444,7 @@ extension Push { guard !Shared.inServiceExtension, let userId = push.remoteUserId() else { return } - let targetId = Ids.target(installation: Installation.id, userId: userId) + let targetId = Push.targetId(for: userId) guard apnsLocked({ apnsReconciled.insert(targetId).inserted }) else { return } @@ -1507,15 +1510,12 @@ extension Push { guard let userId = push.remoteUserId() else { return } - let targetId = Ids.target(installation: Installation.id, userId: userId) + let targetId: String do { - try await upsertTarget(push, targetId: targetId, token: token) + targetId = try await upsertTarget(push, targetId: Push.targetId(for: userId), token: token) + saveTargetId(targetId, for: userId) } catch { - 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))." - )) + push.surface(registrationFailed(error)) return } apnsLocked { apnsLinked[targetId] = apnsLinked[targetId] ?? [] } @@ -1528,7 +1528,38 @@ extension Push { } } - private static func upsertTarget(_ push: Push, targetId: String, token: String) async throws { + /// The id of the target holding this device's token for `userId`: the one the server last used for + /// it, else this install's own. + static func targetId(for userId: String) -> String { + Shared.defaults.string(forKey: "\(targetKey).\(userId)") ?? Ids.target(installation: Installation.id, userId: userId) + } + + private static let targetKey = "{{ spec.info.title | caseLower }}.push.target" + + private static func saveTargetId(_ targetId: String?, for userId: String) { + if let targetId { + Shared.defaults.set(targetId, forKey: "\(targetKey).\(userId)") + } else { + Shared.defaults.removeObject(forKey: "\(targetKey).\(userId)") + } + } + + /// What went wrong registering the device, with a fix only for the failures that have a known one. + private static func registrationFailed(_ error: Swift.Error) -> {{ spec.info.title | caseUcfirst }}Error { + var message = "Could not register this device for APNs: \(error.localizedDescription)" + if let error = error as? {{ spec.info.title | caseUcfirst }}Error { + message += " (\(error.code.map(String.init) ?? "no status")\(error.type.map { ", \($0)" } ?? ""))" + if error.type == "general_unknown_origin" { + message += ". Add an Apple platform with the bundle ID \(Bundle.main.bundleIdentifier ?? "of this app") to the Appwrite project." + } + } + return {{ spec.info.title | caseUcfirst }}Error(message: message) + } + + /// Create or update the target for this device's token and return its id. The server may answer with + /// another of the user's targets: the session's existing push target, or the one already holding the + /// token. + private static func upsertTarget(_ push: Push, targetId: String, token: String) async throws -> String { // `environment` and `bundleId` let the server pick the APNs provider for this app and build. // A server without that support ignores them. let params: [String: Any?] = [ @@ -1538,28 +1569,43 @@ extension Push { "bundleId": Bundle.main.bundleIdentifier, ] let headers = projectHeaders(push) + let update = params.filter { $0.key != "targetId" } do { - let _: Any = try await push.client.call( + let created: Any = try await push.client.call( method: "POST", path: "/account/targets/push", headers: headers, params: params, converter: { $0 } ) - } catch let error as {{ spec.info.title | caseUcfirst }}Error where error.code == 409 { - // 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( - method: "PUT", - path: "/account/targets/\(targetId)/push", - headers: headers, - params: params.filter { $0.key != "targetId" }, - converter: { $0 } - ) + return (created as? [String: Any])?["$id"] as? String ?? targetId + } catch let conflict as {{ spec.info.title | caseUcfirst }}Error where conflict.code == 409 { + // The target exists: 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. + do { + let _: Any = try await push.client.call( + method: "PUT", path: "/account/targets/\(targetId)/push", headers: headers, params: update, converter: { $0 }) + return targetId + } catch let error as {{ spec.info.title | caseUcfirst }}Error where error.code == 404 { + // Another of the user's targets holds the token: update that one. + guard let held = try await heldTarget(push, token: token) else { + throw conflict + } + let _: Any = try await push.client.call( + method: "PUT", path: "/account/targets/\(held)/push", headers: headers, params: update, converter: { $0 }) + return held + } } } + /// The user's push target holding `token`, from the account's targets. + private static func heldTarget(_ push: Push, token: String) async throws -> String? { + let account: Any = try await push.client.call( + method: "GET", path: "/account", headers: projectHeaders(push), converter: { $0 }) + let targets = (account as? [String: Any])?["targets"] as? [[String: Any]] ?? [] + return targets.first { $0["providerType"] as? String == "push" && $0["identifier"] as? String == token }?["$id"] as? String + } + // Push calls the API directly, so it doesn't depend on a Messaging service being generated. private static func projectHeaders(_ push: Push) -> [String: String] { ["X-Appwrite-Project": push.client.config["project"] ?? "", "content-type": "application/json"] From 4911c2a20347e49b32657894f569e2f01351cc30 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 13:42:08 +0530 Subject: [PATCH 38/40] fix(swift): ask for gzip so compressed responses decode 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. --- templates/apple/Sources/Client.swift.twig | 5 +++++ templates/swift/Sources/Client.swift.twig | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/templates/apple/Sources/Client.swift.twig b/templates/apple/Sources/Client.swift.twig index 26f8607ee7..5e73641248 100644 --- a/templates/apple/Sources/Client.swift.twig +++ b/templates/apple/Sources/Client.swift.twig @@ -367,6 +367,11 @@ open class Client { for (key, value) in self.headers.merging(headers, uniquingKeysWith: { $1 }) { request.headers.add(name: key, value: value) } + // NIO asks for "deflate, gzip" unless told otherwise, and some servers answer deflate with a raw + // stream it can't inflate. gzip is decoded the same everywhere. + if compression, !request.headers.contains(name: "accept-encoding") { + request.headers.add(name: "accept-encoding", value: "gzip") + } request.addDomainCookies() diff --git a/templates/swift/Sources/Client.swift.twig b/templates/swift/Sources/Client.swift.twig index 18d0982bac..b98e9495a8 100644 --- a/templates/swift/Sources/Client.swift.twig +++ b/templates/swift/Sources/Client.swift.twig @@ -342,6 +342,11 @@ open class Client { for (key, value) in self.headers.merging(headers, uniquingKeysWith: { $1 }) { request.headers.add(name: key, value: value) } + // NIO asks for "deflate, gzip" unless told otherwise, and some servers answer deflate with a raw + // stream it can't inflate. gzip is decoded the same everywhere. + if !request.headers.contains(name: "accept-encoding") { + request.headers.add(name: "accept-encoding", value: "gzip") + } request.addDomainCookies() From c23ea4a446d43df81c033f6a2ecff364dccd4ea4 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 14:26:51 +0530 Subject: [PATCH 39/40] fix(apple): start the reconcile only once the broker has the subscription 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. --- templates/apple/Sources/Services/Push.swift.twig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index dd2d9f745a..5c715f1ded 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -515,7 +515,6 @@ open class Push: Service { Push.apnsPrepare(self) } - Push.scheduleReconcile(self) let generation = sync.sync { closeGeneration } let mqtt: MQTTClient do { @@ -585,6 +584,9 @@ open class Push: Service { throw described } + // Only now that the broker has the subscription, so the reconcile never runs on topics a slow + // connection has not claimed yet. + Push.scheduleReconcile(self) if background { Push.apnsEnable(self, topics: topics) } From b65d7393091d690fa656df72cbbf3275a42605a3 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Wed, 14 Oct 2026 14:30:27 +0530 Subject: [PATCH 40/40] fix(apple): start the reconcile only from a confirmed subscription 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. --- templates/apple/Sources/Services/Push.swift.twig | 1 - 1 file changed, 1 deletion(-) diff --git a/templates/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 5c715f1ded..036819b1f7 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -1543,7 +1543,6 @@ extension Push { } apnsLocked { apnsLinked[targetId] = apnsLinked[targetId] ?? [] } apnsLinkedTopics.register(targetId) - scheduleReconcile(push) // A topic unsubscribed while the target request was in flight is not linked. let wanted = Set(push.backgroundTopics()) for topic in topics where Ids.isMessagingTopic(topic) && wanted.contains(topic) {