diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index ae8e11d..ba00b70 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -111,6 +111,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let mediaControlsStateCoordinator = MediaControlsStateCoordinator.shared let systemTimerBridge = SystemTimerBridge.shared let extensionXPCServiceHost = ExtensionXPCServiceHost.shared + let extensionRPCServer = ExtensionRPCServer.shared var closeNotchWorkItem: DispatchWorkItem? private var previousScreens: [NSScreen]? private var onboardingWindowController: NSWindowController? @@ -159,6 +160,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { windowSizeUpdateWorkItem?.cancel() NotificationCenter.default.removeObserver(self) extensionXPCServiceHost.stop() + extensionRPCServer.stop() // Restore Lunar's native OSD if integration was active LunarManager.shared.appWillTerminate() @@ -394,6 +396,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { LockScreenLiveActivityWindowManager.shared.configure(viewModel: vm) LockScreenManager.shared.configure(viewModel: vm) extensionXPCServiceHost.start() + extensionRPCServer.start() // Migrate legacy progress bar settings Defaults.Keys.migrateProgressBarStyle() diff --git a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift index 8f43953..4c833b8 100644 --- a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift @@ -128,6 +128,7 @@ final class ExtensionLiveActivityManager: ObservableObject { if previousCount != activeActivities.count { Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions) ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID) + ExtensionRPCServer.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID) logDiagnostics("Removed live activity \(activityID) for \(bundleIdentifier); remaining: \(activeActivities.count)") broadcastSnapshot() } @@ -140,7 +141,10 @@ final class ExtensionLiveActivityManager: ObservableObject { withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier } } - ids.forEach { ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) } + ids.forEach { + ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) + ExtensionRPCServer.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) + } if !ids.isEmpty { logDiagnostics("Removed all live activities for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))") broadcastSnapshot() diff --git a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift index 372669e..7cbdcab 100644 --- a/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionLockScreenWidgetManager.swift @@ -118,6 +118,7 @@ final class ExtensionLockScreenWidgetManager: ObservableObject { if previousCount != activeWidgets.count { Logger.log("Dismissed extension widget \(widgetID) from \(bundleIdentifier)", category: .extensions) ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: widgetID) + ExtensionRPCServer.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: widgetID) logDiagnostics("Removed lock screen widget \(widgetID) for \(bundleIdentifier); remaining: \(activeWidgets.count)") broadcastSnapshot() } @@ -128,7 +129,10 @@ final class ExtensionLockScreenWidgetManager: ObservableObject { .filter { $0.bundleIdentifier == bundleIdentifier } .map { $0.descriptor.id } activeWidgets.removeAll { $0.bundleIdentifier == bundleIdentifier } - ids.forEach { ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0) } + ids.forEach { + ExtensionXPCServiceHost.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0) + ExtensionRPCServer.shared.notifyWidgetDismiss(bundleIdentifier: bundleIdentifier, widgetID: $0) + } if !ids.isEmpty { logDiagnostics("Removed all lock screen widgets for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))") broadcastSnapshot() diff --git a/DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift b/DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift index f4a7117..28d1cf7 100644 --- a/DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionNotchExperienceManager.swift @@ -137,6 +137,7 @@ final class ExtensionNotchExperienceManager: ObservableObject { guard previousCount != activeExperiences.count else { return } Logger.log("Dismissed notch experience \(experienceID) from \(bundleIdentifier)", category: .extensions) ExtensionXPCServiceHost.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: experienceID) + ExtensionRPCServer.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: experienceID) logDiagnostics("Removed notch experience \(experienceID) for \(bundleIdentifier); remaining: \(activeExperiences.count)") broadcastSnapshot() } @@ -148,7 +149,10 @@ final class ExtensionNotchExperienceManager: ObservableObject { withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { activeExperiences.removeAll { $0.bundleIdentifier == bundleIdentifier } } - ids.forEach { ExtensionXPCServiceHost.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: $0) } + ids.forEach { + ExtensionXPCServiceHost.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: $0) + ExtensionRPCServer.shared.notifyNotchExperienceDismiss(bundleIdentifier: bundleIdentifier, experienceID: $0) + } if !ids.isEmpty { logDiagnostics("Removed all notch experiences for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))") broadcastSnapshot() diff --git a/DynamicIsland/services/Extensions/ExtensionRPCMessages.swift b/DynamicIsland/services/Extensions/ExtensionRPCMessages.swift new file mode 100644 index 0000000..8a97145 --- /dev/null +++ b/DynamicIsland/services/Extensions/ExtensionRPCMessages.swift @@ -0,0 +1,184 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +// MARK: - JSON-RPC 2.0 Message Types + +/// Incoming JSON-RPC 2.0 request from a client. +struct RPCRequest: Codable { + let jsonrpc: String + let method: String + let params: RPCParams? + let id: String +} + +/// Outgoing JSON-RPC 2.0 success response. +struct RPCSuccessResponse: Codable { + let jsonrpc: String + let result: [String: RPCValue] + let id: String + + init(result: [String: RPCValue], id: String) { + self.jsonrpc = "2.0" + self.result = result + self.id = id + } +} + +/// Outgoing JSON-RPC 2.0 error response. +struct RPCErrorResponse: Codable { + let jsonrpc: String + let error: RPCErrorObject + let id: String? + + init(error: RPCErrorObject, id: String?) { + self.jsonrpc = "2.0" + self.error = error + self.id = id + } +} + +/// JSON-RPC error object. +struct RPCErrorObject: Codable { + let code: Int + let message: String + let data: String? + + init(code: Int, message: String, data: String? = nil) { + self.code = code + self.message = message + self.data = data + } +} + +/// Outgoing JSON-RPC 2.0 notification (server → client, no id). +struct RPCNotification: Codable { + let jsonrpc: String + let method: String + let params: [String: RPCValue] + + init(method: String, params: [String: RPCValue]) { + self.jsonrpc = "2.0" + self.method = method + self.params = params + } +} + +// MARK: - Standard Error Codes + +enum RPCErrorCode { + static let parseError = -32700 + static let invalidRequest = -32600 + static let methodNotFound = -32601 + static let invalidParams = -32602 + static let internalError = -32603 + // Application-specific codes + static let unauthorized = -32001 + static let featureDisabled = -32002 + static let capacityExceeded = -32003 + static let descriptorInvalid = -32004 + static let unsupported = -32005 +} + +// MARK: - Flexible JSON Value + +/// Type-erased JSON value for RPC params and results. +enum RPCValue: Codable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case null + case object([String: RPCValue]) + case array([RPCValue]) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let b = try? container.decode(Bool.self) { + self = .bool(b) + } else if let i = try? container.decode(Int.self) { + self = .int(i) + } else if let d = try? container.decode(Double.self) { + self = .double(d) + } else if let s = try? container.decode(String.self) { + self = .string(s) + } else if let obj = try? container.decode([String: RPCValue].self) { + self = .object(obj) + } else if let arr = try? container.decode([RPCValue].self) { + self = .array(arr) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON type") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let s): try container.encode(s) + case .int(let i): try container.encode(i) + case .double(let d): try container.encode(d) + case .bool(let b): try container.encode(b) + case .null: try container.encodeNil() + case .object(let o): try container.encode(o) + case .array(let a): try container.encode(a) + } + } + + var stringValue: String? { + if case .string(let s) = self { return s } + return nil + } + var boolValue: Bool? { + if case .bool(let b) = self { return b } + return nil + } + var objectValue: [String: RPCValue]? { + if case .object(let o) = self { return o } + return nil + } +} + +// MARK: - Flexible Params + +/// JSON-RPC params can be an object or individual values. +struct RPCParams: Codable { + let values: [String: RPCValue] + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + self.values = try container.decode([String: RPCValue].self) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(values) + } + + subscript(key: String) -> RPCValue? { + values[key] + } + + /// Extract a JSON sub-object as raw Data for use with JSONDecoder. + func jsonData(for key: String) -> Data? { + guard let value = values[key] else { return nil } + return try? JSONEncoder().encode(value) + } +} diff --git a/DynamicIsland/services/Extensions/ExtensionRPCServer.swift b/DynamicIsland/services/Extensions/ExtensionRPCServer.swift new file mode 100644 index 0000000..13a9dd2 --- /dev/null +++ b/DynamicIsland/services/Extensions/ExtensionRPCServer.swift @@ -0,0 +1,294 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Network +import Defaults + +/// WebSocket server for Atoll RPC. +/// Uses Apple's Network.framework (`NWListener`) — no external dependencies. +/// Listens on localhost:9020 for JSON-RPC 2.0 requests over WebSocket. +@MainActor +final class ExtensionRPCServer { + static let shared = ExtensionRPCServer() + + private var listener: NWListener? + private var connections: [UUID: RPCClientConnection] = [:] + private let port: UInt16 = 9020 + private let queue = DispatchQueue(label: "com.ebullioscopic.Atoll.rpc.server", qos: .userInitiated) + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + private init() {} + + // MARK: - Lifecycle + + func start() { + guard listener == nil else { + logDiagnostics("RPC server already running") + return + } + + let params = NWParameters(tls: nil) + let wsOptions = NWProtocolWebSocket.Options() + wsOptions.autoReplyPing = true + params.defaultProtocolStack.applicationProtocols.insert(wsOptions, at: 0) + + do { + listener = try NWListener(using: params, on: NWEndpoint.Port(integerLiteral: port)) + } catch { + Logger.log("Failed to create RPC listener: \(error.localizedDescription)", category: .extensions) + return + } + + listener?.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + self?.handleListenerState(state) + } + } + + listener?.newConnectionHandler = { [weak self] connection in + Task { @MainActor in + self?.handleNewConnection(connection) + } + } + + listener?.start(queue: queue) + } + + func stop() { + listener?.cancel() + listener = nil + for (_, conn) in connections { + conn.connection.cancel() + } + connections.removeAll() + logDiagnostics("RPC server stopped") + } + + // MARK: - Client Notifications + + func notifyActivityDismiss(bundleIdentifier: String, activityID: String) { + sendNotification( + to: bundleIdentifier, + method: "atoll.activityDidDismiss", + params: [ + "bundleIdentifier": .string(bundleIdentifier), + "activityID": .string(activityID) + ] + ) + } + + func notifyWidgetDismiss(bundleIdentifier: String, widgetID: String) { + sendNotification( + to: bundleIdentifier, + method: "atoll.widgetDidDismiss", + params: [ + "bundleIdentifier": .string(bundleIdentifier), + "widgetID": .string(widgetID) + ] + ) + } + + func notifyNotchExperienceDismiss(bundleIdentifier: String, experienceID: String) { + sendNotification( + to: bundleIdentifier, + method: "atoll.notchExperienceDidDismiss", + params: [ + "bundleIdentifier": .string(bundleIdentifier), + "experienceID": .string(experienceID) + ] + ) + } + + func notifyAuthorizationChange(bundleIdentifier: String, isAuthorized: Bool) { + sendNotification( + to: bundleIdentifier, + method: "atoll.authorizationDidChange", + params: [ + "bundleIdentifier": .string(bundleIdentifier), + "isAuthorized": .bool(isAuthorized) + ] + ) + } + + // MARK: - Connection Handling + + private func handleListenerState(_ state: NWListener.State) { + switch state { + case .ready: + Logger.log("Started Atoll RPC WebSocket server on port \(port)", category: .extensions) + case .failed(let error): + Logger.log("RPC server failed: \(error.localizedDescription)", category: .extensions) + // Attempt restart after delay + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in + self?.listener = nil + self?.start() + } + case .cancelled: + logDiagnostics("RPC server listener cancelled") + default: + break + } + } + + private func handleNewConnection(_ nwConnection: NWConnection) { + let connID = UUID() + let clientConn = RPCClientConnection( + id: connID, + connection: nwConnection, + bundleIdentifier: nil + ) + connections[connID] = clientConn + + nwConnection.stateUpdateHandler = { [weak self] state in + Task { @MainActor in + self?.handleConnectionState(connID: connID, state: state) + } + } + + nwConnection.start(queue: queue) + receiveMessage(connID: connID) + logDiagnostics("RPC client connected (id: \(connID.uuidString.prefix(8)))") + } + + private func handleConnectionState(connID: UUID, state: NWConnection.State) { + switch state { + case .failed, .cancelled: + connections.removeValue(forKey: connID) + logDiagnostics("RPC client disconnected (id: \(connID.uuidString.prefix(8)))") + default: + break + } + } + + private func receiveMessage(connID: UUID) { + guard let clientConn = connections[connID] else { return } + let connection = clientConn.connection + + connection.receiveMessage { [weak self] content, context, isComplete, error in + guard let self else { return } + + if let error { + Task { @MainActor in + self.logDiagnostics("RPC receive error for \(connID.uuidString.prefix(8)): \(error.localizedDescription)") + self.connections.removeValue(forKey: connID) + } + return + } + + if let data = content, !data.isEmpty { + Task { @MainActor in + self.processMessage(data: data, connID: connID) + } + } + + // Continue receiving + Task { @MainActor in + self.receiveMessage(connID: connID) + } + } + } + + private func processMessage(data: Data, connID: UUID) { + guard var clientConn = connections[connID] else { return } + + // Parse JSON-RPC request + guard let request = try? decoder.decode(RPCRequest.self, from: data) else { + let errorResponse = RPCErrorResponse( + error: RPCErrorObject(code: RPCErrorCode.parseError, message: "Invalid JSON-RPC request"), + id: nil + ) + sendResponse(errorResponse, to: connID) + return + } + + // Resolve bundle identifier from first authorization request + if clientConn.bundleIdentifier == nil, + let params = request.params, + let bi = params["bundleIdentifier"]?.stringValue { + clientConn.bundleIdentifier = bi + connections[connID] = clientConn + } + + let service = ExtensionRPCService( + bundleIdentifier: clientConn.bundleIdentifier ?? "unknown", + server: self + ) + + let responseData = service.handleRequest(request) + sendRawData(responseData, to: connID) + } + + // MARK: - Send Helpers + + private func sendResponse(_ response: Codable, to connID: UUID) { + guard let data = try? encoder.encode(response) else { return } + sendRawData(data, to: connID) + } + + private func sendRawData(_ data: Data, to connID: UUID) { + guard let clientConn = connections[connID] else { return } + + let metadata = NWProtocolWebSocket.Metadata(opcode: .text) + let context = NWConnection.ContentContext(identifier: "rpc-response", metadata: [metadata]) + + clientConn.connection.send( + content: data, + contentContext: context, + isComplete: true, + completion: .contentProcessed { error in + if let error { + Task { @MainActor in + self.logDiagnostics("RPC send error: \(error.localizedDescription)") + } + } + } + ) + } + + private func sendNotification(to bundleIdentifier: String, method: String, params: [String: RPCValue]) { + let notification = RPCNotification(method: method, params: params) + guard let data = try? encoder.encode(notification) else { return } + + for (_, clientConn) in connections where clientConn.bundleIdentifier == bundleIdentifier { + let metadata = NWProtocolWebSocket.Metadata(opcode: .text) + let context = NWConnection.ContentContext(identifier: "rpc-notification", metadata: [metadata]) + + clientConn.connection.send( + content: data, + contentContext: context, + isComplete: true, + completion: .contentProcessed { _ in } + ) + } + } + + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } +} + +// MARK: - Client Connection + +struct RPCClientConnection { + let id: UUID + let connection: NWConnection + var bundleIdentifier: String? +} diff --git a/DynamicIsland/services/Extensions/ExtensionRPCService.swift b/DynamicIsland/services/Extensions/ExtensionRPCService.swift new file mode 100644 index 0000000..6306bac --- /dev/null +++ b/DynamicIsland/services/Extensions/ExtensionRPCService.swift @@ -0,0 +1,312 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import Defaults +import AtollExtensionKit + +/// Handles JSON-RPC method calls for a single WebSocket connection. +/// Mirrors the functionality of `ExtensionXPCService` but uses JSON-RPC transport. +@MainActor +final class ExtensionRPCService { + let bundleIdentifier: String + private weak var server: ExtensionRPCServer? + + private let liveActivityManager = ExtensionLiveActivityManager.shared + private let widgetManager = ExtensionLockScreenWidgetManager.shared + private let notchManager = ExtensionNotchExperienceManager.shared + private let authorizationManager = ExtensionAuthorizationManager.shared + + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + init(bundleIdentifier: String, server: ExtensionRPCServer) { + self.bundleIdentifier = bundleIdentifier + self.server = server + } + + // MARK: - Method Routing + + func handleRequest(_ request: RPCRequest) -> Data { + let result: Codable + + switch request.method { + case "atoll.getVersion": + result = handleGetVersion(id: request.id) + + case "atoll.requestAuthorization": + result = handleRequestAuthorization(params: request.params, id: request.id) + + case "atoll.checkAuthorization": + result = handleCheckAuthorization(params: request.params, id: request.id) + + case "atoll.presentLiveActivity": + result = handlePresentLiveActivity(params: request.params, id: request.id) + + case "atoll.updateLiveActivity": + result = handleUpdateLiveActivity(params: request.params, id: request.id) + + case "atoll.dismissLiveActivity": + result = handleDismissLiveActivity(params: request.params, id: request.id) + + case "atoll.presentLockScreenWidget": + result = handlePresentLockScreenWidget(params: request.params, id: request.id) + + case "atoll.updateLockScreenWidget": + result = handleUpdateLockScreenWidget(params: request.params, id: request.id) + + case "atoll.dismissLockScreenWidget": + result = handleDismissLockScreenWidget(params: request.params, id: request.id) + + case "atoll.presentNotchExperience": + result = handlePresentNotchExperience(params: request.params, id: request.id) + + case "atoll.updateNotchExperience": + result = handleUpdateNotchExperience(params: request.params, id: request.id) + + case "atoll.dismissNotchExperience": + result = handleDismissNotchExperience(params: request.params, id: request.id) + + default: + result = RPCErrorResponse( + error: RPCErrorObject(code: RPCErrorCode.methodNotFound, message: "Method not found: \(request.method)"), + id: request.id + ) + } + + return (try? encoder.encode(result)) ?? Data() + } + + // MARK: - Version + + private func handleGetVersion(id: String) -> RPCSuccessResponse { + RPCSuccessResponse( + result: ["version": .string(AtollExtensionKitVersion)], + id: id + ) + } + + // MARK: - Authorization + + private func handleRequestAuthorization(params: RPCParams?, id: String) -> Codable { + guard Defaults[.enableThirdPartyExtensions] else { + return RPCErrorResponse( + error: RPCErrorObject(code: RPCErrorCode.featureDisabled, message: "Extensions are disabled"), + id: id + ) + } + + let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier + let entry = authorizationManager.ensureEntryExists(bundleIdentifier: bi, appName: bi) + + if entry.isAuthorized { + return RPCSuccessResponse(result: ["authorized": .bool(true)], id: id) + } + + // Auto-authorize for now (user can revoke in settings) + authorizationManager.authorize(bundleIdentifier: bi, appName: bi) + logDiagnostics("Authorized RPC client \(bi)") + + return RPCSuccessResponse(result: ["authorized": .bool(true)], id: id) + } + + private func handleCheckAuthorization(params: RPCParams?, id: String) -> Codable { + let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier + + guard Defaults[.enableThirdPartyExtensions] else { + return RPCSuccessResponse(result: ["authorized": .bool(false)], id: id) + } + + let entry = authorizationManager.authorizationEntry(for: bi) + let authorized = entry?.isAuthorized ?? false + + return RPCSuccessResponse(result: ["authorized": .bool(authorized)], id: id) + } + + // MARK: - Live Activities + + private func handlePresentLiveActivity(params: RPCParams?, id: String) -> Codable { + guard let descriptorData = params?.jsonData(for: "descriptor") else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id) + } + + do { + let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + try liveActivityManager.present(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) + logDiagnostics("RPC: Presented live activity \(descriptor.id) for \(descriptor.bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } catch let error as ExtensionValidationError { + return errorResponse(from: error, id: id) + } catch { + return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) + } + } + + private func handleUpdateLiveActivity(params: RPCParams?, id: String) -> Codable { + guard let descriptorData = params?.jsonData(for: "descriptor") else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id) + } + + do { + let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) + try liveActivityManager.update(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) + logDiagnostics("RPC: Updated live activity \(descriptor.id) for \(descriptor.bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } catch let error as ExtensionValidationError { + return errorResponse(from: error, id: id) + } catch { + return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) + } + } + + private func handleDismissLiveActivity(params: RPCParams?, id: String) -> Codable { + guard let activityID = params?["activityID"]?.stringValue else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing activityID", id: id) + } + let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier + liveActivityManager.dismiss(activityID: activityID, bundleIdentifier: bi) + logDiagnostics("RPC: Dismissed live activity \(activityID) for \(bi)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } + + // MARK: - Lock Screen Widgets + + private func handlePresentLockScreenWidget(params: RPCParams?, id: String) -> Codable { + guard let descriptorData = params?.jsonData(for: "descriptor") else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id) + } + + do { + let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + try widgetManager.present(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) + logDiagnostics("RPC: Presented widget \(descriptor.id) for \(descriptor.bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } catch let error as ExtensionValidationError { + return errorResponse(from: error, id: id) + } catch { + return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) + } + } + + private func handleUpdateLockScreenWidget(params: RPCParams?, id: String) -> Codable { + guard let descriptorData = params?.jsonData(for: "descriptor") else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id) + } + + do { + let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) + try widgetManager.update(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) + logDiagnostics("RPC: Updated widget \(descriptor.id) for \(descriptor.bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } catch let error as ExtensionValidationError { + return errorResponse(from: error, id: id) + } catch { + return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) + } + } + + private func handleDismissLockScreenWidget(params: RPCParams?, id: String) -> Codable { + guard let widgetID = params?["widgetID"]?.stringValue else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing widgetID", id: id) + } + let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier + widgetManager.dismiss(widgetID: widgetID, bundleIdentifier: bi) + logDiagnostics("RPC: Dismissed widget \(widgetID) for \(bi)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } + + // MARK: - Notch Experiences + + private func handlePresentNotchExperience(params: RPCParams?, id: String) -> Codable { + guard let descriptorData = params?.jsonData(for: "descriptor") else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id) + } + + do { + let descriptor = try decoder.decode(AtollNotchExperienceDescriptor.self, from: descriptorData) + try ExtensionDescriptorValidator.validate(descriptor) + try notchManager.present(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) + logDiagnostics("RPC: Presented notch experience \(descriptor.id) for \(descriptor.bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } catch let error as ExtensionValidationError { + return errorResponse(from: error, id: id) + } catch { + return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) + } + } + + private func handleUpdateNotchExperience(params: RPCParams?, id: String) -> Codable { + guard let descriptorData = params?.jsonData(for: "descriptor") else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing descriptor", id: id) + } + + do { + let descriptor = try decoder.decode(AtollNotchExperienceDescriptor.self, from: descriptorData) + try notchManager.update(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) + logDiagnostics("RPC: Updated notch experience \(descriptor.id) for \(descriptor.bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } catch let error as ExtensionValidationError { + return errorResponse(from: error, id: id) + } catch { + return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) + } + } + + private func handleDismissNotchExperience(params: RPCParams?, id: String) -> Codable { + guard let experienceID = params?["experienceID"]?.stringValue else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing experienceID", id: id) + } + let bi = params?["bundleIdentifier"]?.stringValue ?? bundleIdentifier + notchManager.dismiss(experienceID: experienceID, bundleIdentifier: bi) + logDiagnostics("RPC: Dismissed notch experience \(experienceID) for \(bi)") + return RPCSuccessResponse(result: ["success": .bool(true)], id: id) + } + + // MARK: - Helpers + + private func errorResponse(code: Int, message: String, id: String) -> RPCErrorResponse { + RPCErrorResponse( + error: RPCErrorObject(code: code, message: message), + id: id + ) + } + + private func errorResponse(from error: ExtensionValidationError, id: String) -> RPCErrorResponse { + let code: Int + switch error { + case .featureDisabled: code = RPCErrorCode.featureDisabled + case .unauthorized: code = RPCErrorCode.unauthorized + case .invalidDescriptor: code = RPCErrorCode.descriptorInvalid + case .exceedsCapacity: code = RPCErrorCode.capacityExceeded + case .unsupportedContent: code = RPCErrorCode.unsupported + case .rateLimited: code = RPCErrorCode.internalError + case .duplicateIdentifier: code = RPCErrorCode.descriptorInvalid + } + return RPCErrorResponse( + error: RPCErrorObject(code: code, message: error.localizedDescription ?? "Unknown error"), + id: id + ) + } + + private func logDiagnostics(_ message: String) { + guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } + Logger.log(message, category: .extensions) + } +}