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? @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 = [] @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 } }