From 1e324ed9cdda9afdd87352919ca29fb80034f1ef Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 10:48:42 +0530 Subject: [PATCH 1/6] 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 38edf625b4e0a93700cb59a247e8c4c8582d5a51 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 12:27:20 +0530 Subject: [PATCH 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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) } }