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