160 lines
6.4 KiB
Swift
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)
|
|
})
|
|
}
|
|
}
|