From a7e22835378d94209f693348b6f5eea644a8bfe7 Mon Sep 17 00:00:00 2001 From: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Date: Mon, 27 Jul 2026 19:52:05 -0700 Subject: [PATCH] feat(mobile/ios): replay lease subscriptions in notification extension Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Signed-off-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> --- .../Sources/BuzzPushKit/PushLease.swift | 199 ++++++++++++ .../BuzzPushKitTests/PushLeaseTests.swift | 109 +++++++ .../NotificationService.swift | 303 +++++++++++++----- 3 files changed, 526 insertions(+), 85 deletions(-) create mode 100644 mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift create mode 100644 mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift new file mode 100644 index 000000000..5c3d883ce --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushLease.swift @@ -0,0 +1,199 @@ +import Foundation + +public enum PushLeaseError: Error, Equatable { + case unsupportedAuthority(String) + case acceptedAuthorityMissingSubscriptions + case emptySubscriptions +} + +public struct PushLeaseSnapshot: Codable, Equatable, Sendable { + public let communities: [PushLeaseCommunity] + + public init(communities: [PushLeaseCommunity]) { + self.communities = communities + } +} + +public struct PushLeaseCommunity: Codable, Equatable, Sendable { + public let id: String + public let name: String + public let relayUrl: String + public let pubkey: String? + public let pushSubscriptionState: PushLeaseSubscriptionState + + public init( + id: String, + name: String, + relayUrl: String, + pubkey: String?, + pushSubscriptionState: PushLeaseSubscriptionState + ) { + self.id = id + self.name = name + self.relayUrl = relayUrl + self.pubkey = pubkey + self.pushSubscriptionState = pushSubscriptionState + } +} + +public struct PushLeaseSubscriptionState: Codable, Equatable, Sendable { + public enum Authority: String, Codable, Sendable { + case desired + case accepted + } + + public let authority: String + public let desired: [PushLeaseSubscription] + public let accepted: [PushLeaseSubscription]? + + public init( + authority: String, + desired: [PushLeaseSubscription], + accepted: [PushLeaseSubscription]? = nil + ) { + self.authority = authority + self.desired = desired + self.accepted = accepted + } + + /// Workstream A has no lease publisher, so `desired` is the only valid + /// authority today. A later publisher must persist the observed accepted + /// lease and switch this field explicitly. This keeps relay rejection, + /// clamping, or expiry visible rather than assuming acceptance. + public func authoritativeSubscriptions() throws -> [PushLeaseSubscription] { + let subscriptions: [PushLeaseSubscription] + switch authority { + case Authority.desired.rawValue: + subscriptions = desired + case Authority.accepted.rawValue: + guard let accepted else { + throw PushLeaseError.acceptedAuthorityMissingSubscriptions + } + subscriptions = accepted + default: + throw PushLeaseError.unsupportedAuthority(authority) + } + guard !subscriptions.isEmpty else { + throw PushLeaseError.emptySubscriptions + } + return subscriptions + } +} + +public struct PushLeaseSubscription: Codable, Equatable, Sendable { + public let filter: PushLeaseFilter + public let notificationClass: String + public let ignore: [PushLeaseFilter] + public let suppress: PushLeaseSuppression? + + enum CodingKeys: String, CodingKey { + case filter + case notificationClass = "class" + case ignore + case suppress + } + + public init( + filter: PushLeaseFilter, + notificationClass: String, + ignore: [PushLeaseFilter] = [], + suppress: PushLeaseSuppression? = nil + ) { + self.filter = filter + self.notificationClass = notificationClass + self.ignore = ignore + self.suppress = suppress + } +} + +public struct PushLeaseSuppression: Codable, Equatable, Sendable { + public let pTagsMax: Int + + enum CodingKeys: String, CodingKey { + case pTagsMax = "p_tags_max" + } + + public init(pTagsMax: Int) { + self.pTagsMax = pTagsMax + } +} + +public struct PushLeaseFilter: Codable, Equatable, Sendable { + public let kinds: [Int] + public let authors: [String]? + public let pTags: [String]? + public let hTags: [String]? + public let eTags: [String]? + + enum CodingKeys: String, CodingKey { + case kinds + case authors + case pTags = "#p" + case hTags = "#h" + case eTags = "#e" + } + + public init( + kinds: [Int], + authors: [String]? = nil, + pTags: [String]? = nil, + hTags: [String]? = nil, + eTags: [String]? = nil + ) { + self.kinds = kinds + self.authors = authors + self.pTags = pTags + self.hTags = hTags + self.eTags = eTags + } + + public func queryFilter(since: Int?, limit: Int) -> [String: Any] { + var filter: [String: Any] = ["kinds": kinds, "limit": limit] + if let authors { filter["authors"] = authors } + if let pTags { filter["#p"] = pTags } + if let hTags { filter["#h"] = hTags } + if let eTags { filter["#e"] = eTags } + if let since { filter["since"] = since } + return filter + } + + public func matches(_ event: VerifiedNostrEvent) -> Bool { + guard kinds.contains(event.kind) else { return false } + if let authors, !authors.contains(event.pubkey.lowercased()) { return false } + if let pTags, !event.hasAnyTag(named: "p", values: pTags) { return false } + if let hTags, !event.hasAnyTag(named: "h", values: hTags) { return false } + if let eTags, !event.hasAnyTag(named: "e", values: eTags) { return false } + return true + } +} + +public enum PushLeaseMatcher { + public static func matches( + event: VerifiedNostrEvent, + subscription: PushLeaseSubscription + ) -> Bool { + guard subscription.filter.matches(event) else { return false } + if subscription.ignore.contains(where: { $0.matches(event) }) { return false } + if let maximum = subscription.suppress?.pTagsMax, + event.tagCount(named: "p") > maximum + { + return false + } + return true + } +} + +extension VerifiedNostrEvent { + public func tagCount(named name: String) -> Int { + tags.reduce(into: 0) { count, tag in + if tag.count >= 2 && tag[0] == name { count += 1 } + } + } + + public func hasAnyTag(named name: String, values: [String]) -> Bool { + let expected = Set(values.map { $0.lowercased() }) + return tags.contains { tag in + tag.count >= 2 && tag[0] == name && expected.contains(tag[1].lowercased()) + } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift new file mode 100644 index 000000000..3c8190d39 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushLeaseTests.swift @@ -0,0 +1,109 @@ +import XCTest + +@testable import BuzzPushKit + +final class PushLeaseTests: XCTestCase { + private let mine = String(repeating: "a", count: 64) + private let other = String(repeating: "b", count: 64) + + func testDesiredAuthorityIsExplicitAndAcceptedAuthorityRequiresState() throws { + let subscription = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], pTags: [mine]), + notificationClass: "default" + ) + XCTAssertEqual( + try PushLeaseSubscriptionState( + authority: "desired", + desired: [subscription] + ).authoritativeSubscriptions(), + [subscription] + ) + XCTAssertThrowsError( + try PushLeaseSubscriptionState( + authority: "accepted", + desired: [subscription] + ).authoritativeSubscriptions() + ) { error in + XCTAssertEqual(error as? PushLeaseError, .acceptedAuthorityMissingSubscriptions) + } + } + + func testFilterBuildsQueryFromLeaseWithoutHardcodedKinds() { + let filter = PushLeaseFilter( + kinds: [7, 1059], + authors: [other], + pTags: [mine], + hTags: ["channel"], + eTags: [String(repeating: "c", count: 64)] + ) + let query = filter.queryFilter(since: 1_000, limit: 10) + + XCTAssertEqual(query["kinds"] as? [Int], [7, 1059]) + XCTAssertEqual(query["authors"] as? [String], [other]) + XCTAssertEqual(query["#p"] as? [String], [mine]) + XCTAssertEqual(query["#h"] as? [String], ["channel"]) + XCTAssertEqual(query["since"] as? Int, 1_000) + } + + func testPushEligibleKindAbsentFromOldConstantMatchesLease() { + let event = makeEvent(kind: 1059, tags: [["p", mine]]) + let subscription = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [1059], pTags: [mine]), + notificationClass: "default" + ) + + XCTAssertTrue(PushLeaseMatcher.matches(event: event, subscription: subscription)) + } + + func testIgnoreAndHellthreadSuppressionRejectCandidates() { + let ignored = makeEvent(kind: 9, pubkey: other, tags: [["p", mine]]) + let ignoreSubscription = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], pTags: [mine]), + notificationClass: "default", + ignore: [PushLeaseFilter(kinds: [9], authors: [other])] + ) + XCTAssertFalse( + PushLeaseMatcher.matches(event: ignored, subscription: ignoreSubscription) + ) + + let hellthread = makeEvent( + kind: 9, + tags: (0..<21).map { ["p", String(format: "%064x", $0)] } + ) + let suppressed = PushLeaseSubscription( + filter: PushLeaseFilter(kinds: [9], authors: [other]), + notificationClass: "default", + suppress: PushLeaseSuppression(pTagsMax: 20) + ) + XCTAssertFalse(PushLeaseMatcher.matches(event: hellthread, subscription: suppressed)) + } + + func testDecodesSnapshotContractFromDartShape() throws { + let json = """ + {"communities":[{"id":"origin","name":"Team","relayUrl":"https://relay.example.com","pubkey":"\(mine)","pushSubscriptionState":{"authority":"desired","desired":[{"filter":{"kinds":[9],"#p":["\(mine)"]},"class":"default","ignore":[{"kinds":[9],"authors":["\(mine)"]}],"suppress":{"p_tags_max":20}}]}}]} + """ + let snapshot = try JSONDecoder().decode(PushLeaseSnapshot.self, from: Data(json.utf8)) + + XCTAssertEqual(snapshot.communities.count, 1) + XCTAssertEqual( + try snapshot.communities[0].pushSubscriptionState.authoritativeSubscriptions().count, + 1 + ) + } + + private func makeEvent( + kind: Int, + pubkey: String? = nil, + tags: [[String]] = [] + ) -> VerifiedNostrEvent { + VerifiedNostrEvent( + id: String(repeating: "d", count: 64), + pubkey: pubkey ?? other, + createdAt: 1_000, + kind: kind, + tags: tags, + content: "message", + sig: String(repeating: "e", count: 128) + ) + } +} diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift index b85017ffb..36b5be668 100644 --- a/mobile/ios/NotificationService/NotificationService.swift +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -19,26 +19,30 @@ final class NotificationService: UNNotificationServiceExtension { } bestAttemptContent = content - resolver.resolve { [weak self] resolution in + resolver.resolve { [weak self] result in guard let self else { return } - if let resolution { + switch result { + case .notification(let resolution): content.title = resolution.title content.body = resolution.body - if let subtitle = resolution.subtitle { - content.subtitle = subtitle - } + content.subtitle = resolution.subtitle ?? "" if let threadIdentifier = resolution.threadIdentifier { content.threadIdentifier = threadIdentifier } + case .diagnostic(let message): + content.title = "Buzz notification needs attention" + content.body = message + content.subtitle = "" + content.threadIdentifier = "buzz.push.diagnostic" + case .none: + break } self.finish(content) } } override func serviceExtensionTimeWillExpire() { - if let bestAttemptContent { - finish(bestAttemptContent) - } + if let bestAttemptContent { finish(bestAttemptContent) } } private func finish(_ content: UNNotificationContent) { @@ -48,129 +52,254 @@ final class NotificationService: UNNotificationServiceExtension { } } -struct BuzzPushResolution: Decodable { +struct BuzzPushResolution { let title: String let body: String let subtitle: String? let threadIdentifier: String? } +enum BuzzPushResolutionResult { + case notification(BuzzPushResolution) + case diagnostic(String) + case none +} + protocol BuzzPushNotificationResolving { - func resolve(completion: @escaping (BuzzPushResolution?) -> Void) + func resolve(completion: @escaping (BuzzPushResolutionResult) -> Void) } final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { + private struct Candidate { + let resolution: BuzzPushResolution + let event: VerifiedNostrEvent + let community: PushLeaseCommunity + } + private let session: URLSession private let appGroupIdentifier: String? private let keychainAccessGroup: String? private let defaults: UserDefaults? + private let fileManager: FileManager init( session: URLSession = .shared, - appGroupIdentifier: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String, - keychainAccessGroup: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String + appGroupIdentifier: String? = Bundle.main.object( + forInfoDictionaryKey: "BuzzAppGroupIdentifier" + ) as? String, + keychainAccessGroup: String? = Bundle.main.object( + forInfoDictionaryKey: "BuzzKeychainAccessGroup" + ) as? String, + fileManager: FileManager = .default ) { self.session = session self.appGroupIdentifier = appGroupIdentifier self.keychainAccessGroup = keychainAccessGroup defaults = appGroupIdentifier.flatMap(UserDefaults.init(suiteName:)) + self.fileManager = fileManager } - func resolve(completion: @escaping (BuzzPushResolution?) -> Void) { - let loadedCommunities = loadCommunities() + func resolve(completion: @escaping (BuzzPushResolutionResult) -> Void) { + let loadedCommunities: [PushLeaseCommunity] + do { + loadedCommunities = try loadCommunities() + } catch { + completion(.diagnostic("Open Buzz to refresh notification subscriptions.")) + return + } 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 } + guard !communities.isEmpty else { + completion(.diagnostic("Open Buzz to restore notification credentials.")) + return + } + let group = DispatchGroup() let lock = NSLock() - var candidates: [(BuzzPushResolution, VerifiedNostrEvent, BuzzPushCommunity)] = [] + var candidates: [Candidate] = [] + var diagnostics: [String] = [] for community in communities { group.enter() - query(community) { candidate in - if let candidate { - lock.lock(); candidates.append((candidate.0, candidate.1, community)); lock.unlock() + query(community) { result in + lock.lock() + switch result { + case .candidate(let candidate): candidates.append(candidate) + case .diagnostic(let diagnostic): diagnostics.append(diagnostic) + case .none: break } + 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 + let sorted = candidates.sorted { lhs, rhs in + if lhs.event.createdAt != rhs.event.createdAt { + return lhs.event.createdAt > rhs.event.createdAt + } + if lhs.event.id != rhs.event.id { return lhs.event.id < rhs.event.id } + return lhs.community.id < rhs.community.id + } + guard let winner = sorted.first else { + completion(diagnostics.sorted().first.map(BuzzPushResolutionResult.diagnostic) ?? .none) + return } for candidate in candidates { self.defaults?.set( - PushWatermark.persistedTimestamp(eventTimestamp: candidate.1.createdAt), - forKey: PushWatermark.key(communityID: candidate.2.id) + PushWatermark.persistedTimestamp(eventTimestamp: candidate.event.createdAt), + forKey: PushWatermark.key(communityID: candidate.community.id) ) } - completion(newest?.0) + completion(.notification(winner.resolution)) } } + private enum QueryResult { + case candidate(Candidate) + case diagnostic(String) + case none + } + private func query( - _ community: BuzzPushCommunity, - completion: @escaping ((BuzzPushResolution, VerifiedNostrEvent)?) -> Void + _ community: PushLeaseCommunity, + completion: @escaping (QueryResult) -> Void ) { - guard let privateKey = loadPrivateKey(communityID: community.id), let pubkey = community.pubkey else { - completion(nil); return + guard let privateKey = loadPrivateKey(communityID: community.id) else { + completion(.diagnostic("Open Buzz to restore notification credentials.")) + return + } + let subscriptions: [PushLeaseSubscription] + do { + subscriptions = try community.pushSubscriptionState.authoritativeSubscriptions() + } catch { + completion(.diagnostic("Open Buzz to refresh notification subscriptions.")) + 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)! + let since = PushWatermark.querySince(watermark: watermark) + let filters = subscriptions.map { $0.filter.queryFilter(since: since, limit: 10) } + guard let body = try? JSONSerialization.data(withJSONObject: filters) else { + completion(.diagnostic("Buzz notification subscriptions are invalid.")) + return + } + guard let relayURL = community.relayURL, + let url = URL(string: "/query", relativeTo: relayURL) + else { + completion(.diagnostic("Buzz notification relay URL is invalid.")) + return + } var request = URLRequest(url: url) - request.httpMethod = "POST"; request.httpBody = body; request.timeoutInterval = 8 + 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 } + guard + let auth = try? NostrHTTPAuth.authorizationHeader( + url: url, + method: "POST", + body: body, + privateKeyHex: privateKey + ) + else { + completion(.diagnostic("Buzz could not authenticate notification catch-up.")) + 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 - )) + guard let response = response as? HTTPURLResponse, + (200..<300).contains(response.statusCode), + let data, + let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data) + else { + completion(.none) + return + } + completion( + Self.decodeResolution( + events: events, + community: community, + subscriptions: subscriptions + ) + ) }.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) + events: [VerifiedNostrEvent], + community: PushLeaseCommunity, + subscriptions: [PushLeaseSubscription] + ) -> QueryResult { + let matching = events.filter { event in + event.hasValidIDAndSignature() + && PushWatermark.isAcceptable(eventTimestamp: event.createdAt) + && subscriptions.contains(where: { + PushLeaseMatcher.matches(event: event, subscription: $0) + }) }.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) + } + + for event in matching { + let resolution: BuzzPushResolution + if event.kind == 9 { + let body = previewBody(event.content) + guard !body.isEmpty else { continue } + let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1] + resolution = BuzzPushResolution( + title: shortPubkey(event.pubkey), + body: body, + subtitle: community.name, + threadIdentifier: channel ?? community.id + ) + } else { + // Wake-only kinds are fetched from the authoritative lease, but this + // extension cannot render them yet. + resolution = BuzzPushResolution( + title: "Buzz notification needs attention", + body: "Buzz received a kind \(event.kind) wake that this app version cannot display.", + subtitle: community.name, + threadIdentifier: "buzz.push.unsupported-kind.\(event.kind)" + ) + } + return .candidate(Candidate(resolution: resolution, event: event, community: community)) + } + return .none } 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 + 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 { @@ -195,34 +324,38 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] - if let keychainAccessGroup, !keychainAccessGroup.isEmpty { query[kSecAttrAccessGroup as String] = keychainAccessGroup } + 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 } + let data = item as? Data + else { return nil } return String(data: data, encoding: .utf8) } - private func loadCommunities() -> [BuzzPushCommunity] { + private func loadCommunities() throws -> [PushLeaseCommunity] { 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 + let container = fileManager.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) + else { + throw CocoaError(.fileNoSuchFile) + } + let snapshotURL = container.appendingPathComponent("push-communities.json") + let data = try Data(contentsOf: snapshotURL) + return try JSONDecoder().decode(PushLeaseSnapshot.self, from: data).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")! +extension PushLeaseCommunity { + fileprivate var relayURL: URL? { + guard let url = URL(string: relayUrl), + let scheme = url.scheme?.lowercased(), + scheme == "https" || scheme == "http" + else { + return nil + } + return url } }