diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b422434..fed7c00 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/Ebullioscopic/AtollExtensionKit", "state" : { "branch" : "main", - "revision" : "ebe35277572a349fad26abeb6fc552cb5251d584" + "revision" : "e2d30afedbbcc259bec7eeb1bea0757e52f4ca01" } }, { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 553e8f1..c00538e 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1661,10 +1661,6 @@ } } }, - "±10 Seconds" : { - "comment" : "Displayed as the name of the music skip behavior option.", - "isCommentAutoGenerated" : true - }, "0" : { "comment" : "A label displayed in a circular gauge to indicate the minimum battery level.", "isCommentAutoGenerated" : true, @@ -2017,10 +2013,6 @@ } } }, - "Accent" : { - "comment" : "Display name for the \"Accent\" caps lock indicator tint mode.", - "isCommentAutoGenerated" : true - }, "Accent color" : { "localizations" : { "ar" : { @@ -2366,10 +2358,6 @@ } } }, - "Adaptive gradient" : { - "comment" : "Name of the timer icon color mode option that uses an adaptive gradient.", - "isCommentAutoGenerated" : true - }, "Add" : { "localizations" : { "ar" : { @@ -3989,10 +3977,6 @@ } } }, - "Apple Music" : { - "comment" : "Names for the different media player types.", - "isCommentAutoGenerated" : true - }, "Applies the notch-style parallax effect to the lock screen media widget album art." : { "localizations" : { "pt-BR" : { @@ -4437,10 +4421,6 @@ } } }, - "Bar" : { - "comment" : "Localized name for the \"Bar\" timer progress style.", - "isCommentAutoGenerated" : true - }, "Battery" : { "localizations" : { "ar" : { @@ -8816,10 +8796,6 @@ } } }, - "Digital" : { - "comment" : "The display name for the \"Digital\" reminder presentation style.", - "isCommentAutoGenerated" : true - }, "Dimensions" : { "localizations" : { "pt-BR" : { @@ -12367,10 +12343,6 @@ } } }, - "Green" : { - "comment" : "Display name for the \"Green\" Caps Lock Indicator Tint Mode.", - "isCommentAutoGenerated" : true - }, "Height:" : { "extractionState" : "stale", "localizations" : { @@ -16134,6 +16106,7 @@ } }, "Minutes" : { + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -18365,10 +18338,6 @@ } } }, - "Now Playing" : { - "comment" : "Localized name for each media controller type.", - "isCommentAutoGenerated" : true - }, "Number of lines kept in the scrollback buffer. Higher values use more memory." : { }, @@ -18771,10 +18740,6 @@ } } }, - "Panel" : { - "comment" : "Name for the \"Panel\" clipboard display mode.", - "isCommentAutoGenerated" : true - }, "Panel mode shows clipboard in a floating window near the notch." : { "comment" : "A description of the \"Panel\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -20480,10 +20445,6 @@ } } }, - "Ring" : { - "comment" : "Localized name for the \"Ring\" timer progress style.", - "isCommentAutoGenerated" : true - }, "Rotation:" : { "extractionState" : "stale", "localizations" : { @@ -21328,10 +21289,6 @@ } } }, - "Separate Tab" : { - "comment" : "Name of the clipboard display mode option that opens the clipboard in a separate tab.", - "isCommentAutoGenerated" : true - }, "Separate Tab mode integrates Copied Items and Notes into a single view. If both are enabled, Notes appear on the right and Clipboard on the left." : { "comment" : "A description of the \"Separate Tab\" clipboard display mode.", "isCommentAutoGenerated" : true, @@ -22423,14 +22380,6 @@ } } }, - "Shows screen assistant as a dropdown attached to the AI button" : { - "comment" : "Description of the \"popover\" display mode for the screen assistant.", - "isCommentAutoGenerated" : true - }, - "Shows screen assistant in a floating panel near the notch" : { - "comment" : "Description of the \"Panel\" screen assistant display mode.", - "isCommentAutoGenerated" : true - }, "Shows the model's reasoning process before the final answer" : { "localizations" : { "fr" : { @@ -22505,10 +22454,6 @@ } } }, - "Skip forward or backward by ten seconds" : { - "comment" : "Description of the \"Skip forward or backward by ten seconds\" music skip behavior option.", - "isCommentAutoGenerated" : true - }, "Slider color" : { "localizations" : { "ar" : { @@ -23077,10 +23022,6 @@ } } }, - "Spotify" : { - "comment" : "Name of a media controller type for Spotify.", - "isCommentAutoGenerated" : true - }, "Square" : { "localizations" : { "ar" : { @@ -23193,10 +23134,6 @@ }, "Standard notch media controls are hidden. Re-enable the toggle above to restore them." : { - }, - "Standard previous/next track controls" : { - "comment" : "Description of a music skip behavior option that uses standard previous/next track controls.", - "isCommentAutoGenerated" : true }, "Start" : { "localizations" : { @@ -24608,10 +24545,6 @@ }, "shouldTranslate" : false }, - "Track Skip" : { - "comment" : "Name of the music skip behavior option that skips to the next track.", - "isCommentAutoGenerated" : true - }, "Transform" : { "extractionState" : "stale", "localizations" : { @@ -26616,10 +26549,6 @@ "comment" : "A footer note explaining the app's funding.", "isCommentAutoGenerated" : true }, - "Youtube Music" : { - "comment" : "Name for the \"Youtube Music\" media controller type in settings.", - "isCommentAutoGenerated" : true - }, "YouTube Music requires this third-party app to be installed: " : { "localizations" : { "ar" : { diff --git a/DynamicIsland/services/Extensions/ExtensionRPCService.swift b/DynamicIsland/services/Extensions/ExtensionRPCService.swift index 6306bac..383011e 100644 --- a/DynamicIsland/services/Extensions/ExtensionRPCService.swift +++ b/DynamicIsland/services/Extensions/ExtensionRPCService.swift @@ -35,6 +35,18 @@ final class ExtensionRPCService { private let decoder = JSONDecoder() private let encoder = JSONEncoder() + // Keys whose values represent Swift enums that use {"type":...} in client format. + // These need to be transformed to Swift Codable format: {"caseName": {params}}. + private static let enumKeys: Set = [ + "leadingIcon", "trailingContent", "progressIndicator", + "badgeIcon", "leadingContent", "icon", "tint" + ] + + // Enum-typed fields that can appear inside arrays (like content elements) + private static let contentElementTypeFields: Set = [ + "text", "icon", "progress", "graph", "gauge", "spacer", "divider", "webView" + ] + init(bundleIdentifier: String, server: ExtensionRPCServer) { self.bundleIdentifier = bundleIdentifier self.server = server @@ -146,7 +158,8 @@ final class ExtensionRPCService { } do { - let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) + let transformed = Self.transformClientJSON(descriptorData) + let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: transformed) try ExtensionDescriptorValidator.validate(descriptor) try liveActivityManager.present(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) logDiagnostics("RPC: Presented live activity \(descriptor.id) for \(descriptor.bundleIdentifier)") @@ -154,6 +167,8 @@ final class ExtensionRPCService { } catch let error as ExtensionValidationError { return errorResponse(from: error, id: id) } catch { + logDiagnostics("RPC: Decode error for presentLiveActivity: \(error)") + logDiagnostics("RPC: Raw payload: \(String(data: descriptorData, encoding: .utf8) ?? "")") return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) } } @@ -164,13 +179,15 @@ final class ExtensionRPCService { } do { - let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: descriptorData) + let transformed = Self.transformClientJSON(descriptorData) + let descriptor = try decoder.decode(AtollLiveActivityDescriptor.self, from: transformed) 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 { + logDiagnostics("RPC: Decode error for updateLiveActivity: \(error)") return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) } } @@ -193,7 +210,8 @@ final class ExtensionRPCService { } do { - let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) + let transformed = Self.transformClientJSON(descriptorData) + let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: transformed) try ExtensionDescriptorValidator.validate(descriptor) try widgetManager.present(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) logDiagnostics("RPC: Presented widget \(descriptor.id) for \(descriptor.bundleIdentifier)") @@ -201,6 +219,8 @@ final class ExtensionRPCService { } catch let error as ExtensionValidationError { return errorResponse(from: error, id: id) } catch { + logDiagnostics("RPC: Decode error for presentWidget: \(error)") + logDiagnostics("RPC: Raw payload: \(String(data: descriptorData, encoding: .utf8) ?? "")") return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) } } @@ -211,13 +231,15 @@ final class ExtensionRPCService { } do { - let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: descriptorData) + let transformed = Self.transformClientJSON(descriptorData) + let descriptor = try decoder.decode(AtollLockScreenWidgetDescriptor.self, from: transformed) 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 { + logDiagnostics("RPC: Decode error for updateWidget: \(error)") return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) } } @@ -240,7 +262,8 @@ final class ExtensionRPCService { } do { - let descriptor = try decoder.decode(AtollNotchExperienceDescriptor.self, from: descriptorData) + let transformed = Self.transformClientJSON(descriptorData) + let descriptor = try decoder.decode(AtollNotchExperienceDescriptor.self, from: transformed) try ExtensionDescriptorValidator.validate(descriptor) try notchManager.present(descriptor: descriptor, bundleIdentifier: descriptor.bundleIdentifier) logDiagnostics("RPC: Presented notch experience \(descriptor.id) for \(descriptor.bundleIdentifier)") @@ -248,6 +271,8 @@ final class ExtensionRPCService { } catch let error as ExtensionValidationError { return errorResponse(from: error, id: id) } catch { + logDiagnostics("RPC: Decode error for presentNotchExperience: \(error)") + logDiagnostics("RPC: Raw payload: \(String(data: descriptorData, encoding: .utf8) ?? "")") return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) } } @@ -258,13 +283,15 @@ final class ExtensionRPCService { } do { - let descriptor = try decoder.decode(AtollNotchExperienceDescriptor.self, from: descriptorData) + let transformed = Self.transformClientJSON(descriptorData) + let descriptor = try decoder.decode(AtollNotchExperienceDescriptor.self, from: transformed) 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 { + logDiagnostics("RPC: Decode error for updateNotchExperience: \(error)") return errorResponse(code: RPCErrorCode.internalError, message: error.localizedDescription, id: id) } } @@ -309,4 +336,119 @@ final class ExtensionRPCService { guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } Logger.log(message, category: .extensions) } + + // MARK: - Client JSON Transformation + + /// Converts client wire format to Swift Codable format for enum types. + /// + /// Client sends: `{ "type": "symbol", "name": "timer", "size": 16 }` + /// Swift expects: `{ "symbol": { "name": "timer", "size": 16 } }` + /// + /// For enums with unnamed parameters (e.g., `case text(String, font:, color:)`): + /// Client sends: `{ "type": "text", "text": "LIVE", "font": {...} }` + /// Swift expects: `{ "text": { "_0": "LIVE", "font": {...} } }` + static func transformClientJSON(_ data: Data) -> Data { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return data + } + let transformed = transformObject(json) + return (try? JSONSerialization.data(withJSONObject: transformed)) ?? data + } + + private static func transformObject(_ obj: [String: Any]) -> [String: Any] { + var result: [String: Any] = [:] + + for (key, value) in obj { + if let dict = value as? [String: Any] { + if enumKeys.contains(key), let typeName = dict["type"] as? String { + // Transform: { "type": "symbol", ... } → { "symbol": { ... } } + result[key] = transformEnumValue(dict, typeName: typeName) + } else { + result[key] = transformObject(dict) + } + } else if let arr = value as? [[String: Any]] { + // Arrays like "content", "elements", "sections" + result[key] = arr.map { item in + if let typeName = item["type"] as? String, + (key == "content" || key == "elements") { + return transformEnumValue(item, typeName: typeName) + } + return transformObject(item) + } + } else { + result[key] = value + } + } + + return result + } + + /// Transforms `{ "type": "caseName", ...params }` → `{ "caseName": { ...params } }`. + /// Handles special cases where Swift enum cases have unnamed first parameters: + /// - `text` / `marquee`: `text` field → `_0` + /// - `icon`: `icon` field → `_0` (AtollIconDescriptor) + /// - `progress`: `indicator` field → `_0` (AtollProgressIndicator) + /// - `webView`: `content` field → `_0` (AtollWidgetWebContentDescriptor) + /// - `animation`: `data` field → `_0` (Data) + private static func transformEnumValue(_ dict: [String: Any], typeName: String) -> [String: Any] { + var inner: [String: Any] = [:] + + for (k, v) in dict where k != "type" { + if let nestedDict = v as? [String: Any] { + // Check if this nested dict is an enum-typed field + if (enumKeys.contains(k) || k == "indicator"), + let nestedType = nestedDict["type"] as? String { + inner[k] = transformEnumValue(nestedDict, typeName: nestedType) + } else { + inner[k] = transformObject(nestedDict) + } + } else if let nestedArr = v as? [[String: Any]] { + inner[k] = nestedArr.map { item in + if let t = item["type"] as? String { + return transformEnumValue(item, typeName: t) + } + return transformObject(item) + } + } else { + inner[k] = v + } + } + + // Handle unnamed first parameter for specific enum cases. + // Swift's auto-synthesized Codable encodes unnamed associated values as `_0`, `_1`, etc. + switch typeName { + case "text": + // AtollTrailingContent.text(String, ...) AND AtollWidgetContentElement.text(String, ...) + if let textValue = inner.removeValue(forKey: "text") { + inner["_0"] = textValue + } + case "marquee": + // AtollTrailingContent.marquee(String, ...) + if let textValue = inner.removeValue(forKey: "text") { + inner["_0"] = textValue + } + case "icon": + // AtollWidgetContentElement.icon(AtollIconDescriptor, tint:) + if let iconValue = inner.removeValue(forKey: "icon") { + inner["_0"] = iconValue + } + case "progress": + // AtollWidgetContentElement.progress(AtollProgressIndicator, value:, color:) + if let indicatorValue = inner.removeValue(forKey: "indicator") { + inner["_0"] = indicatorValue + } + case "webView": + // AtollWidgetContentElement.webView(AtollWidgetWebContentDescriptor) + if let contentValue = inner.removeValue(forKey: "content") { + inner["_0"] = contentValue + } + case "animation": + // AtollTrailingContent.animation(data:, size:) — data is named but let's be safe + break + default: + break + } + + return [typeName: inner] + } }