test(mobile/ios): exercise push notification resolver

Co-authored-by: Tom Brow <tomb@block.xyz>
Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp
2026-07-31 19:37:49 -07:00
parent db73dc44af
commit 32915c58d7
3 changed files with 488 additions and 164 deletions

View File

@@ -0,0 +1,192 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
/// Content resolved from unread Buzz events for a mutable push notification.
public struct BuzzPushResolution: Decodable, Equatable, Sendable {
public let title: String
public let body: String
public let subtitle: String?
public let threadIdentifier: String?
public init(title: String, body: String, subtitle: String?, threadIdentifier: String?) {
self.title = title
self.body = body
self.subtitle = subtitle
self.threadIdentifier = threadIdentifier
}
}
/// Resolves the content used to mutate a generic Buzz push notification.
public protocol BuzzPushNotificationResolving {
func resolve(completion: @escaping (BuzzPushResolution?) -> Void)
}
/// Reads configured Buzz communities and resolves their newest unread event.
public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
private let session: URLSession
private let defaults: UserDefaults?
private let loadCommunitiesData: () -> Data?
private let loadPrivateKey: (String) -> String?
private let now: () -> Int
/// Creates a resolver around the notification extension's App Group and Keychain I/O.
public init(
session: URLSession,
defaults: UserDefaults?,
loadCommunitiesData: @escaping () -> Data?,
loadPrivateKey: @escaping (String) -> String?,
now: @escaping () -> Int = { Int(Date().timeIntervalSince1970) }
) {
self.session = session
self.defaults = defaults
self.loadCommunitiesData = loadCommunitiesData
self.loadPrivateKey = loadPrivateKey
self.now = now
}
public func resolve(completion: @escaping (BuzzPushResolution?) -> Void) {
let loadedCommunities = loadCommunities()
removeStaleWatermarks(activeCommunityIDs: Set(loadedCommunities.map(\.id)))
let communities = loadedCommunities.filter {
$0.pubkey?.isEmpty == false && loadPrivateKey($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(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, now: self.now())
},
community: community
))
}.resume()
}
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)
}
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
}
static func shortPubkey(_ pubkey: String) -> String {
pubkey.count > 8 ? String(pubkey.prefix(8)) + "…" : pubkey
}
private func loadCommunities() -> [BuzzPushCommunity] {
guard let data = loadCommunitiesData(),
let decoded = try? JSONDecoder().decode(BuzzPushSnapshot.self, from: data)
else { return [] }
return decoded.communities
}
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)
}
}
}
/// Serialized community configuration shared with the notification service extension.
public struct BuzzPushSnapshot: Decodable, Sendable {
public let communities: [BuzzPushCommunity]
}
/// Community configuration needed to query unread events for a push notification.
public struct BuzzPushCommunity: Decodable, Equatable, Sendable {
public let id: String
public let name: String
public let relayUrl: String
public let pubkey: String?
public init(id: String, name: String, relayUrl: String, pubkey: String?) {
self.id = id
self.name = name
self.relayUrl = relayUrl
self.pubkey = pubkey
}
var relayURL: URL {
URL(string: relayUrl) ?? URL(string: "http://127.0.0.1")!
}
}

View File

