192 lines
8.7 KiB
Swift
192 lines
8.7 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import UserNotifications
|
|
|
|
@MainActor
|
|
final class SybilNotificationPlatform: AgentNotificationPlatform {
|
|
static let shared = SybilNotificationPlatform()
|
|
private var center: UNUserNotificationCenter { .current() }
|
|
// UIKit's callback has no request ID. Keep an outstanding request tied to its
|
|
// original generation until it drains; queue the next session behind it.
|
|
private var outstanding: UUID?
|
|
private var queued: UUID?
|
|
private var registeredGeneration: UUID?
|
|
|
|
var environment: AgentAPNSEnvironment? {
|
|
#if targetEnvironment(simulator)
|
|
return isFixture ? .sandbox : nil
|
|
#elseif targetEnvironment(macCatalyst)
|
|
return nil
|
|
#else
|
|
return AgentAPNSEnvironment(entitlementSetting: Bundle.main.object(forInfoDictionaryKey: "SybilAPNSEnvironment") as? String)
|
|
#endif
|
|
}
|
|
var allowsLoopback: Bool { isFixture }
|
|
var isFixture: Bool {
|
|
#if DEBUG && targetEnvironment(simulator)
|
|
return ProcessInfo.processInfo.environment["SYBIL_PUSH_FIXTURE"] == "1"
|
|
#else
|
|
return false
|
|
#endif
|
|
}
|
|
private var fixturePermission: AgentNotificationPermission = .notDetermined
|
|
func permission() async -> AgentNotificationPermission {
|
|
if isFixture {
|
|
if ProcessInfo.processInfo.environment["SYBIL_PUSH_PERMISSION"] == "authorized" { return .authorized }
|
|
if ProcessInfo.processInfo.environment["SYBIL_PUSH_PERMISSION"] == "denied" { return .denied }
|
|
return fixturePermission
|
|
}
|
|
guard environment != nil else { return .notDetermined }
|
|
switch await center.notificationSettings().authorizationStatus {
|
|
case .authorized, .ephemeral: return .authorized
|
|
case .provisional: return .provisional
|
|
case .denied: return .denied
|
|
default: return .notDetermined
|
|
}
|
|
}
|
|
func requestPermission() async throws -> Bool {
|
|
if isFixture {
|
|
fixturePermission = ProcessInfo.processInfo.environment["SYBIL_PUSH_DENIED"] == "1" ? .denied : .authorized
|
|
return fixturePermission == .authorized
|
|
}
|
|
guard environment != nil else { return false }
|
|
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
|
}
|
|
func register(generation: UUID) {
|
|
guard environment != nil else { return }
|
|
if let outstanding {
|
|
if outstanding != generation { queued = generation }
|
|
return
|
|
}
|
|
outstanding = generation
|
|
if isFixture {
|
|
// Deliberately synthetic bytes; never call Apple's registrar in a simulator.
|
|
Task { didRegister(Data([0x00, 0x01, 0x0a, 0xff] + Array(repeating: 0x42, count: 28))) }
|
|
} else {
|
|
#if !targetEnvironment(simulator) && !targetEnvironment(macCatalyst)
|
|
UIApplication.shared.registerForRemoteNotifications()
|
|
#endif
|
|
}
|
|
}
|
|
func unregister() {
|
|
queued = nil; registeredGeneration = nil
|
|
#if !targetEnvironment(simulator) && !targetEnvironment(macCatalyst)
|
|
UIApplication.shared.unregisterForRemoteNotifications()
|
|
#endif
|
|
// Do not reassign an old callback to a new session.
|
|
}
|
|
func didRegister(_ data: Data) {
|
|
let run = outstanding ?? registeredGeneration
|
|
outstanding = nil
|
|
if let run {
|
|
registeredGeneration = run
|
|
SybilAgentNotifications.shared.receivedDeviceToken(data, generation: run)
|
|
}
|
|
drainQueued()
|
|
}
|
|
func didFail() {
|
|
let run = outstanding ?? registeredGeneration
|
|
outstanding = nil
|
|
if let run { SybilAgentNotifications.shared.registrationFailed(generation: run) }
|
|
drainQueued()
|
|
}
|
|
private func drainQueued() {
|
|
if let next = queued { queued = nil; register(generation: next) }
|
|
}
|
|
func delivered() async -> [AgentDeliveredNotification] {
|
|
if isFixture || environment == nil { return [] }
|
|
return await center.deliveredNotifications().map {
|
|
AgentDeliveredNotification(identifier: $0.request.identifier, payload: AgentPushPayload($0.request.content.userInfo))
|
|
}
|
|
}
|
|
func removeDelivered(identifiers: [String]) {
|
|
guard !isFixture, environment != nil else { return }
|
|
center.removeDeliveredNotifications(withIdentifiers: identifiers)
|
|
}
|
|
}
|
|
|
|
/// Public app-delegate bridge. The package coordinator remains injectable in tests.
|
|
@MainActor
|
|
public enum SybilPushNotifications {
|
|
public struct Event: Sendable {
|
|
let payload: AgentPushPayload?
|
|
public init(userInfo: [AnyHashable: Any]) { payload = AgentPushPayload(userInfo) }
|
|
}
|
|
#if DEBUG && targetEnvironment(simulator)
|
|
private static var fixtureWasActive = false
|
|
private static var fixtureForegroundSequence = 0
|
|
#endif
|
|
public static func didRegister(deviceToken: Data) { SybilNotificationPlatform.shared.didRegister(deviceToken) }
|
|
public static func didFailToRegister() { SybilNotificationPlatform.shared.didFail() }
|
|
|
|
public static func presentationOptions(event: Event) -> UNNotificationPresentationOptions {
|
|
guard let payload = event.payload else { return [] }
|
|
let notifications = SybilAgentNotifications.shared
|
|
Task { await notifications.syncRead() }
|
|
// Covers foreground bootstrap before a SwiftUI scene has attached too.
|
|
guard UIApplication.shared.applicationState != .active, notifications.shouldPresent(payload) else { return [] }
|
|
return [.banner, .list, .sound]
|
|
}
|
|
public static func didRespond(userInfo: [AnyHashable: Any], identifier: String) {
|
|
didRespond(event: Event(userInfo: userInfo), identifier: identifier)
|
|
}
|
|
public static func didRespond(event: Event, identifier: String) {
|
|
guard let payload = event.payload else { return }
|
|
SybilAgentNotifications.shared.notificationResponse(payload, identifier: identifier)
|
|
}
|
|
public static func receiveBackground(userInfo: [AnyHashable: Any], completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) {
|
|
guard let payload = AgentPushPayload(userInfo), payload.kind == .readState else { completion(.noData); return }
|
|
let notifications = SybilAgentNotifications.shared
|
|
let settings = SybilSettingsStore()
|
|
if let base = settings.normalizedAPIBaseURL {
|
|
notifications.restoreBackgroundScope(baseURL: base, token: settings.trimmedTokenOrNil, payload: payload)
|
|
}
|
|
AgentBackgroundCompletion.run(timeout: .seconds(5), operation: {
|
|
await notifications.receiveBackground(payload) ? .newData : .noData
|
|
}, completion: completion)
|
|
}
|
|
|
|
public static func launchFixtureIfRequested() {
|
|
#if DEBUG && targetEnvironment(simulator)
|
|
guard SybilNotificationPlatform.shared.isFixture,
|
|
ProcessInfo.processInfo.environment["SYBIL_PUSH_TAP"] == "1" else { return }
|
|
didRespond(userInfo: ["version": 1, "sourceId": "11111111-1111-4111-8111-111111111111", "kind": "message", "messageId": "outbound:3", "cursor": "3"], identifier: "fixture-system-request")
|
|
#endif
|
|
}
|
|
|
|
static func foregroundFixtureIfRequested() {
|
|
#if DEBUG && targetEnvironment(simulator)
|
|
guard SybilNotificationPlatform.shared.isFixture else { return }
|
|
let wasActive = fixtureWasActive
|
|
fixtureWasActive = true
|
|
guard wasActive, ProcessInfo.processInfo.environment["SYBIL_PUSH_TAP_ON_FOREGROUND"] == "1" else { return }
|
|
fixtureForegroundSequence += 1
|
|
didRespond(userInfo: ["version": 1, "sourceId": "11111111-1111-4111-8111-111111111111", "kind": "message", "messageId": "fixture-warm-tap-\(fixtureForegroundSequence)", "cursor": "3"], identifier: "fixture-warm-system-request")
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// A bounded callback gate: even an uncooperative notification-center fixture (or
|
|
/// OS suspension) cannot delay the fetch completion waiting for a network/socket.
|
|
@MainActor
|
|
enum AgentBackgroundCompletion {
|
|
static func run(timeout: Duration, operation: @escaping @MainActor () async -> UIBackgroundFetchResult,
|
|
completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) {
|
|
let gate = Gate(completion)
|
|
let work = Task { gate.finish(await operation()) }
|
|
Task {
|
|
try? await Task.sleep(for: timeout)
|
|
if !gate.finished { work.cancel(); gate.finish(.noData) }
|
|
}
|
|
}
|
|
@MainActor private final class Gate {
|
|
var finished = false
|
|
let completion: @MainActor (UIBackgroundFetchResult) -> Void
|
|
init(_ completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) { self.completion = completion }
|
|
func finish(_ result: UIBackgroundFetchResult) {
|
|
guard !finished else { return }
|
|
finished = true; completion(result)
|
|
}
|
|
}
|
|
}
|