diff --git a/src/SDK/Language/Apple.php b/src/SDK/Language/Apple.php index 1145688d5b..f18e9ecf8c 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', @@ -256,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/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/apple/Sources/Services/Push.swift.twig b/templates/apple/Sources/Services/Push.swift.twig index 5d7eadcd7a..036819b1f7 100644 --- a/templates/apple/Sources/Services/Push.swift.twig +++ b/templates/apple/Sources/Services/Push.swift.twig @@ -2,9 +2,23 @@ import Foundation import MQTTNIO import NIOCore +#if canImport(ObjectiveC) + import ObjectiveC +#endif +#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 +51,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 +87,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 +166,22 @@ 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 + 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. The app's own delegate + // still decides how a new one is presented. + let delivered = Push.isDelivered(notification) + _ = Push.handleRemoteNotification(info) + 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([]) @@ -236,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)? @@ -352,11 +393,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. @@ -425,6 +510,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 } @@ -496,8 +584,17 @@ 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) + } + deliverHandedOff() + 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 +625,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 +659,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 { @@ -569,13 +680,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 @@ -592,6 +705,10 @@ 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 { @@ -683,7 +800,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 } @@ -826,9 +943,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 }) { @@ -861,6 +980,7 @@ open class Push: Service { throw described } + startHeartbeat() onOpenCb?() return mqtt } @@ -919,6 +1039,7 @@ open class Push: Service { if !infos.isEmpty { try await brokerSubscribe(client, infos) } + startHeartbeat() onOpenCb?() return } catch { @@ -1018,17 +1139,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 && notification.visible && postedTitles.insert(title).inserted { notify(pushMessage, title: title, notification: notification) } } @@ -1038,18 +1169,19 @@ 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 ?? (notification.raw ? 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 } @@ -1065,8 +1197,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) @@ -1076,7 +1208,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) @@ -1107,18 +1239,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 @@ -1154,7 +1289,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() { @@ -1172,3 +1307,1033 @@ 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: 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? + 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 + + /// 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() + } + } + + 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 { + 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 { + Shared.share(push) + return + } + Shared.share(backgroundPush(userId: userId) ?? push) + let targetId = Push.targetId(for: userId) + let linked = apnsLocked { apnsLinked[targetId] ?? [] } + Task { + for topic in topics where linked.contains(topic) { + await unlink(push, targetId: targetId, topic: topic) + } + // 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 } + 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) + 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), + 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 + /// launch linked that no background subscription claims now, so the server stops waking the + /// device for them. A topic subscribed later is linked again. + static func scheduleReconcile(_ push: Push) { + guard !Shared.inServiceExtension, let userId = push.remoteUserId() else { + return + } + let targetId = Push.targetId(for: 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, fallback: scheduler.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 { + await deleteTarget(push, targetId: targetId) + } + return + } + for topic in LinkedTopics.stale(saved: apnsLinkedTopics.topics(targetId), claimed: claimed) { + 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 where !push.backgroundTopics().isEmpty { + Task { await register(push, token: hex, topics: push.backgroundTopics()) } + } + } + + 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)" + + (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) + } + } + + // 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: String + do { + targetId = try await upsertTarget(push, targetId: Push.targetId(for: userId), token: token) + saveTargetId(targetId, for: userId) + } catch { + push.surface(registrationFailed(error)) + return + } + apnsLocked { apnsLinked[targetId] = apnsLinked[targetId] ?? [] } + apnsLinkedTopics.register(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) { + await link(push, targetId: targetId, topic: topic) + } + } + + /// 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?] = [ + "targetId": targetId, + "identifier": token, + "environment": Environment.current.rawValue, + "bundleId": Bundle.main.bundleIdentifier, + ] + let headers = projectHeaders(push) + let update = params.filter { $0.key != "targetId" } + do { + let created: Any = try await push.client.call( + method: "POST", + path: "/account/targets/push", + headers: headers, + params: params, + 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"] + } + + private static func link(_ push: Push, targetId: String, topic: String) async { + let already = apnsLocked { apnsLinked[targetId]?.contains(topic) ?? false } + guard !already else { + return + } + do { + 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. + } catch { + push.surface(error) + 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) { + await unlink(push, targetId: targetId, topic: topic) + } + } + + private static func unlink(_ push: Push, targetId: String, topic: String) async { + var unlinked = true + do { + 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. + } catch { + push.surface(error) + unlinked = false + } + apnsLocked { _ = apnsLinked[targetId]?.remove(topic) } + if unlinked { + apnsLinkedTopics.remove(topic, from: targetId) + } + // 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) + } + } + + // 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 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) + } + #if canImport(UserNotifications) + Task { await Push.clearPlaceholders(subscribed: Set(filters)) } + #endif + } + + #if canImport(UserNotifications) + /// 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]).map(isStale) == true } + .map(\.request.identifier) + if !stale.isEmpty { + center.removeDeliveredNotifications(withIdentifiers: stale) + } + } + #endif + + 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 { [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 + 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() + } + + /// 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()) + startHeartbeat() + 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: 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 { + #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 + #endif + } + + /// 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.. 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 } + } + + /// 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() + } + + 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) + if links.isEmpty { + defaults.removeObject(forKey: LinkedTopics.key) + } else { + defaults.set(links, forKey: LinkedTopics.key) + } + } + } + } + + /// 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" + + private let defaults: UserDefaults + private var ids: [String] + private var lookup: Set + + 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 { + 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 { + 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 + } + } + + 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. + 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 `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 + 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 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 + 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 + } + } +} + +// 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/Sources/Services/PushServiceExtension.swift.twig b/templates/apple/Sources/Services/PushServiceExtension.swift.twig new file mode 100644 index 0000000000..ac1a2a38fb --- /dev/null +++ b/templates/apple/Sources/Services/PushServiceExtension.swift.twig @@ -0,0 +1,438 @@ +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 placeholderKey = "{{ spec.info.title | caseLower }}.push.placeholder" + 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 + + 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 { + 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 + } + + /// 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 { + 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 { + 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] { + 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) + } + } + + static func enqueue(_ messages: [PushMessage]) { + append(messages, to: "\(prefix).pending") + } + + 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 + } + } + + private static func append(_ messages: [PushMessage], to key: String) { + guard !messages.isEmpty else { + return + } + locked { + 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 { + 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. + 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. + if message == nil, Push.Shared.isAppLive(), await appHasIt(content) { + return + } + if message == nil { + let fetched = await fetch(session, credential: credential) + Push.Shared.handOff(fetched) + let shown = fetched.filter { PushNotificationContent(message: $0).visible } + message = shown.first + Push.Shared.enqueue(Array(shown.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) + keep(push) + + 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 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 + content.title = notification.title ?? title ?? message.topic + 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 + Push.downloadAttachment(image) { continuation.resume(returning: $0) } + } + if let attachment { + content.attachments = [attachment] + } + } + } + + /// 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() + 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) < Push.Shared.liveWindow + else { + 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 + } + } + + 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 new file mode 100644 index 0000000000..51841c7d9d --- /dev/null +++ b/templates/apple/Tests/PushTests.swift.twig @@ -0,0 +1,366 @@ +import Foundation +import Testing + +@testable import {{ spec.info.title | caseUcfirst }} + +#if canImport(ObjectiveC) + import ObjectiveC +#endif + +@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 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() { + 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: 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")) + } + + // 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 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 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) + } + + @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( + 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 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")]) + 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 expiredQueuedMessagesAreDropped() { + withSharedDefaults { + Push.Shared.enqueue([message("news", "1")]) + #expect(Push.Shared.dequeue(at: Date().addingTimeInterval(Push.Shared.pendingLifetime + 1)) == nil) + #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. +#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") + } + + @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") + } + } + + /// 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 + } + } +#endif 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()