Skip to content
Draft
Show file tree
Hide file tree
Changes from 54 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
d7e16af
Update folder structure
pinieb Jul 8, 2026
f9b72b1
feat(swift): set up production package structure with swift-json-sche…
pinieb Jul 8, 2026
eed034d
feat(swift): define A2UI v0.9.1 common type schemas with swift-json-s…
pinieb Jul 8, 2026
16c1805
feat(swift): add A2UICore error types, JSONValue path extensions, and…
pinieb Jul 8, 2026
e4180c2
feat(swift): add component catalog protocols and resolved model types
pinieb Jul 8, 2026
3c9b79e
feat(swift): implement SurfaceViewModel core engine with dynamic valu…
pinieb Jul 8, 2026
27fb900
feat(swift): implement SurfaceViewModel core engine with dynamic valu…
pinieb Jul 8, 2026
5099ea1
Merge branch 'swift-6' into swift-7
pinieb Jul 8, 2026
c454306
feat(swift): add SwiftUI rendering layer with Surface, CatalogView, a…
pinieb Jul 8, 2026
8c629d3
Merge branch 'main' into swift-2
pinieb Jul 10, 2026
1d5d29b
Fix testMakeContextResolvesA2UIRefs
pinieb Jul 11, 2026
115ff74
Fix formatting script
pinieb Jul 14, 2026
6d70301
Address feedback
pinieb Jul 14, 2026
dbdf6ea
Update swift/core/Sources/A2UIJSON/A2UICommonSchema.swift
pinieb Jul 14, 2026
d4e4f99
Merge branch 'main' into swift-2
pinieb Jul 14, 2026
1d1f093
Merge branch 'swift-2' into swift-3
pinieb Jul 14, 2026
c4171d8
Addressing feedback
pinieb Jul 15, 2026
f7825e4
Apply suggestions from code review
pinieb Jul 15, 2026
d5cd20b
Merge branch 'swift-3' of https://github.com/pinieb/a2ui into swift-3
pinieb Jul 15, 2026
c0678e3
Merge branch 'main' into swift-3
pinieb Jul 15, 2026
2638251
Merge branch 'swift-3' into swift-4
pinieb Jul 15, 2026
7807746
Update swift/core/Sources/A2UICore/Extensions/JSONValue+Path.swift
pinieb Jul 15, 2026
bd2fe59
Addressing feedback
pinieb Jul 15, 2026
9b231a2
Merge branch 'main' into swift-4
pinieb Jul 15, 2026
8294b02
Merge branch 'main' into swift-2
pinieb Jul 17, 2026
a0b6950
Merge branch 'swift-2' into swift-3
pinieb Jul 17, 2026
5a4c8f5
Conforming to spec
pinieb Jul 17, 2026
0900cce
Merge branch 'main' into swift-3
pinieb Jul 17, 2026
ba892be
Merge branch 'main' into swift-3
pinieb Jul 20, 2026
8153a08
Merge branch 'swift-3' into swift-4
pinieb Jul 20, 2026
a485cb1
Fix formatting
pinieb Jul 20, 2026
473bfd8
Merge branch 'swift-4' into swift-5
pinieb Jul 20, 2026
3602beb
Addressing feedback
pinieb Jul 20, 2026
fa3933e
Merge branch 'swift-5' into swift-6
pinieb Jul 20, 2026
a519676
Merge branch 'swift-6' into swift-7
pinieb Jul 20, 2026
3e3ae78
Addressing feedback
pinieb Jul 20, 2026
a2a3eb3
feat(swift): implement SurfaceViewModel core engine with dynamic valu…
pinieb Jul 8, 2026
3e65d60
Aligning architecture
pinieb Jul 21, 2026
3c609dd
Addressing feedback
pinieb Jul 21, 2026
fc984b8
Merge remote-tracking branch 'upstream/main' into swift-4
pinieb Jul 22, 2026
a15a2f2
Addressing feedback
pinieb Jul 23, 2026
0f8ffec
Merge remote-tracking branch 'upstream/main' into swift-4
pinieb Jul 23, 2026
20ce8a0
Merge branch 'swift-5' of https://github.com/pinieb/a2ui into swift-5
pinieb Jul 23, 2026
29d82e5
Merge branch 'swift-4' into swift-5
pinieb Jul 23, 2026
064315e
Addressing feedback
pinieb Jul 23, 2026
63d5b5a
Prevent node cycles
pinieb Jul 23, 2026
4a70523
Merge branch 'main' into swift-5
pinieb Jul 24, 2026
cf24827
Use a set instead of an array
pinieb Jul 24, 2026
87f7a84
Merge branch 'swift-5' of https://github.com/pinieb/a2ui into swift-5
pinieb Jul 24, 2026
c156664
Merge branch 'swift-5' into swift-6
pinieb Jul 24, 2026
83feec0
Fix merge issues
pinieb Jul 24, 2026
27c9a51
Merge branch 'main' into swift-6
pinieb Jul 24, 2026
859f804
Merge branch 'main' into swift-6
pinieb Jul 24, 2026
429a6ce
Merge branch 'swift-6' into swift-7
pinieb Jul 24, 2026
ec2ba7f
Addressing autoreview feedback
pinieb Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions swift/core/Sources/A2UICore/Extensions/JSONValue+Path.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,7 @@ extension JSONValue {
switch self {
case .integer(let value): return value
case .number(let value):
if value >= Double(Int.min) && value <= Double(Int.max) && value == value.rounded() {
return Int(value)
}
return nil
return Int(exactly: value)
default: return nil
}
}
Expand Down
110 changes: 110 additions & 0 deletions swift/core/Sources/A2UICore/Models/SurfaceGroupModel.swift
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 {
Comment thread
pinieb marked this conversation as resolved.
Outdated
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 swift/core/Sources/A2UICore/Processing/MessageErrorMapper.swift
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 swift/core/Sources/A2UICore/Processing/MessageParser.swift
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
}
}
Loading
Loading