import Foundation import UserNotifications struct DesktopUserAction: Decodable, Equatable { let id: String let label: String } struct DesktopUserRequest: Decodable { let id: String let kind: String let title: String let message: String let createdAt: Date let expiresAt: Date let actions: [DesktopUserAction] } struct DesktopAgentWindow: Decodable { let id: String let url: String } func decodeDesktopRequests(_ data: Data) throws -> [DesktopUserRequest] { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in let value = try decoder.singleValueContainer().decode(String.self) let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: value) { return date } formatter.formatOptions = [.withInternetDateTime] if let date = formatter.date(from: value) { return date } throw DecodingError.dataCorruptedError(in: try decoder.singleValueContainer(), debugDescription: "Invalid request timestamp") } return try decoder.decode([DesktopUserRequest].self, from: data) } struct DesktopUserRequestState { private(set) var pending: [DesktopUserRequest] = [] private var announced: Set = [] private var responding: Set = [] private var completed: Set = [] mutating func beginResponse(baseUrl: String, requestId: String, action: String, now: Date) -> URLRequest? { guard !responding.contains(requestId), let pendingRequest = pending.first(where: { $0.id == requestId }), pendingRequest.expiresAt > now, pendingRequest.actions.contains(where: { $0.id == action }), let id = requestId.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-._~"))), let url = URL(string: baseUrl.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/desktop/requests/\(id)/respond"), let body = try? JSONEncoder().encode(["action": action]) else { return nil } var response = URLRequest(url: url) response.httpMethod = "POST" response.setValue("application/json", forHTTPHeaderField: "Content-Type") response.httpBody = body responding.insert(requestId) return response } mutating func completeResponse(_ id: String, accepted: Bool) { responding.remove(id) if accepted { completed.insert(id); pending.removeAll { $0.id == id } } } func isResponding(_ id: String) -> Bool { responding.contains(id) } mutating func reconcile(_ requests: [DesktopUserRequest], now: Date) -> [String] { let previousIds = Set(pending.map(\.id)) pending = requests.filter { $0.expiresAt > now && !completed.contains($0.id) }.sorted { $0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt < $1.createdAt } let currentIds = Set(pending.map(\.id)) return previousIds.subtracting(currentIds).sorted() } func notificationsToAnnounce() -> [DesktopUserRequest] { pending.filter { !announced.contains($0.id) && !responding.contains($0.id) } } mutating func markAnnounced(_ id: String) { if pending.contains(where: { $0.id == id }) { announced.insert(id) } } } extension DesktopUserRequest { var notificationIdentifier: String { "desktop-request." + id } func notificationCategory() -> UNNotificationCategory { UNNotificationCategory( identifier: notificationIdentifier, actions: actions.map { UNNotificationAction(identifier: $0.id, title: $0.label, options: []) }, intentIdentifiers: [], options: [.customDismissAction]) } func notificationRequest() -> UNNotificationRequest { let content = UNMutableNotificationContent() content.title = title content.body = message content.categoryIdentifier = notificationIdentifier content.userInfo = ["requestId": id] content.sound = .default return UNNotificationRequest(identifier: notificationIdentifier, content: content, trigger: nil) } } struct DesktopAgentWindowState { private var opened: Set = [] mutating func takeUnopened(_ windows: [DesktopAgentWindow]) -> [DesktopAgentWindow] { windows.filter { opened.insert($0.id).inserted } } }