diff --git a/renderers/swiftui/.gitignore b/renderers/swiftui/.gitignore new file mode 100644 index 000000000..66199a963 --- /dev/null +++ b/renderers/swiftui/.gitignore @@ -0,0 +1,19 @@ +# Xcode +DerivedData/ +*.xcuserstate +xcuserdata/ + +# Swift Package Manager +.build/ +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.swiftpm/xcode/xcuserdata/ + +# macOS +.DS_Store + +# IDE +*.xcworkspace/xcuserdata/ + +# Development notes (not part of the library) +DEVELOPMENT_PLAN.md +PR_REVIEW.md diff --git a/renderers/swiftui/Package.swift b/renderers/swiftui/Package.swift new file mode 100644 index 000000000..1c4279dc1 --- /dev/null +++ b/renderers/swiftui/Package.swift @@ -0,0 +1,46 @@ +// swift-tools-version: 5.9 + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import PackageDescription + +let package = Package( + name: "A2UI", + platforms: [ + .iOS(.v17), + .macOS(.v14), + .tvOS(.v17), + .watchOS(.v10), + .visionOS(.v1), + ], + products: [ + .library( + name: "A2UI", + targets: ["A2UI"] + ), + ], + targets: [ + .target( + name: "A2UI", + path: "Sources/A2UI" + ), + .testTarget( + name: "A2UITests", + dependencies: ["A2UI"], + path: "Tests/A2UITests", + resources: [.copy("TestData")] + ), + ] +) diff --git a/renderers/swiftui/README.md b/renderers/swiftui/README.md new file mode 100644 index 000000000..9d2b6dddd --- /dev/null +++ b/renderers/swiftui/README.md @@ -0,0 +1,179 @@ +# A2UI SwiftUI Renderer + +A native SwiftUI renderer for the [A2UI](https://github.com/google/A2UI) protocol. +Renders agent-generated JSON into native iOS and macOS interfaces using SwiftUI. + +## Requirements + +- iOS 17.0+ / macOS 14.0+ +- Swift 5.9+ +- Xcode 15+ + +## Installation + +Since the `Package.swift` lives in the `renderers/swiftui/` subdirectory (not the +repository root), use a local path reference: + +**In `Package.swift`:** + +```swift +dependencies: [ + .package(path: "../path/to/A2UI/renderers/swiftui"), +] +``` + +**In Xcode:** File → Add Package Dependencies → Add Local… → select the +`renderers/swiftui` directory. + +## Quick Start + +```swift +import A2UI + +// 1. Load A2UI messages (from a JSON file, network response, etc.) +let data = try Data(contentsOf: jsonFileURL) +let messages = try JSONDecoder().decode([ServerToClientMessage].self, from: data) + +// 2. Render the surface +A2UIRendererView(messages: messages) +``` + +### Live Agent Streaming + +```swift +import A2UI + +// Stream messages from an A2A agent +A2UIRendererView(stream: messageStream, onAction: { action in + print("User triggered: \(action.name)") +}) +``` + +### JSONL Stream Parsing + +```swift +import A2UI + +let parser = JSONLStreamParser() +let manager = SurfaceManager() + +// Parse from async byte stream (e.g. URLSession) +let (bytes, _) = try await URLSession.shared.bytes(for: request) +for try await message in parser.messages(from: bytes) { + try manager.processMessage(message) +} + +// Render in SwiftUI — View only observes, no stream logic +A2UIRendererView(manager: manager) +``` + +## Supported Components + +All 18 standard A2UI components are implemented: + +| Category | Components | +|----------|-----------| +| Display | Text, Image, Icon, Video, AudioPlayer, Divider | +| Layout | Row, Column, List, Card, Tabs, Modal | +| Input | Button, TextField, CheckBox, DateTimeInput, Slider, MultipleChoice | + +### Component Mapping + +| A2UI Component | SwiftUI Implementation | +|---------------|----------------------| +| Text | `SwiftUI.Text` with usageHint → font mapping (h1–h6) | +| Image | `AsyncImage` with usageHint variants (avatar, icon, feature, header) | +| Icon | `Image(systemName:)` with Material → SF Symbol mapping | +| Video | `AVPlayerViewController` (iOS) / `VideoPlayer` (macOS) | +| AudioPlayer | `AVPlayer` with custom play/pause controls | +| Row | `HStack` with distribution and alignment | +| Column | `VStack` with distribution and alignment | +| List | `LazyVStack` / `LazyHStack` with template support | +| Card | Rounded-corner container with shadow | +| Tabs | Segmented tab bar with content switching | +| Modal | `.sheet` presentation | +| Button | Primary / secondary styles with action callbacks | +| TextField | `SwiftUI.TextField` / `TextEditor` with two-way binding | +| CheckBox | `Toggle` | +| DateTimeInput | `DatePicker` | +| Slider | `SwiftUI.Slider` | +| MultipleChoice | Checkbox list or chips (FlowLayout) with filtering | +| Divider | `SwiftUI.Divider` | + +## Architecture + +``` +Sources/A2UI/ +├── Models/ Codable data models (Messages, Components, Primitives) +├── Processing/ SurfaceViewModel (state) + JSONLStreamParser (streaming) +├── Views/ A2UIComponentView (recursive renderer) +├── Styling/ A2UIStyle + SwiftUI Environment integration +├── Networking/ A2AClient (JSON-RPC over HTTP) +└── A2UIRenderer.swift Public API entry point +``` + +The renderer uses `@Observable` (Observation framework) for property-level +reactivity, matching the Signal-based approach used by the official Lit and +Angular renderers. + +## Running Tests + +```bash +cd renderers/swiftui +swift test +``` + +84 tests across 5 test files cover message decoding, component parsing, data +binding, path resolution, template rendering, catalog functions, validation, +JSONL streaming, incremental updates, and Codable round-trips. + +## Demo App + +The demo app is located at `samples/client/swiftui/A2UIDemoApp/` in the +repository root. It demonstrates both offline sample rendering and live A2A +agent integration. + +Open `samples/client/swiftui/A2UIDemoApp/A2UIDemoApp.xcodeproj` in Xcode and run on a +simulator or device. + +## Known Limitations + +- Requires iOS 17+ / macOS 14+ (uses `@Observable` from the Observation framework). +- Custom (non-standard) component types are decoded but not rendered. +- Video playback uses `UIViewControllerRepresentable` on iOS; macOS uses a + `VideoPlayer` fallback. +- No built-in Content Security Policy enforcement for image/video URLs — + applications should validate URLs from untrusted agents. + +## Security + +**Important:** The sample code provided is for demonstration purposes and +illustrates the mechanics of A2UI and the Agent-to-Agent (A2A) protocol. When +building production applications, it is critical to treat any agent operating +outside of your direct control as a potentially untrusted entity. + +All operational data received from an external agent — including its AgentCard, +messages, artifacts, and task statuses — should be handled as untrusted input. +For example, a malicious agent could provide crafted data in its fields (e.g., +name, skills.description) that, if used without sanitization to construct +prompts for a Large Language Model (LLM), could expose your application to +prompt injection attacks. + +Similarly, any UI definition or data stream received must be treated as +untrusted. Malicious agents could attempt to spoof legitimate interfaces to +deceive users (phishing), inject malicious scripts via property values (XSS), +or generate excessive layout complexity to degrade client performance (DoS). If +your application supports optional embedded content (such as iframes or web +views), additional care must be taken to prevent exposure to malicious external +sites. + +**Developer Responsibility:** Failure to properly validate data and strictly +sandbox rendered content can introduce severe vulnerabilities. Developers are +responsible for implementing appropriate security measures — such as input +sanitization, Content Security Policies (CSP), strict isolation for optional +embedded content, and secure credential handling — to protect their systems and +users. + +## License + +Apache 2.0 — see [LICENSE](LICENSE). diff --git a/renderers/swiftui/Sources/A2UI/A2UIRenderer.swift b/renderers/swiftui/Sources/A2UI/A2UIRenderer.swift new file mode 100644 index 000000000..b43046f6c --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/A2UIRenderer.swift @@ -0,0 +1,76 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// The main entry point for rendering A2UI surfaces in SwiftUI. +/// +/// This view is purely declarative — it observes a `SurfaceManager` and renders +/// its surfaces. Message processing, stream consumption, and error handling +/// belong in the app layer. +/// +/// Usage: +/// ```swift +/// // App layer: manage stream + errors +/// let manager = SurfaceManager() +/// for try await message in parser.messages(from: bytes) { +/// try manager.processMessage(message) +/// } +/// +/// // View layer: just render +/// A2UIRendererView(manager: manager, onAction: { action in +/// Task { try await client.sendAction(action, surfaceId: "main") } +/// }) +/// ``` +public struct A2UIRendererView: View { + private let manager: SurfaceManager + private let onAction: ((ResolvedAction) -> Void)? + + public init( + manager: SurfaceManager, + onAction: ((ResolvedAction) -> Void)? = nil + ) { + self.manager = manager + self.onAction = onAction + } + + public var body: some View { + Group { + if manager.orderedSurfaceIds.isEmpty { + ContentUnavailableView( + "No Surface", + systemImage: "rectangle.dashed", + description: Text("Waiting for A2UI messages…") + ) + } else { + VStack(spacing: 0) { + ForEach(manager.orderedSurfaceIds, id: \.self) { surfaceId in + if let vm = manager.surfaces[surfaceId], + let rootNode = vm.componentTree { + ScrollView { + A2UIComponentView( + node: rootNode, viewModel: vm + ) + .padding() + } + .tint(vm.a2uiStyle.primaryColor) + .environment(\.a2uiStyle, vm.a2uiStyle) + } + } + } + } + } + .environment(\.a2uiActionHandler, onAction) + } +} diff --git a/renderers/swiftui/Sources/A2UI/Models/AnyCodable.swift b/renderers/swiftui/Sources/A2UI/Models/AnyCodable.swift new file mode 100644 index 000000000..59df87c0e --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Models/AnyCodable.swift @@ -0,0 +1,108 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// A type-erased Codable value for handling dynamic JSON structures. +/// Supports: String, Double, Bool, nil, Array, Dictionary. +public enum AnyCodable: Codable, CustomStringConvertible, Sendable { + case string(String) + case number(Double) + case bool(Bool) + case null + case array([AnyCodable]) + case dictionary([String: AnyCodable]) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + return + } + if let value = try? container.decode(Bool.self) { + self = .bool(value) + return + } + if let value = try? container.decode(Double.self) { + self = .number(value) + return + } + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + if let value = try? container.decode([AnyCodable].self) { + self = .array(value) + return + } + if let value = try? container.decode([String: AnyCodable].self) { + self = .dictionary(value) + return + } + + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Cannot decode AnyCodable") + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .null: try container.encodeNil() + case .array(let value): try container.encode(value) + case .dictionary(let value): try container.encode(value) + } + } + + /// Convenience accessors + public var stringValue: String? { + if case .string(let v) = self { return v } + return nil + } + + public var numberValue: Double? { + if case .number(let v) = self { return v } + return nil + } + + public var boolValue: Bool? { + if case .bool(let v) = self { return v } + return nil + } + + public var arrayValue: [AnyCodable]? { + if case .array(let v) = self { return v } + return nil + } + + public var dictionaryValue: [String: AnyCodable]? { + if case .dictionary(let v) = self { return v } + return nil + } + + public var description: String { + switch self { + case .string(let v): return "\"\(v)\"" + case .number(let v): return "\(v)" + case .bool(let v): return "\(v)" + case .null: return "null" + case .array(let v): return "\(v)" + case .dictionary(let v): return "\(v)" + } + } +} diff --git a/renderers/swiftui/Sources/A2UI/Models/ComponentTypes.swift b/renderers/swiftui/Sources/A2UI/Models/ComponentTypes.swift new file mode 100644 index 000000000..cbf53d64a --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Models/ComponentTypes.swift @@ -0,0 +1,267 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// All standard A2UI v0.8 component types, plus `.custom` for extensions. +public enum ComponentType: Hashable { + case Text, Image, Icon, Video, AudioPlayer + case Row, Column, List, Card, Tabs, Divider, Modal + case Button, CheckBox, TextField, DateTimeInput, MultipleChoice, Slider + case custom(String) + + /// Map from raw type name string to `ComponentType`. + public static func from(_ typeName: String) -> ComponentType { + switch typeName { + case "Text": return .Text + case "Image": return .Image + case "Icon": return .Icon + case "Video": return .Video + case "AudioPlayer": return .AudioPlayer + case "Row": return .Row + case "Column": return .Column + case "List": return .List + case "Card": return .Card + case "Tabs": return .Tabs + case "Divider": return .Divider + case "Modal": return .Modal + case "Button": return .Button + case "CheckBox": return .CheckBox + case "TextField": return .TextField + case "DateTimeInput": return .DateTimeInput + case "MultipleChoice": return .MultipleChoice + case "Slider": return .Slider + default: return .custom(typeName) + } + } +} + +// MARK: - Basic Content + +public struct TextProperties: Codable { + public var text: StringValue + public var usageHint: String? +} + +public struct ImageProperties: Codable { + public var url: StringValue + public var usageHint: String? + public var fit: String? +} + +public struct IconProperties: Codable { + /// Either a standard icon name string or a custom icon with SVG path. + public var name: IconNameValue +} + +/// Represents the `Icon.name` property which can be either a standard +/// icon name string or a custom icon with an SVG path. +public enum IconNameValue: Codable { + case standard(StringValue) + case customPath(String) + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + switch raw { + case .string(let s): + self = .standard(StringValue(literalString: s)) + case .dictionary(let dict): + // Custom SVG path: {"path": "M10 20 L30 40..."} — the value + // starts with an SVG command letter, not "/" (data binding). + if dict.count == 1, + let pathStr = dict["path"]?.stringValue, + Self.looksLikeSVGPath(pathStr) { + self = .customPath(pathStr) + } else { + // Data binding, literalString, or function call — decode as StringValue. + let sv = try StringValue(from: decoder) + self = .standard(sv) + } + default: + self = .standard(StringValue()) + } + } + + /// Heuristic: an SVG path starts with a move command (M/m) and contains + /// drawing commands, while a data-model path starts with "/" or is a + /// relative key like "iconName". + private static func looksLikeSVGPath(_ value: String) -> Bool { + let trimmed = value.trimmingCharacters(in: .whitespaces) + guard let first = trimmed.first else { return false } + // SVG paths start with M (absolute) or m (relative) move-to command + return (first == "M" || first == "m") && trimmed.count > 2 + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .standard(let sv): + try sv.encode(to: encoder) + case .customPath(let path): + var container = encoder.singleValueContainer() + try container.encode(["path": path]) + } + } +} + +// MARK: - Media + +public struct VideoProperties: Codable { + public var url: StringValue +} + +public struct AudioPlayerProperties: Codable { + public var url: StringValue + public var description: StringValue? +} + +// MARK: - Layout & Containers + +public struct RowProperties: Codable { + public var children: ChildrenReference + public var distribution: String? + public var alignment: String? +} + +public struct ColumnProperties: Codable { + public var children: ChildrenReference + public var distribution: String? + public var alignment: String? +} + +public struct ListProperties: Codable { + public var children: ChildrenReference + public var direction: String? + public var alignment: String? +} + +public struct CardProperties: Codable { + public var child: String +} + +public struct TabItemEntry: Codable { + public var title: StringValue + public var child: String +} + +public struct TabsProperties: Codable { + public var tabItems: [TabItemEntry] +} + +public struct ModalProperties: Codable { + public var entryPointChild: String + public var contentChild: String +} + +public struct DividerProperties: Codable { + public var axis: String? +} + +// MARK: - Interactive & Input + +public struct ButtonProperties: Codable { + public var child: String + public var action: Action + public var primary: Bool? +} + +public struct TextFieldProperties: Codable { + public var label: StringValue + public var text: StringValue? + public var textFieldType: String? + public var validationRegexp: String? +} + +public struct CheckBoxProperties: Codable { + public var label: StringValue + public var value: BooleanValue +} + +public struct SliderProperties: Codable { + public var label: StringValue? + public var value: NumberValue + public var minValue: Double? + public var maxValue: Double? +} + +public struct DateTimeInputProperties: Codable { + public var value: StringValue + public var enableDate: Bool? + public var enableTime: Bool? + public var label: StringValue? +} + +public struct MultipleChoiceOption: Codable { + public var label: StringValue + public var value: String +} + +/// v0.8 `selections` value: `{"path":"..."}` or `{"literalArray":[...]}`. +public struct StringListValue { + public var path: String? + public var literalArray: [String]? +} + +extension StringListValue: Codable { + private enum CodingKeys: String, CodingKey { + case path, literalArray + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + switch raw { + case .dictionary(let dict): + self.path = dict["path"]?.stringValue + self.literalArray = dict["literalArray"]?.arrayValue?.compactMap(\.stringValue) + case .string(let s): + self.path = s + self.literalArray = nil + default: + self.path = nil + self.literalArray = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(path, forKey: .path) + try container.encodeIfPresent(literalArray, forKey: .literalArray) + } +} + +public struct MultipleChoiceProperties: Codable { + public var description: StringValue? + public var variant: String? + public var options: [MultipleChoiceOption]? + public var selections: StringListValue? + public var filterable: Bool? + public var maxAllowedSelections: Int? +} + +// MARK: - Typed Property Extraction + +extension RawComponentPayload { + + /// The component type parsed from the dynamic key name. + /// Returns `.custom(typeName)` for unknown types instead of nil. + public var componentType: ComponentType { + ComponentType.from(typeName) + } + + /// Decode the raw properties dictionary into a strongly-typed struct. + /// Re-encodes `[String: AnyCodable]` to JSON, then decodes into `T`. + public func typedProperties(_ type: T.Type) throws -> T { + let data = try JSONEncoder().encode(properties) + return try JSONDecoder().decode(T.self, from: data) + } +} diff --git a/renderers/swiftui/Sources/A2UI/Models/Components.swift b/renderers/swiftui/Sources/A2UI/Models/Components.swift new file mode 100644 index 000000000..8d0016a7d --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Models/Components.swift @@ -0,0 +1,264 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +// MARK: - RawComponentInstance + +/// A raw component instance from a surfaceUpdate message. +/// v0.8 nested format: `{"component":{"TextField":{...}}}`. +public struct RawComponentInstance { + public var id: String + public var weight: Double? + public var component: RawComponentPayload? +} + +extension RawComponentInstance: Codable { + private enum CodingKeys: String, CodingKey { + case id, weight, component + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + guard case .dictionary(let dict) = raw else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, + debugDescription: "Component instance must be an object") + ) + } + + guard let id = dict["id"]?.stringValue else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, + debugDescription: "Component instance missing 'id'") + ) + } + self.id = id + self.weight = dict["weight"]?.numberValue + + guard let componentVal = dict["component"] else { + self.component = nil + return + } + + if case .dictionary(let compDict) = componentVal { + // v0.8 nested format: {"TypeName": {prop1:..., prop2:...}} + guard let (typeName, propsVal) = compDict.first else { + self.component = nil + return + } + if case .dictionary(let props) = propsVal { + self.component = RawComponentPayload(typeName: typeName, properties: props) + } else { + self.component = RawComponentPayload(typeName: typeName, properties: [:]) + } + } else { + self.component = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encodeIfPresent(weight, forKey: .weight) + try container.encodeIfPresent(component, forKey: .component) + } +} + +// MARK: - RawComponentPayload + +/// Wraps the dynamic component type and its properties. +/// v0.8 JSON: `{"Text": {"text": {...}, "usageHint": "h1"}}`. +public struct RawComponentPayload: Codable { + public var typeName: String + public var properties: [String: AnyCodable] + + public init(typeName: String, properties: [String: AnyCodable]) { + self.typeName = typeName + self.properties = properties + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: DynamicKey.self) + guard let firstKey = container.allKeys.first else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Empty component object") + ) + } + self.typeName = firstKey.stringValue + self.properties = try container.decode([String: AnyCodable].self, forKey: firstKey) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: DynamicKey.self) + guard let key = DynamicKey(stringValue: typeName) else { return } + try container.encode(properties, forKey: key) + } +} + +// MARK: - ChildrenReference + +/// The set of children for a container component (Row, Column, List). +/// v0.8 format: `{"explicitList":["a","b"]}` or `{"template":{...}}`. +public struct ChildrenReference { + public var explicitList: [String]? + public var template: TemplateReference? +} + +extension ChildrenReference: Codable { + private enum CodingKeys: String, CodingKey { + case explicitList, template + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + switch raw { + case .dictionary(let dict): + // v0.8: {"explicitList":[...]} or {"template":{...}} + if case .array(let items) = dict["explicitList"] { + self.explicitList = items.compactMap(\.stringValue) + } else { + self.explicitList = nil + } + if let tDict = dict["template"]?.dictionaryValue, + let cid = tDict["componentId"]?.stringValue, + let db = tDict["dataBinding"]?.stringValue { + self.template = TemplateReference(componentId: cid, dataBinding: db) + } else { + self.template = nil + } + default: + self.explicitList = nil + self.template = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(explicitList, forKey: .explicitList) + try container.encodeIfPresent(template, forKey: .template) + } +} + +// MARK: - TemplateReference + +/// A template for generating dynamic lists from data model arrays/maps. +public struct TemplateReference: Codable { + public var componentId: String + public var dataBinding: String +} + +// MARK: - Action + +/// An action triggered by user interaction (e.g., button click). +/// v0.8 format: `{"name":"tap","context":[{"key":"k","value":{...}}]}`. +public struct Action { + public var name: String + public var context: [ActionContextEntry]? +} + +extension Action: Codable { + private enum CodingKeys: String, CodingKey { + case name, context + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + guard case .dictionary(let dict) = raw else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, + debugDescription: "Action must be an object") + ) + } + + guard let name = dict["name"]?.stringValue else { + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, + debugDescription: "Action: expected 'name'") + ) + } + // v0.8: {"name":"tap","context":[{"key":"k","value":{...}}]} + self.name = name + if case .array(let items) = dict["context"] { + self.context = items.compactMap(Self.decodeV08ContextEntry) + } else { + self.context = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encodeIfPresent(context, forKey: .context) + } + + // MARK: - Helpers + + private static func decodeV08ContextEntry(_ item: AnyCodable) -> ActionContextEntry? { + guard case .dictionary(let d) = item, + let key = d["key"]?.stringValue, + let valRaw = d["value"] else { return nil } + return ActionContextEntry(key: key, value: boundValueFromAnyCodable(valRaw)) + } + + private static func boundValueFromAnyCodable(_ value: AnyCodable) -> BoundValue { + switch value { + case .string(let s): + return BoundValue(literalString: s) + case .number(let n): + return BoundValue(literalNumber: n) + case .bool(let b): + return BoundValue(literalBoolean: b) + case .dictionary(let dict): + if let path = dict["path"]?.stringValue { + return BoundValue(path: path) + } + if let s = dict["literalString"]?.stringValue { + return BoundValue(literalString: s) + } + if let n = dict["literalNumber"]?.numberValue { + return BoundValue(literalNumber: n) + } + if let b = dict["literalBoolean"]?.boolValue { + return BoundValue(literalBoolean: b) + } + return BoundValue() + default: + return BoundValue() + } + } +} + +// MARK: - ActionContextEntry + +/// A key-value pair in an action's context payload. +public struct ActionContextEntry: Codable { + public var key: String + public var value: BoundValue +} + +// MARK: - ResolvedAction + +/// An action whose context paths have been resolved to actual values. +public struct ResolvedAction: Sendable { + public let name: String + public let sourceComponentId: String + public let context: [String: AnyCodable] + + public init(name: String, sourceComponentId: String, context: [String: AnyCodable]) { + self.name = name + self.sourceComponentId = sourceComponentId + self.context = context + } +} diff --git a/renderers/swiftui/Sources/A2UI/Models/DynamicKey.swift b/renderers/swiftui/Sources/A2UI/Models/DynamicKey.swift new file mode 100644 index 000000000..d2fe2ad01 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Models/DynamicKey.swift @@ -0,0 +1,32 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// A CodingKey that can represent any string key. +/// Used for decoding dynamic JSON keys like component type names. +public struct DynamicKey: CodingKey { + public var stringValue: String + public var intValue: Int? + + public init?(stringValue: String) { + self.stringValue = stringValue + self.intValue = nil + } + + public init?(intValue: Int) { + self.stringValue = "\(intValue)" + self.intValue = intValue + } +} diff --git a/renderers/swiftui/Sources/A2UI/Models/Messages.swift b/renderers/swiftui/Sources/A2UI/Models/Messages.swift new file mode 100644 index 000000000..d6b8c5951 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Models/Messages.swift @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// A single message from the A2UI server to the client. +/// Each message contains exactly one of the four possible v0.8 payloads. +public struct ServerToClientMessage: Codable { + public var beginRendering: BeginRenderingMessage? + public var surfaceUpdate: SurfaceUpdateMessage? + public var dataModelUpdate: DataModelUpdateMessage? + public var deleteSurface: DeleteSurfaceMessage? +} + +/// Signals the client to begin rendering a surface. +public struct BeginRenderingMessage: Codable { + public var surfaceId: String + public var root: String + public var styles: [String: String]? +} + +/// Adds or updates components in a surface's component buffer. +public struct SurfaceUpdateMessage: Codable { + public var surfaceId: String + public var components: [RawComponentInstance] +} + +/// Updates the data model for a surface (v0.8 format with `contents` array). +public struct DataModelUpdateMessage: Codable { + public var surfaceId: String + public var path: String? + public var contents: [ValueMapEntry] +} + +/// Removes a surface and all its associated data. +public struct DeleteSurfaceMessage: Codable { + public var surfaceId: String +} diff --git a/renderers/swiftui/Sources/A2UI/Models/Primitives.swift b/renderers/swiftui/Sources/A2UI/Models/Primitives.swift new file mode 100644 index 000000000..f96b5efd2 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Models/Primitives.swift @@ -0,0 +1,180 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +// MARK: - StringValue + +/// A value that can be either a literal string or a path to the data model. +/// v0.8 format: `{"literalString":"..."}` or `{"path":"..."}`. +public struct StringValue { + public var path: String? + public var literalString: String? + public var literal: String? + + public init(path: String? = nil, literalString: String? = nil, literal: String? = nil) { + self.path = path + self.literalString = literalString + self.literal = literal + } + + public var literalValue: String? { + literalString ?? literal + } +} + +extension StringValue: Codable { + private enum CodingKeys: String, CodingKey { + case path, literalString, literal + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + switch raw { + case .string(let s): + self.path = nil + self.literalString = s + self.literal = nil + case .dictionary(let dict): + self.path = dict["path"]?.stringValue + self.literalString = dict["literalString"]?.stringValue + self.literal = dict["literal"]?.stringValue + default: + self.path = nil + self.literalString = nil + self.literal = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(path, forKey: .path) + try container.encodeIfPresent(literalString, forKey: .literalString) + try container.encodeIfPresent(literal, forKey: .literal) + } +} + +// MARK: - NumberValue + +/// A value that can be either a literal number or a path to the data model. +/// v0.8 format: `{"literalNumber":42}` or `{"path":"..."}`. +public struct NumberValue { + public var path: String? + public var literalNumber: Double? + public var literal: Double? + + public var literalValue: Double? { + literalNumber ?? literal + } +} + +extension NumberValue: Codable { + private enum CodingKeys: String, CodingKey { + case path, literalNumber, literal + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + switch raw { + case .number(let n): + self.path = nil + self.literalNumber = n + self.literal = nil + case .dictionary(let dict): + self.path = dict["path"]?.stringValue + self.literalNumber = dict["literalNumber"]?.numberValue + self.literal = dict["literal"]?.numberValue + default: + self.path = nil + self.literalNumber = nil + self.literal = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(path, forKey: .path) + try container.encodeIfPresent(literalNumber, forKey: .literalNumber) + try container.encodeIfPresent(literal, forKey: .literal) + } +} + +// MARK: - BooleanValue + +/// A value that can be either a literal boolean or a path to the data model. +/// v0.8 format: `{"literalBoolean":true}` or `{"path":"..."}`. +public struct BooleanValue { + public var path: String? + public var literalBoolean: Bool? + public var literal: Bool? + + public var literalValue: Bool? { + literalBoolean ?? literal + } +} + +extension BooleanValue: Codable { + private enum CodingKeys: String, CodingKey { + case path, literalBoolean, literal + } + + public init(from decoder: Decoder) throws { + let raw = try AnyCodable(from: decoder) + switch raw { + case .bool(let b): + self.path = nil + self.literalBoolean = b + self.literal = nil + case .dictionary(let dict): + self.path = dict["path"]?.stringValue + self.literalBoolean = dict["literalBoolean"]?.boolValue + self.literal = dict["literal"]?.boolValue + default: + self.path = nil + self.literalBoolean = nil + self.literal = nil + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(path, forKey: .path) + try container.encodeIfPresent(literalBoolean, forKey: .literalBoolean) + try container.encodeIfPresent(literal, forKey: .literal) + } +} + +// MARK: - BoundValue + +/// A general bound value that can hold any literal type or a path reference. +/// Used in action context entries. +public struct BoundValue: Codable { + public var path: String? + public var literalString: String? + public var literalNumber: Double? + public var literalBoolean: Bool? +} + +// MARK: - ValueMapEntry + +/// An entry in the data model update's `contents` array (v0.8). +/// Uses `key` + one of the `value*` fields. +public struct ValueMapEntry: Codable { + public var key: String + public var valueString: String? + public var valueNumber: Double? + public var valueBoolean: Bool? + public var valueBool: Bool? + public var valueMap: [ValueMapEntry]? +} diff --git a/renderers/swiftui/Sources/A2UI/Networking/A2AClient.swift b/renderers/swiftui/Sources/A2UI/Networking/A2AClient.swift new file mode 100644 index 000000000..8966f99ce --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Networking/A2AClient.swift @@ -0,0 +1,583 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// HTTP client that speaks the A2A JSON-RPC protocol to communicate with +/// an A2UI-capable Agent. +/// +/// Create via the factory method to auto-discover the endpoint from the agent card +/// (matching the official JS SDK's `A2AClient.fromCardUrl`): +/// ```swift +/// let client = try await A2AClient.fromAgentCardURL( +/// URL(string: "http://localhost:10003/.well-known/agent-card.json")! +/// ) +/// let result = try await client.sendText("Find me restaurants") +/// ``` +/// +/// Or create directly with a known endpoint: +/// ```swift +/// let client = A2AClient(endpointURL: URL(string: "http://localhost:10003")!) +/// ``` +public final class A2AClient: Sendable { + + /// The JSON-RPC endpoint URL (discovered from agent card or provided directly). + public let endpointURL: URL + + /// Agent capabilities discovered from the agent card, if available. + public let agentCard: AgentCardInfo? + + private let session: URLSession + + private static let extensionHeader = "https://a2ui.org/a2a-extension/a2ui/v0.8" + private static let a2uiMimeType = "application/json+a2ui" + private static let standardCatalogId = "https://a2ui.org/specification/v0_8/standard_catalog_definition.json" + + /// Create a client that sends directly to `endpointURL`. + public init(endpointURL: URL, timeoutInterval: TimeInterval = 120) { + self.endpointURL = endpointURL + self.agentCard = nil + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = timeoutInterval + self.session = URLSession(configuration: config) + } + + /// Internal initializer used by the `fromAgentCardURL` factory. + private init(endpointURL: URL, agentCard: AgentCardInfo, timeoutInterval: TimeInterval) { + self.endpointURL = endpointURL + self.agentCard = agentCard + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = timeoutInterval + self.session = URLSession(configuration: config) + } + + // MARK: - Factory (matching JS SDK A2AClient.fromCardUrl) + + /// Create a client by first fetching the agent card to discover the endpoint URL + /// and capabilities — matching the JS SDK's `A2AClient.fromCardUrl()`. + public static func fromAgentCardURL( + _ cardURL: URL, + timeoutInterval: TimeInterval = 120 + ) async throws -> A2AClient { + var request = URLRequest(url: cardURL) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(extensionHeader, forHTTPHeaderField: "X-A2A-Extensions") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw A2AError.agentCardFetchFailed(url: cardURL) + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let urlString = json["url"] as? String, + let endpointURL = URL(string: urlString) else { + throw A2AError.agentCardInvalid + } + + let card = AgentCardInfo( + name: json["name"] as? String ?? "Unknown", + url: urlString, + streaming: (json["capabilities"] as? [String: Any])?["streaming"] as? Bool ?? false, + iconUrl: json["iconUrl"] as? String, + supportedCatalogIds: Self.extractCatalogIds(from: json) + ) + + return A2AClient(endpointURL: endpointURL, agentCard: card, timeoutInterval: timeoutInterval) + } + + /// Convenience: create a client from a base URL by appending the well-known agent card path. + public static func fromBaseURL( + _ baseURL: URL, + timeoutInterval: TimeInterval = 120 + ) async throws -> A2AClient { + let cardURL = baseURL.appendingPathComponent(".well-known/agent-card.json") + return try await fromAgentCardURL(cardURL, timeoutInterval: timeoutInterval) + } + + // MARK: - Public API + + /// The catalog IDs this client will declare support for. + /// Custom catalogs are listed first so the agent prefers them over the standard catalog. + private var supportedCatalogIds: [String] { + if let card = agentCard, !card.supportedCatalogIds.isEmpty { + // Put custom (non-standard) catalog IDs first, then standard + var custom: [String] = [] + var standard: [String] = [] + for id in card.supportedCatalogIds { + if id == Self.standardCatalogId { + standard.append(id) + } else { + custom.append(id) + } + } + return custom + standard + } + return [Self.standardCatalogId] + } + + /// Extract supportedCatalogIds from agent card's extensions. + private static func extractCatalogIds(from json: [String: Any]) -> [String] { + guard let capabilities = json["capabilities"] as? [String: Any], + let extensions = capabilities["extensions"] as? [[String: Any]] else { + return [] + } + var ids: [String] = [] + for ext in extensions { + if let params = ext["params"] as? [String: Any], + let catalogIds = params["supportedCatalogIds"] as? [String] { + ids.append(contentsOf: catalogIds) + } + } + return ids + } + + /// Send a text query to the agent and return the result. + public func sendText(_ text: String, contextId: String? = nil) async throws -> SendResult { + let parts: [[String: Any]] = [ + ["kind": "text", "text": text] + ] + return try await sendMessage(parts: parts, contextId: contextId) + } + + /// Report a client-side error to the agent. + public func sendError(_ error: A2UIClientError, contextId: String? = nil) async throws -> SendResult { + var errorDict: [String: Any] = [ + "kind": error.kind.rawValue, + "message": error.message, + "timestamp": ISO8601DateFormatter().string(from: Date()) + ] + if let cid = error.componentId { errorDict["componentId"] = cid } + if let sid = error.surfaceId { errorDict["surfaceId"] = sid } + + let parts: [[String: Any]] = [ + [ + "kind": "data", + "data": ["error": errorDict] as [String: Any], + "metadata": ["mimeType": Self.a2uiMimeType] + ] + ] + return try await sendMessage(parts: parts, contextId: contextId) + } + + /// Send a resolved user action back to the agent. + public func sendAction( + _ action: ResolvedAction, + surfaceId: String, + contextId: String? = nil + ) async throws -> SendResult { + return try await sendMessage(parts: makeActionParts(action, surfaceId: surfaceId), contextId: contextId) + } + + // MARK: - Shared Helpers + + /// Build the JSON-RPC `data` parts for a user action. + private func makeActionParts(_ action: ResolvedAction, surfaceId: String) -> [[String: Any]] { + let userAction: [String: Any] = [ + "userAction": [ + "name": action.name, + "actionName": action.name, + "surfaceId": surfaceId, + "sourceComponentId": action.sourceComponentId, + "timestamp": ISO8601DateFormatter().string(from: Date()), + "context": action.context.mapValues { $0.toJSONValue() } + ] as [String: Any] + ] + return [ + [ + "kind": "data", + "data": userAction, + "metadata": ["mimeType": Self.a2uiMimeType] + ] + ] + } + + /// Build a JSON-RPC request body with A2UI client capabilities. + private func buildJSONRPCBody( + method: String, + parts: [[String: Any]], + contextId: String? + ) throws -> Data { + var messageDict: [String: Any] = [ + "messageId": UUID().uuidString, + "role": "user", + "parts": parts, + "kind": "message", + "metadata": [ + "a2uiClientCapabilities": [ + "supportedCatalogIds": supportedCatalogIds + ] as [String: Any] + ] as [String: Any] + ] + if let contextId { messageDict["contextId"] = contextId } + + let body: [String: Any] = [ + "jsonrpc": "2.0", + "method": method, + "id": UUID().uuidString, + "params": ["message": messageDict] as [String: Any] + ] + return try JSONSerialization.data(withJSONObject: body) + } + + // MARK: - Streaming API (message/stream via SSE) + + /// Send a text query and receive streaming events (status updates + final A2UI messages). + /// Uses `message/stream` with SSE, matching the JS SDK's `sendMessageStream`. + public func sendTextStream(_ text: String, contextId: String? = nil) -> AsyncThrowingStream { + let parts: [[String: Any]] = [["kind": "text", "text": text]] + return sendMessageStream(parts: parts, contextId: contextId) + } + + /// Send a user action and receive streaming events. + public func sendActionStream( + _ action: ResolvedAction, + surfaceId: String, + contextId: String? = nil + ) -> AsyncThrowingStream { + return sendMessageStream(parts: makeActionParts(action, surfaceId: surfaceId), contextId: contextId) + } + + private func sendMessageStream( + parts: [[String: Any]], + contextId: String? + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let jsonData = try self.buildJSONRPCBody(method: "message/stream", parts: parts, contextId: contextId) + + var request = URLRequest(url: self.endpointURL) + request.httpMethod = "POST" + request.httpBody = jsonData + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.setValue(Self.extensionHeader, forHTTPHeaderField: "X-A2A-Extensions") + request.timeoutInterval = 120 + + let (bytes, response) = try await self.session.bytes(for: request) + + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + throw A2AError.httpError(statusCode: code, body: "SSE stream failed") + } + + try await self.parseSSEStream(bytes: bytes, continuation: continuation) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + /// Parse an SSE byte stream, yielding `StreamEvent`s as they arrive. + private func parseSSEStream( + bytes: S, + continuation: AsyncThrowingStream.Continuation + ) async throws where S.Element == UInt8 { + // SSE parser — handles both formats: + // 1. Multi-line: multiple `data:` lines joined by empty line boundary + // 2. Single-line: each `data:` line is a complete JSON event (a2a-sdk default) + var pendingDataLines: [String] = [] + + for try await line in bytes.lines { + if line.hasPrefix("data:") { + let payload = String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces) + if !payload.isEmpty { + if payload.hasPrefix("{"), let event = parseSSEEvent(payload) { + flushPending(&pendingDataLines, to: continuation) + continuation.yield(event) + } else { + pendingDataLines.append(payload) + } + } + } else if line.isEmpty { + flushPending(&pendingDataLines, to: continuation) + } + } + flushPending(&pendingDataLines, to: continuation) + } + + /// Flush accumulated multi-line SSE data and yield the parsed event. + private func flushPending( + _ lines: inout [String], + to continuation: AsyncThrowingStream.Continuation + ) { + guard !lines.isEmpty else { return } + let dataContent = lines.joined(separator: "\n") + lines.removeAll() + if let event = parseSSEEvent(dataContent) { + continuation.yield(event) + } + } + + /// Parse a single SSE `data:` payload into a `StreamEvent`. + /// + /// Supports two formats: + /// 1. JSON-RPC wrapped: `{ "jsonrpc": "2.0", "result": { ... } }` + /// 2. Bare A2A events: `{ "kind": "status-update"|"task", ... }` (a2a-sdk default) + private func parseSSEEvent(_ dataContent: String) -> StreamEvent? { + guard let data = dataContent.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + + // Unwrap JSON-RPC envelope if present, otherwise use the raw event object + let event = (json["result"] as? [String: Any]) ?? json + + let taskId = event["taskId"] as? String ?? event["id"] as? String + let contextId = event["contextId"] as? String + let isFinal = event["final"] as? Bool ?? false + + let taskState: A2ATaskState + var statusText: String? + + if let status = event["status"] as? [String: Any], + let stateStr = status["state"] as? String { + taskState = A2ATaskState(rawValue: stateStr) ?? .unknown + + if let message = status["message"] as? [String: Any], + let parts = message["parts"] as? [[String: Any]] { + for part in parts { + if let kind = part["kind"] as? String, kind == "text", + let text = part["text"] as? String { + statusText = text + } + } + } + } else { + taskState = .unknown + } + + // Look for A2UI messages in all known locations + let allParts = extractParts(from: event) + if let messages = try? decodeA2UIMessages(from: allParts), !messages.isEmpty { + return .result(SendResult( + messages: messages, + taskState: taskState, + taskId: taskId, + contextId: contextId + )) + } + + // No A2UI content — emit as a status event if we have a known state + if taskState != .unknown { + return .status(state: taskState, text: statusText, taskId: taskId, contextId: contextId, isFinal: isFinal) + } + + return nil + } + + // MARK: - JSON-RPC Transport (message/send, non-streaming) + + private func sendMessage(parts: [[String: Any]], contextId: String?) async throws -> SendResult { + let jsonData = try buildJSONRPCBody(method: "message/send", parts: parts, contextId: contextId) + + var request = URLRequest(url: endpointURL) + request.httpMethod = "POST" + request.httpBody = jsonData + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(Self.extensionHeader, forHTTPHeaderField: "X-A2A-Extensions") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw A2AError.invalidResponse + } + guard (200..<300).contains(httpResponse.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "" + throw A2AError.httpError(statusCode: httpResponse.statusCode, body: body) + } + + return try parseResponse(from: data) + } + + // MARK: - Response Parsing + + /// Parse the full JSON-RPC response, extracting A2UI messages and task metadata. + /// + /// Handles both Task and Message response shapes: + /// - Task: `{ "result": { "kind": "task", "id": "...", "contextId": "...", "status": { "state": "...", "message": { "parts": [...] } } } }` + /// - Message: `{ "result": { "kind": "message", "parts": [...] } }` + private func parseResponse(from data: Data) throws -> SendResult { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw A2AError.invalidResponse + } + + if let error = json["error"] as? [String: Any] { + let message = error["message"] as? String ?? "Unknown agent error" + throw A2AError.agentError(message: message) + } + + guard let result = json["result"] as? [String: Any] else { + throw A2AError.invalidResponse + } + + let taskId = result["id"] as? String + let contextId = result["contextId"] as? String + let taskState: A2ATaskState + if let status = result["status"] as? [String: Any], + let stateStr = status["state"] as? String { + taskState = A2ATaskState(rawValue: stateStr) ?? .unknown + } else { + taskState = .unknown + } + + let parts = extractParts(from: result) + let messages = try decodeA2UIMessages(from: parts) + + return SendResult( + messages: messages, + taskState: taskState, + taskId: taskId, + contextId: contextId + ) + } + + /// Decode A2UI `ServerToClientMessage` objects from response parts. + private func decodeA2UIMessages(from parts: [[String: Any]]) throws -> [ServerToClientMessage] { + let decoder = JSONDecoder() + var messages: [ServerToClientMessage] = [] + + for part in parts { + guard let kind = part["kind"] as? String, kind == "data", + let metadata = part["metadata"] as? [String: Any], + let mimeType = metadata["mimeType"] as? String, + mimeType == Self.a2uiMimeType, + let payload = part["data"] else { + continue + } + + let payloadData = try JSONSerialization.data(withJSONObject: payload) + + if let arr = payload as? [[String: Any]] { + for item in arr { + let itemData = try JSONSerialization.data(withJSONObject: item) + let msg = try decoder.decode(ServerToClientMessage.self, from: itemData) + messages.append(msg) + } + } else { + let msg = try decoder.decode(ServerToClientMessage.self, from: payloadData) + messages.append(msg) + } + } + + return messages + } + + /// Walk the result structure to find `parts` arrays at various nesting levels. + private func extractParts(from result: [String: Any]) -> [[String: Any]] { + // Check status.message.parts (task status with inline message) + if let status = result["status"] as? [String: Any], + let message = status["message"] as? [String: Any], + let parts = message["parts"] as? [[String: Any]] { + return parts + } + // Check artifact.parts (streaming artifact events) + if let artifact = result["artifact"] as? [String: Any], + let parts = artifact["parts"] as? [[String: Any]] { + return parts + } + // Check message.parts (direct message response) + if let message = result["message"] as? [String: Any], + let parts = message["parts"] as? [[String: Any]] { + return parts + } + // Check top-level parts + if let parts = result["parts"] as? [[String: Any]] { + return parts + } + return [] + } +} + +// MARK: - Supporting Types + +/// Result returned by all `send*` methods, including A2UI messages and task metadata. +public struct SendResult: Sendable { + public let messages: [ServerToClientMessage] + public let taskState: A2ATaskState + public let taskId: String? + public let contextId: String? +} + +/// A2A task states as defined by the protocol. +public enum A2ATaskState: String, Sendable { + case submitted + case working + case inputRequired = "input-required" + case completed + case canceled + case failed + case rejected + case authRequired = "auth-required" + case unknown +} + +/// Events emitted by the streaming SSE API (`sendTextStream` / `sendActionStream`). +public enum StreamEvent: Sendable { + /// Intermediate status update (e.g. "working", with optional status text). + case status(state: A2ATaskState, text: String?, taskId: String?, contextId: String?, isFinal: Bool) + /// Final (or intermediate) result containing decoded A2UI messages. + case result(SendResult) +} + +/// Basic agent card info discovered during `fromAgentCardURL`. +public struct AgentCardInfo: Sendable { + public let name: String + public let url: String + public let streaming: Bool + public let iconUrl: String? + public let supportedCatalogIds: [String] +} + +// MARK: - Errors + +public enum A2AError: LocalizedError { + case invalidResponse + case httpError(statusCode: Int, body: String) + case agentError(message: String) + case agentCardFetchFailed(url: URL) + case agentCardInvalid + + public var errorDescription: String? { + switch self { + case .invalidResponse: + return "Invalid response from A2A agent" + case .httpError(let code, let body): + return "HTTP \(code): \(body.prefix(200))" + case .agentError(let message): + return "Agent error: \(message)" + case .agentCardFetchFailed(let url): + return "Failed to fetch agent card from \(url)" + case .agentCardInvalid: + return "Agent card is missing required 'url' field" + } + } +} + +// MARK: - AnyCodable JSON helpers + +extension AnyCodable { + func toJSONValue() -> Any { + switch self { + case .string(let s): return s + case .number(let n): return n + case .bool(let b): return b + case .null: return NSNull() + case .array(let arr): return arr.map { $0.toJSONValue() } + case .dictionary(let dict): return dict.mapValues { $0.toJSONValue() } + } + } +} diff --git a/renderers/swiftui/Sources/A2UI/Processing/ComponentNode.swift b/renderers/swiftui/Sources/A2UI/Processing/ComponentNode.swift new file mode 100644 index 000000000..87aba3043 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Processing/ComponentNode.swift @@ -0,0 +1,141 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(AVFoundation) && !os(watchOS) +import AVFoundation +#endif +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif +import Observation + +// MARK: - ComponentUIState Protocol & Concrete Types + +public protocol ComponentUIState: AnyObject {} + +@Observable +public final class TabsUIState: ComponentUIState { + public var selectedIndex: Int = 0 +} + +@Observable +public final class ModalUIState: ComponentUIState { + public var isPresented: Bool = false +} + +@Observable +public final class AudioPlayerUIState: ComponentUIState { + public var isPlaying: Bool = false + public var currentTime: Double = 0 + public var duration: Double = 0 + #if canImport(AVKit) && !os(watchOS) + public var player: AVPlayer? + var timeObserver: Any? + #endif +} + +@Observable +public final class VideoUIState: ComponentUIState { + #if canImport(AVKit) && !os(watchOS) + public var player: AVPlayer? + #endif + #if canImport(UIKit) && !os(watchOS) + /// Cached first-frame thumbnail. Fetched once asynchronously, persists + /// across LazyVStack recycling and tree rebuilds. + public var thumbnail: UIImage? + public var thumbnailLoaded = false + #elseif canImport(AppKit) + public var thumbnail: NSImage? + public var thumbnailLoaded = false + #endif +} + +@Observable +public final class MultipleChoiceUIState: ComponentUIState { + public var filterText: String = "" +} + +// MARK: - Accessibility Attributes + +/// Accessibility attributes from the A2UI spec's `ComponentCommon`. +public struct A2UIAccessibility { + public var label: StringValue? + public var description: StringValue? +} + +// MARK: - ComponentNode + +/// A resolved node in the component tree. +/// +/// The tree is rebuilt by `SurfaceViewModel.rebuildComponentTree()` whenever +/// the component buffer or data model changes. UI state (`uiState`) is +/// migrated across rebuilds by matching node IDs, so that stateful views +/// (Tabs selectedIndex, Modal isPresented, etc.) survive LazyVStack recycling. +@Observable +public final class ComponentNode: Identifiable { + /// Full ID = baseComponentId + idSuffix (unique within the tree). + public let id: String + + /// The key into `SurfaceViewModel.components` dictionary. + public let baseComponentId: String + + /// Resolved component type. + public let type: ComponentType + + /// Data context path for this node (e.g. "/items/0"). + public let dataContextPath: String + + /// Layout weight (flex-grow equivalent). + public var weight: Double? + + /// Raw payload — view layer calls `typedProperties()` at render time so + /// that path-bound values read from `@Observable dataModel` and trigger + /// precise SwiftUI updates. + public var payload: RawComponentPayload + + /// Pre-resolved child nodes. + public var children: [ComponentNode] + + /// Per-node UI state. Rebuilt trees get a fresh default; the migration + /// step replaces it with the previous instance (same object reference) + /// so SwiftUI does not see a change. + public var uiState: (any ComponentUIState)? + + /// Accessibility attributes parsed from the component instance. + public var accessibility: A2UIAccessibility? + + public init( + id: String, + baseComponentId: String, + type: ComponentType, + dataContextPath: String, + weight: Double?, + payload: RawComponentPayload, + children: [ComponentNode] = [], + uiState: (any ComponentUIState)? = nil, + accessibility: A2UIAccessibility? = nil + ) { + self.id = id + self.baseComponentId = baseComponentId + self.type = type + self.dataContextPath = dataContextPath + self.weight = weight + self.payload = payload + self.children = children + self.uiState = uiState + self.accessibility = accessibility + } +} diff --git a/renderers/swiftui/Sources/A2UI/Processing/DataStore.swift b/renderers/swiftui/Sources/A2UI/Processing/DataStore.swift new file mode 100644 index 000000000..c780f8e02 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Processing/DataStore.swift @@ -0,0 +1,293 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import Observation + +// MARK: - ObservableValue (Fine-grained Data Model) + +/// A single observable slot in the data model. +/// +/// Each top-level key in `DataStore.storage` is wrapped in its own +/// `ObservableValue`. When a View reads `observableValue.value`, SwiftUI's +/// `@Observable` tracking registers a dependency on **this specific slot** +/// — not the entire data model dictionary. This means updating key "A" +/// will only invalidate Views that read key "A", leaving Views that read +/// key "B" untouched. +@Observable +public final class ObservableValue { + public var value: AnyCodable + + public init(_ value: AnyCodable) { + self.value = value + } +} + +// MARK: - DataStore + +/// Observable data store for a single A2UI surface. +/// Analogous to the data model management in web_core's A2uiMessageProcessor. +/// +/// Owns the `[String: ObservableValue]` dictionary and all path resolution, +/// read, and write logic. `SurfaceViewModel` delegates data operations here. +@Observable +public final class DataStore { + /// Fine-grained observable data store. Each top-level key is wrapped in + /// its own `ObservableValue` so that mutations to one key do not + /// invalidate Views that only read a different key. + private var storage: [String: ObservableValue] = [:] + + public init() {} + + // MARK: - Bulk Accessors + + /// Backward-compatible computed accessor that materialises the data model + /// as a plain dictionary. Useful for tests and bulk inspection. + /// **Writing** through this setter replaces the entire store (all keys + /// are touched), so prefer `setData(path:value:)` for targeted updates. + public var dataModel: [String: AnyCodable] { + get { + storage.mapValues { $0.value } + } + set { + // Build a new store, reusing existing ObservableValue objects + // for keys whose value hasn't changed. + var updated: [String: ObservableValue] = [:] + for (key, value) in newValue { + if let existing = storage[key] { + existing.value = value + updated[key] = existing + } else { + updated[key] = ObservableValue(value) + } + } + storage = updated + } + } + + /// All top-level keys currently in the data store (for debugging). + public var dataStoreKeys: [String] { + Array(storage.keys).sorted() + } + + /// Remove all entries (used by `handleDeleteSurface`). + public func removeAll() { + storage.removeAll() + } + + // MARK: - Path Resolution + + /// Normalize bracket/dot notation to slash-delimited paths. + /// `bookRecommendations[0].title` → `bookRecommendations/0/title` + /// `book.0.title` → `book/0/title` + /// `/items[0]/title` → `/items/0/title` + public func normalizePath(_ path: String) -> String { + if path == "." || path == "/" { return path } + guard path.contains("[") || path.contains(".") else { return path } + + // Replace bracket notation [N] with .N + let dotPath = path.replacingOccurrences( + of: "\\[(\\d+)\\]", with: ".$1", options: .regularExpression + ) + + // Split by dots, then split each segment by slashes to flatten + let segments = dotPath + .split(separator: ".") + .flatMap { $0.split(separator: "/") } + .map(String.init) + guard !segments.isEmpty else { return path } + + let joined = segments.joined(separator: "/") + return path.hasPrefix("/") ? "/\(joined)" : joined + } + + /// Resolve a relative path against a data context path into an absolute path. + public func resolvePath(_ path: String, context: String) -> String { + let normalized = normalizePath(path) + if normalized == "." || normalized.isEmpty { return context } + if normalized.hasPrefix("/") { return normalized } + if context == "/" { return "/\(normalized)" } + let base = context.hasSuffix("/") ? context : "\(context)/" + return "\(base)\(normalized)" + } + + // MARK: - Data Read + + /// Traverse the data model by a slash-delimited path. + /// Supports: `/name`, `/items/0/title`, `/items/item1/name`, etc. + /// + /// The first segment is resolved against `storage`, so SwiftUI only + /// tracks the specific `ObservableValue` for that top-level key. + public func getDataByPath(_ path: String) -> AnyCodable? { + let normalized = normalizePath(path) + let segments = normalized.split(separator: "/").map(String.init) + guard let firstKey = segments.first else { return nil } + + // Read from the per-key ObservableValue — this is the observation + // boundary. SwiftUI will only track THIS slot, not the whole store. + guard let slot = storage[firstKey] else { return nil } + var current: AnyCodable = slot.value + + for segment in segments.dropFirst() { + switch current { + case .dictionary(let dict): + guard let next = dict[segment] else { return nil } + current = next + case .array(let arr): + guard let index = Int(segment), index >= 0, index < arr.count else { return nil } + current = arr[index] + default: + return nil + } + } + return current + } + + // MARK: - Data Write + + /// Write a value into the data model at a given path (for input components). + public func setData(path: String, value: AnyCodable, dataContextPath: String = "/") { + let fullPath = resolvePath(path, context: dataContextPath) + let segments = fullPath.split(separator: "/").map(String.init) + guard !segments.isEmpty else { return } + + if segments.count == 1 { + setTopLevelData(key: segments[0], value: value) + return + } + setNestedValue(path: fullPath, value: value) + } + + // MARK: - Array Data Helpers (MultipleChoice) + + /// Resolve a `StringListValue` to an array of selected value strings. + /// When both `path` and a literal array are present, the literal seeds the data model once. + public func resolveStringArray( + _ selections: StringListValue, + dataContextPath: String = "/" + ) -> [String] { + if let path = selections.path { + let full = resolvePath(path, context: dataContextPath) + if let literal = selections.literalArray, getDataByPath(full) == nil { + let arr: AnyCodable = .array(literal.map { .string($0) }) + setData(path: path, value: arr, dataContextPath: dataContextPath) + } + if case .array(let items) = getDataByPath(full) { + return items.compactMap(\.stringValue) + } + } + if let arr = selections.literalArray { return arr } + return [] + } + + /// Write an array of strings into the data model at the given path. + public func setStringArray( + path: String, values: [String], + dataContextPath: String = "/" + ) { + let arr: AnyCodable = .array(values.map { .string($0) }) + setData(path: path, value: arr, dataContextPath: dataContextPath) + } + + // MARK: - Top-level Data Write + + /// Write a value to a top-level key in the data store, reusing an + /// existing `ObservableValue` when the key already exists so that only + /// Views observing this specific key are invalidated. + private func setTopLevelData(key: String, value: AnyCodable) { + if let existing = storage[key] { + existing.value = value + } else { + storage[key] = ObservableValue(value) + } + } + + // MARK: - Nested Path Write + + private func setNestedValue(path: String, value: AnyCodable) { + let segments = path.split(separator: "/").map(String.init) + guard let topKey = segments.first else { return } + + let existingTop = storage[topKey]?.value ?? .dictionary([:]) + if segments.count == 1 { + setTopLevelData(key: topKey, value: value) + return + } + + let rest = segments.dropFirst() + let updated = Self.setValue(value, in: existingTop, along: rest) + setTopLevelData(key: topKey, value: updated) + } + + private static func setValue( + _ value: AnyCodable, + in container: AnyCodable, + along segments: ArraySlice + ) -> AnyCodable { + guard let key = segments.first else { return value } + let rest = segments.dropFirst() + + if let index = Int(key) { + // Numeric key → array container + var arr: [AnyCodable] + if case .array(let existing) = container { + arr = existing + } else { + arr = [] + } + // Extend array if needed + while arr.count <= index { + arr.append(.dictionary([:])) + } + let nextDefault: AnyCodable = { + guard let nextKey = rest.first else { return value } + return Int(nextKey) != nil ? .array([]) : .dictionary([:]) + }() + let child: AnyCodable + if rest.isEmpty { + child = arr[index] + } else if case .dictionary(let d) = arr[index], d.isEmpty { + child = nextDefault + } else { + child = arr[index] + } + arr[index] = rest.isEmpty ? value : setValue(value, in: child, along: rest) + return .array(arr) + } + + switch container { + case .dictionary(var dict): + let nextDefault: AnyCodable = { + guard let nextKey = rest.first else { return value } + return Int(nextKey) != nil ? .array([]) : .dictionary([:]) + }() + let child = dict[key] ?? nextDefault + dict[key] = rest.isEmpty ? value : setValue(value, in: child, along: rest) + return .dictionary(dict) + case .array(var arr): + guard let index = Int(key), index >= 0, index < arr.count else { return container } + arr[index] = rest.isEmpty ? value : setValue(value, in: arr[index], along: rest) + return .array(arr) + default: + // Container is a leaf value but we need to go deeper — create dict + var dict: [String: AnyCodable] = [:] + let nextDefault: AnyCodable = { + guard let nextKey = rest.first else { return value } + return Int(nextKey) != nil ? .array([]) : .dictionary([:]) + }() + dict[key] = rest.isEmpty ? value : setValue(value, in: nextDefault, along: rest) + return .dictionary(dict) + } + } +} diff --git a/renderers/swiftui/Sources/A2UI/Processing/JSONLStreamParser.swift b/renderers/swiftui/Sources/A2UI/Processing/JSONLStreamParser.swift new file mode 100644 index 000000000..a248e0a04 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Processing/JSONLStreamParser.swift @@ -0,0 +1,113 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// Parses JSONL (JSON Lines) data where each line is a separate +/// `ServerToClientMessage` JSON object. +/// +/// Agents send A2UI messages as a JSONL stream — one JSON object per line. +/// This parser handles both synchronous (string-based) and asynchronous +/// (URL/byte stream) scenarios. +public final class JSONLStreamParser { + + private let decoder = JSONDecoder() + + public init() {} + + // MARK: - Synchronous Parsing + + /// Parse a single JSONL line into a message. Returns `nil` for blank lines + /// or lines that fail to decode. + public func parseLine(_ line: String) -> ServerToClientMessage? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let data = trimmed.data(using: .utf8) else { return nil } + return try? decoder.decode(ServerToClientMessage.self, from: data) + } + + /// Parse a multi-line JSONL string into an array of messages. + public func parseLines(_ text: String) -> [ServerToClientMessage] { + text.components(separatedBy: .newlines).compactMap(parseLine) + } + + // MARK: - Async Stream (for URLSession / file streams) + + /// Parse an `AsyncSequence` of bytes (e.g. from `URLSession.bytes(for:)`) + /// and yield messages as they arrive. + /// + /// Transport errors (network failures, connection resets, etc.) are propagated + /// to the caller via the throwing stream, matching how web renderers surface + /// errors to the application layer for handling (snackbar, fallback, etc.). + @available(iOS 15.0, macOS 12.0, *) + public func messages( + from bytes: S + ) -> AsyncThrowingStream where S.Element == UInt8 { + AsyncThrowingStream { continuation in + Task { + var buffer = Data() + do { + for try await byte in bytes { + if byte == UInt8(ascii: "\n") { + if !buffer.isEmpty, + let msg = try? decoder.decode( + ServerToClientMessage.self, from: buffer + ) { + continuation.yield(msg) + } + buffer.removeAll(keepingCapacity: true) + } else { + buffer.append(byte) + } + } + // Handle last line without trailing newline + if !buffer.isEmpty, + let msg = try? decoder.decode( + ServerToClientMessage.self, from: buffer + ) { + continuation.yield(msg) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + /// Parse an `AsyncLineSequence` (e.g. from `URL.lines` or + /// `URLSession.bytes(for:).lines`). + /// + /// Transport errors are propagated to the caller, consistent with how + /// web renderers handle stream errors at the application layer. + @available(iOS 15.0, macOS 12.0, *) + public func messages( + fromLines lines: S + ) -> AsyncThrowingStream where S.Element == String { + AsyncThrowingStream { continuation in + Task { + do { + for try await line in lines { + if let msg = parseLine(line) { + continuation.yield(msg) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } +} diff --git a/renderers/swiftui/Sources/A2UI/Processing/SurfaceManager.swift b/renderers/swiftui/Sources/A2UI/Processing/SurfaceManager.swift new file mode 100644 index 000000000..cef175933 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Processing/SurfaceManager.swift @@ -0,0 +1,74 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import Observation + +/// Manages multiple A2UI surfaces, each keyed by its `surfaceId`. +/// +/// The A2UI protocol supports rendering multiple independent surfaces +/// simultaneously (e.g., a contact card and an org chart side by side). +/// `SurfaceManager` routes incoming messages to the correct +/// `SurfaceViewModel` based on each message's `surfaceId`. +@Observable +public final class SurfaceManager { + /// All active surfaces, keyed by surfaceId. + public private(set) var surfaces: [String: SurfaceViewModel] = [:] + + /// Ordered list of surface IDs, preserving the order in which they were created. + public private(set) var orderedSurfaceIds: [String] = [] + + public init() {} + + /// Remove all surfaces — matching the Angular renderer's `clearSurfaces()`. + /// Called before processing a fresh response so old surfaces don't accumulate. + public func clearAll() { + surfaces.removeAll() + orderedSurfaceIds.removeAll() + } + + /// Process an array of messages, routing each to the correct surface. + public func processMessages(_ messages: [ServerToClientMessage]) throws { + for message in messages { + try processMessage(message) + } + } + + /// Process a single message, routing it to the correct surface by surfaceId. + public func processMessage(_ message: ServerToClientMessage) throws { + if let ds = message.deleteSurface { + surfaces.removeValue(forKey: ds.surfaceId) + orderedSurfaceIds.removeAll { $0 == ds.surfaceId } + return + } + + guard let surfaceId = extractSurfaceId(from: message) else { return } + + let vm = surfaces[surfaceId] ?? { + let new = SurfaceViewModel() + surfaces[surfaceId] = new + orderedSurfaceIds.append(surfaceId) + return new + }() + + try vm.processMessage(message) + } + + /// Extract the surfaceId from any message type. + private func extractSurfaceId(from message: ServerToClientMessage) -> String? { + message.beginRendering?.surfaceId + ?? message.surfaceUpdate?.surfaceId + ?? message.dataModelUpdate?.surfaceId + } +} diff --git a/renderers/swiftui/Sources/A2UI/Processing/SurfaceViewModel.swift b/renderers/swiftui/Sources/A2UI/Processing/SurfaceViewModel.swift new file mode 100644 index 000000000..1fc974ce8 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Processing/SurfaceViewModel.swift @@ -0,0 +1,734 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation +import Observation + +/// Core state manager for a single A2UI surface. +/// Processes the four message types and maintains the component buffer + data model. +/// +/// Uses `@Observable` with per-key `ObservableValue` slots for the data model. +/// When only `dataStore["name"]` changes, only Views that read that specific key +/// re-render — matching the Signal-based approach used by the official Lit and +/// Angular renderers. +@Observable +public final class SurfaceViewModel { + public var surfaceId: String? + public var rootComponentId: String? + public var components: [String: RawComponentInstance] = [:] + public var styles: [String: String] = [:] + public var a2uiStyle = A2UIStyle() + public var lastAction: ResolvedAction? + public var componentTree: ComponentNode? + + /// Extracted data store that owns all path resolution, read, and write logic. + public let dataStore = DataStore() + + /// Backward-compatible computed accessor delegating to `dataStore`. + public var dataModel: [String: AnyCodable] { + get { dataStore.dataModel } + set { dataStore.dataModel = newValue } + } + + /// All top-level keys currently in the data store (for debugging). + public var dataStoreKeys: [String] { dataStore.dataStoreKeys } + + public init() {} + + /// Process an array of server-to-client messages in order. + public func processMessages(_ messages: [ServerToClientMessage]) throws { + for message in messages { + try processMessage(message) + } + } + + /// Process a single server-to-client message (used by JSONL stream parsing). + public func processMessage(_ message: ServerToClientMessage) throws { + if let br = message.beginRendering { + handleBeginRendering(br) + } + if let su = message.surfaceUpdate { + try handleSurfaceUpdate(su) + } + if let dm = message.dataModelUpdate { + handleDataModelUpdate(dm) + } + if message.deleteSurface != nil { + handleDeleteSurface() + } + } + + // MARK: - Message Handlers + + private func handleBeginRendering(_ message: BeginRenderingMessage) { + surfaceId = message.surfaceId + rootComponentId = message.root + styles = message.styles ?? [:] + a2uiStyle = A2UIStyle(from: styles) + rebuildComponentTree() + } + + private func handleSurfaceUpdate(_ message: SurfaceUpdateMessage) throws { + for component in message.components { + components[component.id] = component + } + rebuildComponentTree() + } + + private func handleDataModelUpdate(_ message: DataModelUpdateMessage) { + let converted = Self.convertValueMap(message.contents) + if let path = message.path, path != "/" { + dataStore.setData(path: path, value: .dictionary(converted)) + } else { + for (key, value) in converted { + if key.contains(".") || key.contains("[") { + // Flat dotted/bracket key (e.g. "chart.items[0].label") + // → normalize to slash path and set via dataStore + let normalized = normalizePath(key) + dataStore.setData(path: "/\(normalized)", value: value) + } else { + dataStore.setData(path: key, value: value) + } + } + } + // Data-bound values are read reactively from per-key ObservableValues. + // Only rebuild when template-driven structure may have changed. + rebuildComponentTreeIfNeeded() + } + + private func handleDeleteSurface() { + rootComponentId = nil + components.removeAll() + dataStore.removeAll() + styles.removeAll() + a2uiStyle = A2UIStyle() + componentTree = nil + } + + // MARK: - Data Store Delegation + + /// Resolve a relative path against a data context path into an absolute path. + public func resolvePath(_ path: String, context: String) -> String { + dataStore.resolvePath(path, context: context) + } + + /// Normalize bracket/dot notation to slash-delimited paths. + public func normalizePath(_ path: String) -> String { + dataStore.normalizePath(path) + } + + /// Traverse the data model by a slash-delimited path. + public func getDataByPath(_ path: String) -> AnyCodable? { + dataStore.getDataByPath(path) + } + + /// Write a value into the data model at a given path. + public func setData(path: String, value: AnyCodable, dataContextPath: String = "/") { + dataStore.setData(path: path, value: value, dataContextPath: dataContextPath) + } + + /// Resolve a `StringListValue` to an array of selected value strings. + public func resolveStringArray( + _ selections: StringListValue, + dataContextPath: String = "/" + ) -> [String] { + dataStore.resolveStringArray(selections, dataContextPath: dataContextPath) + } + + /// Write an array of strings into the data model at the given path. + public func setStringArray( + path: String, values: [String], + dataContextPath: String = "/" + ) { + dataStore.setStringArray(path: path, values: values, dataContextPath: dataContextPath) + } + + // MARK: - Data Binding (Path Resolution) + + /// Resolve a `StringValue` to an actual string, looking up paths in the data model. + /// When both `path` and a literal are present, the literal seeds the data model as + /// the initial value (only if the path has no existing value) and the result is + /// always read from the data model so that user edits are preserved. + public func resolveString(_ value: StringValue, dataContextPath: String = "/") -> String { + if let path = value.path { + let fullPath = resolvePath(path, context: dataContextPath) + if let literal = value.literalValue, getDataByPath(fullPath) == nil { + setData(path: path, value: .string(literal), dataContextPath: dataContextPath) + } + if let data = getDataByPath(fullPath) { + return data.stringValue ?? "" + } + // Fallback: inside a template context, an absolute path like "/name" + // may actually refer to a field relative to the current item. + if path.hasPrefix("/"), dataContextPath != "/" { + let relative = String(path.dropFirst()) + let fallback = resolvePath(relative, context: dataContextPath) + if let data = getDataByPath(fallback) { + return data.stringValue ?? "" + } + } + } + if let literal = value.literalValue { return literal } + return "" + } + + /// Resolve a `NumberValue` to an actual number. + /// When both `path` and a literal are present, the literal seeds the data model once. + public func resolveNumber(_ value: NumberValue, dataContextPath: String = "/") -> Double? { + if let path = value.path { + let fullPath = resolvePath(path, context: dataContextPath) + if let literal = value.literalValue, getDataByPath(fullPath) == nil { + setData(path: path, value: .number(literal), dataContextPath: dataContextPath) + } + if let result = getDataByPath(fullPath)?.numberValue { + return result + } + // Fallback: inside a template context, treat absolute path as relative. + if path.hasPrefix("/"), dataContextPath != "/" { + let relative = String(path.dropFirst()) + let fallback = resolvePath(relative, context: dataContextPath) + return getDataByPath(fallback)?.numberValue + } + } + if let literal = value.literalValue { return literal } + return nil + } + + /// Resolve a `BooleanValue` to an actual boolean. + /// When both `path` and a literal are present, the literal seeds the data model once. + public func resolveBoolean(_ value: BooleanValue, dataContextPath: String = "/") -> Bool? { + if let path = value.path { + let fullPath = resolvePath(path, context: dataContextPath) + if let literal = value.literalValue, getDataByPath(fullPath) == nil { + setData(path: path, value: .bool(literal), dataContextPath: dataContextPath) + } + if let result = getDataByPath(fullPath)?.boolValue { + return result + } + // Fallback: inside a template context, treat absolute path as relative. + if path.hasPrefix("/"), dataContextPath != "/" { + let relative = String(path.dropFirst()) + let fallback = resolvePath(relative, context: dataContextPath) + return getDataByPath(fallback)?.boolValue + } + } + if let literal = value.literalValue { return literal } + return nil + } + + // MARK: - Action Resolution + + /// Resolve an action's context entries, converting paths to actual values. + public func resolveAction( + _ action: Action, + sourceComponentId: String, + dataContextPath: String = "/" + ) -> ResolvedAction { + var resolved: [String: AnyCodable] = [:] + for entry in action.context ?? [] { + if let path = entry.value.path { + let full = resolvePath(path, context: dataContextPath) + var value = getDataByPath(full) + // Fallback: inside a template context, treat absolute path as relative. + if value == nil, path.hasPrefix("/"), dataContextPath != "/" { + let relative = String(path.dropFirst()) + let fallback = resolvePath(relative, context: dataContextPath) + value = getDataByPath(fallback) + } + resolved[entry.key] = value ?? .null + } else if let s = entry.value.literalString { + resolved[entry.key] = .string(s) + } else if let n = entry.value.literalNumber { + resolved[entry.key] = .number(n) + } else if let b = entry.value.literalBoolean { + resolved[entry.key] = .bool(b) + } + } + return ResolvedAction( + name: action.name, + sourceComponentId: sourceComponentId, + context: resolved + ) + } + + // MARK: - ValueMap → Dictionary Conversion + + /// Recursively converts `[ValueMapEntry]` into `[String: AnyCodable]`. + public static func convertValueMap(_ entries: [ValueMapEntry]) -> [String: AnyCodable] { + var result: [String: AnyCodable] = [:] + for entry in entries { + if let s = entry.valueString { + result[entry.key] = .string(s) + } else if let n = entry.valueNumber { + result[entry.key] = .number(n) + } else if let b = entry.valueBoolean ?? entry.valueBool { + result[entry.key] = .bool(b) + } else if let map = entry.valueMap { + result[entry.key] = .dictionary(convertValueMap(map)) + } + } + return result + } + + // MARK: - Component Node Builder (Public API for Custom Renderers) + + /// Build a standalone `ComponentNode` for a component referenced by ID. + /// Useful for custom renderers that need to render child components (e.g. image + /// references via `imageChildId`) that are not part of the standard `children` array. + public func buildComponentNode( + for componentId: String, + dataContextPath: String = "/" + ) -> ComponentNode? { + guard let instance = components[componentId], + let payload = instance.component else { + return nil + } + return ComponentNode( + id: componentId, + baseComponentId: componentId, + type: payload.componentType, + dataContextPath: dataContextPath, + weight: instance.weight, + payload: payload, + children: [] + ) + } + + // MARK: - Component Tree Building + + /// Rebuild the resolved component tree from the current component buffer + /// and data model, migrating UI state from the previous tree by ID match. + public func rebuildComponentTree() { + guard let rootId = rootComponentId else { + componentTree = nil + return + } + + // 1. Collect old UI states + var oldStateMap: [String: any ComponentUIState] = [:] + if let oldTree = componentTree { + collectUIStates(from: oldTree, into: &oldStateMap) + } + + // 2. Build new tree + var visited = Set() + guard let newTree = buildNodeRecursive( + baseComponentId: rootId, + visited: &visited, + dataContextPath: "/", + idSuffix: "" + ) else { + componentTree = nil + return + } + + // 3. Migrate UI states from old tree + migrateUIStates(node: newTree, from: oldStateMap) + + // 4. Try to update existing tree in-place to preserve object identity. + // If the structure matches (same IDs in same order), we patch the + // existing nodes so SwiftUI does not see a new object graph. + if let existingTree = componentTree { + if updateTreeInPlace(existing: existingTree, from: newTree) { + return // patched in-place, no root replacement needed + } + } + + // 5. Structure changed — must replace the root + componentTree = newTree + } + + /// Light rebuild for data model changes: only rebuild if template-driven + /// children actually changed (array/dict size changed). If the tree + /// structure is identical, the existing nodes stay in place and the views + /// re-read data reactively from per-key `ObservableValue` slots. + private func rebuildComponentTreeIfNeeded() { + guard let rootId = rootComponentId else { + componentTree = nil + return + } + guard componentTree != nil else { + // No existing tree — full build + rebuildComponentTree() + return + } + + // Speculatively build a new tree and compare structure + var visited = Set() + guard let candidate = buildNodeRecursive( + baseComponentId: rootId, + visited: &visited, + dataContextPath: "/", + idSuffix: "" + ) else { + componentTree = nil + return + } + + if let existingTree = componentTree, treeStructureMatches(existing: existingTree, candidate: candidate) { + // Structure unchanged — views read data reactively, no update needed + return + } + + // Structure changed (e.g. template array grew) — full rebuild with migration + rebuildComponentTree() + } + + /// Check if two trees have the same ID structure (same IDs in same order). + private func treeStructureMatches(existing: ComponentNode, candidate: ComponentNode) -> Bool { + guard existing.id == candidate.id, + existing.children.count == candidate.children.count else { + return false + } + for i in existing.children.indices { + if !treeStructureMatches(existing: existing.children[i], candidate: candidate.children[i]) { + return false + } + } + return true + } + + /// Recursively patch an existing tree from a new tree, preserving object + /// identity for `ComponentNode` instances. Returns true if the patch succeeded + /// (structure was identical), false if the structure differs and a full + /// replacement is needed. + private func updateTreeInPlace(existing: ComponentNode, from newNode: ComponentNode) -> Bool { + guard existing.id == newNode.id, + existing.children.count == newNode.children.count else { + return false + } + // Patch mutable properties while keeping the same object reference + existing.payload = newNode.payload + existing.weight = newNode.weight + if let newState = newNode.uiState, existing.uiState == nil { + existing.uiState = newState + } + for i in existing.children.indices { + if !updateTreeInPlace(existing: existing.children[i], from: newNode.children[i]) { + return false + } + } + return true + } + + /// Recursively build a `ComponentNode` for the given component ID. + private func buildNodeRecursive( + baseComponentId: String, + visited: inout Set, + dataContextPath: String, + idSuffix: String + ) -> ComponentNode? { + guard !visited.contains(baseComponentId) else { return nil } + guard let instance = components[baseComponentId], + let payload = instance.component else { + return nil + } + + let type = payload.componentType + + visited.insert(baseComponentId) + defer { visited.remove(baseComponentId) } + + let fullId = baseComponentId + idSuffix + let children = resolveNodeChildren( + type: type, + payload: payload, + visited: &visited, + dataContextPath: dataContextPath, + idSuffix: idSuffix + ) + + // Parse accessibility attributes from the raw instance + let accessibility = Self.parseAccessibility(from: instance) + + let node = ComponentNode( + id: fullId, + baseComponentId: baseComponentId, + type: type, + dataContextPath: dataContextPath, + weight: instance.weight, + payload: payload, + children: children, + uiState: createDefaultUIState(for: type), + accessibility: accessibility + ) + return node + } + + /// Dispatch child resolution by component type. + private func resolveNodeChildren( + type: ComponentType, + payload: RawComponentPayload, + visited: inout Set, + dataContextPath: String, + idSuffix: String + ) -> [ComponentNode] { + switch type { + case .Column: + guard let props = try? payload.typedProperties(ColumnProperties.self) else { return [] } + return resolveChildrenReference( + props.children, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + case .Row: + guard let props = try? payload.typedProperties(RowProperties.self) else { return [] } + return resolveChildrenReference( + props.children, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + case .List: + guard let props = try? payload.typedProperties(ListProperties.self) else { return [] } + return resolveChildrenReference( + props.children, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + case .Card: + guard let props = try? payload.typedProperties(CardProperties.self) else { return [] } + if let child = buildNodeRecursive( + baseComponentId: props.child, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) { + return [child] + } + return [] + case .Button: + guard let props = try? payload.typedProperties(ButtonProperties.self) else { return [] } + if let child = buildNodeRecursive( + baseComponentId: props.child, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) { + return [child] + } + return [] + case .Tabs: + guard let props = try? payload.typedProperties(TabsProperties.self) else { return [] } + return props.tabItems.compactMap { item in + buildNodeRecursive( + baseComponentId: item.child, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + } + case .Modal: + guard let props = try? payload.typedProperties(ModalProperties.self) else { return [] } + var children: [ComponentNode] = [] + if let entry = buildNodeRecursive( + baseComponentId: props.entryPointChild, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) { + children.append(entry) + } + if let content = buildNodeRecursive( + baseComponentId: props.contentChild, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) { + children.append(content) + } + return children + default: + // Leaf components (Text, Image, Icon, Divider, TextField, CheckBox, + // Slider, DateTimeInput, Video, AudioPlayer, MultipleChoice) have no children. + // Custom components: attempt to resolve children from a "children" property. + if case .custom = type { + return resolveCustomChildren( + payload: payload, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + } + return [] + } + } + + /// Attempt to resolve children for a custom (non-standard) component + /// by looking for a "children" key in its properties. + private func resolveCustomChildren( + payload: RawComponentPayload, + visited: inout Set, + dataContextPath: String, + idSuffix: String + ) -> [ComponentNode] { + guard let childrenRaw = payload.properties["children"] else { return [] } + // Try to decode as ChildrenReference + do { + let data = try JSONEncoder().encode(childrenRaw) + let ref = try JSONDecoder().decode(ChildrenReference.self, from: data) + return resolveChildrenReference( + ref, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + } catch { + // Try as a single child ID + if let childId = childrenRaw.stringValue { + if let child = buildNodeRecursive( + baseComponentId: childId, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) { + return [child] + } + } + return [] + } + } + + /// Resolve a `ChildrenReference` into child nodes (explicit list or template). + private func resolveChildrenReference( + _ children: ChildrenReference, + visited: inout Set, + dataContextPath: String, + idSuffix: String + ) -> [ComponentNode] { + if let list = children.explicitList { + return list.compactMap { childId in + buildNodeRecursive( + baseComponentId: childId, visited: &visited, + dataContextPath: dataContextPath, idSuffix: idSuffix + ) + } + } + if let template = children.template { + return resolveTemplateChildren( + template, visited: &visited, + dataContextPath: dataContextPath + ) + } + return [] + } + + /// Expand a template reference against the data model (Array or Dictionary). + private func resolveTemplateChildren( + _ template: TemplateReference, + visited: inout Set, + dataContextPath: String + ) -> [ComponentNode] { + let fullDataPath = resolvePath(template.dataBinding, context: dataContextPath) + guard let data = getDataByPath(fullDataPath) else { return [] } + + switch data { + case .array(let items): + return items.indices.compactMap { index in + let childContext = "\(fullDataPath)/\(index)" + let suffix = templateSuffix(dataContextPath: dataContextPath, index: index) + return buildNodeRecursive( + baseComponentId: template.componentId, + visited: &visited, + dataContextPath: childContext, + idSuffix: suffix + ) + } + case .dictionary(let dict): + let sortedKeys = dict.keys.sorted() + return sortedKeys.compactMap { key in + let childContext = "\(fullDataPath)/\(key)" + let suffix = ":\(key)" + return buildNodeRecursive( + baseComponentId: template.componentId, + visited: &visited, + dataContextPath: childContext, + idSuffix: suffix + ) + } + default: + return [] + } + } + + /// Build a synthetic ID suffix matching web_core format: `:parentIdx:childIdx` + private func templateSuffix(dataContextPath: String, index: Int) -> String { + let parentIndices = dataContextPath + .split(separator: "/") + .filter { $0.allSatisfy(\.isNumber) } + let allIndices = parentIndices.map(String.init) + [String(index)] + return ":\(allIndices.joined(separator: ":"))" + } + + // MARK: - UI State Migration + + /// Recursively collect all `[id: uiState]` entries from a tree. + private func collectUIStates( + from node: ComponentNode, + into map: inout [String: any ComponentUIState] + ) { + if let state = node.uiState { + map[node.id] = state + } + for child in node.children { + collectUIStates(from: child, into: &map) + } + } + + /// Recursively replace default UI states with old ones matched by ID. + private func migrateUIStates( + node: ComponentNode, + from map: [String: any ComponentUIState] + ) { + if let oldState = map[node.id], let newState = node.uiState, + type(of: oldState) == type(of: newState) { + node.uiState = oldState + } + for child in node.children { + migrateUIStates(node: child, from: map) + } + } + + /// Create a default UI state for component types that need one. + private func createDefaultUIState(for type: ComponentType) -> (any ComponentUIState)? { + switch type { + case .Tabs: return TabsUIState() + case .Modal: return ModalUIState() + case .AudioPlayer: return AudioPlayerUIState() + case .Video: return VideoUIState() + case .MultipleChoice: return MultipleChoiceUIState() + case .custom: return nil + default: return nil + } + } + + // MARK: - Accessibility Parsing + + /// Parse accessibility attributes from a raw component instance. + private static func parseAccessibility(from instance: RawComponentInstance) -> A2UIAccessibility? { + guard let payload = instance.component, + let accessibilityRaw = payload.properties["accessibility"], + case .dictionary(let dict) = accessibilityRaw else { + return nil + } + + var label: StringValue? + var description: StringValue? + + if let labelRaw = dict["label"] { + label = decodeStringValue(from: labelRaw) + } + if let descRaw = dict["description"] { + description = decodeStringValue(from: descRaw) + } + + guard label != nil || description != nil else { return nil } + return A2UIAccessibility(label: label, description: description) + } + + /// Decode a StringValue from an AnyCodable (handles string literal and path). + private static func decodeStringValue(from raw: AnyCodable) -> StringValue? { + switch raw { + case .string(let s): + return StringValue(literalString: s) + case .dictionary(let dict): + if let path = dict["path"]?.stringValue { + return StringValue(path: path) + } + return nil + default: + return nil + } + } +} diff --git a/renderers/swiftui/Sources/A2UI/Styling/A2UIStyle.swift b/renderers/swiftui/Sources/A2UI/Styling/A2UIStyle.swift new file mode 100644 index 000000000..c52f73191 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Styling/A2UIStyle.swift @@ -0,0 +1,979 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// Global style context parsed from `beginRendering.styles`. +/// +/// Override the default text appearance per variant using the view modifier: +/// +/// ```swift +/// A2UIRendererView(manager: manager) +/// .a2uiTextStyle(for: .h1, font: .system(size: 48), weight: .black) +/// .a2uiTextStyle(for: .caption, font: .caption2, color: .gray) +/// ``` +/// +/// Or set the full style at once: +/// +/// ```swift +/// A2UIRendererView(manager: manager) +/// .environment(\.a2uiStyle, A2UIStyle(primaryColor: .blue)) +/// ``` +public struct A2UIStyle: Equatable, Sendable { + public var primaryColor: Color + public var fontFamily: String? + /// Per-variant overrides for Text component appearance. + /// Prefer using the `.a2uiTextStyle(for:...)` view modifier over setting + /// this directly. + public var textStyles: [String: TextStyle] + + /// Appearance overrides for the Card component. + public var cardStyle: CardStyle + + /// Per-variant overrides for Button component appearance. + /// When a variant has an override, the framework switches to custom drawing + /// instead of the default system ButtonStyle. When no override is set, + /// the system ButtonStyle is used. + public var buttonStyles: [String: ButtonVariantStyle] + + /// Appearance overrides for the TextField component. + public var textFieldStyle: TextFieldComponentStyle + + /// Appearance overrides for the CheckBox component. + public var checkBoxStyle: CheckBoxComponentStyle + + /// Appearance overrides for the MultipleChoice component. + public var multipleChoiceStyle: MultipleChoiceComponentStyle + + /// Appearance overrides for the Slider component. + public var sliderStyle: SliderComponentStyle + + /// Appearance overrides for the DateTimeInput component. + public var dateTimeInputStyle: DateTimeInputComponentStyle + + /// Appearance overrides for the Tabs component. + public var tabsStyle: TabsComponentStyle + + /// Appearance overrides for the Modal component. + public var modalStyle: ModalComponentStyle + + /// Appearance overrides for the Video component. + public var videoStyle: VideoComponentStyle + + /// Appearance overrides for the AudioPlayer component. + public var audioPlayerStyle: AudioPlayerComponentStyle + + public init( + primaryColor: Color = .accentColor, + fontFamily: String? = nil, + textStyles: [String: TextStyle] = [:], + iconOverrides: [String: String] = [:], + imageStyles: [String: ImageStyle] = [:], + cardStyle: CardStyle = .init(), + buttonStyles: [String: ButtonVariantStyle] = [:], + textFieldStyle: TextFieldComponentStyle = .init(), + checkBoxStyle: CheckBoxComponentStyle = .init(), + multipleChoiceStyle: MultipleChoiceComponentStyle = .init(), + sliderStyle: SliderComponentStyle = .init(), + dateTimeInputStyle: DateTimeInputComponentStyle = .init(), + tabsStyle: TabsComponentStyle = .init(), + modalStyle: ModalComponentStyle = .init(), + videoStyle: VideoComponentStyle = .init(), + audioPlayerStyle: AudioPlayerComponentStyle = .init() + ) { + self.primaryColor = primaryColor + self.fontFamily = fontFamily + self.textStyles = textStyles + self.iconOverrides = iconOverrides + self.imageStyles = imageStyles + self.cardStyle = cardStyle + self.buttonStyles = buttonStyles + self.textFieldStyle = textFieldStyle + self.checkBoxStyle = checkBoxStyle + self.multipleChoiceStyle = multipleChoiceStyle + self.sliderStyle = sliderStyle + self.dateTimeInputStyle = dateTimeInputStyle + self.tabsStyle = tabsStyle + self.modalStyle = modalStyle + self.videoStyle = videoStyle + self.audioPlayerStyle = audioPlayerStyle + } + + /// Build from the raw `[String: String]` dictionary provided by `beginRendering`. + public init(from styles: [String: String]) { + if let hex = styles["primaryColor"] { + self.primaryColor = Color(hex: hex) + } else { + self.primaryColor = .accentColor + } + self.fontFamily = styles["font"] + self.textStyles = [:] + self.iconOverrides = [:] + self.imageStyles = [:] + self.cardStyle = .init() + self.buttonStyles = [:] + self.checkBoxStyle = .init() + self.multipleChoiceStyle = .init() + self.textFieldStyle = .init() + self.sliderStyle = .init() + self.dateTimeInputStyle = .init() + self.tabsStyle = .init() + self.modalStyle = .init() + self.videoStyle = .init() + self.audioPlayerStyle = .init() + } + + /// The seven text variants defined by the A2UI protocol. + public enum TextVariant: String, CaseIterable, Sendable { + case h1, h2, h3, h4, h5, body, caption + } + + /// Appearance overrides for a single text variant. + public struct TextStyle: Equatable, Sendable { + public var font: Font? + public var weight: Font.Weight? + public var color: Color? + + public init( + font: Font? = nil, + weight: Font.Weight? = nil, + color: Color? = nil + ) { + self.font = font + self.weight = weight + self.color = color + } + } + + // MARK: - Icon Styling + + /// Per-icon SF Symbol overrides. Keys are `IconName.rawValue` strings. + /// Prefer using the `.a2uiIcon(_:systemName:)` view modifier over setting + /// this directly. + public var iconOverrides: [String: String] + + /// The 59 standard icon names defined by the A2UI basic catalog, + /// with their default SF Symbol mappings. + public enum IconName: String, CaseIterable, Sendable { + case accountCircle + case add + case arrowBack + case arrowForward + case attachFile + case calendarToday + case call + case camera + case check + case close + case delete + case download + case edit + case event + case error + case fastForward + case favorite + case favoriteOff + case folder + case help + case home + case info + case locationOn + case lock + case lockOpen + case mail + case menu + case moreVert + case moreHoriz + case notificationsOff + case notifications + case pause + case payment + case person + case phone + case photo + case play + case print + case refresh + case rewind + case search + case send + case settings + case share + case shoppingCart + case skipNext + case skipPrevious + case star + case starHalf + case starOff + case stop + case upload + case visibility + case visibilityOff + case volumeDown + case volumeMute + case volumeOff + case volumeUp + case warning + + /// The default SF Symbol name for this icon. + public var defaultSystemName: String { + switch self { + case .accountCircle: return "person.circle" + case .add: return "plus" + case .arrowBack: return "chevron.left" + case .arrowForward: return "chevron.right" + case .attachFile: return "paperclip" + case .calendarToday: return "calendar" + case .call: return "phone" + case .camera: return "camera" + case .check: return "checkmark" + case .close: return "xmark" + case .delete: return "trash" + case .download: return "arrow.down.circle" + case .edit: return "pencil" + case .event: return "calendar.badge.clock" + case .error: return "exclamationmark.circle" + case .fastForward: return "forward" + case .favorite: return "heart.fill" + case .favoriteOff: return "heart" + case .folder: return "folder" + case .help: return "questionmark.circle" + case .home: return "house" + case .info: return "info.circle" + case .locationOn: return "mappin.and.ellipse" + case .lock: return "lock" + case .lockOpen: return "lock.open" + case .mail: return "envelope" + case .menu: return "line.3.horizontal" + case .moreVert: return "ellipsis" + case .moreHoriz: return "ellipsis" + case .notificationsOff: return "bell.slash" + case .notifications: return "bell" + case .pause: return "pause" + case .payment: return "creditcard" + case .person: return "person" + case .phone: return "phone" + case .photo: return "photo" + case .play: return "play" + case .print: return "printer" + case .refresh: return "arrow.clockwise" + case .rewind: return "backward" + case .search: return "magnifyingglass" + case .send: return "paperplane" + case .settings: return "gearshape" + case .share: return "square.and.arrow.up" + case .shoppingCart: return "cart" + case .skipNext: return "forward.end" + case .skipPrevious: return "backward.end" + case .star: return "star.fill" + case .starHalf: return "star.leadinghalf.filled" + case .starOff: return "star" + case .stop: return "stop" + case .upload: return "arrow.up.circle" + case .visibility: return "eye" + case .visibilityOff: return "eye.slash" + case .volumeDown: return "speaker.wave.1" + case .volumeMute: return "speaker" + case .volumeOff: return "speaker.slash" + case .volumeUp: return "speaker.wave.3" + case .warning: return "exclamationmark.triangle" + } + } + } + + /// Resolves the SF Symbol name for a given A2UI icon name string. + public func sfSymbolName(for iconName: String) -> String { + if let override = iconOverrides[iconName] { + return override + } + if let known = IconName(rawValue: iconName) { + return known.defaultSystemName + } + return "questionmark.diamond" + } + + // MARK: - Image Styling + + /// Per-variant overrides for Image component appearance. + /// Prefer using the `.a2uiImageStyle(for:...)` view modifier over setting + /// this directly. + public var imageStyles: [String: ImageStyle] + + /// The six image variants defined by the A2UI protocol. + public enum ImageVariant: String, CaseIterable, Sendable { + case icon, avatar, smallFeature, mediumFeature, largeFeature, header + } + + /// Appearance overrides for a single image variant. + public struct ImageStyle: Equatable, Sendable { + public var width: CGFloat? + public var height: CGFloat? + public var cornerRadius: CGFloat? + + public init( + width: CGFloat? = nil, + height: CGFloat? = nil, + cornerRadius: CGFloat? = nil + ) { + self.width = width + self.height = height + self.cornerRadius = cornerRadius + } + } + + // MARK: - Card Styling + + /// Appearance overrides for the Card container. + /// + /// All properties are optional. When `nil`, the Card uses system defaults: + /// - `padding`: system `.padding()` (no explicit value — lets SwiftUI decide) + /// - `cornerRadius`: system-appropriate continuous corner radius + /// - `shadow*`: system-appropriate subtle shadow + /// - `backgroundColor`: system `.background` ShapeStyle + /// + /// Set explicit values only when you need to override. + public struct CardStyle: Equatable, Sendable { + public var padding: CGFloat? + public var cornerRadius: CGFloat? + public var shadowRadius: CGFloat? + public var shadowColor: Color? + public var shadowY: CGFloat? + public var backgroundColor: Color? + + public init( + padding: CGFloat? = nil, + cornerRadius: CGFloat? = nil, + shadowRadius: CGFloat? = nil, + shadowColor: Color? = nil, + shadowY: CGFloat? = nil, + backgroundColor: Color? = nil + ) { + self.padding = padding + self.cornerRadius = cornerRadius + self.shadowRadius = shadowRadius + self.shadowColor = shadowColor + self.shadowY = shadowY + self.backgroundColor = backgroundColor + } + } + + // MARK: - TextField Styling + + /// Appearance overrides for the TextField component. + /// All properties optional — `nil` means use system defaults. + public struct TextFieldComponentStyle: Equatable, Sendable { + /// Minimum height for `longText` (TextEditor). Nil → system default. + public var longTextMinHeight: CGFloat? + /// Background for `longText`. Nil → system `.fill.quaternary`. + public var longTextBackgroundColor: Color? + /// Error message color. Nil → system `.red`. + public var errorColor: Color? + + public init( + longTextMinHeight: CGFloat? = nil, + longTextBackgroundColor: Color? = nil, + errorColor: Color? = nil + ) { + self.longTextMinHeight = longTextMinHeight + self.longTextBackgroundColor = longTextBackgroundColor + self.errorColor = errorColor + } + } + + // MARK: - CheckBox Styling + + /// Appearance overrides for the CheckBox (Toggle) component. + public struct CheckBoxComponentStyle: Equatable, Sendable { + public var tintColor: Color? + public var labelFont: Font? + public var labelColor: Color? + + public init( + tintColor: Color? = nil, + labelFont: Font? = nil, + labelColor: Color? = nil + ) { + self.tintColor = tintColor + self.labelFont = labelFont + self.labelColor = labelColor + } + } + + // MARK: - MultipleChoice Styling + + /// Appearance overrides for the MultipleChoice component. + /// All properties optional — `nil` means use system defaults. + public struct MultipleChoiceComponentStyle: Equatable, Sendable { + /// Font for the description label above the choices. + public var descriptionFont: Font? + /// Color for the description label. + public var descriptionColor: Color? + /// Tint color for selected chips and checkmarks. + public var tintColor: Color? + + public init( + descriptionFont: Font? = nil, + descriptionColor: Color? = nil, + tintColor: Color? = nil + ) { + self.descriptionFont = descriptionFont + self.descriptionColor = descriptionColor + self.tintColor = tintColor + } + } + + // MARK: - Tabs Styling + + /// Appearance overrides for the Tabs component. + public struct TabsComponentStyle: Equatable, Sendable { + /// Color of the selected tab text and indicator. + public var selectedColor: Color? + /// Color of unselected tab text. + public var unselectedColor: Color? + /// Font for tab titles. + public var titleFont: Font? + + public init( + selectedColor: Color? = nil, + unselectedColor: Color? = nil, + titleFont: Font? = nil + ) { + self.selectedColor = selectedColor + self.unselectedColor = unselectedColor + self.titleFont = titleFont + } + } + + // MARK: - Modal Styling + + /// Appearance overrides for the Modal component. + public struct ModalComponentStyle: Equatable, Sendable { + /// Whether to show the close button inside the modal. `nil` = show (system default). + public var showCloseButton: Bool? + /// Padding around the modal content. + public var contentPadding: CGFloat? + + public init( + showCloseButton: Bool? = nil, + contentPadding: CGFloat? = nil + ) { + self.showCloseButton = showCloseButton + self.contentPadding = contentPadding + } + } + + // MARK: - Video Styling + + /// Appearance overrides for the Video component. + public struct VideoComponentStyle: Equatable, Sendable { + /// Corner radius for the video player. + public var cornerRadius: CGFloat? + + public init(cornerRadius: CGFloat? = nil) { + self.cornerRadius = cornerRadius + } + } + + // MARK: - AudioPlayer Styling + + /// Appearance overrides for the AudioPlayer component. + public struct AudioPlayerComponentStyle: Equatable, Sendable { + /// Tint color for the play button and progress slider. + public var tintColor: Color? + /// Font for the description label. + public var labelFont: Font? + /// Corner radius for the container. + public var cornerRadius: CGFloat? + + public init( + tintColor: Color? = nil, + labelFont: Font? = nil, + cornerRadius: CGFloat? = nil + ) { + self.tintColor = tintColor + self.labelFont = labelFont + self.cornerRadius = cornerRadius + } + } + + // MARK: - DateTimeInput Styling + + /// Appearance overrides for the DateTimeInput component. + public struct DateTimeInputComponentStyle: Equatable, Sendable { + /// Tint color for the date picker. + public var tintColor: Color? + /// Font for the label text. + public var labelFont: Font? + /// Color for the label text. + public var labelColor: Color? + + public init( + tintColor: Color? = nil, + labelFont: Font? = nil, + labelColor: Color? = nil + ) { + self.tintColor = tintColor + self.labelFont = labelFont + self.labelColor = labelColor + } + } + + // MARK: - Slider Styling + + /// Appearance overrides for the Slider component. + public struct SliderComponentStyle: Equatable, Sendable { + public var tintColor: Color? + public var labelFont: Font? + public var labelColor: Color? + public var valueFont: Font? + public var valueColor: Color? + public var valueFormatter: @Sendable (Double) -> String + + public init( + tintColor: Color? = nil, + labelFont: Font? = nil, + labelColor: Color? = nil, + valueFont: Font? = nil, + valueColor: Color? = nil, + valueFormatter: @escaping @Sendable (Double) -> String = { + $0.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", $0) + : String(format: "%.1f", $0) + } + ) { + self.tintColor = tintColor + self.labelFont = labelFont + self.labelColor = labelColor + self.valueFont = valueFont + self.valueColor = valueColor + self.valueFormatter = valueFormatter + } + + public static func == (lhs: SliderComponentStyle, rhs: SliderComponentStyle) -> Bool { + lhs.tintColor == rhs.tintColor + && lhs.labelFont == rhs.labelFont + && lhs.labelColor == rhs.labelColor + && lhs.valueFont == rhs.valueFont + && lhs.valueColor == rhs.valueColor + } + } + + // MARK: - Button Styling + + /// The three button variants defined by the A2UI protocol. + public enum ButtonVariant: String, CaseIterable, Sendable { + case primary + case borderless + /// The default style when no variant is specified. + case `default` + } + + /// Appearance overrides for a single button variant. + public struct ButtonVariantStyle: Equatable, Sendable { + public var foregroundColor: Color? + public var backgroundColor: Color? + public var cornerRadius: CGFloat? + public var horizontalPadding: CGFloat? + public var verticalPadding: CGFloat? + + public init( + foregroundColor: Color? = nil, + backgroundColor: Color? = nil, + cornerRadius: CGFloat? = nil, + horizontalPadding: CGFloat? = nil, + verticalPadding: CGFloat? = nil + ) { + self.foregroundColor = foregroundColor + self.backgroundColor = backgroundColor + self.cornerRadius = cornerRadius + self.horizontalPadding = horizontalPadding + self.verticalPadding = verticalPadding + } + } +} + +// MARK: - Client Error + +/// Describes a client-side error that should be reported back to the agent. +public struct A2UIClientError: Error, Sendable { + public enum Kind: String, Sendable { + case unknownComponent + case dataBindingFailed + case decodingFailed + case other + } + + public let kind: Kind + public let message: String + public let componentId: String? + public let surfaceId: String? + + public init( + kind: Kind, + message: String, + componentId: String? = nil, + surfaceId: String? = nil + ) { + self.kind = kind + self.message = message + self.componentId = componentId + self.surfaceId = surfaceId + } +} + +// MARK: - SwiftUI Environment + +private struct A2UIStyleKey: EnvironmentKey { + static let defaultValue = A2UIStyle() +} + +private struct A2UIActionHandlerKey: EnvironmentKey { + static let defaultValue: ((ResolvedAction) -> Void)? = nil +} + +extension EnvironmentValues { + public var a2uiStyle: A2UIStyle { + get { self[A2UIStyleKey.self] } + set { self[A2UIStyleKey.self] = newValue } + } + + public var a2uiActionHandler: ((ResolvedAction) -> Void)? { + get { self[A2UIActionHandlerKey.self] } + set { self[A2UIActionHandlerKey.self] = newValue } + } +} + +// MARK: - View Modifier API + +extension View { + /// Override the appearance of a specific A2UI text variant. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiTextStyle(for: .h1, font: .system(size: 48), weight: .black) + /// .a2uiTextStyle(for: .caption, font: .caption2, color: .gray) + /// ``` + /// + /// Only the properties you specify are overridden; the rest fall back to + /// built-in defaults. Multiple calls compose naturally — each one adds or + /// replaces the override for that variant. + public func a2uiTextStyle( + for variant: A2UIStyle.TextVariant, + font: Font? = nil, + weight: Font.Weight? = nil, + color: Color? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + var existing = style.textStyles[variant.rawValue] ?? .init() + if let font { existing.font = font } + if let weight { existing.weight = weight } + if let color { existing.color = color } + style.textStyles[variant.rawValue] = existing + } + } + + /// Override the SF Symbol used for a specific A2UI icon name. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiIcon(.home, systemName: "house.fill") + /// .a2uiIcon(.search, systemName: "doc.text.magnifyingglass") + /// ``` + /// + /// You can also pass a raw icon name string for any custom icon names + /// not in the standard A2UI catalog: + /// + /// ```swift + /// .a2uiIcon("customIcon", systemName: "star.circle") + /// ``` + public func a2uiIcon( + _ icon: A2UIStyle.IconName, + systemName: String + ) -> some View { + a2uiIcon(icon.rawValue, systemName: systemName) + } + + /// Override the SF Symbol used for a raw A2UI icon name string. + public func a2uiIcon( + _ iconName: String, + systemName: String + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + style.iconOverrides[iconName] = systemName + } + } + + /// Override the appearance of a specific A2UI button variant. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiButtonStyle(for: .primary, backgroundColor: .blue, cornerRadius: 12) + /// .a2uiButtonStyle(for: .borderless, foregroundColor: .red) + /// .a2uiButtonStyle(for: .default, backgroundColor: .gray.opacity(0.2)) + /// ``` + /// + /// Only the properties you specify are overridden; the rest fall back to + /// built-in defaults. Multiple calls compose naturally. + public func a2uiButtonStyle( + for variant: A2UIStyle.ButtonVariant, + foregroundColor: Color? = nil, + backgroundColor: Color? = nil, + cornerRadius: CGFloat? = nil, + horizontalPadding: CGFloat? = nil, + verticalPadding: CGFloat? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + var existing = style.buttonStyles[variant.rawValue] ?? .init() + if let foregroundColor { existing.foregroundColor = foregroundColor } + if let backgroundColor { existing.backgroundColor = backgroundColor } + if let cornerRadius { existing.cornerRadius = cornerRadius } + if let horizontalPadding { existing.horizontalPadding = horizontalPadding } + if let verticalPadding { existing.verticalPadding = verticalPadding } + style.buttonStyles[variant.rawValue] = existing + } + } + + /// Override the appearance of the A2UI TextField component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiTextFieldStyle(longTextMinHeight: 150, errorColor: .orange) + /// ``` + public func a2uiTextFieldStyle( + longTextMinHeight: CGFloat? = nil, + longTextBackgroundColor: Color? = nil, + errorColor: Color? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let longTextMinHeight { style.textFieldStyle.longTextMinHeight = longTextMinHeight } + if let longTextBackgroundColor { style.textFieldStyle.longTextBackgroundColor = longTextBackgroundColor } + if let errorColor { style.textFieldStyle.errorColor = errorColor } + } + } + + /// Override the appearance of the A2UI CheckBox (Toggle) component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiCheckBoxStyle(tintColor: .green, labelFont: .headline) + /// ``` + public func a2uiCheckBoxStyle( + tintColor: Color? = nil, + labelFont: Font? = nil, + labelColor: Color? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let tintColor { style.checkBoxStyle.tintColor = tintColor } + if let labelFont { style.checkBoxStyle.labelFont = labelFont } + if let labelColor { style.checkBoxStyle.labelColor = labelColor } + } + } + + /// Override the appearance of the A2UI MultipleChoice component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiMultipleChoiceStyle(tintColor: .purple, descriptionFont: .headline) + /// ``` + public func a2uiMultipleChoiceStyle( + descriptionFont: Font? = nil, + descriptionColor: Color? = nil, + tintColor: Color? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let descriptionFont { style.multipleChoiceStyle.descriptionFont = descriptionFont } + if let descriptionColor { style.multipleChoiceStyle.descriptionColor = descriptionColor } + if let tintColor { style.multipleChoiceStyle.tintColor = tintColor } + } + } + + /// Override the appearance of the A2UI Slider component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiSliderStyle(tintColor: .orange, valueFormatter: { "\(Int($0))%" }) + /// ``` + public func a2uiSliderStyle( + tintColor: Color? = nil, + labelFont: Font? = nil, + labelColor: Color? = nil, + valueFont: Font? = nil, + valueColor: Color? = nil, + valueFormatter: (@Sendable (Double) -> String)? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let tintColor { style.sliderStyle.tintColor = tintColor } + if let labelFont { style.sliderStyle.labelFont = labelFont } + if let labelColor { style.sliderStyle.labelColor = labelColor } + if let valueFont { style.sliderStyle.valueFont = valueFont } + if let valueColor { style.sliderStyle.valueColor = valueColor } + if let valueFormatter { style.sliderStyle.valueFormatter = valueFormatter } + } + } + + /// Override the appearance of the A2UI DateTimeInput component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiDateTimeInputStyle(tintColor: .blue, labelFont: .headline) + /// ``` + public func a2uiDateTimeInputStyle( + tintColor: Color? = nil, + labelFont: Font? = nil, + labelColor: Color? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let tintColor { style.dateTimeInputStyle.tintColor = tintColor } + if let labelFont { style.dateTimeInputStyle.labelFont = labelFont } + if let labelColor { style.dateTimeInputStyle.labelColor = labelColor } + } + } + + /// Override the appearance of the A2UI Tabs component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiTabsStyle(selectedColor: .blue, titleFont: .headline) + /// ``` + public func a2uiTabsStyle( + selectedColor: Color? = nil, + unselectedColor: Color? = nil, + titleFont: Font? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let selectedColor { style.tabsStyle.selectedColor = selectedColor } + if let unselectedColor { style.tabsStyle.unselectedColor = unselectedColor } + if let titleFont { style.tabsStyle.titleFont = titleFont } + } + } + + /// Override the appearance of the A2UI Modal component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiModalStyle(showCloseButton: true, contentPadding: 20) + /// ``` + public func a2uiModalStyle( + showCloseButton: Bool? = nil, + contentPadding: CGFloat? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let showCloseButton { style.modalStyle.showCloseButton = showCloseButton } + if let contentPadding { style.modalStyle.contentPadding = contentPadding } + } + } + + /// Override the appearance of the A2UI Video component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiVideoStyle(cornerRadius: 12) + /// ``` + public func a2uiVideoStyle( + cornerRadius: CGFloat? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let cornerRadius { style.videoStyle.cornerRadius = cornerRadius } + } + } + + /// Override the appearance of the A2UI AudioPlayer component. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiAudioPlayerStyle(tintColor: .purple, cornerRadius: 12) + /// ``` + public func a2uiAudioPlayerStyle( + tintColor: Color? = nil, + labelFont: Font? = nil, + cornerRadius: CGFloat? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let tintColor { style.audioPlayerStyle.tintColor = tintColor } + if let labelFont { style.audioPlayerStyle.labelFont = labelFont } + if let cornerRadius { style.audioPlayerStyle.cornerRadius = cornerRadius } + } + } + + /// Override the appearance of the A2UI Card container. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiCardStyle(cornerRadius: 16, shadowRadius: 8) + /// ``` + /// + /// Only the properties you specify are overridden; the rest fall back to + /// built-in defaults. + public func a2uiCardStyle( + padding: CGFloat? = nil, + cornerRadius: CGFloat? = nil, + shadowRadius: CGFloat? = nil, + shadowColor: Color? = nil, + shadowY: CGFloat? = nil, + backgroundColor: Color? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + if let padding { style.cardStyle.padding = padding } + if let cornerRadius { style.cardStyle.cornerRadius = cornerRadius } + if let shadowRadius { style.cardStyle.shadowRadius = shadowRadius } + if let shadowColor { style.cardStyle.shadowColor = shadowColor } + if let shadowY { style.cardStyle.shadowY = shadowY } + if let backgroundColor { style.cardStyle.backgroundColor = backgroundColor } + } + } + + /// Override the appearance of a specific A2UI image variant. + /// + /// ```swift + /// A2UIRendererView(manager: manager) + /// .a2uiImageStyle(for: .avatar, width: 48, height: 48, cornerRadius: 24) + /// .a2uiImageStyle(for: .header, height: 300) + /// ``` + /// + /// Only the properties you specify are overridden; the rest fall back to + /// built-in defaults. Multiple calls compose naturally. + public func a2uiImageStyle( + for variant: A2UIStyle.ImageVariant, + width: CGFloat? = nil, + height: CGFloat? = nil, + cornerRadius: CGFloat? = nil + ) -> some View { + self.transformEnvironment(\.a2uiStyle) { style in + var existing = style.imageStyles[variant.rawValue] ?? .init() + if let width { existing.width = width } + if let height { existing.height = height } + if let cornerRadius { existing.cornerRadius = cornerRadius } + style.imageStyles[variant.rawValue] = existing + } + } +} + +// MARK: - Color Hex Initializer + +extension Color { + /// Create a `Color` from a hex string like `#FF5722` or `FF5722`. + init(hex: String) { + let cleaned = hex.trimmingCharacters(in: .init(charactersIn: "#")) + guard cleaned.count == 6, + let value = UInt64(cleaned, radix: 16) else { + self = .accentColor + return + } + let r = Double((value >> 16) & 0xFF) / 255 + let g = Double((value >> 8) & 0xFF) / 255 + let b = Double(value & 0xFF) / 255 + self.init(red: r, green: g, blue: b) + } + +} diff --git a/renderers/swiftui/Sources/A2UI/Views/A2UIComponentView.swift b/renderers/swiftui/Sources/A2UI/Views/A2UIComponentView.swift new file mode 100644 index 000000000..98b851efc --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Views/A2UIComponentView.swift @@ -0,0 +1,90 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +/// Recursively renders a pre-resolved `ComponentNode` and its children. +/// +/// All child resolution and template expansion is performed ahead-of-time by +/// `SurfaceViewModel.rebuildComponentTree()`. This view reads `node.children` +/// directly and never resolves children at render time. +/// +/// UI state (Tabs selectedIndex, Modal isPresented, etc.) lives on +/// `node.uiState` — an `@Observable` object that is migrated across tree +/// rebuilds by ID match, surviving LazyVStack view recycling. +public struct A2UIComponentView: View { + public let node: ComponentNode + public var viewModel: SurfaceViewModel + + public init(node: ComponentNode, viewModel: SurfaceViewModel) { + self.node = node + self.viewModel = viewModel + } + + private var dataContextPath: String { node.dataContextPath } + + public var body: some View { + renderComponent(node.type) + .modifier(WeightModifier(weight: node.weight)) + .modifier(AccessibilityModifier( + accessibility: node.accessibility, + viewModel: viewModel, + dataContextPath: dataContextPath + )) + } + + @ViewBuilder + private func renderComponent(_ type: ComponentType) -> some View { + switch type { + case .Text: + A2UIText(node: node, viewModel: viewModel) + case .Image: + A2UIImage(node: node, viewModel: viewModel) + case .Column: + A2UIColumn(node: node, viewModel: viewModel) + case .Row: + A2UIRow(node: node, viewModel: viewModel) + case .Card: + A2UICard(node: node, viewModel: viewModel) + case .Button: + A2UIButton(node: node, viewModel: viewModel) + case .Icon: + A2UIIcon(node: node, viewModel: viewModel) + case .Divider: + A2UIDivider(node: node) + case .TextField: + A2UITextField(node: node, viewModel: viewModel) + case .CheckBox: + A2UICheckBox(node: node, viewModel: viewModel) + case .Slider: + A2UISlider(node: node, viewModel: viewModel) + case .DateTimeInput: + A2UIDateTimeInput(node: node, viewModel: viewModel) + case .List: + A2UIList(node: node, viewModel: viewModel) + case .Video: + A2UIVideo(node: node, viewModel: viewModel) + case .AudioPlayer: + A2UIAudioPlayer(node: node, viewModel: viewModel) + case .Tabs: + A2UITabs(node: node, viewModel: viewModel) + case .Modal: + A2UIModal(node: node, viewModel: viewModel) + case .MultipleChoice: + A2UIMultipleChoice(node: node, viewModel: viewModel) + case .custom: + A2UICustom(node: node, viewModel: viewModel) + } + } +} diff --git a/renderers/swiftui/Sources/A2UI/Views/A2UICustom.swift b/renderers/swiftui/Sources/A2UI/Views/A2UICustom.swift new file mode 100644 index 000000000..d8604a825 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Views/A2UICustom.swift @@ -0,0 +1,49 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import SwiftUI + +struct A2UICustom: View { + let node: ComponentNode + var viewModel: SurfaceViewModel + + @Environment(\.a2uiCustomComponentRenderer) private var customRenderer + + var body: some View { + if case .custom(let typeName) = node.type { + if let renderer = customRenderer, + let customView = renderer(typeName, node, node.children, viewModel) { + customView + } else { + // Fallback: render children in a VStack + VStack(alignment: .leading, spacing: 8) { + ForEach(node.children) { child in + A2UIComponentView(node: child, viewModel: viewModel) + } + } + } + } + } +} + +// MARK: - Previews + +#Preview("Custom - Unknown Fallback") { + if let (vm, root) = previewViewModel(jsonl: """ + {"beginRendering":{"surfaceId":"s","root":"root"}} + {"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","component":{"Canvas":{"children":{"explicitList":["t1"]}}}},{"id":"t1","component":{"Text":{"text":{"literalString":"Child of custom component"}}}}]}} + """) { + A2UIComponentView(node: root, viewModel: vm).padding() + } +} diff --git a/renderers/swiftui/Sources/A2UI/Views/Components/A2UIAudioPlayer.swift b/renderers/swiftui/Sources/A2UI/Views/Components/A2UIAudioPlayer.swift new file mode 100644 index 000000000..77aaf8c05 --- /dev/null +++ b/renderers/swiftui/Sources/A2UI/Views/Components/A2UIAudioPlayer.swift @@ -0,0 +1,209 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if canImport(AVKit) && !os(watchOS) +import AVKit +#endif +import SwiftUI + +struct A2UIAudioPlayer: View { + let node: ComponentNode + var viewModel: SurfaceViewModel + + @Environment(\.a2uiStyle) private var style + + private var dataContextPath: String { node.dataContextPath } + + var body: some View { + if let props = try? node.payload.typedProperties(AudioPlayerProperties.self) { + AudioPlayerNodeView( + url: viewModel.resolveString(props.url, dataContextPath: dataContextPath), + label: props.description.map { + viewModel.resolveString($0, dataContextPath: dataContextPath) + }, + uiState: node.uiState as? AudioPlayerUIState, + apStyle: style.audioPlayerStyle + ) + } + } +} + +// MARK: - AudioPlayerNodeView + +#if canImport(AVKit) && !os(watchOS) +/// Audio player with progress bar, matching `