-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(swift): add SwiftUI rendering layer with Surface, CatalogView, and DataBinding bridge #2093
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
pinieb
wants to merge
55
commits into
a2ui-project:main
Choose a base branch
from
pinieb:swift-7
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,648
−704
Draft
Changes from 54 commits
Commits
Show all changes
55 commits
Select commit
Hold shift + click to select a range
d7e16af
Update folder structure
pinieb f9b72b1
feat(swift): set up production package structure with swift-json-sche…
pinieb eed034d
feat(swift): define A2UI v0.9.1 common type schemas with swift-json-s…
pinieb 16c1805
feat(swift): add A2UICore error types, JSONValue path extensions, and…
pinieb e4180c2
feat(swift): add component catalog protocols and resolved model types
pinieb 3c9b79e
feat(swift): implement SurfaceViewModel core engine with dynamic valu…
pinieb 27fb900
feat(swift): implement SurfaceViewModel core engine with dynamic valu…
pinieb 5099ea1
Merge branch 'swift-6' into swift-7
pinieb c454306
feat(swift): add SwiftUI rendering layer with Surface, CatalogView, a…
pinieb 8c629d3
Merge branch 'main' into swift-2
pinieb 1d5d29b
Fix testMakeContextResolvesA2UIRefs
pinieb 115ff74
Fix formatting script
pinieb 6d70301
Address feedback
pinieb dbdf6ea
Update swift/core/Sources/A2UIJSON/A2UICommonSchema.swift
pinieb d4e4f99
Merge branch 'main' into swift-2
pinieb 1d1f093
Merge branch 'swift-2' into swift-3
pinieb c4171d8
Addressing feedback
pinieb f7825e4
Apply suggestions from code review
pinieb d5cd20b
Merge branch 'swift-3' of https://github.com/pinieb/a2ui into swift-3
pinieb c0678e3
Merge branch 'main' into swift-3
pinieb 2638251
Merge branch 'swift-3' into swift-4
pinieb 7807746
Update swift/core/Sources/A2UICore/Extensions/JSONValue+Path.swift
pinieb bd2fe59
Addressing feedback
pinieb 9b231a2
Merge branch 'main' into swift-4
pinieb 8294b02
Merge branch 'main' into swift-2
pinieb a0b6950
Merge branch 'swift-2' into swift-3
pinieb 5a4c8f5
Conforming to spec
pinieb 0900cce
Merge branch 'main' into swift-3
pinieb ba892be
Merge branch 'main' into swift-3
pinieb 8153a08
Merge branch 'swift-3' into swift-4
pinieb a485cb1
Fix formatting
pinieb 473bfd8
Merge branch 'swift-4' into swift-5
pinieb 3602beb
Addressing feedback
pinieb fa3933e
Merge branch 'swift-5' into swift-6
pinieb a519676
Merge branch 'swift-6' into swift-7
pinieb 3e3ae78
Addressing feedback
pinieb a2a3eb3
feat(swift): implement SurfaceViewModel core engine with dynamic valu…
pinieb 3e65d60
Aligning architecture
pinieb 3c609dd
Addressing feedback
pinieb fc984b8
Merge remote-tracking branch 'upstream/main' into swift-4
pinieb a15a2f2
Addressing feedback
pinieb 0f8ffec
Merge remote-tracking branch 'upstream/main' into swift-4
pinieb 20ce8a0
Merge branch 'swift-5' of https://github.com/pinieb/a2ui into swift-5
pinieb 29d82e5
Merge branch 'swift-4' into swift-5
pinieb 064315e
Addressing feedback
pinieb 63d5b5a
Prevent node cycles
pinieb 4a70523
Merge branch 'main' into swift-5
pinieb cf24827
Use a set instead of an array
pinieb 87f7a84
Merge branch 'swift-5' of https://github.com/pinieb/a2ui into swift-5
pinieb c156664
Merge branch 'swift-5' into swift-6
pinieb 83feec0
Fix merge issues
pinieb 27c9a51
Merge branch 'main' into swift-6
pinieb 859f804
Merge branch 'main' into swift-6
pinieb 429a6ce
Merge branch 'swift-6' into swift-7
pinieb ec2ba7f
Addressing autoreview feedback
pinieb File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
110 changes: 110 additions & 0 deletions
110
swift/core/Sources/A2UICore/Models/SurfaceGroupModel.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| // 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 Combine | ||
| import Foundation | ||
| import OrderedCollections | ||
| import OrderedJSON | ||
|
|
||
| /// The root state model managing the collection of active surfaces. | ||
| /// | ||
| /// `SurfaceGroupModel` owns the surface dictionary, lifecycle | ||
| /// (add/remove), and cross-surface aggregation such as | ||
| /// `sendDataModel` tracking. It mirrors the `SurfaceGroupModel` type | ||
| /// in the `web_core` reference implementation. | ||
| public final class SurfaceGroupModel: @unchecked Sendable, ObservableObject { | ||
| private let lock = NSRecursiveLock() | ||
| private var surfaces: [String: SurfaceViewModel] = [:] | ||
| private var sendDataModelSurfaces: Set<String> = [] | ||
|
|
||
| /// The dictionary of active surfaces, published to the UI on the | ||
| /// Main Thread. | ||
| @Published public private(set) var surfacesMap: [String: SurfaceViewModel] = [:] | ||
|
|
||
| public init() {} | ||
|
|
||
| // MARK: - Surface Lifecycle | ||
|
|
||
| /// Adds a surface to the group. | ||
| /// | ||
| /// If a surface with the same ID already exists, the call is | ||
| /// silently ignored (matching `web_core`'s behavior). | ||
| public func addSurface(_ vm: SurfaceViewModel) { | ||
| lock.withLock { | ||
| guard surfaces[vm.surfaceID] == nil else { return } | ||
| surfaces[vm.surfaceID] = vm | ||
| publishSnapshot() | ||
| } | ||
| } | ||
|
|
||
| /// Removes a surface from the group by its ID. | ||
| public func removeSurface(id: String) { | ||
| lock.withLock { | ||
| guard surfaces[id] != nil else { return } | ||
| surfaces.removeValue(forKey: id) | ||
| sendDataModelSurfaces.remove(id) | ||
| publishSnapshot() | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Surface Lookup | ||
|
|
||
| /// Retrieves a surface by its ID. | ||
| public func surface(id: String) -> SurfaceViewModel? { | ||
| lock.withLock { surfaces[id] } | ||
| } | ||
|
|
||
| /// Returns a snapshot of all active surfaces. | ||
| public func allSurfaces() -> [String: SurfaceViewModel] { | ||
| lock.withLock { surfaces } | ||
| } | ||
|
|
||
| // MARK: - send Data Model | ||
|
|
||
| /// Marks a surface as requesting data-model reporting. | ||
| public func setSendDataModel(surfaceID: String, enabled: Bool) { | ||
| lock.withLock { | ||
| if enabled { | ||
| sendDataModelSurfaces.insert(surfaceID) | ||
| } else { | ||
| sendDataModelSurfaces.remove(surfaceID) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Aggregates the data models of all surfaces that have | ||
| /// `sendDataModel` enabled. | ||
| /// | ||
| /// Returns `nil` if no surfaces have the flag set. | ||
| public func getClientDataModel() -> JSONValue? { | ||
| lock.withLock { | ||
| var result: OrderedDictionary<String, JSONValue> = [:] | ||
| for surfaceID in sendDataModelSurfaces { | ||
| guard let vm = surfaces[surfaceID] else { continue } | ||
| result[surfaceID] = vm.dataModel.snapshot() | ||
| } | ||
| guard !result.isEmpty else { return nil } | ||
| return .object(result) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Private | ||
|
|
||
| private func publishSnapshot() { | ||
| let snapshot = surfaces | ||
| DispatchQueue.main.async { [weak self] in | ||
| self?.surfacesMap = snapshot | ||
| } | ||
| } | ||
| } | ||
136 changes: 136 additions & 0 deletions
136
swift/core/Sources/A2UICore/Processing/MessageErrorMapper.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| // 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 | ||
|
|
||
| /// Converts internal Swift errors (e.g. `DecodingError`) into | ||
| /// spec-compliant `ClientServerError` values suitable for sending | ||
| /// to the server. | ||
| /// | ||
| /// This type encapsulates the mapping logic that was previously | ||
| /// inlined in `MessageProcessor.handleError`. It is a value type | ||
| /// with no mutable state, making it safe to share across threads. | ||
| public struct MessageErrorMapper: Sendable { | ||
| public init() {} | ||
|
|
||
| /// Maps an error to a `ClientServerError` suitable for the | ||
| /// client-to-server `error` message. | ||
| /// | ||
| /// - Parameters: | ||
| /// - error: The internal error to convert. | ||
| /// - surfaceID: The surface ID to attribute the error to. | ||
| /// - Returns: A `ClientServerError` matching the v0.9.1 wire | ||
| /// format. | ||
| public func map( | ||
| _ error: Error, | ||
| surfaceID: String | ||
| ) -> ClientServerError { | ||
| if let genericError = error as? GenericError { | ||
| return .generic(genericError) | ||
| } | ||
|
|
||
| if let decodingError = error as? DecodingError { | ||
| return mapDecodingError(decodingError, surfaceID: surfaceID) | ||
| } | ||
|
|
||
| return .generic( | ||
| GenericError( | ||
| code: "PARSING_FAILED", | ||
| surfaceID: surfaceID, | ||
| message: error.localizedDescription | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| // MARK: - DecodingError Mapping | ||
|
|
||
| private func mapDecodingError( | ||
| _ error: DecodingError, | ||
| surfaceID: String | ||
| ) -> ClientServerError { | ||
| let codingPath = resolveCodingPath(from: error) | ||
| let description = resolveDecodingErrorDescription(error) | ||
|
|
||
| switch error { | ||
| case .typeMismatch, .valueNotFound, .keyNotFound: | ||
| return .validationFailed( | ||
| ValidationFailedError( | ||
| surfaceID: surfaceID, | ||
| path: codingPath, | ||
| message: description | ||
| ) | ||
| ) | ||
| case .dataCorrupted: | ||
| return .generic( | ||
| GenericError( | ||
| code: "PARSING_FAILED", | ||
| surfaceID: surfaceID, | ||
| message: description | ||
| ) | ||
| ) | ||
| @unknown default: | ||
| return .generic( | ||
| GenericError( | ||
| code: "PARSING_FAILED", | ||
| surfaceID: surfaceID, | ||
| message: description | ||
| ) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private func resolveCodingPath( | ||
| from error: DecodingError | ||
| ) -> String { | ||
| let codingPath: [CodingKey] | ||
| switch error { | ||
| case .typeMismatch(_, let context), | ||
| .valueNotFound(_, let context), | ||
| .keyNotFound(_, let context), | ||
| .dataCorrupted(let context): | ||
| codingPath = context.codingPath | ||
| @unknown default: | ||
| codingPath = [] | ||
| } | ||
|
|
||
| guard !codingPath.isEmpty else { return "" } | ||
| let pointer = codingPath.map { key in | ||
| if let intVal = key.intValue { | ||
| return String(intVal) | ||
| } else { | ||
| return key.stringValue | ||
| .replacingOccurrences(of: "~", with: "~0") | ||
| .replacingOccurrences(of: "/", with: "~1") | ||
| } | ||
| }.joined(separator: "/") | ||
| return "/" + pointer | ||
| } | ||
|
|
||
| private func resolveDecodingErrorDescription( | ||
| _ error: DecodingError | ||
| ) -> String { | ||
| switch error { | ||
| case .typeMismatch(let type, let context): | ||
| return "Type mismatch: expected type '\(type)', got '\(context.debugDescription)'" | ||
| case .valueNotFound(let type, let context): | ||
| return "Missing value: expected non-nil '\(type)', got '\(context.debugDescription)'" | ||
| case .keyNotFound(let key, let context): | ||
| return "Missing required key '\(key.stringValue)': \(context.debugDescription)" | ||
| case .dataCorrupted(let context): | ||
| return "JSON syntax error: \(context.debugDescription)" | ||
| @unknown default: | ||
| return "Unknown decoding error" | ||
| } | ||
| } | ||
| } |
63 changes: 63 additions & 0 deletions
63
swift/core/Sources/A2UICore/Processing/MessageParser.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| // 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 thread-safe parser for decoding A2UI server-to-client messages. | ||
| public final class MessageParser: Sendable { | ||
| private let decoder: JSONDecoder | ||
|
|
||
| public init(decoder: JSONDecoder = JSONDecoder()) { | ||
| self.decoder = decoder | ||
| } | ||
|
|
||
| /// Parses a `ServerToClientMessage` from a JSON-encoded string. | ||
| public func parse(jsonString: String) throws -> ServerToClientMessage { | ||
| let data = Data(jsonString.utf8) | ||
| return try decode(jsonData: data) | ||
| } | ||
|
|
||
| /// Decodes a `ServerToClientMessage` from raw JSON data. | ||
| public func decode(jsonData: Data) throws -> ServerToClientMessage { | ||
| try decoder.decode(ServerToClientMessage.self, from: jsonData) | ||
| } | ||
|
|
||
| /// Best-effort extraction of a `surfaceId` from a raw JSON line. | ||
| /// | ||
| /// This is used as a fallback when `parse(jsonString:)` fails, so | ||
| /// the caller can still attribute the error to the correct | ||
| /// surface. Returns `nil` if the JSON is malformed or contains no | ||
| /// `surfaceId` field. | ||
| public func extractSurfaceID(fromLine line: String) -> String? { | ||
| guard let data = line.data(using: .utf8), | ||
| let dict = try? JSONSerialization.jsonObject(with: data) | ||
| as? [String: Any] | ||
| else { | ||
| return nil | ||
| } | ||
|
|
||
| for value in dict.values { | ||
| guard let subDict = value as? [String: Any], | ||
| let rawID = subDict["surfaceId"] | ||
| else { continue } | ||
|
|
||
| if let strID = rawID as? String { | ||
| return strID | ||
| } else if let numID = rawID as? NSNumber { | ||
| return numID.stringValue | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.