feat(agent): add opt-in push notifications

This commit is contained in:
2026-10-14 22:03:21 -07:00
parent 80748f764f
commit a1c0264d36
51 changed files with 4815 additions and 121 deletions
@@ -11,6 +11,7 @@ public struct SplitView: View {
@State private var hasPendingQuickQuestionPresentation = false
@State private var isQuickQuestionPresented = false
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
@State private var notificationSceneID = UUID()
private var keyboardActions: SybilKeyboardActions? {
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
@@ -92,8 +93,14 @@ public struct SplitView: View {
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
await viewModel.bootstrap()
SybilPushNotifications.foregroundFixtureIfRequested()
viewModel.notifications.setScene(notificationSceneID, active: scenePhase == .active)
routePendingNotification()
presentPendingQuickQuestionIfPossible()
}
.onChange(of: viewModel.notifications.pendingRouteID) { _, _ in routePendingNotification() }
.onChange(of: viewModel.notifications.scope) { _, _ in routePendingNotification() }
.onDisappear { viewModel.notifications.setScene(notificationSceneID, active: false) }
.onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in
guard request > 0 else {
return
@@ -101,6 +108,7 @@ public struct SplitView: View {
queueQuickQuestionPresentation()
}
.onChange(of: viewModel.isCheckingSession) { _, _ in
routePendingNotification()
presentPendingQuickQuestionIfPossible()
}
.onChange(of: viewModel.isAuthenticated) { _, _ in
@@ -110,11 +118,14 @@ public struct SplitView: View {
viewModel.agent.isModalPresented = presented
}
.onChange(of: scenePhase) { _, nextPhase in
viewModel.notifications.setScene(notificationSceneID, active: nextPhase == .active)
switch nextPhase {
case .background:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
case .active:
SybilPushNotifications.foregroundFixtureIfRequested()
routePendingNotification()
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
@@ -138,6 +149,13 @@ public struct SplitView: View {
}
}
private func routePendingNotification() {
guard scenePhase == .active, viewModel.consumeAgentNotificationRoute() else { return }
hasPendingQuickQuestionPresentation = false
isQuickQuestionPresented = false
columnVisibility = .detailOnly
}
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
return size.width < size.height ? .showSidebar : .hidden
}
@@ -18,7 +18,7 @@ struct AnyEncodable: Encodable {
}
}
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting, AgentPushAPIClienting {
private let configuration: APIConfiguration
private let session: URLSession
@@ -64,8 +64,51 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
try await request("/v1/agent/read-state", method: "GET", responseType: AgentReadState.self)
}
func agentPushConfig() async throws -> AgentPushConfig {
let data = try await pushRequest(path: "/v1/agent/push/config", method: "GET")
guard let config = try? JSONDecoder().decode(AgentPushConfig.self, from: data) else { throw APIError.invalidResponse }
return config
}
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt {
let data = try await pushRequest(path: "/v1/agent/push/installations/\(id.uuidString.lowercased())", method: "PUT", body: AnyEncodable(registration))
guard let receipt = try? JSONDecoder().decode(AgentPushReceipt.self, from: data) else { throw APIError.invalidResponse }
return receipt
}
func agentRevokePush(id: UUID) async throws {
_ = try await pushRequest(path: "/v1/agent/push/installations/\(id.uuidString.lowercased())", method: "DELETE")
}
private func pushRequest(path: String, method: String, body: AnyEncodable? = nil) async throws -> Data {
#if DEBUG && targetEnvironment(simulator)
let allowLoopback = true // Local URLProtocol/UI fixtures; no device enrollment.
#else
let allowLoopback = false
#endif
guard AgentPushScope.canonicalBackend(configuration.baseURL, allowLoopback: allowLoopback) != nil,
configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { throw APIError.invalidBaseURL }
var request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
request.timeoutInterval = 8
request.setValue("no-store", forHTTPHeaderField: "Cache-Control")
// Same bounded, redirect-rejecting authenticated Agent transport as attachments.
// Never decode/log a server error body that might reflect an address or token.
let (data, status, _) = try await attachmentBytes(request, maximum: 64 * 1024)
guard method == "DELETE" ? status == 204 : (200...299).contains(status) else { throw AgentPushRequestError(status: status) }
return data
}
func agentMarkRead(cursor: String) async throws -> AgentReadState {
try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(["cursor": try AgentCursor.normalize(cursor)]), responseType: AgentReadState.self)
try await agentMarkRead(cursor: cursor, sourceID: nil)
}
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
var body = ["cursor": try AgentCursor.normalize(cursor)]
if let sourceID {
guard AgentPushPayload.validSource(sourceID) else { throw APIError.invalidResponse }
body["sourceId"] = sourceID
}
return try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(body), responseType: AgentReadState.self)
}
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
@@ -73,6 +73,7 @@ struct AgentPage: Codable, Sendable {
struct AgentReadState: Codable, Sendable {
var cursor: String
var updatedAt: String?
var sourceId: String? = nil
}
struct AgentReceipt: Codable, Sendable {
var id: Int64
@@ -95,6 +96,7 @@ enum AgentEvent: Sendable {
case ready(String?)
case messages(AgentPage)
case activity(String, AgentActivity)
case readState(AgentReadState)
case heartbeat
case error(String)
@@ -118,6 +120,13 @@ enum AgentEvent: Sendable {
}
return .activity(id, activity)
case "pong", "heartbeat": return .heartbeat
case "read_state":
// Additive optional frame: malformed new metadata must not tear down
// an otherwise compatible Agent connection.
guard let read = try? AgentCoding.decoder().decode(AgentReadState.self, from: data),
AgentPushPayload.validCursor(read.cursor) != nil,
read.sourceId.map(AgentPushPayload.validSource) ?? true else { return nil }
return .readState(read)
case "error": return .error(envelope.error ?? "Agent connection error.")
default: return nil
}
@@ -0,0 +1,159 @@
import Foundation
import CoreFoundation
import CryptoKit
struct AgentPushConfig: Codable, Sendable {
var version: Int
var enabled: Bool
var apnsAvailable: Bool
var webPushAvailable: Bool
var sourceId: String?
var quarantined: Bool
var available: Bool { version == 1 && enabled && apnsAvailable && !quarantined && sourceId.map(AgentPushPayload.validSource) == true }
}
enum AgentAPNSEnvironment: String, Codable, Sendable {
case sandbox, production
// The app's Info.plist and entitlement expand the SAME XcodeGen build setting.
// Never guess from DEBUG, token length, simulator, or marketing version.
init?(entitlementSetting: String?) {
switch entitlementSetting {
case "development": self = .sandbox
case "production": self = .production
default: return nil
}
}
}
struct AgentPushRegistration: Encodable, Equatable, Sendable {
let transport = "apns"
let enabled = true
var token: String
var environment: AgentAPNSEnvironment
let topic = "net.buzzert.sybil2"
}
struct AgentPushReceipt: Codable, Sendable {
var installationId: String
var transport: String
var enabled: Bool
var version: Int
}
protocol AgentPushAPIClienting: Sendable {
func agentPushConfig() async throws -> AgentPushConfig
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt
func agentRevokePush(id: UUID) async throws
func agentReadState() async throws -> AgentReadState
}
struct AgentPushRequestError: LocalizedError, Sendable {
var status: Int
var retryable: Bool { status == 408 || status == 429 || status >= 500 }
var errorDescription: String? { "Agent notification request failed (\(status))." }
}
struct AgentPushScope: Codable, Equatable, Hashable, Sendable {
var backend: String
// ADMIN_TOKEN identifies one shared personal principal, not individual people.
var owner = "shared-personal-admin"
var credentialScope: String
var sourceID: String
static func credentialScope(backend: String, token: String) -> String {
// Nonsecret consent epoch, never logged. A credential replacement requires
// new consent even when the server still represents one personal principal.
SHA256.hash(data: Data((backend + "\n" + token.trimmingCharacters(in: .whitespacesAndNewlines)).utf8))
.map { String(format: "%02x", $0) }.joined()
}
static func canonicalBackend(_ url: URL, allowLoopback: Bool = false) -> String? {
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = parts.host?.lowercased(), parts.user == nil, parts.password == nil,
parts.query == nil, parts.fragment == nil,
parts.scheme?.lowercased() == "https" || (allowLoopback && parts.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(host)) else { return nil }
parts.scheme = parts.scheme?.lowercased(); parts.host = host
if (parts.scheme == "https" && parts.port == 443) || (parts.scheme == "http" && parts.port == 80) { parts.port = nil }
let path = parts.path
guard !path.contains("//"), !path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) else { return nil }
parts.path = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty ? "" : "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return parts.url?.absoluteString
}
}
struct AgentPushPayload: Equatable, Sendable {
enum Kind: String { case message, readState = "read_state" }
var sourceID: String
var kind: Kind
var cursor: String
var messageID: String?
init?(_ info: [AnyHashable: Any]) {
guard let version = info["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(), version == 1,
let source = info["sourceId"] as? String, Self.validSource(source),
let name = info["kind"] as? String, let kind = Kind(rawValue: name),
let raw = info[kind == .message ? "cursor" : "readCursor"] as? String,
let cursor = Self.validCursor(raw) else { return nil }
let message = info["messageId"] as? String
if kind == .message && (message?.isEmpty != false || (message?.utf8.count ?? 0) > 256) { return nil }
self.sourceID = source.lowercased(); self.kind = kind; self.cursor = cursor; self.messageID = message
}
static func validSource(_ value: String) -> Bool {
value.utf8.count == 36 && UUID(uuidString: value) != nil
}
static func validCursor(_ value: String) -> String? {
guard value.utf8.count <= 16, let normalized = try? AgentCursor.normalize(value),
!AgentCursor.less("9007199254740991", normalized) else { return nil }
return normalized
}
}
struct AgentDeliveredNotification: Sendable {
var identifier: String // UNNotification.request.identifier, never messageId.
var payload: AgentPushPayload?
}
enum AgentNotificationPermission: Sendable { case notDetermined, denied, authorized, provisional }
@MainActor
protocol AgentNotificationPlatform: AnyObject {
var environment: AgentAPNSEnvironment? { get }
var allowsLoopback: Bool { get }
func permission() async -> AgentNotificationPermission
func requestPermission() async throws -> Bool
func register(generation: UUID)
func unregister()
func delivered() async -> [AgentDeliveredNotification]
func removeDelivered(identifiers: [String])
}
struct AgentPushRecord: Codable, Equatable {
var scope: AgentPushScope
var installationID = UUID()
var optedIn = false
var mayBeRegistered = false
var version: Int?
}
struct AgentPushPersistence: Codable {
var records: [AgentPushRecord] = []
var tombstones: [AgentPushRecord] = []
}
@MainActor
struct AgentPushStorage {
var read: () throws -> AgentPushPersistence
var write: (AgentPushPersistence) throws -> Void
static func defaults(_ defaults: UserDefaults = .standard) -> Self {
let key = "sybil.ios.agentPush.v1"
return Self(read: {
guard let data = defaults.data(forKey: key) else { return .init() }
return try JSONDecoder().decode(AgentPushPersistence.self, from: data)
}, write: { value in
// No authentication credential or device token is ever persisted here.
defaults.set(try JSONEncoder().encode(value), forKey: key)
})
}
}
@@ -0,0 +1,343 @@
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
}
}
@@ -15,6 +15,7 @@ final class SybilAgentRoom {
private(set) var connection = "Offline"
private(set) var error: String?
private(set) var isLoading = false
private(set) var requiresReload = false
private(set) var outbox: [AgentOutboxRow] = []
let attachmentDraft = AgentAttachmentDraft()
private(set) var attachmentSessionID = UUID()
@@ -23,6 +24,11 @@ final class SybilAgentRoom {
func setAttachmentModal(_ id: UUID, presented: Bool) {
if presented { attachmentModals.insert(id) } else { attachmentModals.remove(id) }
}
func dismissAttachmentPresentations() {
// Existing picker/share views observe this identity and dismiss themselves.
// Keep draft contents and transcript/view identity intact during routing.
attachmentSessionID = UUID()
}
var composer: String {
get { attachmentDraft.caption }
set { attachmentDraft.caption = newValue }
@@ -31,6 +37,15 @@ final class SybilAgentRoom {
var isModalPresented = false
var isSelected = false
private(set) var isActive = true
@ObservationIgnored var onAcknowledgedRead: ((AgentReadState) -> Void)?
private enum ReadSourceBinding: Equatable {
case unbound, legacy, source(String)
}
@ObservationIgnored private var readBinding: ReadSourceBinding = .unbound
private var readSourceID: String? {
if case let .source(id) = readBinding { return id }
return nil
}
@ObservationIgnored private var attachmentDownloads = AgentAttachmentDownloads()
@ObservationIgnored private var client: (any AgentAPIClienting)?
@@ -89,8 +104,7 @@ final class SybilAgentRoom {
Task { await oldDownloads.invalidate() }
attachmentDownloads = AgentAttachmentDownloads()
attachmentDraft.configure(client: client, storage: draftStorage)
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
resetHistory()
error = nil; storageReadable = true
do {
outbox = try storage.read().map { row in
@@ -154,10 +168,32 @@ final class SybilAgentRoom {
}
}
func retryConnection() { flush(); stop(); if isActive { start() } }
func retryConnection() {
flush(); stop()
// Explicit Reconnect starts a new history/source boundary. Foreground and
// network recovery must not relabel the preserved old room automatically.
if requiresReload { resetHistory(); error = nil }
if isActive { start() }
}
private func resetHistory() {
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
readBinding = .unbound; requiresReload = false
}
private func requireReload(_ reason: String) -> AgentFailure {
let failure = AgentFailure(message: reason + " Reconnect to load fresh Agent history.")
requiresReload = true
// Preserve already accepted old cursors/history, cancel every old writer,
// and invalidate callbacks before allowing any further source observation.
flush(); stop()
isLoading = false; connection = "Reload required"; error = failure.localizedDescription
return failure
}
private func start() {
guard isActive, let client, connectionTask == nil else { return }
guard !requiresReload, isActive, let client, connectionTask == nil else { return }
let run = generation
isLoading = !hasLoaded
connection = "Connecting"
@@ -168,7 +204,7 @@ final class SybilAgentRoom {
guard let self, self.generation == run else { return }
let recovered = self.wasNetworkOffline && satisfied
self.wasNetworkOffline = !satisfied
if recovered { self.retryConnection() }
if recovered && !self.requiresReload { self.retryConnection() }
}
}
monitor.start(queue: DispatchQueue(label: "net.buzzert.sybil.agent-network"))
@@ -245,7 +281,7 @@ final class SybilAgentRoom {
func catchUp(client: any AgentAPIClienting, run: UUID? = nil, replay: Bool = false) async throws {
let run = run ?? generation
guard generation == run, !Task.isCancelled, !syncing else { return }
guard !requiresReload, generation == run, !Task.isCancelled, !syncing else { return }
syncing = true
defer { if generation == run { syncing = false } }
var cursor = pending.cursor
@@ -264,15 +300,21 @@ final class SybilAgentRoom {
}
func receive(_ event: AgentEvent, replay: Bool = false) throws {
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
let before = pending
var next = pending
switch event {
case let .messages(page): try next.apply(page)
case let .messages(page):
try next.apply(page)
// History has no source field. Pin even an empty first page before
// publishing it so a later UUID cannot adopt legacy cursor space.
if readBinding == .unbound { readBinding = .legacy }
case let .activity(id, activity):
try next.checkConversation(id)
next.activity = activity
case let .ready(id): if let id { try next.checkConversation(id) }
case let .error(message): throw AgentFailure(message: message)
case let .readState(read): try applyRead(read); return
case .heartbeat: return
}
presenter.receive(event, before: before, after: next, replay: replay, now: Date())
@@ -315,32 +357,53 @@ final class SybilAgentRoom {
try applyRead(read)
}
func applyRead(_ read: AgentReadState) throws {
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, read.cursor)
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
let cursor = try AgentCursor.normalize(read.cursor)
let incoming: ReadSourceBinding
if let source = read.sourceId {
guard AgentPushPayload.validSource(source) else { throw requireReload("Agent read source is invalid.") }
incoming = .source(source.lowercased())
} else { incoming = .legacy }
guard readBinding == .unbound || readBinding == incoming else {
throw requireReload("Agent read source changed.")
}
readBinding = incoming
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, cursor)
pending.readCursor = acknowledgedRead
schedulePublish()
onAcknowledgedRead?(read)
}
func markRead(renderedCursor: String) {
guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return }
guard !requiresReload, hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return }
do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() }
catch { self.error = error.localizedDescription }
}
private func flushRead() {
guard let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
guard !requiresReload, readBinding != .unbound, let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
let run = generation
readTask = Task { [weak self] in
guard let self else { return }
defer { if self.generation == run { self.readTask = nil } }
do {
while self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
while !self.requiresReload, self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
let captured = self.desiredRead
let read = try await client.agentMarkRead(cursor: captured)
let read = try await client.agentMarkRead(cursor: captured, sourceID: self.readSourceID)
guard self.generation == run, !Task.isCancelled else { return }
guard !AgentCursor.less(try AgentCursor.normalize(read.cursor), captured) else {
throw AgentFailure(message: "Agent read state was not acknowledged.")
guard let cursor = try? AgentCursor.normalize(read.cursor), !AgentCursor.less(cursor, captured) else {
throw self.requireReload("Agent read state was not acknowledged.")
}
try self.applyRead(read)
}
} catch { if self.generation == run { self.error = error.localizedDescription } }
} catch {
if self.generation == run {
let isHTTPConflict: Bool
if case APIError.httpError(statusCode: 409, message: _) = error { isHTTPConflict = true }
else { isHTTPConflict = (error as? AgentAttachmentIssue)?.statusCode == 409 }
if isHTTPConflict {
_ = self.requireReload("Agent read source changed.")
} else { self.error = error.localizedDescription }
}
}
}
}
@@ -364,6 +427,7 @@ final class SybilAgentRoom {
@discardableResult
func send(_ content: String, attachments: [AgentAttachment] = [], draftID: String? = nil) -> Bool {
guard !requiresReload else { return false }
do {
_ = try AgentSubmission(content: content, requestID: UUID().uuidString, attachments: attachments)
if !attachments.isEmpty {
@@ -398,6 +462,7 @@ final class SybilAgentRoom {
try storage.write(outbox.filter { !$0.confirmed })
}
private func dispatch(_ id: String) {
guard !requiresReload else { return }
guard let client, sends[id] == nil, let index = outbox.firstIndex(where: { $0.id == id }), !outbox[index].seenOnServer else { return }
if outbox[index].draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() { return }
outbox[index].status = "sending"; outbox[index].error = nil
@@ -12,6 +12,7 @@ protocol AgentAPIClienting: Sendable {
func agentMessages(after: String?, before: String?) async throws -> AgentPage
func agentReadState() async throws -> AgentReadState
func agentMarkRead(cursor: String) async throws -> AgentReadState
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment
@@ -51,6 +52,7 @@ extension APIConfiguration {
let token = authToken?.trimmingCharacters(in: .whitespacesAndNewlines)
let value: JSONValue = .object([
"type": .string("auth"),
"clientKind": .string("native"),
"token": token.flatMap { $0.isEmpty ? nil : JSONValue.string($0) } ?? .null,
"after": .string(try AgentCursor.normalize(after))
])
@@ -60,6 +62,9 @@ extension APIConfiguration {
// Text-only test/legacy clients fail closed for the new wire path.
extension AgentAPIClienting {
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
try await agentMarkRead(cursor: cursor)
}
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
guard submission.attachmentIDs.isEmpty else { throw AgentAttachmentIssue.unavailable }
return try await agentSubmit(content: submission.content, requestID: submission.requestId)
@@ -0,0 +1,191 @@
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)
}
}
}
@@ -38,6 +38,11 @@ struct SybilPhoneShellView: View {
@State private var sidebarHighlightClearTask: Task<Void, Never>?
@State private var openingSelectionRequestID: UUID?
init(viewModel: SybilViewModel) {
self.viewModel = viewModel
_route = State(initialValue: viewModel.selectedItem == .agent ? .agent : .draftChat)
}
private var canRecognizeSidebarSwipe: Bool {
!isSidebarOverlayPresented && !sidebarSwipeIsCompleting
}
@@ -94,6 +99,10 @@ struct SybilPhoneShellView: View {
viewModel.agent.isObscured = covered
}
.onDisappear { viewModel.agent.isObscured = false }
.onChange(of: viewModel.agentNotificationRouteRequest) { _, _ in
openingSelectionRequestID = nil
showRouteAndClearSidebarHighlight(.agent)
}
.onChange(of: scenePhase) { _, nextPhase in
switch nextPhase {
case .background:
@@ -1,5 +1,6 @@
import Observation
import SwiftUI
import UIKit
struct SybilSettingsView: View {
@Bindable var viewModel: SybilViewModel
@@ -86,6 +87,35 @@ struct SybilSettingsView: View {
.foregroundStyle(SybilTheme.textMuted)
}
VStack(alignment: .leading, spacing: 10) {
Text("Agent Notifications").font(.sybil(.title3, weight: .semibold))
Text("Get an alert when the Agent has a new message. Previews say only “You have a new message.” Reading elsewhere clears older alerts when iOS allows the app to sync.")
.font(.sybil(.footnote)).foregroundStyle(SybilTheme.textMuted)
Text(viewModel.notifications.status)
.font(.sybil(.footnote)).accessibilityIdentifier("agent-push-status")
if viewModel.notifications.pendingRevocations > 0 {
Text("A previous registration is awaiting revocation. It will retry when you reconnect with its original server credentials.")
.font(.sybil(.footnote)).foregroundStyle(SybilTheme.textMuted)
}
if viewModel.notifications.optedIn {
Button("Disable Notifications") { Task { await viewModel.notifications.disable() } }
.accessibilityIdentifier("agent-push-disable")
} else {
Button("Enable Notifications") { Task { await viewModel.notifications.enable() } }
.disabled(!viewModel.notifications.available)
.accessibilityIdentifier("agent-push-enable")
}
if viewModel.notifications.permission == .denied {
Button("Open iOS Notification Settings") {
if let url = URL(string: UIApplication.openNotificationSettingsURLString) { UIApplication.shared.open(url) }
}
}
Button("Refresh Notification Status") { Task { await viewModel.notifications.refresh() } }
.accessibilityIdentifier("agent-push-refresh")
}
.buttonStyle(.bordered)
.tint(SybilTheme.primarySoft)
if let authError = viewModel.authError {
Text(authError)
.font(.sybil(.footnote))
@@ -157,6 +157,9 @@ private actor SearchStreamStatus {
final class SybilViewModel {
let settings: SybilSettingsStore
let agent = SybilAgentRoom()
let notifications: SybilAgentNotifications
private(set) var agentNotificationRouteRequest = 0
@ObservationIgnored private var sessionGeneration = UUID()
var isCheckingSession = true
var isAuthenticated = false
@@ -245,11 +248,13 @@ final class SybilViewModel {
init(
settings: SybilSettingsStore = SybilSettingsStore(),
notifications: SybilAgentNotifications? = nil,
clientFactory: @escaping (APIConfiguration) -> any SybilAPIClienting = { configuration in
SybilAPIClient(configuration: configuration)
}
) {
self.settings = settings
self.notifications = notifications ?? .shared
self.clientFactory = clientFactory
let initialProvider = settings.preferredProvider
let initialModel = settings.preferredModelByProvider[initialProvider] ?? "gpt-4.1-mini"
@@ -640,9 +645,13 @@ final class SybilViewModel {
}
func reconnect() async {
let run = UUID()
sessionGeneration = run
agent.disconnect()
agent.onAcknowledgedRead = nil
setSidebarSearchQuery("")
isCheckingSession = true
isAuthenticated = false
authError = nil
errorMessage = nil
resetSelectionLoading()
@@ -659,7 +668,13 @@ final class SybilViewModel {
resetQuickQuestion()
draftIdentity = UUID()
composerAttachments = []
// Keep the old authenticated push client alive until revoke has been
// attempted; never use the edited URL/token to revoke an old registration.
await notifications.transition(baseURL: settings.normalizedAPIBaseURL, token: settings.trimmedTokenOrNil)
guard sessionGeneration == run else { return }
settings.persist()
let baseURL = settings.normalizedAPIBaseURL
let pushToken = settings.trimmedTokenOrNil
SybilLog.info(
SybilLog.app,
@@ -669,6 +684,7 @@ final class SybilViewModel {
do {
let client = try client()
let session = try await client.verifySession()
guard sessionGeneration == run else { return }
isAuthenticated = session.authenticated
authMode = session.mode
authError = nil
@@ -679,12 +695,23 @@ final class SybilViewModel {
)
await loadInitialData(using: client)
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL = settings.normalizedAPIBaseURL {
guard sessionGeneration == run else { return }
if let pushClient = client as? any AgentPushAPIClienting, let baseURL {
await notifications.bind(client: pushClient, baseURL: baseURL, authenticated: session.authenticated, mode: session.mode, token: pushToken)
}
guard sessionGeneration == run else { return }
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL {
agent.onAcknowledgedRead = { [weak self] read in
guard let self, self.sessionGeneration == run else { return }
let pushRun = self.notifications.generation
Task { await self.notifications.acknowledged(read, generation: pushRun) }
}
agent.configure(client: agentClient, storage: .file(baseURL: baseURL), draftStorage: .file(baseURL: baseURL))
agent.setActive(isAppActive)
}
startActiveRunPolling()
} catch {
guard sessionGeneration == run else { return }
stopActiveRunPolling()
isAuthenticated = false
authMode = nil
@@ -1143,7 +1170,6 @@ final class SybilViewModel {
func refreshAfterSettingsChange() async {
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
settings.persist()
await reconnect()
}
@@ -1159,8 +1185,19 @@ final class SybilViewModel {
}
func markAppActiveForNetwork() {
let wasInactive = !isAppActive
isAppActive = true
agent.setActive(true)
if wasInactive { Task { await notifications.refresh() } }
}
@discardableResult
func consumeAgentNotificationRoute() -> Bool {
guard isAuthenticated, !isCheckingSession, notifications.consumeRoute() else { return false }
agent.dismissAttachmentPresentations()
select(.agent)
agentNotificationRouteRequest += 1
return true
}
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {