344 lines
19 KiB
Swift
344 lines
19 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
/// One app-lifetime owner of push enrollment. OS addresses stay in memory; retired
|
|
/// installation IDs are never reused. Every suspension checks its session generation.
|
|
@MainActor @Observable
|
|
final class SybilAgentNotifications {
|
|
static let shared = SybilAgentNotifications(platform: SybilNotificationPlatform.shared, storage: .defaults())
|
|
|
|
private(set) var optedIn = false
|
|
private(set) var available = false
|
|
private(set) var permission: AgentNotificationPermission = .notDetermined
|
|
private(set) var status = "Connect to a server to configure notifications."
|
|
private(set) var pendingRouteID: UUID?
|
|
private(set) var generation = UUID()
|
|
private(set) var scope: AgentPushScope?
|
|
private(set) var pendingRevocations = 0
|
|
@ObservationIgnored private let platform: any AgentNotificationPlatform
|
|
@ObservationIgnored private let storage: AgentPushStorage
|
|
@ObservationIgnored private var saved = AgentPushPersistence()
|
|
@ObservationIgnored private var storageHealthy = true
|
|
@ObservationIgnored private var client: (any AgentPushAPIClienting)?
|
|
@ObservationIgnored private var backend: String?
|
|
@ObservationIgnored private var credentialScope: String?
|
|
@ObservationIgnored private var token: String?
|
|
@ObservationIgnored private var uploaded: AgentPushRegistration?
|
|
@ObservationIgnored private var enrollment: Task<Void, Never>?
|
|
@ObservationIgnored private var refreshing: UUID?
|
|
@ObservationIgnored private var pendingPayload: AgentPushPayload?
|
|
@ObservationIgnored private var consumedResponses: [String] = []
|
|
@ObservationIgnored private var readCursor = "0"
|
|
@ObservationIgnored private var foregroundScenes: Set<UUID> = []
|
|
@ObservationIgnored private var sessionBackend: String?
|
|
@ObservationIgnored private var sessionIdentity: String?
|
|
|
|
func transition(baseURL: URL?, token: String?) async {
|
|
// Multiple windows and ordinary reconnects share one app enrollment. Compare
|
|
// credentials only in memory; never derive/persist an owner from a token.
|
|
let canonical = baseURL.flatMap { AgentPushScope.canonicalBackend($0, allowLoopback: platform.allowsLoopback) }
|
|
let identity = canonical.flatMap { backend in token.map { AgentPushScope.credentialScope(backend: backend, token: $0) } }
|
|
guard sessionBackend != canonical || sessionIdentity != identity else { return }
|
|
let hadSession = sessionBackend != nil || sessionIdentity != nil
|
|
sessionBackend = canonical; sessionIdentity = identity
|
|
if hadSession { await deactivate() }
|
|
}
|
|
|
|
init(platform: any AgentNotificationPlatform, storage: AgentPushStorage) {
|
|
self.platform = platform; self.storage = storage
|
|
do { saved = try storage.read(); pendingRevocations = saved.tombstones.count }
|
|
catch { storageHealthy = false; status = "Notification settings could not be restored." }
|
|
}
|
|
|
|
private func persist() -> Bool {
|
|
do { try storage.write(saved); pendingRevocations = saved.tombstones.count; return true }
|
|
catch { storageHealthy = false; available = false; status = "Notification settings could not be saved."; return false }
|
|
}
|
|
private var recordIndex: Int? { scope.flatMap { scope in saved.records.firstIndex { $0.scope == scope } } }
|
|
|
|
/// Call BEFORE overwriting the old session settings/client. This invalidates OS,
|
|
/// config, read, permission and upload callbacks before attempting revocation.
|
|
@discardableResult func deactivate() async -> UUID {
|
|
let oldClient = client
|
|
let oldScope = scope
|
|
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
|
|
let retiringGeneration = generation
|
|
refreshing = nil; available = false; optedIn = false; readCursor = "0"
|
|
scope = nil; client = nil; backend = nil; credentialScope = nil
|
|
if oldScope != nil { pendingPayload = nil; pendingRouteID = nil }
|
|
platform.unregister()
|
|
if let oldScope { retire(oldScope, clearOptIn: true) }
|
|
// Server DELETE is a terminal UUID tombstone, even if PUT is in flight.
|
|
// Attempt it before settings are replaced. A stale PUT also retries revoke.
|
|
if let oldClient, let oldScope {
|
|
await revokePending(client: oldClient, scope: oldScope)
|
|
}
|
|
if generation == retiringGeneration {
|
|
status = pendingRevocations > 0 ? "Revocation pending. Reconnect to the original server to retry." : "Notifications are off."
|
|
}
|
|
return retiringGeneration
|
|
}
|
|
|
|
func bind(client: any AgentPushAPIClienting, baseURL: URL, authenticated: Bool, mode: String, token: String?) async {
|
|
await transition(baseURL: baseURL, token: token)
|
|
let requestedBackend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback)
|
|
let requestedIdentity = requestedBackend.flatMap { backend in token.map { AgentPushScope.credentialScope(backend: backend, token: $0) } }
|
|
guard sessionBackend == requestedBackend, sessionIdentity == requestedIdentity else { return }
|
|
guard authenticated, mode == "token", let token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, storageHealthy,
|
|
let backend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback) else {
|
|
if self.client != nil { await deactivate() }
|
|
available = false; status = "Notifications require a token-authenticated HTTPS server."; return
|
|
}
|
|
self.client = client; self.backend = backend
|
|
self.credentialScope = AgentPushScope.credentialScope(backend: backend, token: token)
|
|
await refresh()
|
|
}
|
|
|
|
func refresh() async {
|
|
guard refreshing == nil, let client, let backend, let credentialScope else { return }
|
|
let refreshID = UUID()
|
|
refreshing = refreshID
|
|
let run = generation
|
|
defer { if refreshing == refreshID { refreshing = nil } }
|
|
do {
|
|
let config = try await client.agentPushConfig()
|
|
guard generation == run else { return }
|
|
guard config.version == 1, let source = config.sourceId, AgentPushPayload.validSource(source) else {
|
|
available = false; status = "This server does not support Agent notifications."; return
|
|
}
|
|
let nextScope = AgentPushScope(backend: backend, credentialScope: credentialScope, sourceID: source.lowercased())
|
|
if let scope, scope != nextScope {
|
|
let retired = await deactivate()
|
|
guard generation == retired else { return }
|
|
self.client = client; self.backend = backend; self.credentialScope = credentialScope
|
|
await refresh()
|
|
return
|
|
}
|
|
await revokePending(client: client, scope: nextScope)
|
|
guard generation == run else { return }
|
|
scope = nextScope
|
|
if recordIndex == nil { saved.records.append(AgentPushRecord(scope: nextScope)); guard persist() else { return } }
|
|
optedIn = recordIndex.map { saved.records[$0].optedIn } ?? false
|
|
available = config.available && platform.environment != nil
|
|
permission = await platform.permission()
|
|
guard generation == run else { return }
|
|
if permission == .denied {
|
|
await disable()
|
|
guard self.scope == nextScope else { return }
|
|
status = "Notifications are denied in iOS Settings."
|
|
return
|
|
}
|
|
if !available {
|
|
if let index = recordIndex, saved.records[index].mayBeRegistered {
|
|
// Invalidate both the cached success and any outstanding PUT
|
|
// before replacing the UUID. A fresh callback must enroll the
|
|
// replacement even when APNs returns the same token.
|
|
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
|
|
refreshing = nil; platform.unregister()
|
|
let retirementRun = generation
|
|
retire(nextScope); await revokePending(client: client, scope: nextScope)
|
|
guard generation == retirementRun else { return }
|
|
}
|
|
status = "Agent notifications are unavailable on this server or build."
|
|
} else if optedIn && (permission == .authorized || permission == .provisional) {
|
|
status = "Registering notifications…"
|
|
// A fresh OS callback is required on each launch/foreground refresh.
|
|
platform.register(generation: run)
|
|
} else { status = optedIn ? "Enable notifications to allow alerts." : "Notifications are off." }
|
|
await syncRead()
|
|
} catch {
|
|
guard generation == run else { return }
|
|
available = false
|
|
status = "Agent notifications are unavailable. Retry after reconnecting."
|
|
}
|
|
}
|
|
|
|
/// The only code path that can display the system permission prompt.
|
|
func enable() async {
|
|
guard available, let index = recordIndex else { return }
|
|
let run = generation
|
|
do {
|
|
let current = await platform.permission()
|
|
guard generation == run else { return }
|
|
let allowed: Bool
|
|
if current == .notDetermined { allowed = try await platform.requestPermission() }
|
|
else { allowed = current == .authorized || current == .provisional }
|
|
guard generation == run else { return }
|
|
permission = await platform.permission()
|
|
guard generation == run else { return }
|
|
guard allowed else { status = "Notifications are denied in iOS Settings."; return }
|
|
saved.records[index].optedIn = true
|
|
guard persist() else { return }
|
|
optedIn = true; status = "Registering notifications…"
|
|
platform.register(generation: run)
|
|
} catch { if generation == run { status = "Could not request notification permission. Try again." } }
|
|
}
|
|
|
|
func disable() async {
|
|
guard let scope else { return }
|
|
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
|
|
let disablingGeneration = generation
|
|
refreshing = nil
|
|
optedIn = false; platform.unregister()
|
|
retire(scope, clearOptIn: true)
|
|
if let client { await revokePending(client: client, scope: scope) }
|
|
if generation == disablingGeneration {
|
|
status = pendingRevocations > 0 ? "Notifications off; revocation pending until this server reconnects." : "Notifications are off."
|
|
}
|
|
}
|
|
|
|
private func retire(_ scope: AgentPushScope, clearOptIn: Bool = false) {
|
|
guard let index = saved.records.firstIndex(where: { $0.scope == scope }) else { return }
|
|
let old = saved.records[index]
|
|
if self.scope == scope { uploaded = nil; token = nil }
|
|
if old.mayBeRegistered && !saved.tombstones.contains(where: { $0.installationID == old.installationID }) {
|
|
saved.tombstones.append(old)
|
|
}
|
|
saved.records[index] = AgentPushRecord(scope: scope, optedIn: clearOptIn ? false : old.optedIn)
|
|
_ = persist()
|
|
}
|
|
|
|
private func revokePending(client: any AgentPushAPIClienting, scope: AgentPushScope) async {
|
|
for record in saved.tombstones.filter({ $0.scope == scope }) {
|
|
do {
|
|
try await client.agentRevokePush(id: record.installationID)
|
|
saved.tombstones.removeAll { $0.installationID == record.installationID }
|
|
_ = persist()
|
|
} catch { /* Durable nonsecret tombstone survives offline/logout. */ }
|
|
}
|
|
}
|
|
|
|
func receivedDeviceToken(_ data: Data, generation run: UUID) {
|
|
guard generation == run, available, optedIn, !data.isEmpty, data.count <= 256 else { return }
|
|
token = data.map { String(format: "%02x", $0) }.joined()
|
|
startEnrollment()
|
|
}
|
|
func registrationFailed(generation run: UUID) {
|
|
guard generation == run else { return }
|
|
status = "iOS registration failed. Retry when connected."
|
|
}
|
|
|
|
private func startEnrollment() {
|
|
guard enrollment == nil, optedIn, available, let client, let token,
|
|
let environment = platform.environment, let index = recordIndex else { return }
|
|
let registration = AgentPushRegistration(token: token, environment: environment)
|
|
guard uploaded != registration else { status = "Notifications are enabled."; return }
|
|
let record = saved.records[index], run = generation
|
|
// Persist revocation intent BEFORE the first request, even if its response is lost.
|
|
saved.records[index].mayBeRegistered = true
|
|
guard persist() else { return }
|
|
enrollment = Task { [weak self] in
|
|
guard let self else { return }
|
|
defer {
|
|
if self.generation == run {
|
|
self.enrollment = nil
|
|
if self.token != token { self.startEnrollment() }
|
|
}
|
|
}
|
|
for attempt in 0..<3 {
|
|
do {
|
|
let receipt = try await client.agentRegisterPush(id: record.installationID, registration: registration)
|
|
guard self.generation == run else {
|
|
await self.revokePending(client: client, scope: record.scope); return
|
|
}
|
|
guard UUID(uuidString: receipt.installationId) == record.installationID,
|
|
receipt.transport == "apns", receipt.enabled, receipt.version > 0 else { throw APIError.invalidResponse }
|
|
guard let index = self.recordIndex, self.saved.records[index].installationID == record.installationID else { return }
|
|
self.saved.records[index].version = receipt.version
|
|
guard self.persist() else { return }
|
|
self.uploaded = registration; self.status = "Notifications are enabled."
|
|
return
|
|
} catch {
|
|
guard self.generation == run else {
|
|
await self.revokePending(client: client, scope: record.scope); return
|
|
}
|
|
if let failure = error as? AgentPushRequestError, !failure.retryable {
|
|
self.status = "The server rejected this notification registration. Disable it and check the server configuration before enabling again."
|
|
return
|
|
}
|
|
self.status = "Registration pending. Retry when connected."
|
|
if attempt < 2 { try? await Task.sleep(for: .seconds(attempt + 1)) }
|
|
guard self.generation == run else { return }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func setScene(_ id: UUID, active: Bool) {
|
|
if active { foregroundScenes.insert(id) } else { foregroundScenes.remove(id) }
|
|
}
|
|
|
|
func shouldPresent(_ payload: AgentPushPayload) -> Bool {
|
|
// Foreground updates already flow through the Agent transcript. Never mark
|
|
// them read here; the visible, bottom-rendered, settled guard owns that.
|
|
matches(payload) && payload.kind == .message && foregroundScenes.isEmpty && AgentCursor.less(readCursor, payload.cursor)
|
|
}
|
|
|
|
private func matches(_ payload: AgentPushPayload) -> Bool {
|
|
guard let scope, scope.sourceID == payload.sourceID else { return false }
|
|
// The wire payload has no backend origin. If one source is known at two
|
|
// origins, refuse to route/clear rather than guess which server sent it.
|
|
// Credentials may rotate for the same shared personal owner and runtime.
|
|
// Old revoke tombstones must not veto a newly consented installation there.
|
|
return !(saved.records + saved.tombstones).contains {
|
|
$0.scope.sourceID == payload.sourceID &&
|
|
($0.scope.backend != scope.backend || $0.scope.owner != scope.owner)
|
|
}
|
|
}
|
|
|
|
func syncRead() async {
|
|
guard let client else { return }
|
|
let run = generation
|
|
if let read = try? await client.agentReadState(), generation == run { await acknowledged(read, generation: run) }
|
|
}
|
|
|
|
func acknowledged(_ read: AgentReadState, generation run: UUID) async {
|
|
guard generation == run, let source = read.sourceId, source.lowercased() == scope?.sourceID,
|
|
let cursor = AgentPushPayload.validCursor(read.cursor) else { return }
|
|
readCursor = (try? AgentCursor.advance(readCursor, cursor)) ?? readCursor
|
|
await clearDelivered(generation: run)
|
|
}
|
|
|
|
private func clearDelivered(generation run: UUID) async {
|
|
let delivered = await platform.delivered()
|
|
guard generation == run else { return }
|
|
let ids = delivered.compactMap { notification -> String? in
|
|
guard let payload = notification.payload, matches(payload), payload.kind == .message,
|
|
!AgentCursor.less(readCursor, payload.cursor) else { return nil }
|
|
return notification.identifier
|
|
}
|
|
if !ids.isEmpty { platform.removeDelivered(identifiers: ids) }
|
|
}
|
|
|
|
func receiveBackground(_ payload: AgentPushPayload) async -> Bool {
|
|
guard payload.kind == .readState, matches(payload) else { return false }
|
|
await acknowledged(AgentReadState(cursor: payload.cursor, sourceId: payload.sourceID), generation: generation)
|
|
// No foreground socket, no PUT read receipt, no badge reset. APNs is the
|
|
// source of this scoped read hint; foreground GET converges missed hints.
|
|
return true
|
|
}
|
|
|
|
/// Cold background delivery may run before SwiftUI/auth bootstrap. Only restore
|
|
/// the opted-in record at the currently saved backend; never enroll from here.
|
|
func restoreBackgroundScope(baseURL: URL, token: String?, payload: AgentPushPayload) {
|
|
guard client == nil, let token, !token.isEmpty, let backend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback),
|
|
let record = saved.records.first(where: { $0.scope.backend == backend && $0.scope.credentialScope == AgentPushScope.credentialScope(backend: backend, token: token) && $0.scope.sourceID == payload.sourceID && $0.optedIn && $0.mayBeRegistered }) else { return }
|
|
scope = record.scope
|
|
}
|
|
|
|
func notificationResponse(_ payload: AgentPushPayload, identifier: String) {
|
|
guard payload.kind == .message else { return }
|
|
let identity = payload.sourceID + "|" + (payload.messageID ?? "") + "|" + payload.cursor
|
|
guard !consumedResponses.contains(identity) else { return }
|
|
consumedResponses.append(identity)
|
|
if consumedResponses.count > 64 { consumedResponses.removeFirst() }
|
|
pendingPayload = payload; pendingRouteID = UUID()
|
|
}
|
|
|
|
func consumeRoute() -> Bool {
|
|
guard let payload = pendingPayload, client != nil, matches(payload) else { return false }
|
|
pendingPayload = nil; pendingRouteID = nil
|
|
return true
|
|
}
|
|
}
|