@@ -0,0 +1,262 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
import XCTest
@testable import BuzzPushKit
final class BuzzPushNotificationResolverTests: XCTestCase {
private static let privateKey = String(repeating: "0", count: 63) + "1"
private static let ownPubkey =
"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
private static let now = Int(Date().timeIntervalSince1970)
private static let gatewayBody = "Reconnect to your relay now"
override func setUp() {
super.setUp()
URLProtocolStub.reset()
}
override func tearDown() {
URLProtocolStub.reset()
super.tearDown()
}
func testResolveReturnsNilWhenCommunitiesDataIsMissing() {
let result = resolve(makeResolver(communitiesData: nil))
XCTAssertNil(result)
XCTAssertTrue(URLProtocolStub.requests.isEmpty)
}
func testResolveReturnsNilWhenCommunitiesDataIsUndecodable() {
let result = resolve(makeResolver(communitiesData: Data("not json".utf8)))
XCTAssertNil(result)
XCTAssertTrue(URLProtocolStub.requests.isEmpty)
}
func testResolveReturnsNilOnKeychainMiss() throws {
let result = resolve(makeResolver(
communitiesData: try snapshotData([community()]),
privateKeys: [:]
))
XCTAssertNil(result)
XCTAssertTrue(URLProtocolStub.requests.isEmpty)
}
func testResolveReturnsNilForNon2xxRelayResponse() throws {
URLProtocolStub.handler = { request in
Self.response(request, status: 503, data: Data())
}
let result = resolve(makeResolver(communitiesData: try snapshotData([community()])))
XCTAssertNil(result)
XCTAssertEqual(URLProtocolStub.requests.count, 1)
}
func testResolveReturnsNilForUndecodableRelayResponse() throws {
URLProtocolStub.handler = { request in
Self.response(request, status: 200, data: Data("not events".utf8))
}
let result = resolve(makeResolver(communitiesData: try snapshotData([community()])))
XCTAssertNil(result)
XCTAssertEqual(URLProtocolStub.requests.count, 1)
}
func testDecodeResolutionFiltersOwnPubkeyEvent() {
let result = BuzzPushNotificationResolver.decodeResolution(
events: [event(pubkey: Self.ownPubkey, content: "This should be filtered")],
community: community()
)
XCTAssertNil(result)
}
func testDecodeResolutionReturnsNilWhenSanitizedPreviewIsEmpty() {
let event = event(content: " \n\t ")
let result = BuzzPushNotificationResolver.decodeResolution(
events: [event],
community: community()
)
XCTAssertNil(result)
}
func testPreviewBodySanitizesCodeLinksAndWhitespace() {
let content = """
Before ```swift
print("secret")
``` `inline` [docs](https://example.com/docs)
![image](https://example.com/image.png) https://example.com/raw
After
"""
XCTAssertEqual(
BuzzPushNotificationResolver.previewBody(content),
"Before [code] inline docs image [link] After"
)
}
func testPreviewBodyTruncatesTo178CharactersIncludingEllipsis() {
let preview = BuzzPushNotificationResolver.previewBody(String(repeating: "x", count: 200))
XCTAssertEqual(preview.count, 178)
XCTAssertEqual(preview, String(repeating: "x", count: 177) + "…")
}
func testDecodeResolutionUsesLowestIDWhenCreatedAtTies() {
let result = BuzzPushNotificationResolver.decodeResolution(
events: [
event(id: "a", content: "lower ID", createdAt: Self.now),
event(id: "b", content: "higher ID", createdAt: Self.now),
],
community: community()
)
XCTAssertEqual(result?.1.id, "a")
XCTAssertEqual(result?.0.body, "lower ID")
}
func testResolveSucceedsAndMutatesGatewayContent() throws {
let event = try JSONDecoder().decode(
VerifiedNostrEvent.self,
from: Data(Self.fixtureEvent.utf8)
)
URLProtocolStub.handler = { request in
Self.response(request, status: 200, data: try JSONEncoder().encode([event]))
}
let result = try XCTUnwrap(resolve(makeResolver(
communitiesData: try snapshotData([community()])
)))
XCTAssertNotEqual(result.title, Self.gatewayBody)
XCTAssertNotEqual(result.body, Self.gatewayBody)
XCTAssertEqual(result.title, String(event.pubkey.prefix(8)) + "…")
XCTAssertEqual(result.body, "Hello Buzz")
XCTAssertEqual(result.subtitle, "Community")
XCTAssertEqual(result.threadIdentifier, "channel-id")
}
private func makeResolver(
communitiesData: Data?,
privateKeys: [String: String] = ["community-id": privateKey]
) -> BuzzPushNotificationResolver {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [URLProtocolStub.self]
return BuzzPushNotificationResolver(
session: URLSession(configuration: configuration),
defaults: nil,
loadCommunitiesData: { communitiesData },
loadPrivateKey: { privateKeys[$0] },
now: { Self.now }
)
}
private func resolve(_ resolver: BuzzPushNotificationResolver) -> BuzzPushResolution? {
let completed = expectation(description: "resolver completed")
var result: BuzzPushResolution?
resolver.resolve {
result = $0
completed.fulfill()
}
wait(for: [completed], timeout: 2)
return result
}
private func community(
id: String = "community-id",
name: String = "Community",
relayUrl: String = "https://relay.example",
pubkey: String? = ownPubkey
) -> BuzzPushCommunity {
BuzzPushCommunity(id: id, name: name, relayUrl: relayUrl, pubkey: pubkey)
}
private func snapshotData(_ communities: [BuzzPushCommunity]) throws -> Data {
let dictionaries: [[String: Any]] = communities.map {
[
"id": $0.id,
"name": $0.name,
"relayUrl": $0.relayUrl,
"pubkey": $0.pubkey as Any,
]
}
return try JSONSerialization.data(withJSONObject: ["communities": dictionaries])
}
private func event(
id: String = "event-id",
pubkey: String = "author-pubkey",
content: String,
createdAt: Int = now,
kind: Int = 9,
tags: [[String]] = []
) -> VerifiedNostrEvent {
VerifiedNostrEvent(
id: id,
pubkey: pubkey,
createdAt: createdAt,
kind: kind,
tags: tags,
content: content,
sig: "signature"
)
}
private static let fixtureEvent = #"""
{"id":"2d827228c07234a9c30ac19e0d2f01bcc09ca3b1004e95a429b4771a8b42ebb8","sig":"44085c838c0afc8328ce6fb95d4c851021d6b43f15b7f1206ce225a433c2337336837af3df26cdd9301111d16a72508d47130bb2c9206ae4d7dbea336ede1d4d","content":" Hello [Buzz](https://buzz.block.xyz) ","kind":9,"pubkey":"c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5","created_at":1785551670,"tags":[["h","channel-id"]]}
"""#
private static func response(
_ request: URLRequest,
status: Int,
data: Data
) -> (HTTPURLResponse, Data) {
let response = HTTPURLResponse(
url: request.url!,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: ["Content-Type": "application/json"]
)!
return (response, data)
}
}
private final class URLProtocolStub: URLProtocol, @unchecked Sendable {
static let lock = NSLock()
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
static var requests: [URLRequest] = []
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
Self.lock.lock()
Self.requests.append(request)
let handler = Self.handler
Self.lock.unlock()
do {
let (response, data) = try handler?(request) ?? { throw URLError(.unsupportedURL) }()
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch {
client?.urlProtocol(self, didFailWithError: error)
}
}
override func stopLoading() {}
static func reset() {
lock.lock()
handler = nil
requests = []
lock.unlock()
}
}

View File

@@ -6,7 +6,27 @@ import UserNotifications
final class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
private var resolver: BuzzPushNotificationResolving = BuzzPushNotificationResolver()
private lazy var resolver: BuzzPushNotificationResolving = {
let appGroupIdentifier = Bundle.main.object(
forInfoDictionaryKey: "BuzzAppGroupIdentifier"
) as? String
let keychainAccessGroup = Bundle.main.object(
forInfoDictionaryKey: "BuzzKeychainAccessGroup"
) as? String
return BuzzPushNotificationResolver(
session: .shared,
defaults: appGroupIdentifier.flatMap(UserDefaults.init(suiteName:)),
loadCommunitiesData: {
Self.loadCommunitiesData(appGroupIdentifier: appGroupIdentifier)
},
loadPrivateKey: { communityID in
Self.loadPrivateKey(
communityID: communityID,
keychainAccessGroup: keychainAccessGroup
)
}
)
}()
override func didReceive(
_ request: UNNotificationRequest,
@@ -46,148 +66,11 @@ final class NotificationService: UNNotificationServiceExtension {
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? {
private static func loadPrivateKey(
communityID: String,
keychainAccessGroup: String?
) -> String? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "buzz.push.nse.signing",
@@ -195,34 +78,21 @@ 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 }
return String(data: data, encoding: .utf8)
}
private func loadCommunities() -> [BuzzPushCommunity] {
private static func loadCommunitiesData(appGroupIdentifier: String?) -> Data? {
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")!
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupIdentifier
)
else { return nil }
return try? Data(contentsOf: container.appendingPathComponent("push-communities.json"))
}
}