This commit is contained in:
Hariharan Mudaliar 2026-03-15 23:59:13 +05:30
commit bfb36cb9d1
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
7 changed files with 808 additions and 3 deletions

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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?
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}