feat(agent): add opt-in push notifications
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SybilAPNSEnvironment</key>
|
||||
<string>$(SYBIL_APNS_ENVIRONMENT)</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array><string>remote-notification</string></array>
|
||||
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Sybil
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
struct SybilApp: App
|
||||
@@ -18,11 +19,13 @@ struct SybilApp: App
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||
final class SybilAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
SybilPushNotifications.launchFixtureIfRequested()
|
||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.start()
|
||||
@@ -30,6 +33,31 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
SybilPushNotifications.didRegister(deviceToken: deviceToken)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
SybilPushNotifications.didFailToRegister()
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
SybilPushNotifications.receiveBackground(userInfo: userInfo) { result in completionHandler(result) }
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
||||
let event = SybilPushNotifications.Event(userInfo: notification.request.content.userInfo)
|
||||
return await SybilPushNotifications.presentationOptions(event: event)
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
|
||||
if response.actionIdentifier == UNNotificationDefaultActionIdentifier {
|
||||
let event = SybilPushNotifications.Event(userInfo: response.notification.request.content.userInfo)
|
||||
await SybilPushNotifications.didRespond(event: event, identifier: response.notification.request.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
SybilMacQuickQuestionController.shared.stop()
|
||||
@@ -73,6 +101,9 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
if let shortcutItem = connectionOptions.shortcutItem {
|
||||
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||
}
|
||||
if let response = connectionOptions.notificationResponse, response.actionIdentifier == UNNotificationDefaultActionIdentifier {
|
||||
SybilPushNotifications.didRespond(userInfo: response.notification.request.content.userInfo, identifier: response.notification.request.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>$(SYBIL_APNS_ENVIRONMENT)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -5,6 +5,12 @@ targets:
|
||||
- iOS
|
||||
- macCatalyst
|
||||
deploymentTarget: "18.0"
|
||||
attributes:
|
||||
SystemCapabilities:
|
||||
com.apple.Push:
|
||||
enabled: 1
|
||||
com.apple.BackgroundModes:
|
||||
enabled: 1
|
||||
sources:
|
||||
- Sources
|
||||
- Resources
|
||||
@@ -25,6 +31,9 @@ targets:
|
||||
PRODUCT_MODULE_NAME: SybilApp
|
||||
DEVELOPMENT_TEAM: DQQH5H6GBD
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]": Apps/Sybil/Sybil.entitlements
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]": Apps/Sybil/Sybil.entitlements
|
||||
INFOPLIST_KEY_SybilAPNSEnvironment: "$(SYBIL_APNS_ENVIRONMENT)"
|
||||
SWIFT_VERSION: 6.0
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
TARGETED_DEVICE_FAMILY: "1,2,6"
|
||||
@@ -41,7 +50,10 @@ targets:
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
||||
configs:
|
||||
Debug:
|
||||
SYBIL_APNS_ENVIRONMENT: development
|
||||
Release:
|
||||
SYBIL_APNS_ENVIRONMENT: production
|
||||
CODE_SIGN_STYLE: Manual
|
||||
CODE_SIGN_IDENTITY: Apple Distribution
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]": Apple Distribution
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import Sybil
|
||||
|
||||
@MainActor private final class PushMemory {
|
||||
var value = AgentPushPersistence()
|
||||
var failWrite = false
|
||||
var storage: AgentPushStorage { .init(read: { self.value }, write: { value in
|
||||
if self.failWrite { throw URLError(.cannotWriteToFile) }
|
||||
self.value = value
|
||||
}) }
|
||||
}
|
||||
@MainActor private final class PushPlatform: AgentNotificationPlatform {
|
||||
var environment: AgentAPNSEnvironment? = .sandbox
|
||||
var allowsLoopback = true
|
||||
var authorization: AgentNotificationPermission = .notDetermined
|
||||
var allow = true
|
||||
var prompts = 0
|
||||
var registrations: [UUID] = []
|
||||
var unregisters = 0
|
||||
var notifications: [AgentDeliveredNotification] = []
|
||||
var removed: [String] = []
|
||||
var holdPermission = false
|
||||
var holdPermissionRead = false
|
||||
var permissionReadWaiter: CheckedContinuation<AgentNotificationPermission, Never>?
|
||||
var permissionWaiter: CheckedContinuation<Bool, Never>?
|
||||
func permission() async -> AgentNotificationPermission {
|
||||
if holdPermissionRead { return await withCheckedContinuation { permissionReadWaiter = $0 } }
|
||||
return authorization
|
||||
}
|
||||
func requestPermission() async -> Bool {
|
||||
prompts += 1
|
||||
if holdPermission { return await withCheckedContinuation { permissionWaiter = $0 } }
|
||||
authorization = allow ? .authorized : .denied
|
||||
return allow
|
||||
}
|
||||
func register(generation: UUID) { registrations.append(generation) }
|
||||
func unregister() { unregisters += 1 }
|
||||
func delivered() async -> [AgentDeliveredNotification] { notifications }
|
||||
func removeDelivered(identifiers: [String]) { removed += identifiers; notifications.removeAll { identifiers.contains($0.identifier) } }
|
||||
}
|
||||
private actor PushAPI: AgentPushAPIClienting {
|
||||
var config = AgentPushConfig(version: 1, enabled: true, apnsAvailable: true, webPushAvailable: false, sourceId: "11111111-1111-4111-8111-111111111111", quarantined: false)
|
||||
var read = AgentReadState(cursor: "0", sourceId: "11111111-1111-4111-8111-111111111111")
|
||||
var puts: [(UUID, AgentPushRegistration)] = []
|
||||
var deletes: [UUID] = []
|
||||
var failures = 0
|
||||
var offline = false
|
||||
var oldServer = false
|
||||
var revokeStatus: Int?
|
||||
var holdPut = false
|
||||
var putWaiter: CheckedContinuation<Void, Never>?
|
||||
var holdConfig = false
|
||||
var configWaiter: CheckedContinuation<Void, Never>?
|
||||
func agentPushConfig() async throws -> AgentPushConfig {
|
||||
if holdConfig { await withCheckedContinuation { configWaiter = $0 } }
|
||||
if oldServer { throw URLError(.badServerResponse) }
|
||||
return config
|
||||
}
|
||||
func agentReadState() -> AgentReadState { read }
|
||||
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt {
|
||||
puts.append((id, registration))
|
||||
if holdPut { await withCheckedContinuation { putWaiter = $0 } }
|
||||
if failures > 0 { failures -= 1; throw URLError(.networkConnectionLost) }
|
||||
return .init(installationId: id.uuidString, transport: "apns", enabled: true, version: puts.count)
|
||||
}
|
||||
func agentRevokePush(id: UUID) throws {
|
||||
deletes.append(id)
|
||||
if let revokeStatus { throw AgentPushRequestError(status: revokeStatus) }
|
||||
if offline { throw URLError(.notConnectedToInternet) }
|
||||
}
|
||||
func failRevoke(status: Int) { revokeStatus = status }
|
||||
func set(failures: Int = 0, offline: Bool = false, holdPut: Bool = false, oldServer: Bool = false, holdConfig: Bool = false) {
|
||||
self.failures = failures; self.offline = offline; self.holdPut = holdPut; self.oldServer = oldServer; self.holdConfig = holdConfig
|
||||
}
|
||||
func source(_ id: String) { config.sourceId = id; read.sourceId = id }
|
||||
func setAvailable(_ available: Bool) { config.enabled = available }
|
||||
func releasePut() { holdPut = false; putWaiter?.resume(); putWaiter = nil }
|
||||
func releaseConfig() { holdConfig = false; configWaiter?.resume(); configWaiter = nil }
|
||||
func requests() -> [(UUID, AgentPushRegistration)] { puts }
|
||||
func revokes() -> [UUID] { deletes }
|
||||
func waitingPut() -> Bool { putWaiter != nil }
|
||||
func waitingConfig() -> Bool { configWaiter != nil }
|
||||
}
|
||||
@MainActor private func pushEventually(_ predicate: () async -> Bool) async -> Bool {
|
||||
for _ in 0..<300 { if await predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
|
||||
return false
|
||||
}
|
||||
private func pushPayload(_ cursor: String = "4", source: String = "11111111-1111-4111-8111-111111111111", kind: String = "message") -> AgentPushPayload {
|
||||
AgentPushPayload(["version": 1, "sourceId": source, "kind": kind, "cursor": cursor, "readCursor": cursor, "messageId": "outbound:\(cursor)"])!
|
||||
}
|
||||
@MainActor private func bindPush(_ notifications: SybilAgentNotifications, _ api: PushAPI, url: String = "https://example.test/api", mode: String = "token") async {
|
||||
await notifications.bind(client: api, baseURL: URL(string: url)!, authenticated: true, mode: mode, token: mode == "token" ? "fixture-token" : nil)
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct AgentNotificationTests {
|
||||
@Test func payloadValidationAndCanonicalScope() throws {
|
||||
#expect(AgentPushPayload(["version": true, "sourceId": "s", "kind": "message", "cursor": "1", "messageId": "m"]) == nil)
|
||||
#expect(AgentPushPayload(["version": 2, "sourceId": "s", "kind": "message", "cursor": "1", "messageId": "m"]) == nil)
|
||||
#expect(AgentPushPayload(["version": 1, "sourceId": "s", "kind": "message", "cursor": 1, "messageId": "m"]) == nil)
|
||||
for cursor in ["-1", "1e3", "9007199254740992", "1.0", ""] { #expect(AgentPushPayload.validCursor(cursor) == nil) }
|
||||
#expect(AgentPushPayload.validCursor("0004") == "4")
|
||||
#expect(AgentPushScope.canonicalBackend(URL(string: "https://EXAMPLE.test:443/api/")!) == "https://example.test/api")
|
||||
for url in ["https://user:secret@example.test/api", "https://example.test/api?token=bad", "https://example.test/#fragment", "http://example.test/api", "https://example.test/api/../other"] {
|
||||
#expect(AgentPushScope.canonicalBackend(URL(string: url)!) == nil)
|
||||
}
|
||||
#expect(AgentAPNSEnvironment(entitlementSetting: "production") == .production)
|
||||
#expect(AgentAPNSEnvironment(entitlementSetting: "development") == .sandbox)
|
||||
#expect(AgentAPNSEnvironment(entitlementSetting: "DEBUG") == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func explicitAllowFreshTokenRotationAndIdempotence() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api)
|
||||
#expect(platform.prompts == 0 && platform.registrations.isEmpty)
|
||||
await push.enable()
|
||||
#expect(platform.prompts == 1 && platform.registrations.count == 1)
|
||||
#expect(await api.requests().isEmpty)
|
||||
let run = push.generation
|
||||
push.receivedDeviceToken(Data([0, 1, 10, 255]), generation: run)
|
||||
#expect(await pushEventually { await api.requests().count == 1 && memory.value.records.first?.version != nil })
|
||||
let first = try #require(await api.requests().first)
|
||||
#expect(first.1.token == "00010aff" && first.1.environment == .sandbox)
|
||||
push.receivedDeviceToken(Data([0, 1, 10, 255]), generation: run)
|
||||
#expect(await api.requests().count == 1)
|
||||
push.receivedDeviceToken(Data([0x42, 0xab]), generation: run)
|
||||
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version == 2 })
|
||||
let next = try #require(await api.requests().last)
|
||||
#expect(first.0 == next.0 && next.1.token == "42ab")
|
||||
let encoded = String(decoding: try JSONEncoder().encode(memory.value), as: UTF8.self)
|
||||
#expect(!encoded.contains("00010aff") && !encoded.contains("42ab"))
|
||||
await push.refresh()
|
||||
#expect(platform.prompts == 1 && platform.registrations.count == 2)
|
||||
let relaunchedPlatform = PushPlatform(); relaunchedPlatform.authorization = .authorized
|
||||
let relaunched = SybilAgentNotifications(platform: relaunchedPlatform, storage: memory.storage)
|
||||
await bindPush(relaunched, api)
|
||||
#expect(relaunchedPlatform.registrations.count == 1 && relaunchedPlatform.prompts == 0)
|
||||
#expect(await api.requests().count == 2) // persisted token is never used.
|
||||
}
|
||||
|
||||
@Test @MainActor func deniedOpenOldServerAndPersistenceFailureNeverEnroll() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api, mode: "open"); await push.enable()
|
||||
#expect(platform.prompts == 0 && platform.registrations.isEmpty)
|
||||
await api.set(oldServer: true); await bindPush(push, api)
|
||||
#expect(!push.available && platform.prompts == 0)
|
||||
await api.set(); await bindPush(push, api)
|
||||
platform.allow = false; await push.enable()
|
||||
#expect(platform.prompts == 1 && !push.optedIn && platform.registrations.isEmpty)
|
||||
platform.authorization = .authorized; memory.failWrite = true
|
||||
await push.enable()
|
||||
#expect(platform.registrations.isEmpty)
|
||||
#expect(await api.requests().isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func retryThenRevokeUsesRetiredUUID() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api); await push.enable(); await api.set(failures: 1)
|
||||
push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version != nil })
|
||||
let ids = await api.requests().map(\.0)
|
||||
#expect(Set(ids).count == 1)
|
||||
await push.disable()
|
||||
#expect(await api.revokes().contains(ids[0]))
|
||||
#expect(!push.optedIn && push.pendingRevocations == 0)
|
||||
await push.enable(); push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { await api.requests().count == 3 })
|
||||
#expect(await api.requests().last?.0 != ids[0])
|
||||
}
|
||||
|
||||
@Test @MainActor func logoutAndServerSwitchRejectLateOSAndUploadCallbacks() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, old); await push.enable(); await old.set(holdPut: true)
|
||||
let oldRun = push.generation
|
||||
push.receivedDeviceToken(Data([1]), generation: oldRun)
|
||||
#expect(await pushEventually { await old.waitingPut() })
|
||||
let oldID = try #require(await old.requests().first?.0)
|
||||
await push.deactivate()
|
||||
await next.source("22222222-2222-4222-8222-222222222222"); await bindPush(push, next, url: "https://other.test/api")
|
||||
await push.enable()
|
||||
push.receivedDeviceToken(Data([0xff]), generation: oldRun)
|
||||
#expect(await next.requests().isEmpty)
|
||||
await old.releasePut()
|
||||
#expect(await pushEventually { await old.revokes().contains(oldID) })
|
||||
#expect(await next.revokes().isEmpty)
|
||||
#expect(push.scope?.sourceID == "22222222-2222-4222-8222-222222222222" && push.optedIn)
|
||||
push.receivedDeviceToken(Data([2]), generation: push.generation)
|
||||
#expect(await pushEventually { await next.requests().count == 1 })
|
||||
}
|
||||
|
||||
@Test @MainActor func oldConfigAndPermissionCannotBindNewSession() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await old.set(holdConfig: true)
|
||||
let binding = Task { await bindPush(push, old) }
|
||||
#expect(await pushEventually { await old.waitingConfig() })
|
||||
await push.deactivate(); await next.source("22222222-2222-4222-8222-222222222222"); await bindPush(push, next, url: "https://other.test")
|
||||
await old.releaseConfig(); await binding.value
|
||||
#expect(push.scope?.sourceID == "22222222-2222-4222-8222-222222222222")
|
||||
platform.holdPermission = true
|
||||
let enabling = Task { await push.enable() }
|
||||
#expect(await pushEventually { platform.permissionWaiter != nil })
|
||||
await push.deactivate()
|
||||
platform.permissionWaiter?.resume(returning: true); await enabling.value
|
||||
#expect(platform.registrations.isEmpty && !push.optedIn)
|
||||
}
|
||||
|
||||
@Test @MainActor func offlineTombstoneSurvivesRelaunchAndOnlyExactScopeRetries() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api); await push.enable()
|
||||
push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
await api.set(offline: true); await push.deactivate()
|
||||
#expect(memory.value.tombstones.count == 1)
|
||||
let id = try #require(memory.value.tombstones.first?.installationID)
|
||||
let next = PushAPI(), relaunched = SybilAgentNotifications(platform: PushPlatform(), storage: memory.storage)
|
||||
await bindPush(relaunched, next, url: "https://other.test/api")
|
||||
#expect(await next.revokes().isEmpty)
|
||||
await relaunched.deactivate(); await next.source("33333333-3333-4333-8333-333333333333")
|
||||
await bindPush(relaunched, next)
|
||||
#expect(await next.revokes().isEmpty)
|
||||
await relaunched.deactivate(); await next.source("11111111-1111-4111-8111-111111111111"); await bindPush(relaunched, next)
|
||||
#expect(await next.revokes() == [id] && memory.value.tombstones.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func deliveredClearUsesSystemIdentifiersAndRetainsNewerOrUnrelated() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api)
|
||||
platform.notifications = [
|
||||
.init(identifier: "apns-system-a", payload: pushPayload("3")),
|
||||
.init(identifier: "apns-system-b", payload: pushPayload("4")),
|
||||
.init(identifier: "newer", payload: pushPayload("5")),
|
||||
.init(identifier: "foreign", payload: pushPayload("1", source: "44444444-4444-4444-8444-444444444444")),
|
||||
.init(identifier: "unrelated", payload: nil)]
|
||||
await push.acknowledged(.init(cursor: "99"), generation: push.generation)
|
||||
#expect(platform.removed.isEmpty) // legacy reads have no source authority.
|
||||
await push.acknowledged(.init(cursor: "4", sourceId: "11111111-1111-4111-8111-111111111111"), generation: push.generation)
|
||||
#expect(platform.removed == ["apns-system-a", "apns-system-b"])
|
||||
#expect(platform.notifications.map(\.identifier) == ["newer", "foreign", "unrelated"])
|
||||
await push.acknowledged(.init(cursor: "1", sourceId: "11111111-1111-4111-8111-111111111111"), generation: push.generation)
|
||||
#expect(!push.shouldPresent(pushPayload("4")))
|
||||
#expect(push.shouldPresent(pushPayload("5")))
|
||||
let scene = UUID(); push.setScene(scene, active: true)
|
||||
#expect(!push.shouldPresent(pushPayload("5")))
|
||||
push.setScene(scene, active: false)
|
||||
#expect(await push.receiveBackground(pushPayload("5", kind: "read_state")))
|
||||
#expect(platform.removed.last == "newer")
|
||||
#expect(await api.requests().isEmpty) // no read/enrollment side effect.
|
||||
}
|
||||
|
||||
@Test @MainActor func coldTapWaitsForAuthenticationDeduplicatesAndDoesNotRead() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
let payload = pushPayload()
|
||||
push.notificationResponse(payload, identifier: "system-1")
|
||||
#expect(!push.consumeRoute())
|
||||
let request = push.pendingRouteID
|
||||
push.notificationResponse(payload, identifier: "system-2")
|
||||
#expect(push.pendingRouteID == request)
|
||||
await bindPush(push, api, mode: "open"); #expect(!push.consumeRoute())
|
||||
await bindPush(push, api); #expect(push.consumeRoute()); #expect(!push.consumeRoute())
|
||||
#expect(platform.removed.isEmpty)
|
||||
#expect(await api.requests().isEmpty)
|
||||
#expect(push.shouldPresent(payload)) // tap alone did not advance read.
|
||||
push.notificationResponse(pushPayload(source: "44444444-4444-4444-8444-444444444444"), identifier: "wrong")
|
||||
#expect(!push.consumeRoute())
|
||||
}
|
||||
|
||||
@Test @MainActor func backgroundCompletionExactlyOnceIncludingTimeout() async {
|
||||
var outcomes: [UIBackgroundFetchResult] = []
|
||||
AgentBackgroundCompletion.run(timeout: .milliseconds(20), operation: { .newData }, completion: { outcomes.append($0) })
|
||||
#expect(await pushEventually { outcomes.count == 1 })
|
||||
try? await Task.sleep(for: .milliseconds(40)); #expect(outcomes == [.newData])
|
||||
var continuation: CheckedContinuation<UIBackgroundFetchResult, Never>?
|
||||
AgentBackgroundCompletion.run(timeout: .milliseconds(20), operation: { await withCheckedContinuation { continuation = $0 } }, completion: { outcomes.append($0) })
|
||||
#expect(await pushEventually { outcomes.count == 2 })
|
||||
continuation?.resume(returning: .newData)
|
||||
try? await Task.sleep(for: .milliseconds(20)); #expect(outcomes == [.newData, .noData])
|
||||
}
|
||||
|
||||
@Test @MainActor func changedCredentialEpochRequiresConsentAndRejectsColdReadHints() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let original = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(original, api); await original.enable()
|
||||
original.receivedDeviceToken(Data([1]), generation: original.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
let replacementPlatform = PushPlatform(); replacementPlatform.authorization = .authorized
|
||||
let replacement = SybilAgentNotifications(platform: replacementPlatform, storage: memory.storage)
|
||||
replacement.restoreBackgroundScope(baseURL: URL(string: "https://example.test/api")!, token: "replacement-fixture-token", payload: pushPayload(kind: "read_state"))
|
||||
#expect(replacement.scope == nil)
|
||||
await replacement.bind(client: api, baseURL: URL(string: "https://example.test/api")!, authenticated: true, mode: "token", token: "replacement-fixture-token")
|
||||
#expect(!replacement.optedIn && replacementPlatform.registrations.isEmpty)
|
||||
#expect(memory.value.records.count == 2)
|
||||
#expect(memory.value.records[0].scope.credentialScope != memory.value.records[1].scope.credentialScope)
|
||||
#expect(await api.revokes().isEmpty)
|
||||
#expect(replacement.scope?.credentialScope == memory.value.records.last?.scope.credentialScope)
|
||||
}
|
||||
|
||||
@Test @MainActor func deniedPermissionThenAuthorizedRefreshRecovers() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
platform.authorization = .denied
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api)
|
||||
#expect(push.permission == .denied)
|
||||
platform.authorization = .authorized
|
||||
await push.refresh()
|
||||
#expect(push.permission == .authorized && push.available)
|
||||
await push.enable()
|
||||
#expect(platform.registrations.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func equivalentCanonicalBackendDoesNotRetireConsent() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api); await push.enable()
|
||||
let run = push.generation
|
||||
await bindPush(push, api, url: "https://EXAMPLE.test:443/api/")
|
||||
#expect(push.generation == run && push.optedIn)
|
||||
#expect(memory.value.records.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func sameBackendCredentialRotationWith403TombstoneAllowsNewConsentRoutingAndClear() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, old); await push.enable()
|
||||
push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
await old.failRevoke(status: 403)
|
||||
await push.bind(client: next, baseURL: URL(string: "https://example.test/api")!, authenticated: true, mode: "token", token: "replacement-fixture-token")
|
||||
#expect(!push.optedIn && memory.value.tombstones.count == 1)
|
||||
await push.enable(); push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { await next.requests().count == 1 && push.status == "Notifications are enabled." })
|
||||
push.notificationResponse(pushPayload(), identifier: "system-after-rotation")
|
||||
#expect(push.consumeRoute())
|
||||
platform.notifications = [.init(identifier: "actual-system-id", payload: pushPayload())]
|
||||
#expect(await push.receiveBackground(pushPayload(kind: "read_state")))
|
||||
#expect(platform.removed == ["actual-system-id"])
|
||||
#expect(memory.value.tombstones.count == 1)
|
||||
#expect(await next.revokes().isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func distinctBackendWithSameRuntimeSourceStillRefusesPayloads() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, old); await push.enable()
|
||||
push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
await old.failRevoke(status: 403)
|
||||
await bindPush(push, next, url: "https://other.test/api"); await push.enable()
|
||||
push.receivedDeviceToken(Data([2]), generation: push.generation)
|
||||
#expect(await pushEventually { await next.requests().count == 1 })
|
||||
push.notificationResponse(pushPayload(), identifier: "ambiguous-server")
|
||||
#expect(!push.consumeRoute())
|
||||
#expect(!(await push.receiveBackground(pushPayload(kind: "read_state"))))
|
||||
}
|
||||
|
||||
@Test @MainActor func disableDuringConfigOrPermissionRefreshDoesNotBlockNewRefresh() async {
|
||||
for holdConfig in [true, false] {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api)
|
||||
await api.set(holdConfig: holdConfig); platform.holdPermissionRead = !holdConfig
|
||||
let oldRefresh = Task { await push.refresh() }
|
||||
#expect(await pushEventually { holdConfig ? await api.waitingConfig() : platform.permissionReadWaiter != nil })
|
||||
await push.disable()
|
||||
if holdConfig { await api.releaseConfig() }
|
||||
else { platform.holdPermissionRead = false; platform.permissionReadWaiter?.resume(returning: .denied) }
|
||||
await oldRefresh.value
|
||||
platform.authorization = .authorized
|
||||
await push.refresh(); await push.enable()
|
||||
#expect(push.permission == .authorized && platform.registrations.count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func capabilityRecoveryReenrollsSameTokenWithNewInstallation() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api); await push.enable()
|
||||
let bytes = Data([1, 2, 3])
|
||||
push.receivedDeviceToken(bytes, generation: push.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
let oldID = try #require(await api.requests().first?.0)
|
||||
await api.setAvailable(false); await push.refresh()
|
||||
#expect(await api.revokes().contains(oldID))
|
||||
#expect(memory.value.records.first?.installationID != oldID)
|
||||
await api.setAvailable(true); await push.refresh()
|
||||
push.receivedDeviceToken(bytes, generation: push.generation)
|
||||
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version != nil })
|
||||
#expect(await api.requests().last?.0 != oldID)
|
||||
#expect(push.status == "Notifications are enabled.")
|
||||
}
|
||||
|
||||
@Test @MainActor func optionalReadStateAndNativeAuthRemainCompatible() throws {
|
||||
let old = try JSONDecoder().decode(AgentReadState.self, from: Data(#"{"cursor":"4","updatedAt":null}"#.utf8))
|
||||
#expect(old.sourceId == nil)
|
||||
let data = Data(#"{"type":"read_state","sourceId":"11111111-1111-4111-8111-111111111111","cursor":"5","updatedAt":null}"#.utf8)
|
||||
guard case let .readState(read) = try AgentEvent.decode(data) else { Issue.record("Missing read frame"); return }
|
||||
let room = SybilAgentRoom(); try room.applyRead(read); room.flush()
|
||||
#expect(room.snapshot.room.readCursor == "5")
|
||||
#expect(throws: AgentFailure.self) { try room.applyRead(.init(cursor: "99", sourceId: "foreign")) }
|
||||
room.flush()
|
||||
#expect(room.snapshot.room.readCursor == "5")
|
||||
#expect(room.requiresReload)
|
||||
#expect(try AgentEvent.decode(Data(#"{"type":"read_state","cursor":"-1"}"#.utf8)) == nil)
|
||||
let frame = try JSONDecoder().decode(JSONValue.self, from: Data(APIConfiguration(baseURL: URL(string: "https://example.test")!, authToken: "fixture").agentAuthFrame(after: "0").utf8))
|
||||
#expect(frame.objectValue?["clientKind"] == .string("native"))
|
||||
#expect(frame.objectValue?["presenceSessionId"] == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Sybil
|
||||
|
||||
private final class PushURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
static let lock = NSLock()
|
||||
nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) throws -> (Int, Data))?
|
||||
static func install(_ value: @escaping @Sendable (URLRequest) throws -> (Int, Data)) { lock.withLock { handler = value } }
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
do {
|
||||
let handler = Self.lock.withLock { Self.handler! }
|
||||
let (status, data) = try handler(request)
|
||||
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: ["Location": "https://untrusted.test/"])!, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data); client?.urlProtocolDidFinishLoading(self)
|
||||
} catch { client?.urlProtocol(self, didFailWithError: error) }
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
private func pushClient(_ url: String = "https://example.test/api", token: String? = "fixture-token") -> SybilAPIClient {
|
||||
let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [PushURLProtocol.self]
|
||||
return SybilAPIClient(configuration: .init(baseURL: URL(string: url)!, authToken: token), session: URLSession(configuration: config))
|
||||
}
|
||||
private func pushRequestBody(_ request: URLRequest) -> Data {
|
||||
if let body = request.httpBody { return body }
|
||||
guard let stream = request.httpBodyStream else { return Data() }
|
||||
stream.open(); defer { stream.close() }
|
||||
var bytes = Data(), buffer = [UInt8](repeating: 0, count: 1024)
|
||||
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; bytes.append(contentsOf: buffer.prefix(count)) }
|
||||
return bytes
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct AgentPushAPITests {
|
||||
@Test func authenticatedBasePathRegistrationAndRevoke() async throws {
|
||||
let id = UUID()
|
||||
PushURLProtocol.install { request in
|
||||
#expect(request.url?.host == "example.test")
|
||||
#expect(request.url?.query == nil)
|
||||
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-token")
|
||||
#expect(request.value(forHTTPHeaderField: "Cache-Control") == "no-store")
|
||||
#expect(request.timeoutInterval == 8)
|
||||
if request.httpMethod == "GET" {
|
||||
#expect(request.url?.path == "/api/v1/agent/push/config")
|
||||
return (200, Data(#"{"version":1,"enabled":true,"apnsAvailable":true,"webPushAvailable":false,"sourceId":"11111111-1111-4111-8111-111111111111","quarantined":false,"policy":{"future":1}}"#.utf8))
|
||||
}
|
||||
#expect(request.url?.path == "/api/v1/agent/push/installations/" + id.uuidString.lowercased())
|
||||
if request.httpMethod == "DELETE" { return (204, Data()) }
|
||||
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: Any])
|
||||
#expect(Set(object.keys) == Set(["transport", "enabled", "token", "environment", "topic"]))
|
||||
#expect(object["token"] as? String == "00010aff")
|
||||
#expect(object["environment"] as? String == "production")
|
||||
#expect(object["topic"] as? String == "net.buzzert.sybil2")
|
||||
return (200, try JSONEncoder().encode(AgentPushReceipt(installationId: id.uuidString, transport: "apns", enabled: true, version: 7)))
|
||||
}
|
||||
let client = pushClient()
|
||||
#expect(try await client.agentPushConfig().available)
|
||||
#expect(try await client.agentRegisterPush(id: id, registration: .init(token: "00010aff", environment: .production)).version == 7)
|
||||
try await client.agentRevokePush(id: id)
|
||||
}
|
||||
|
||||
@Test func unsupportedRedirectAndReflectedSecretsAreNotExposed() async {
|
||||
for status in [302, 401, 404, 409, 503] {
|
||||
PushURLProtocol.install { _ in (status, Data(#"{"token":"do-not-reflect-this-token","message":"do-not-reflect-this-token"}"#.utf8)) }
|
||||
do { _ = try await pushClient().agentPushConfig(); Issue.record("Unexpected success") }
|
||||
catch { #expect(!error.localizedDescription.contains("do-not-reflect")) }
|
||||
}
|
||||
PushURLProtocol.install { _ in Issue.record("Unsafe request reached transport"); return (200, Data()) }
|
||||
for client in [pushClient(token: nil), pushClient("https://example.test/api?token=bad"), pushClient("https://user:password@example.test")] {
|
||||
do { _ = try await client.agentPushConfig(); Issue.record("Unsafe configuration accepted") }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func sourceAwareReadWritesAndLegacyFallback() async throws {
|
||||
let source = "11111111-1111-4111-8111-111111111111"
|
||||
for expectedSource: String? in [nil, source] {
|
||||
PushURLProtocol.install { request in
|
||||
#expect(request.url?.path == "/api/v1/agent/read-state")
|
||||
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: String])
|
||||
#expect(object["cursor"] == "4" && object["sourceId"] == expectedSource)
|
||||
return (200, try JSONEncoder().encode(AgentReadState(cursor: "4", sourceId: expectedSource)))
|
||||
}
|
||||
#expect(try await pushClient().agentMarkRead(cursor: "4", sourceID: expectedSource).sourceId == expectedSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,12 @@ private actor TestAgentClient: AgentAPIClienting {
|
||||
var requests: [String] = []
|
||||
var submissions: [(String, String)] = []
|
||||
var read = "0"
|
||||
var readSource: String?
|
||||
var readWrites: [(cursor: String, source: String?)] = []
|
||||
var writeReply: AgentReadState?
|
||||
var writeConflict: Bool?
|
||||
var holdWrite = false
|
||||
var heldWrite: CheckedContinuation<Void, Never>?
|
||||
var failRead = false
|
||||
var failSubmit = false
|
||||
var malformedReceipt = false
|
||||
@@ -56,16 +62,34 @@ private actor TestAgentClient: AgentAPIClienting {
|
||||
}
|
||||
func agentReadState() async -> AgentReadState {
|
||||
if holdRead { return await withCheckedContinuation { heldRead = $0 } }
|
||||
return AgentReadState(cursor: read)
|
||||
return AgentReadState(cursor: read, sourceId: readSource)
|
||||
}
|
||||
func pauseRead() { holdRead = true }
|
||||
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read)); heldRead = nil }
|
||||
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read, sourceId: readSource)); heldRead = nil }
|
||||
func hasHeldRead() -> Bool { heldRead != nil }
|
||||
func agentMarkRead(cursor: String) throws -> AgentReadState {
|
||||
if failRead { throw AgentFailure(message: "Offline") }
|
||||
read = try AgentCursor.advance(read, cursor)
|
||||
return AgentReadState(cursor: read)
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState {
|
||||
try await agentMarkRead(cursor: cursor, sourceID: nil)
|
||||
}
|
||||
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
|
||||
readWrites.append((cursor, sourceID))
|
||||
if holdWrite { await withCheckedContinuation { heldWrite = $0 } }
|
||||
if let coded = writeConflict {
|
||||
if coded { throw AgentAttachmentIssue(message: "Source mismatch", code: "source_mismatch", statusCode: 409) }
|
||||
throw APIError.httpError(statusCode: 409, message: "Source mismatch")
|
||||
}
|
||||
if failRead { throw AgentFailure(message: "Offline") }
|
||||
if let writeReply { return writeReply }
|
||||
read = try AgentCursor.advance(read, cursor)
|
||||
return AgentReadState(cursor: read, sourceId: readSource)
|
||||
}
|
||||
func setReadState(_ state: AgentReadState) { read = state.cursor; readSource = state.sourceId }
|
||||
func setWriteReply(_ state: AgentReadState?) { writeReply = state }
|
||||
func setWriteConflict(coded: Bool) { writeConflict = coded }
|
||||
func pauseWrite() { holdWrite = true }
|
||||
func releaseWrite() { holdWrite = false; heldWrite?.resume(); heldWrite = nil }
|
||||
func hasHeldWrite() -> Bool { heldWrite != nil }
|
||||
func writtenReads() -> [(cursor: String, source: String?)] { readWrites }
|
||||
func setHistory(_ pages: [String: AgentPage]) { self.pages = pages }
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
|
||||
#expect(beforeSubmit?() ?? true)
|
||||
submissions.append((content, requestID))
|
||||
@@ -420,6 +444,89 @@ struct AgentTests {
|
||||
#expect(await eventually { room.connection == "Connecting" })
|
||||
#expect(room.messages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor func legacyReadPromotionCannotRelabelQueuedHistoryAndRequiresFreshReload() async throws {
|
||||
let sourceB = "22222222-2222-4222-8222-222222222222"
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
|
||||
await client.setReadState(.init(cursor: "90"))
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.isSelected = true; await client.pauseWrite(); room.markRead(renderedCursor: "100")
|
||||
for _ in 0..<100 {
|
||||
if await client.hasHeldWrite() { break }
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
#expect(await client.hasHeldWrite())
|
||||
#expect(throws: AgentFailure.self) { try room.applyRead(.init(cursor: "0", sourceId: sourceB)) }
|
||||
#expect(room.requiresReload && room.connection == "Reload required")
|
||||
#expect(room.snapshot.room.readCursor == "90" && room.snapshot.room.cursor == "100")
|
||||
await client.setWriteReply(.init(cursor: "100", sourceId: sourceB)); await client.releaseWrite()
|
||||
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
#expect(room.requiresReload && room.snapshot.room.readCursor == "90")
|
||||
#expect(await client.writtenReads().count == 1)
|
||||
#expect(await client.writtenReads().first?.source == nil)
|
||||
#expect(room.messages.map(\.id) == ["old"])
|
||||
|
||||
// Explicit reconnect resets cursor space before discovering B or loading
|
||||
// its history. The old queued100 can never be sent under sourceB.
|
||||
await client.setWriteReply(nil); await client.setReadState(.init(cursor: "0", sourceId: sourceB))
|
||||
await client.setHistory(["0": agentPage([agentMessage("new", entry: "1")], cursor: "1")])
|
||||
room.retryConnection()
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
#expect(!room.requiresReload && room.snapshot.room.readCursor == "0")
|
||||
#expect(room.messages.map(\.id) == ["new"])
|
||||
room.markRead(renderedCursor: "1")
|
||||
#expect(await eventually { room.snapshot.room.readCursor == "1" })
|
||||
let writes = await client.writtenReads()
|
||||
#expect(writes.count == 2 && writes.last?.cursor == "1" && writes.last?.source == sourceB)
|
||||
#expect(!writes.contains { $0.cursor == "100" && $0.source == sourceB })
|
||||
}
|
||||
|
||||
@Test @MainActor func historyBeforeReadPinsLegacySourceBeforePublishing() throws {
|
||||
let room = SybilAgentRoom(); defer { room.stop() }
|
||||
try room.receive(.messages(agentPage([agentMessage("legacy", entry: "100")], cursor: "100")))
|
||||
#expect(throws: AgentFailure.self) {
|
||||
try room.applyRead(.init(cursor: "0", sourceId: "22222222-2222-4222-8222-222222222222"))
|
||||
}
|
||||
#expect(room.requiresReload && room.snapshot.room.cursor == "100" && room.snapshot.room.readCursor == "0")
|
||||
}
|
||||
|
||||
@Test @MainActor func mismatchedAndNonAdvancingReadAcknowledgmentsStopWriter() async throws {
|
||||
let sourceA = "11111111-1111-4111-8111-111111111111"
|
||||
let sourceB = "22222222-2222-4222-8222-222222222222"
|
||||
for reply in [AgentReadState(cursor: "100", sourceId: sourceB), .init(cursor: "90", sourceId: sourceA), .init(cursor: "100"), .init(cursor: "invalid", sourceId: sourceA)] {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
|
||||
await client.setReadState(.init(cursor: "90", sourceId: sourceA)); await client.setWriteReply(reply)
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.isSelected = true; room.markRead(renderedCursor: "100")
|
||||
#expect(await eventually { room.requiresReload })
|
||||
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
#expect(room.snapshot.room.readCursor == "90")
|
||||
#expect(await client.writtenReads().count == 1)
|
||||
#expect(await client.writtenReads().first?.source == sourceA)
|
||||
room.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func readSourceConflict409StopsWriterUntilReload() async {
|
||||
for coded in [false, true] {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
|
||||
await client.setReadState(.init(cursor: "90", sourceId: "11111111-1111-4111-8111-111111111111"))
|
||||
await client.setWriteConflict(coded: coded)
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.isSelected = true; room.markRead(renderedCursor: "100")
|
||||
#expect(await eventually { room.requiresReload })
|
||||
room.markRead(renderedCursor: "100")
|
||||
#expect(room.snapshot.room.readCursor == "90")
|
||||
#expect(await client.writtenReads().count == 1)
|
||||
room.stop()
|
||||
}
|
||||
}
|
||||
@Test @MainActor func interruptedAdmissionForegroundKeepsRetryIdentity() async throws {
|
||||
let client = TestAgentClient(), memory = MemoryOutbox()
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
|
||||
|
||||
@@ -26,6 +26,12 @@ attachment_capability = True
|
||||
next_upload_error = None
|
||||
blocked_proxy = False
|
||||
transcript_comparison = False
|
||||
PUSH_SOURCE = '11111111-1111-4111-8111-111111111111'
|
||||
push_installations = {}
|
||||
push_retired = set()
|
||||
push_requests = []
|
||||
push_available = True
|
||||
auth_required = False
|
||||
CAPABILITY = dict(version=1, maxCount=8, maxTotalBytes=33554432, maxImageBytes=6291456,
|
||||
maxFileBytes=8388608, imageMimeTypes=['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
maxImagePixels=50000000, maxImageDimension=16384, quotaBytes=1073741824, usedBytes=0,
|
||||
@@ -88,7 +94,7 @@ async def reply(content):
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
|
||||
async def route(request):
|
||||
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison
|
||||
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison, push_available, auth_required
|
||||
path = request.path
|
||||
log.append(dict(method=request.method, path=path, query=dict(request.query)))
|
||||
if path == '/control':
|
||||
@@ -100,6 +106,8 @@ async def route(request):
|
||||
reply_tasks.clear()
|
||||
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
|
||||
attachment_capability = True; next_upload_error = None; blocked_proxy = False; transcript_comparison = False
|
||||
push_installations.clear(); push_retired.clear(); push_requests.clear(); log.clear()
|
||||
push_available = True; auth_required = False
|
||||
for i in range(body.get('count', 3)):
|
||||
message(f'History message {i + 1}: local simulator fixture.')
|
||||
elif action == 'transcript-comparison':
|
||||
@@ -126,13 +134,21 @@ async def route(request):
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
elif action == 'read':
|
||||
read_cursor = cursor
|
||||
await broadcast(dict(type='read_state', sourceId=PUSH_SOURCE, cursor=str(read_cursor), updatedAt=stamp()))
|
||||
elif action == 'push-available':
|
||||
push_available = body.get('enabled', True)
|
||||
elif action == 'auth-required':
|
||||
auth_required = body.get('enabled', True)
|
||||
elif action == 'disconnect':
|
||||
for socket in list(sockets):
|
||||
await socket.close(code=1012)
|
||||
elif action == 'malformed':
|
||||
await broadcast(dict(type='messages', conversationId='fixture-root', messages=[{'id': 'broken'}], cursor='999999', beforeCursor=None, hasMore=False))
|
||||
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log))
|
||||
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log,
|
||||
pushRequests=push_requests, pushActive=len(push_installations), pushRetired=len(push_retired)))
|
||||
if path == '/api/v1/auth/session':
|
||||
if auth_required and request.headers.get('Authorization') != 'Bearer fixture-token':
|
||||
return web.json_response(dict(message='Fixture authentication required'), status=401)
|
||||
return web.json_response(dict(authenticated=True, mode='token'))
|
||||
comparison = dict(id='fade-comparison', title='Transcript fade comparison', createdAt=stamp(), updatedAt=stamp())
|
||||
if path == '/api/v1/workspace-items':
|
||||
@@ -157,6 +173,29 @@ async def route(request):
|
||||
if attachment_capability:
|
||||
descriptor['capabilities'] = {'attachments': CAPABILITY}
|
||||
return web.json_response(descriptor)
|
||||
if path == '/api/v1/agent/push/config':
|
||||
if not push_available:
|
||||
return web.json_response(dict(message='Old server fixture'), status=404)
|
||||
return web.json_response(dict(version=1, enabled=True, apnsAvailable=True, webPushAvailable=False,
|
||||
sourceId=PUSH_SOURCE, quarantined=False, policy={}))
|
||||
if path.startswith('/api/v1/agent/push/installations/'):
|
||||
installation = str(uuid.UUID(path.rsplit('/', 1)[1]))
|
||||
if request.method == 'DELETE':
|
||||
push_installations.pop(installation, None); push_retired.add(installation)
|
||||
push_requests.append(dict(method='DELETE', installationId=installation))
|
||||
return web.Response(status=204)
|
||||
if request.method == 'PUT':
|
||||
body = await request.json()
|
||||
assert set(body) == {'transport', 'enabled', 'token', 'environment', 'topic'}
|
||||
assert body['transport'] == 'apns' and body['enabled'] is True
|
||||
assert body['environment'] == 'sandbox' and body['topic'] == 'net.buzzert.sybil2'
|
||||
assert body['token'] == '00010aff' + '42' * 28 # explicitly injected synthetic token
|
||||
if installation in push_retired:
|
||||
return web.json_response(dict(message='Retired installation'), status=409)
|
||||
version = push_installations.get(installation, 0) + 1
|
||||
push_installations[installation] = version
|
||||
push_requests.append(dict(method='PUT', installationId=installation, environment='sandbox'))
|
||||
return web.json_response(dict(installationId=installation, transport='apns', enabled=True, version=version))
|
||||
if path == '/api/v1/agent/attachments' and request.method == 'POST':
|
||||
if not attachment_capability:
|
||||
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
|
||||
@@ -217,7 +256,7 @@ async def route(request):
|
||||
if path == '/api/v1/agent/read-state':
|
||||
if request.method == 'PUT':
|
||||
read_cursor = max(read_cursor, int((await request.json())['cursor']))
|
||||
return web.json_response(dict(cursor=str(read_cursor), updatedAt=stamp()))
|
||||
return web.json_response(dict(cursor=str(read_cursor), updatedAt=stamp(), sourceId=PUSH_SOURCE))
|
||||
if path == '/api/v1/agent/messages':
|
||||
if request.method == 'POST':
|
||||
body = await request.json()
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import XCTest
|
||||
|
||||
/// Local fixture + fake notification center/registrar only. No APNs provider or
|
||||
/// system permission dialog is involved in these deterministic UI scenarios.
|
||||
@MainActor final class AgentPushUITests: XCTestCase {
|
||||
let app = XCUIApplication()
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
try await control(["action": "reset", "count": 3])
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
|
||||
app.launchEnvironment = ["SYBIL_PUSH_FIXTURE": "1", "SYBIL_PUSH_PERMISSION": "authorized"]
|
||||
}
|
||||
override func tearDown() async throws { app.terminate() }
|
||||
@discardableResult private func control(_ body: [String: Any]) async throws -> [String: Any] {
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
|
||||
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
private func settings() {
|
||||
if !app.buttons["Settings"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 20))
|
||||
app.buttons["Show sidebar"].tap()
|
||||
}
|
||||
XCTAssertTrue(app.buttons["Settings"].waitForExistence(timeout: 5)); app.buttons["Settings"].tap()
|
||||
XCTAssertTrue(app.buttons["agent-push-refresh"].waitForExistence(timeout: 5))
|
||||
if !app.buttons["agent-push-refresh"].isHittable { app.scrollViews.firstMatch.swipeUp() }
|
||||
}
|
||||
private func capture(_ name: String) {
|
||||
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
|
||||
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
|
||||
}
|
||||
private func openAgent() {
|
||||
if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 20)); app.buttons["Show sidebar"].tap()
|
||||
}
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 5))
|
||||
}
|
||||
private func ensureDisabled() {
|
||||
if app.buttons["agent-push-disable"].exists { app.buttons["agent-push-disable"].tap() }
|
||||
XCTAssertTrue(app.buttons["agent-push-enable"].waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
func testExplicitEnableRefreshAndDisable() async throws {
|
||||
app.launch(); settings(); ensureDisabled()
|
||||
let before = try await control(["action": "state"])
|
||||
let count = (before["pushRequests"] as? [[String: Any]])?.count ?? 0
|
||||
app.buttons["agent-push-enable"].tap()
|
||||
XCTAssertTrue(app.buttons["agent-push-disable"].waitForExistence(timeout: 5))
|
||||
let status = app.staticTexts["agent-push-status"]
|
||||
let enabled = NSPredicate(format: "label == %@", "Notifications are enabled.")
|
||||
let enabledExpectation = expectation(for: enabled, evaluatedWith: status)
|
||||
await fulfillment(of: [enabledExpectation], timeout: 8)
|
||||
capture("push-enabled-generic-preview")
|
||||
app.buttons["agent-push-refresh"].tap()
|
||||
let state = try await control(["action": "state"])
|
||||
let requests = try XCTUnwrap(state["pushRequests"] as? [[String: Any]])
|
||||
XCTAssertEqual(requests.count, count + 1)
|
||||
XCTAssertEqual(requests.last?["environment"] as? String, "sandbox")
|
||||
app.buttons["agent-push-disable"].tap()
|
||||
XCTAssertTrue(app.buttons["agent-push-enable"].waitForExistence(timeout: 5))
|
||||
let revoked = try await control(["action": "state"])
|
||||
XCTAssertEqual(revoked["pushActive"] as? Int, 0)
|
||||
capture("push-disabled-revoked")
|
||||
}
|
||||
|
||||
func testColdTapRoutesAgentAndPreservesAttachmentCaption() async throws {
|
||||
app.launch()
|
||||
openAgent()
|
||||
let composer = app.textFields["agent-composer"]
|
||||
XCTAssertTrue(composer.waitForExistence(timeout: 5))
|
||||
composer.tap(); composer.typeText("Push fixture saved caption")
|
||||
app.terminate()
|
||||
app.launchEnvironment["SYBIL_PUSH_TAP"] = "1"
|
||||
app.launch()
|
||||
XCTAssertTrue(composer.waitForExistence(timeout: 20))
|
||||
XCTAssertTrue((composer.value as? String ?? "").contains("Push fixture saved caption"))
|
||||
XCTAssertTrue(app.buttons["agent-attach"].exists)
|
||||
capture("push-cold-tap-agent-draft-preserved")
|
||||
}
|
||||
|
||||
func testTapBeforeLoginWaitsThenRoutesAgent() async throws {
|
||||
try await control(["action": "auth-required", "enabled": true])
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", ""]
|
||||
app.launchEnvironment["SYBIL_PUSH_TAP"] = "1"
|
||||
app.launch()
|
||||
XCTAssertTrue(app.buttons["Connect"].waitForExistence(timeout: 15))
|
||||
XCTAssertFalse(app.textFields["agent-composer"].exists)
|
||||
let state = try await control(["action": "state"])
|
||||
XCTAssertEqual(state["readCursor"] as? Int, 0)
|
||||
app.secureTextFields.firstMatch.tap(); app.secureTextFields.firstMatch.typeText("fixture-token")
|
||||
app.buttons["Connect"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 20))
|
||||
capture("push-pending-auth-routed-after-login")
|
||||
}
|
||||
|
||||
func testDeniedAndOldServerLeaveAgentUsable() async throws {
|
||||
app.launchEnvironment["SYBIL_PUSH_PERMISSION"] = "denied"
|
||||
app.launch(); settings()
|
||||
XCTAssertTrue(app.buttons["Open iOS Notification Settings"].waitForExistence(timeout: 5))
|
||||
capture("push-denied-settings")
|
||||
try await control(["action": "push-available", "enabled": false])
|
||||
app.buttons["agent-push-refresh"].tap()
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].exists); app.buttons["Show sidebar"].tap()
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(app.staticTexts["History message 3: local simulator fixture."].waitForExistence(timeout: 5))
|
||||
capture("push-old-server-agent-compatible")
|
||||
}
|
||||
|
||||
func testWarmTapDismissesAttachmentPickerPreservingDraft() async throws {
|
||||
app.launchEnvironment["SYBIL_PUSH_TAP_ON_FOREGROUND"] = "1"
|
||||
app.launch(); openAgent()
|
||||
let composer = app.textFields["agent-composer"]
|
||||
composer.tap(); composer.typeText(" Warm push preserved draft")
|
||||
app.buttons["agent-attach"].tap(); app.buttons["Files"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
|
||||
XCUIDevice.shared.press(.home); app.activate()
|
||||
XCTAssertTrue(composer.waitForExistence(timeout: 10))
|
||||
let visible = expectation(for: NSPredicate(format: "hittable == true"), evaluatedWith: composer)
|
||||
await fulfillment(of: [visible], timeout: 5)
|
||||
XCTAssertFalse(app.buttons["Cancel"].firstMatch.exists)
|
||||
XCTAssertTrue((composer.value as? String ?? "").contains("Warm push preserved draft"))
|
||||
capture("push-warm-tap-dismissed-picker-draft-preserved")
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -10,13 +10,13 @@ generate:
|
||||
|
||||
build: generate
|
||||
set -o pipefail; if command -v xcbeautify >/dev/null 2>&1; then \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}' | xcbeautify; \
|
||||
else \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'; \
|
||||
fi
|
||||
|
||||
test:
|
||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}' -parallel-testing-enabled NO
|
||||
|
||||
run: generate
|
||||
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
||||
|
||||
Reference in New Issue
Block a user