From c99a01af6cf45e74ae19e3d483244f42df373a8e Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Tue, 13 Oct 2026 18:37:30 +0530 Subject: [PATCH 1/2] 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 2/2] 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)