Files
buzz/mobile/ios/NotificationService/NotificationService.swift
T
2026-07-24 11:30:41 -07:00

229 lines
9.0 KiB
Swift

import BuzzPushKit
import Foundation
import Security
import UserNotifications
final class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
private var resolver: BuzzPushNotificationResolving = BuzzPushNotificationResolver()
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
contentHandler(request.content)
return
}
bestAttemptContent = content
resolver.resolve { [weak self] resolution in
guard let self else { return }
if let resolution {
content.title = resolution.title
content.body = resolution.body
if let subtitle = resolution.subtitle {
content.subtitle = subtitle
}
if let threadIdentifier = resolution.threadIdentifier {
content.threadIdentifier = threadIdentifier
}
}
self.finish(content)
}
}
override func serviceExtensionTimeWillExpire() {
if let bestAttemptContent {
finish(bestAttemptContent)
}
}
private func finish(_ content: UNNotificationContent) {
guard let contentHandler else { return }
self.contentHandler = nil
contentHandler(content)
}
}
struct BuzzPushResolution: Decodable {
let title: String
let body: String
let subtitle: String?
let threadIdentifier: String?
}
protocol BuzzPushNotificationResolving {
func resolve(completion: @escaping (BuzzPushResolution?) -> Void)
}
final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
private let session: URLSession
private let appGroupIdentifier: String?
private let keychainAccessGroup: String?
private let defaults: UserDefaults?
init(
session: URLSession = .shared,
appGroupIdentifier: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String,
keychainAccessGroup: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
) {
self.session = session
self.appGroupIdentifier = appGroupIdentifier
self.keychainAccessGroup = keychainAccessGroup
defaults = appGroupIdentifier.flatMap(UserDefaults.init(suiteName:))
}
func resolve(completion: @escaping (BuzzPushResolution?) -> Void) {
let loadedCommunities = loadCommunities()
removeStaleWatermarks(activeCommunityIDs: Set(loadedCommunities.map(\.id)))
let communities = loadedCommunities.filter {
$0.pubkey?.isEmpty == false && loadPrivateKey(communityID: $0.id) != nil
}
guard !communities.isEmpty else { completion(nil); return }
let group = DispatchGroup()
let lock = NSLock()
var candidates: [(BuzzPushResolution, VerifiedNostrEvent, BuzzPushCommunity)] = []
for community in communities {
group.enter()
query(community) { candidate in
if let candidate {
lock.lock(); candidates.append((candidate.0, candidate.1, community)); lock.unlock()
}
group.leave()
}
}
group.notify(queue: .global(qos: .userInitiated)) { [weak self] in
guard let self else { return }
let newest = candidates.max {
$0.1.createdAt == $1.1.createdAt ? $0.1.id > $1.1.id : $0.1.createdAt < $1.1.createdAt
}
for candidate in candidates {
self.defaults?.set(
PushWatermark.persistedTimestamp(eventTimestamp: candidate.1.createdAt),
forKey: PushWatermark.key(communityID: candidate.2.id)
)
}
completion(newest?.0)
}
}
private func query(
_ community: BuzzPushCommunity,
completion: @escaping ((BuzzPushResolution, VerifiedNostrEvent)?) -> Void
) {
guard let privateKey = loadPrivateKey(communityID: community.id), let pubkey = community.pubkey else {
completion(nil); return
}
var filter: [String: Any] = ["kinds": [9, 40002, 45001, 45003], "#p": [pubkey], "limit": 10]
let watermarkKey = PushWatermark.key(communityID: community.id)
let storedWatermark = defaults?.integer(forKey: watermarkKey) ?? 0
let watermark = PushWatermark.queryTimestamp(storedWatermark: storedWatermark)
if watermark != storedWatermark { defaults?.set(watermark, forKey: watermarkKey) }
if let since = PushWatermark.querySince(watermark: watermark) { filter["since"] = since }
guard let body = try? JSONSerialization.data(withJSONObject: [filter]) else { completion(nil); return }
let url = URL(string: "/query", relativeTo: community.relayURL)!
var request = URLRequest(url: url)
request.httpMethod = "POST"; request.httpBody = body; request.timeoutInterval = 8
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
guard let auth = try? NostrHTTPAuth.authorizationHeader(
url: url, method: "POST", body: body, privateKeyHex: privateKey
) else { completion(nil); return }
request.setValue(auth, forHTTPHeaderField: "Authorization")
session.dataTask(with: request) { data, response, _ in
guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode),
let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
else { completion(nil); return }
completion(Self.decodeResolution(
events: events.filter {
$0.hasValidIDAndSignature() && PushWatermark.isAcceptable(eventTimestamp: $0.createdAt)
},
community: community
))
}.resume()
}
private static func decodeResolution(
events: [VerifiedNostrEvent], community: BuzzPushCommunity
) -> (BuzzPushResolution, VerifiedNostrEvent)? {
guard let mine = community.pubkey?.lowercased() else { return nil }
let event = events.filter {
$0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind)
}.sorted {
$0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt
}.first
guard let event else { return nil }
let body = previewBody(event.content)
guard !body.isEmpty else { return nil }
let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1]
return (BuzzPushResolution(
title: shortPubkey(event.pubkey), body: body, subtitle: community.name,
threadIdentifier: channel ?? community.id
), event)
}
private static func previewBody(_ content: String) -> String {
var result = content.replacingOccurrences(of: #"```[\s\S]*?```"#, with: "[code]", options: .regularExpression)
result = result.replacingOccurrences(of: #"`([^`]*)`"#, with: "$1", options: .regularExpression)
result = result.replacingOccurrences(of: #"!?\[([^\]]*)\]\([^)]*\)"#, with: "$1", options: .regularExpression)
result = result.replacingOccurrences(of: #"https?://\S+"#, with: "[link]", options: .regularExpression)
result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines)
return result.count > 180 ? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" : result
}
private static func shortPubkey(_ pubkey: String) -> String {
pubkey.count > 8 ? String(pubkey.prefix(8)) + "…" : pubkey
}
private func removeStaleWatermarks(activeCommunityIDs: Set<String>) {
guard let defaults else { return }
for key in PushWatermark.staleKeys(
in: Array(defaults.dictionaryRepresentation().keys),
activeCommunityIDs: activeCommunityIDs
) {
defaults.removeObject(forKey: key)
}
}
private func loadPrivateKey(communityID: String) -> String? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "buzz.push.nse.signing",
kSecAttrAccount as String: communityID,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
if let keychainAccessGroup, !keychainAccessGroup.isEmpty { query[kSecAttrAccessGroup as String] = keychainAccessGroup }
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
private func loadCommunities() -> [BuzzPushCommunity] {
guard let appGroupIdentifier,
let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier),
let data = try? Data(contentsOf: container.appendingPathComponent("push-communities.json")),
let decoded = try? JSONDecoder().decode(BuzzPushSnapshot.self, from: data)
else { return [] }
return decoded.communities
}
}
struct BuzzPushSnapshot: Decodable {
let communities: [BuzzPushCommunity]
}
struct BuzzPushCommunity: Decodable {
let id: String
let name: String
let relayUrl: String
let pubkey: String?
var relayURL: URL {
URL(string: relayUrl) ?? URL(string: "http://127.0.0.1")!
}
}