Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilAgentNotificationModels.swift
T

160 lines
6.4 KiB
Swift

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)
})
}
}