From df69490c6ba0daa13309e4a80610ae5bf2dbcef6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Mon, 3 Aug 2026 01:16:56 +0800 Subject: [PATCH 1/8] chore: use English runtime diagnostics --- Sources/AstrolabeRuntime/AutomaticRuntimeInstaller.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AstrolabeRuntime/AutomaticRuntimeInstaller.swift b/Sources/AstrolabeRuntime/AutomaticRuntimeInstaller.swift index 9038570..ccff64b 100644 --- a/Sources/AstrolabeRuntime/AutomaticRuntimeInstaller.swift +++ b/Sources/AstrolabeRuntime/AutomaticRuntimeInstaller.swift @@ -51,7 +51,7 @@ package final class AutomaticRuntimeInstaller { }, failureReporter: { error in logger.error( - "Astrolabe Runtime 启动失败:\(error.localizedDescription, privacy: .public)" + "Astrolabe Runtime startup failed: \(error.localizedDescription, privacy: .public)" ) } ) From 0aca1ce4f21bfd6dd6d735605b90ed6773b43c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Tue, 4 Aug 2026 20:27:59 +0800 Subject: [PATCH 2/8] chore: ignore superpowers artifacts --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7941d86..6149e89 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .swiftpm/ .DS_Store Package.resolved +docs/superpowers From 26a0ded5afd659aa34e8a35546aa07abdf536bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Tue, 4 Aug 2026 20:51:54 +0800 Subject: [PATCH 3/8] feat: add iOS UI graph support --- Package.swift | 2 +- .../RuntimeInspectionServices.swift | 9 +- .../AstrolabeRuntimeCore/RuntimeServer.swift | 6 +- .../UIKitHierarchyCollector.swift | 42 +++++++- .../UIKitRuntimeAttributeCollection.swift | 1 + ...ntimeGradientLayerAttributeCollector.swift | 95 ++++++++++++++++ ...IKitRuntimeInspectionServicesFactory.swift | 3 +- .../UIKitRuntimeNodeRelationCollection.swift | 101 ++++++++++++++++++ .../UIKitRuntimeProtocolIdentifiers.swift | 22 ++++ .../RuntimeServerTests.swift | 6 +- .../UIKitHierarchyCollectorTests.swift | 61 ++++++++++- ...untimeInspectionServicesFactoryTests.swift | 4 + .../UIKitRuntimeNodeDetailProviderTests.swift | 84 +++++++++++++++ docs/ios-runtime-strategy.md | 12 ++- 14 files changed, 436 insertions(+), 12 deletions(-) create mode 100644 Sources/AstrolabeRuntimeUIKit/UIKitRuntimeGradientLayerAttributeCollector.swift create mode 100644 Sources/AstrolabeRuntimeUIKit/UIKitRuntimeNodeRelationCollection.swift diff --git a/Package.swift b/Package.swift index 629a8ae..d2f34fb 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/regulusleow/astrolabe-protocol.git", - exact: "2.0.0" + branch: "develop" ) ], targets: [ diff --git a/Sources/AstrolabeRuntimeCore/RuntimeInspectionServices.swift b/Sources/AstrolabeRuntimeCore/RuntimeInspectionServices.swift index a8935b1..6f68fab 100644 --- a/Sources/AstrolabeRuntimeCore/RuntimeInspectionServices.swift +++ b/Sources/AstrolabeRuntimeCore/RuntimeInspectionServices.swift @@ -5,6 +5,8 @@ // Created by 轩辕十四 on 2026/7/10. // +import AstrolabeProtocol + package struct RuntimeInspectionServices: Sendable { /// Provider exposing application, device, and screen metadata. package let appInfoProvider: any RuntimeAppInfoProviding @@ -18,16 +20,21 @@ package struct RuntimeInspectionServices: Sendable { /// Optional provider managing Debug-only temporary attribute patches. package let attributePatchProvider: (any RuntimeAttributePatchProviding)? + /// Capabilities describing payload extensions rather than request handlers. + package let additionalCapabilities: Set + package init( appInfoProvider: any RuntimeAppInfoProviding, hierarchyProvider: any RuntimeHierarchyProviding, nodeDetailProvider: any RuntimeNodeDetailProviding, - attributePatchProvider: (any RuntimeAttributePatchProviding)? = nil + attributePatchProvider: (any RuntimeAttributePatchProviding)? = nil, + additionalCapabilities: Set = [] ) { self.appInfoProvider = appInfoProvider self.hierarchyProvider = hierarchyProvider self.nodeDetailProvider = nodeDetailProvider self.attributePatchProvider = attributePatchProvider + self.additionalCapabilities = additionalCapabilities } } diff --git a/Sources/AstrolabeRuntimeCore/RuntimeServer.swift b/Sources/AstrolabeRuntimeCore/RuntimeServer.swift index 78abd39..a287114 100644 --- a/Sources/AstrolabeRuntimeCore/RuntimeServer.swift +++ b/Sources/AstrolabeRuntimeCore/RuntimeServer.swift @@ -228,7 +228,11 @@ package actor RuntimeServer { Feature(capability: .attributePatching, handler: clearPatchesHandler) ]) } - let capabilities = Array(Set(features.map(\.capability))).sorted { + let capabilities = Array( + Set(features.map(\.capability)).union( + inspectionServices.additionalCapabilities + ) + ).sorted { $0.rawValue < $1.rawValue } let handshakeHandler = TypedRuntimeRequestHandler< diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitHierarchyCollector.swift b/Sources/AstrolabeRuntimeUIKit/UIKitHierarchyCollector.swift index a55db3f..fbb7d73 100644 --- a/Sources/AstrolabeRuntimeUIKit/UIKitHierarchyCollector.swift +++ b/Sources/AstrolabeRuntimeUIKit/UIKitHierarchyCollector.swift @@ -20,6 +20,7 @@ final class UIKitHierarchyCollector { private let colorMapper: UIKitRuntimeColorMapper private let accessibilityMapper: UIKitRuntimeAccessibilityMapper private let attributeCollectorRegistry: UIKitRuntimeAttributeCollectorRegistry + private let relationProviderRegistry: UIKitRuntimeNodeRelationProviderRegistry init( nodeRegistry: RuntimeNodeRegistry, @@ -27,7 +28,8 @@ final class UIKitHierarchyCollector { screenMapper: UIKitRuntimeScreenMapper, colorMapper: UIKitRuntimeColorMapper, accessibilityMapper: UIKitRuntimeAccessibilityMapper, - attributeCollectorRegistry: UIKitRuntimeAttributeCollectorRegistry + attributeCollectorRegistry: UIKitRuntimeAttributeCollectorRegistry, + relationProviderRegistry: UIKitRuntimeNodeRelationProviderRegistry = .init() ) { self.nodeRegistry = nodeRegistry self.targetIdentifier = targetIdentifier @@ -35,6 +37,7 @@ final class UIKitHierarchyCollector { self.colorMapper = colorMapper self.accessibilityMapper = accessibilityMapper self.attributeCollectorRegistry = attributeCollectorRegistry + self.relationProviderRegistry = relationProviderRegistry } func capture( @@ -82,6 +85,10 @@ final class UIKitHierarchyCollector { ancestorVisibleRect: screen.coordinateSpace.bounds ) }) + let relationContext = UIKitRuntimeNodeRelationCollectionContext( + windows: windows, + nodeIDsByObjectIdentity: capturedNodeIDs(in: roots) + ) return RuntimeHierarchySnapshotPayload( snapshotID: try RuntimeOpaqueIdentifier( @@ -96,10 +103,43 @@ final class UIKitHierarchyCollector { space: .screen ), roots: roots, + relations: try relationProviderRegistry.relations( + in: relationContext + ), extensions: nil ) } + private func capturedNodeIDs( + in roots: [RuntimeNode] + ) -> [ObjectIdentifier: RuntimeOpaqueIdentifier] { + var nodeIDsByObjectIdentity = + [ObjectIdentifier: RuntimeOpaqueIdentifier]() + for root in roots { + collectCapturedNodeIDs( + from: root, + into: &nodeIDsByObjectIdentity + ) + } + return nodeIDsByObjectIdentity + } + + private func collectCapturedNodeIDs( + from node: RuntimeNode, + into nodeIDsByObjectIdentity: + inout [ObjectIdentifier: RuntimeOpaqueIdentifier] + ) { + if let object = nodeRegistry.object(for: node.nodeID) { + nodeIDsByObjectIdentity[ObjectIdentifier(object)] = node.nodeID + } + for child in node.children { + collectCapturedNodeIDs( + from: child, + into: &nodeIDsByObjectIdentity + ) + } + } + private func viewNode( for view: UIView, parentID: RuntimeOpaqueIdentifier?, diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAttributeCollection.swift b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAttributeCollection.swift index bac4a1e..d9d13f5 100644 --- a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAttributeCollection.swift +++ b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAttributeCollection.swift @@ -87,6 +87,7 @@ final class UIKitRuntimeAttributeCollectorRegistry { UIKitLayoutAttributeCollector(), UIKitViewAttributeCollector(), UIKitLayerAttributeCollector(), + UIKitGradientLayerAttributeCollector(), UIKitAccessibilityAttributeCollector(), UIKitLabelAttributeCollector(), UIKitImageViewAttributeCollector(), diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeGradientLayerAttributeCollector.swift b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeGradientLayerAttributeCollector.swift new file mode 100644 index 0000000..b93a903 --- /dev/null +++ b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeGradientLayerAttributeCollector.swift @@ -0,0 +1,95 @@ +// +// UIKitRuntimeGradientLayerAttributeCollector.swift +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/8/4. +// + +#if canImport(UIKit) +import AstrolabeProtocol +import QuartzCore + +struct UIKitGradientLayerAttributeCollector: + UIKitRuntimeAttributeCollecting +{ + let category = RuntimeAttributeCategory.gradientLayer + + func supports(_ object: AnyObject) -> Bool { + object is CAGradientLayer + } + + func attributes( + for object: AnyObject, + context: UIKitRuntimeAttributeCollectionContext + ) -> [RuntimeAttribute] { + guard let layer = object as? CAGradientLayer else { + return [] + } + + var attributes = [RuntimeAttribute]() + if let colors = colorValues( + from: layer.colors, + colorMapper: context.colorMapper + ) { + attributes.append( + attribute(.gradientLayerColors, .array(colors)) + ) + } + if let locations = layer.locations { + attributes.append( + attribute( + .gradientLayerLocations, + .array(locations.map { .number($0.doubleValue) }) + ) + ) + } + attributes.append(contentsOf: [ + attribute( + .gradientLayerStartPoint, + unitPointValue(layer.startPoint) + ), + attribute( + .gradientLayerEndPoint, + unitPointValue(layer.endPoint) + ), + attribute(.gradientLayerType, .string(layer.type.rawValue)) + ]) + return attributes + } + + private func colorValues( + from sourceColors: [Any]?, + colorMapper: UIKitRuntimeColorMapper + ) -> [RuntimeJSONValue]? { + guard let sourceColors, + let colors = sourceColors as? [CGColor] else { + return nil + } + var values = [RuntimeJSONValue]() + for color in colors { + guard let runtimeColor = colorMapper.color(from: color) else { + return nil + } + values.append(colorValue(runtimeColor)) + } + return values + } + + private func colorValue(_ color: RuntimeColor) -> RuntimeJSONValue { + .object([ + "colorSpace": .string(color.colorSpace), + "red": .number(color.red), + "green": .number(color.green), + "blue": .number(color.blue), + "alpha": .number(color.alpha) + ]) + } + + private func unitPointValue(_ point: CGPoint) -> RuntimeAttributeValue { + .object([ + "x": .number(point.x), + "y": .number(point.y) + ]) + } +} +#endif diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeInspectionServicesFactory.swift b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeInspectionServicesFactory.swift index de682ed..fcf6818 100644 --- a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeInspectionServicesFactory.swift +++ b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeInspectionServicesFactory.swift @@ -38,7 +38,8 @@ package final class UIKitRuntimeInspectionServicesFactory: Sendable { nodeDetailProvider: UIKitRuntimeNodeDetailProvider( nodeRegistry: nodeRegistry ), - attributePatchProvider: attributePatchProvider + attributePatchProvider: attributePatchProvider, + additionalCapabilities: [.uiGraphRelations] ) } diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeNodeRelationCollection.swift b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeNodeRelationCollection.swift new file mode 100644 index 0000000..c374287 --- /dev/null +++ b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeNodeRelationCollection.swift @@ -0,0 +1,101 @@ +// +// UIKitRuntimeNodeRelationCollection.swift +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/8/4. +// + +#if canImport(UIKit) +import AstrolabeProtocol +import UIKit + +protocol UIKitRuntimeNodeRelationProviding: Sendable { + @MainActor + func relations( + in context: UIKitRuntimeNodeRelationCollectionContext + ) throws -> [RuntimeNodeRelation] +} + +struct UIKitRuntimeNodeRelationCollectionContext { + /// Windows in hierarchy-capture order. + let windows: [UIWindow] + + /// Captured node identifiers keyed by runtime object identity. + private let nodeIDsByObjectIdentity: + [ObjectIdentifier: RuntimeOpaqueIdentifier] + + init( + windows: [UIWindow], + nodeIDsByObjectIdentity: + [ObjectIdentifier: RuntimeOpaqueIdentifier] + ) { + self.windows = windows + self.nodeIDsByObjectIdentity = nodeIDsByObjectIdentity + } + + func nodeID(for object: AnyObject) -> RuntimeOpaqueIdentifier? { + nodeIDsByObjectIdentity[ObjectIdentifier(object)] + } +} + +struct UIKitRuntimeNodeRelationProviderRegistry { + /// Ordered relation providers enabled for one hierarchy collector. + private let providers: [any UIKitRuntimeNodeRelationProviding] + + init( + providers: [any UIKitRuntimeNodeRelationProviding] = [ + UIKitViewBackingLayerRelationProvider() + ] + ) { + self.providers = providers + } + + @MainActor + func relations( + in context: UIKitRuntimeNodeRelationCollectionContext + ) throws -> [RuntimeNodeRelation] { + try providers.flatMap { provider in + try provider.relations(in: context) + } + } +} + +private struct UIKitViewBackingLayerRelationProvider: + UIKitRuntimeNodeRelationProviding +{ + @MainActor + func relations( + in context: UIKitRuntimeNodeRelationCollectionContext + ) throws -> [RuntimeNodeRelation] { + try context.windows.flatMap { window in + try relations(for: window, context: context) + } + } + + @MainActor + private func relations( + for view: UIView, + context: UIKitRuntimeNodeRelationCollectionContext + ) throws -> [RuntimeNodeRelation] { + var relations = [RuntimeNodeRelation]() + if let viewNodeID = context.nodeID(for: view), + let layerNodeID = context.nodeID(for: view.layer) { + relations.append( + RuntimeNodeRelation( + type: .iosViewBackingLayerRelation, + sourceNodeID: viewNodeID, + targetNodeID: layerNodeID, + extensions: try RuntimeExtensionMap() + ) + ) + } + for subview in view.subviews { + relations.append(contentsOf: try self.relations( + for: subview, + context: context + )) + } + return relations + } +} +#endif diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift index a96d4e8..503984b 100644 --- a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift +++ b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift @@ -8,10 +8,17 @@ #if canImport(UIKit) import AstrolabeProtocol +extension RuntimeNamespacedIdentifier { + static let iosViewBackingLayerRelation = iosNamespacedIdentifier( + "view.backingLayer" + ) +} + extension RuntimeAttributeCategory { static let layout = iosCategory("layout") static let view = iosCategory("view") static let layer = iosCategory("layer") + static let gradientLayer = iosCategory("gradientLayer") static let accessibility = iosCategory("accessibility") static let control = iosCategory("control") static let label = iosCategory("label") @@ -55,6 +62,11 @@ extension RuntimeAttributeIdentifier { static let layerBackgroundColor = iosAttribute("layer.backgroundColor") static let borderColor = iosAttribute("layer.borderColor") static let shadowColor = iosAttribute("layer.shadowColor") + static let gradientLayerColors = iosAttribute("gradientLayer.colors") + static let gradientLayerLocations = iosAttribute("gradientLayer.locations") + static let gradientLayerStartPoint = iosAttribute("gradientLayer.startPoint") + static let gradientLayerEndPoint = iosAttribute("gradientLayer.endPoint") + static let gradientLayerType = iosAttribute("gradientLayer.type") static let accessibilityElement = iosAttribute("accessibility.element") static let accessibilityIdentifier = iosAttribute("accessibility.identifier") static let accessibilityLabel = iosAttribute("accessibility.label") @@ -143,6 +155,16 @@ private func iosAttribute(_ path: String) -> RuntimeAttributeIdentifier { } } +private func iosNamespacedIdentifier( + _ path: String +) -> RuntimeNamespacedIdentifier { + do { + return try RuntimeNamespacedIdentifier(rawValue: "ios.\(path)") + } catch { + preconditionFailure("Invalid built-in iOS identifier: \(path)") + } +} + private func patchValueType(_ rawValue: String) -> RuntimePatchValueType { do { return try RuntimePatchValueType(rawValue: rawValue) diff --git a/Tests/AstrolabeRuntimeCoreTests/RuntimeServerTests.swift b/Tests/AstrolabeRuntimeCoreTests/RuntimeServerTests.swift index 7e210ea..e603241 100644 --- a/Tests/AstrolabeRuntimeCoreTests/RuntimeServerTests.swift +++ b/Tests/AstrolabeRuntimeCoreTests/RuntimeServerTests.swift @@ -148,7 +148,8 @@ final class RuntimeServerTests: XCTestCase { appInfoProvider: provider, hierarchyProvider: hierarchyProvider, nodeDetailProvider: nodeDetailProvider, - attributePatchProvider: attributePatchProvider + attributePatchProvider: attributePatchProvider, + additionalCapabilities: [.uiGraphRelations] ) } ) @@ -198,7 +199,8 @@ final class RuntimeServerTests: XCTestCase { .attributePatching, .hierarchySnapshot, .nodeDetail, - .requestCancellation + .requestCancellation, + .uiGraphRelations ] ) diff --git a/Tests/AstrolabeRuntimeUIKitTests/UIKitHierarchyCollectorTests.swift b/Tests/AstrolabeRuntimeUIKitTests/UIKitHierarchyCollectorTests.swift index 3402302..acc811b 100644 --- a/Tests/AstrolabeRuntimeUIKitTests/UIKitHierarchyCollectorTests.swift +++ b/Tests/AstrolabeRuntimeUIKitTests/UIKitHierarchyCollectorTests.swift @@ -16,6 +16,61 @@ import XCTest @MainActor final class UIKitHierarchyCollectorTests: XCTestCase { + func testCollectorEmitsViewBackingLayerRelationsForCapturedNodes() throws { + let registry = RuntimeNodeRegistry() + let collector = makeCollector(nodeRegistry: registry) + let window = UIWindow( + frame: CGRect(x: 0, y: 0, width: 320, height: 640) + ) + let label = UILabel(frame: CGRect(x: 16, y: 20, width: 120, height: 24)) + window.addSubview(label) + + let snapshot = try collector.capture( + windows: [window], + screen: window.screen, + interfaceOrientation: "portrait", + capturedAtUnixTime: 100 + ) + let relations = try XCTUnwrap(snapshot.relations) + + XCTAssertEqual(relations.count, 2) + XCTAssertEqual( + relations.map(\.type.rawValue), + ["ios.view.backingLayer", "ios.view.backingLayer"] + ) + XCTAssertEqual(relations[0].sourceNodeID, registry.nodeID(for: window)) + XCTAssertEqual(relations[0].targetNodeID, registry.nodeID(for: window.layer)) + XCTAssertEqual(relations[1].sourceNodeID, registry.nodeID(for: label)) + XCTAssertEqual(relations[1].targetNodeID, registry.nodeID(for: label.layer)) + XCTAssertFalse(relations.contains { + $0.sourceNodeID == registry.nodeID(for: label.layer) && + $0.targetNodeID == registry.nodeID(for: label) + }) + } + + func testCollectorPreservesRootsWhenRelationProvidersAreEmpty() throws { + let registry = RuntimeNodeRegistry() + let collector = makeCollector( + nodeRegistry: registry, + relationProviderRegistry: UIKitRuntimeNodeRelationProviderRegistry( + providers: [] + ) + ) + let window = UIWindow( + frame: CGRect(x: 0, y: 0, width: 320, height: 640) + ) + + let snapshot = try collector.capture( + windows: [window], + screen: window.screen, + interfaceOrientation: "portrait", + capturedAtUnixTime: 100 + ) + + XCTAssertEqual(snapshot.roots.count, 2) + XCTAssertEqual(snapshot.relations, []) + } + func testCollectorCapturesViewAndLayerTreesWithStableIDs() throws { let registry = RuntimeNodeRegistry() let colorMapper = UIKitRuntimeColorMapper() @@ -284,7 +339,8 @@ final class UIKitHierarchyCollectorTests: XCTestCase { } private func makeCollector( - nodeRegistry: RuntimeNodeRegistry + nodeRegistry: RuntimeNodeRegistry, + relationProviderRegistry: UIKitRuntimeNodeRelationProviderRegistry = .init() ) -> UIKitHierarchyCollector { let colorMapper = UIKitRuntimeColorMapper() let accessibilityMapper = UIKitRuntimeAccessibilityMapper() @@ -298,7 +354,8 @@ final class UIKitHierarchyCollectorTests: XCTestCase { nodeRegistry: nodeRegistry, colorMapper: colorMapper, accessibilityMapper: accessibilityMapper - ) + ), + relationProviderRegistry: relationProviderRegistry ) } diff --git a/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeInspectionServicesFactoryTests.swift b/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeInspectionServicesFactoryTests.swift index fa8295c..363af28 100644 --- a/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeInspectionServicesFactoryTests.swift +++ b/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeInspectionServicesFactoryTests.swift @@ -26,6 +26,10 @@ final class UIKitRuntimeInspectionServicesFactoryTests: XCTestCase { secondServices.hierarchyProvider as? UIKitRuntimeHierarchyProvider ) + XCTAssertEqual( + firstServices.additionalCapabilities, + [.uiGraphRelations] + ) XCTAssertTrue(firstProvider.nodeRegistry === secondProvider.nodeRegistry) let nextSessionProvider = try XCTUnwrap( diff --git a/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeNodeDetailProviderTests.swift b/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeNodeDetailProviderTests.swift index 48d5330..cdc6559 100644 --- a/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeNodeDetailProviderTests.swift +++ b/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeNodeDetailProviderTests.swift @@ -198,6 +198,90 @@ final class UIKitRuntimeNodeDetailPayloadProviderTests: XCTestCase { } } + func testProviderCollectsGradientLayerAttributes() async throws { + let registry = RuntimeNodeRegistry() + let provider = UIKitRuntimeNodeDetailProvider(nodeRegistry: registry) + let layer = CAGradientLayer() + let colorSpace = try XCTUnwrap( + CGColorSpace(name: CGColorSpace.sRGB) + ) + layer.colors = [ + try XCTUnwrap( + CGColor( + colorSpace: colorSpace, + components: [1, 0, 0, 1] + ) + ), + try XCTUnwrap( + CGColor( + colorSpace: colorSpace, + components: [0, 0, 1, 0.5] + ) + ) + ] + layer.locations = [0.25, 0.75] + layer.startPoint = CGPoint(x: 0.1, y: 0.2) + layer.endPoint = CGPoint(x: 0.8, y: 0.9) + layer.type = .radial + + let detail = try await provider.nodeDetail( + for: registry.nodeID(for: layer) + ) + + XCTAssertEqual( + detail.sections.map(\.category), + [.layout, .layer, .gradientLayer] + ) + XCTAssertEqual( + value(.gradientLayerColors, in: detail), + .array([ + .object([ + "colorSpace": .string("srgb"), + "red": .number(1), + "green": .number(0), + "blue": .number(0), + "alpha": .number(1) + ]), + .object([ + "colorSpace": .string("srgb"), + "red": .number(0), + "green": .number(0), + "blue": .number(1), + "alpha": .number(0.5) + ]) + ]) + ) + XCTAssertEqual( + value(.gradientLayerLocations, in: detail), + .array([.number(0.25), .number(0.75)]) + ) + XCTAssertEqual( + value(.gradientLayerStartPoint, in: detail), + .object(["x": .number(0.1), "y": .number(0.2)]) + ) + XCTAssertEqual( + value(.gradientLayerEndPoint, in: detail), + .object(["x": .number(0.8), "y": .number(0.9)]) + ) + XCTAssertEqual( + value(.gradientLayerType, in: detail), + .string("radial") + ) + } + + func testProviderDoesNotInferGradientLocations() async throws { + let registry = RuntimeNodeRegistry() + let provider = UIKitRuntimeNodeDetailProvider(nodeRegistry: registry) + let layer = CAGradientLayer() + layer.locations = nil + + let detail = try await provider.nodeDetail( + for: registry.nodeID(for: layer) + ) + + XCTAssertNil(value(.gradientLayerLocations, in: detail)) + } + func testProviderOmitsUndefinedIntrinsicContentSize() async throws { let registry = RuntimeNodeRegistry() let provider = UIKitRuntimeNodeDetailProvider(nodeRegistry: registry) diff --git a/docs/ios-runtime-strategy.md b/docs/ios-runtime-strategy.md index b82195f..22cee72 100644 --- a/docs/ios-runtime-strategy.md +++ b/docs/ios-runtime-strategy.md @@ -86,7 +86,8 @@ astrolabe-runtime-ios │ ├── WindowProvider │ ├── HierarchyCollector │ ├── NodeRegistry - │ └── AttributeCollectorRegistry + │ ├── AttributeCollectorRegistry + │ └── NodeRelationProviderRegistry ├── AttributeCollectors │ ├── UIViewCollector │ ├── CALayerCollector @@ -96,6 +97,7 @@ astrolabe-runtime-ios │ ├── TextInputCollector │ ├── UIScrollViewCollector │ ├── UIStackViewCollector + │ ├── CAGradientLayerCollector │ ├── AutoLayoutCollector │ └── AccessibilityCollector └── ObjectiveCAdapters @@ -113,6 +115,7 @@ astrolabe-runtime-ios | `HierarchyCollector` | Build the recursive node tree | | `NodeRegistry` | Map session node IDs to weak runtime objects | | `AttributeCollectorRegistry` | Select collectors by runtime object type | +| `NodeRelationProviderRegistry` | Compose providers that emit verified relations between captured nodes | | Attribute collectors | Read one coherent category of typed properties | | `AstrolabeProtocol` | Define and encode the platform-neutral wire contract | @@ -333,8 +336,11 @@ to add unrelated inspection features. Its required scope is: 4. Establish an explicit UIKit-to-Protocol mapper. Platform-neutral semantics use Protocol fields; UIKit class names, Auto Layout facts, and iOS-only attributes remain Runtime-owned extension facts. -5. Keep capability advertisement derived from registered request handlers so - handshake declarations cannot diverge from executable features. +5. Keep request-method capabilities derived from registered request handlers. + Payload-format capabilities such as `uiGraphRelations` are declared by the + inspection-service composition root and unioned into the handshake, so they + remain independent from transport routing without becoming hard-coded in the + server. 6. Split macOS Core tests from iOS Simulator UIKit, facade, and Objective-C integration tests, then run both before the 2.0 release. 7. Update the Host iOS provider against Protocol 2.0 and repeat simulator and From c0e018d8c9339fd15264473648fea168b435f70f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Wed, 5 Aug 2026 15:54:46 +0800 Subject: [PATCH 4/8] feat: add iOS layer mask relations --- .../UIKitHierarchyCollector.swift | 108 ++++++++++++++- .../UIKitRuntimeNodeRelationCollection.swift | 32 ++++- .../UIKitRuntimeProtocolIdentifiers.swift | 4 + .../UIKitHierarchyCollectorTests.swift | 123 ++++++++++++++++++ 4 files changed, 260 insertions(+), 7 deletions(-) diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitHierarchyCollector.swift b/Sources/AstrolabeRuntimeUIKit/UIKitHierarchyCollector.swift index fbb7d73..8da103a 100644 --- a/Sources/AstrolabeRuntimeUIKit/UIKitHierarchyCollector.swift +++ b/Sources/AstrolabeRuntimeUIKit/UIKitHierarchyCollector.swift @@ -12,6 +12,31 @@ import AstrolabeRuntimeObjC import QuartzCore import UIKit +private struct UIKitCapturedLayerContext { + /// Layer captured from the authoritative window layer hierarchy. + let layer: CALayer + + /// Window that owns the authoritative layer hierarchy. + let window: UIWindow + + /// Whether the owner or one of its ancestors hides dependent layers. + let dependencyHiddenByAncestor: Bool + + /// Effective opacity inherited by dependent layers. + let dependencyAncestorAlpha: Double + + /// Visible screen rect inherited by dependent layers. + let dependencyAncestorVisibleRect: CGRect +} + +private enum UIKitLayerCaptureMode { + /// Capture from the authoritative layer hierarchy. + case hierarchy + + /// Capture a detached mask tree using its owner as the coordinate anchor. + case detachedMask(owner: CALayer) +} + @MainActor final class UIKitHierarchyCollector { private let nodeRegistry: RuntimeNodeRegistry @@ -73,6 +98,7 @@ final class UIKitHierarchyCollector { ) } let layerRootOffset = roots.count + var capturedLayerContexts = [UIKitCapturedLayerContext]() roots.append(contentsOf: try windows.enumerated().map { index, window in try layerNode( for: window.layer, @@ -82,11 +108,19 @@ final class UIKitHierarchyCollector { screen: screen, ancestorHidden: false, ancestorAlpha: 1, - ancestorVisibleRect: screen.coordinateSpace.bounds + ancestorVisibleRect: screen.coordinateSpace.bounds, + captureMode: .hierarchy, + capturedLayerContexts: &capturedLayerContexts ) }) + try appendDetachedMaskRoots( + to: &roots, + capturedLayerContexts: capturedLayerContexts, + screen: screen + ) let relationContext = UIKitRuntimeNodeRelationCollectionContext( windows: windows, + layers: capturedLayerContexts.map(\.layer), nodeIDsByObjectIdentity: capturedNodeIDs(in: roots) ) @@ -225,7 +259,9 @@ final class UIKitHierarchyCollector { screen: UIScreen, ancestorHidden: Bool, ancestorAlpha: Double, - ancestorVisibleRect: CGRect + ancestorVisibleRect: CGRect, + captureMode: UIKitLayerCaptureMode, + capturedLayerContexts: inout [UIKitCapturedLayerContext] ) throws -> RuntimeNode { let nodeID = nodeRegistry.nodeID(for: layer) let hiddenByAncestor = ancestorHidden @@ -234,7 +270,8 @@ final class UIKitHierarchyCollector { layer, isRoot: parentID == nil, window: window, - screen: screen + screen: screen, + captureMode: captureMode ) let frameInScreen = cgRect(geometry.frameInScreen) let visibleRect = ancestorVisibleRect.intersection(frameInScreen) @@ -251,6 +288,15 @@ final class UIKitHierarchyCollector { let childVisibleRect = layer.masksToBounds ? visibleRect : ancestorVisibleRect + if case .hierarchy = captureMode { + capturedLayerContexts.append(UIKitCapturedLayerContext( + layer: layer, + window: window, + dependencyHiddenByAncestor: hiddenByAncestor || layer.isHidden, + dependencyAncestorAlpha: effectiveAlpha, + dependencyAncestorVisibleRect: visibleRect + )) + } let children = try (layer.sublayers ?? []).enumerated().map { index, sublayer in try layerNode( @@ -261,7 +307,9 @@ final class UIKitHierarchyCollector { screen: screen, ancestorHidden: hiddenByAncestor || layer.isHidden, ancestorAlpha: effectiveAlpha, - ancestorVisibleRect: childVisibleRect + ancestorVisibleRect: childVisibleRect, + captureMode: captureMode, + capturedLayerContexts: &capturedLayerContexts ) } @@ -322,9 +370,17 @@ final class UIKitHierarchyCollector { _ layer: CALayer, isRoot: Bool, window: UIWindow, - screen: UIScreen + screen: UIScreen, + captureMode: UIKitLayerCaptureMode ) -> RuntimeNodeGeometry { - let rectInWindow = layer.convert(layer.bounds, to: window.layer) + let rectInWindow: CGRect + switch captureMode { + case .hierarchy: + rectInWindow = layer.convert(layer.bounds, to: window.layer) + case let .detachedMask(owner): + let rectInOwner = layer.convert(layer.bounds, to: owner) + rectInWindow = owner.convert(rectInOwner, to: window.layer) + } let rectInScreen = window.convert( rectInWindow, to: screen.coordinateSpace @@ -339,6 +395,46 @@ final class UIKitHierarchyCollector { ) } + private func appendDetachedMaskRoots( + to roots: inout [RuntimeNode], + capturedLayerContexts: [UIKitCapturedLayerContext], + screen: UIScreen + ) throws { + var capturedLayerIdentities = Set( + capturedLayerContexts.map { ObjectIdentifier($0.layer) } + ) + var ignoredLayerContexts = [UIKitCapturedLayerContext]() + for context in capturedLayerContexts { + guard let mask = context.layer.mask, + capturedLayerIdentities.insert(ObjectIdentifier(mask)).inserted + else { + continue + } + capturedLayerIdentities.formUnion(layerIdentities(in: mask)) + roots.append(try layerNode( + for: mask, + parentID: nil, + siblingIndex: roots.count, + window: context.window, + screen: screen, + ancestorHidden: context.dependencyHiddenByAncestor, + ancestorAlpha: context.dependencyAncestorAlpha, + ancestorVisibleRect: context.dependencyAncestorVisibleRect, + captureMode: .detachedMask(owner: context.layer), + capturedLayerContexts: &ignoredLayerContexts + )) + } + } + + private func layerIdentities( + in root: CALayer + ) -> Set { + (root.sublayers ?? []).reduce(into: [ObjectIdentifier(root)]) { + identities, layer in + identities.formUnion(layerIdentities(in: layer)) + } + } + private func runtimeType(for object: AnyObject) -> RuntimeType { let classChain = RuntimeMetadataAdapter.classChain(for: object) return RuntimeType( diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeNodeRelationCollection.swift b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeNodeRelationCollection.swift index c374287..2b1a171 100644 --- a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeNodeRelationCollection.swift +++ b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeNodeRelationCollection.swift @@ -20,16 +20,21 @@ struct UIKitRuntimeNodeRelationCollectionContext { /// Windows in hierarchy-capture order. let windows: [UIWindow] + /// Layers in authoritative hierarchy-capture order. + let layers: [CALayer] + /// Captured node identifiers keyed by runtime object identity. private let nodeIDsByObjectIdentity: [ObjectIdentifier: RuntimeOpaqueIdentifier] init( windows: [UIWindow], + layers: [CALayer], nodeIDsByObjectIdentity: [ObjectIdentifier: RuntimeOpaqueIdentifier] ) { self.windows = windows + self.layers = layers self.nodeIDsByObjectIdentity = nodeIDsByObjectIdentity } @@ -44,7 +49,8 @@ struct UIKitRuntimeNodeRelationProviderRegistry { init( providers: [any UIKitRuntimeNodeRelationProviding] = [ - UIKitViewBackingLayerRelationProvider() + UIKitViewBackingLayerRelationProvider(), + UIKitLayerMaskRelationProvider() ] ) { self.providers = providers @@ -60,6 +66,30 @@ struct UIKitRuntimeNodeRelationProviderRegistry { } } +private struct UIKitLayerMaskRelationProvider: + UIKitRuntimeNodeRelationProviding +{ + @MainActor + func relations( + in context: UIKitRuntimeNodeRelationCollectionContext + ) throws -> [RuntimeNodeRelation] { + try context.layers.compactMap { layer in + guard let mask = layer.mask, + let layerNodeID = context.nodeID(for: layer), + let maskNodeID = context.nodeID(for: mask) + else { + return nil + } + return RuntimeNodeRelation( + type: .iosLayerMaskRelation, + sourceNodeID: layerNodeID, + targetNodeID: maskNodeID, + extensions: try RuntimeExtensionMap() + ) + } + } +} + private struct UIKitViewBackingLayerRelationProvider: UIKitRuntimeNodeRelationProviding { diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift index 503984b..c92ab70 100644 --- a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift +++ b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift @@ -9,6 +9,10 @@ import AstrolabeProtocol extension RuntimeNamespacedIdentifier { + static let iosLayerMaskRelation = iosNamespacedIdentifier( + "layer.mask" + ) + static let iosViewBackingLayerRelation = iosNamespacedIdentifier( "view.backingLayer" ) diff --git a/Tests/AstrolabeRuntimeUIKitTests/UIKitHierarchyCollectorTests.swift b/Tests/AstrolabeRuntimeUIKitTests/UIKitHierarchyCollectorTests.swift index acc811b..21d9366 100644 --- a/Tests/AstrolabeRuntimeUIKitTests/UIKitHierarchyCollectorTests.swift +++ b/Tests/AstrolabeRuntimeUIKitTests/UIKitHierarchyCollectorTests.swift @@ -16,6 +16,129 @@ import XCTest @MainActor final class UIKitHierarchyCollectorTests: XCTestCase { + func testCollectorCapturesDetachedMaskAndEmitsLayerMaskRelation() throws { + let registry = RuntimeNodeRegistry() + let collector = makeCollector(nodeRegistry: registry) + let window = UIWindow( + frame: CGRect(x: 0, y: 0, width: 320, height: 640) + ) + let maskedView = UIView( + frame: CGRect(x: 20, y: 40, width: 120, height: 80) + ) + let mask = CAShapeLayer() + mask.frame = maskedView.bounds + maskedView.layer.mask = mask + window.addSubview(maskedView) + + let snapshot = try collector.capture( + windows: [window], + screen: window.screen, + interfaceOrientation: "portrait", + capturedAtUnixTime: 100 + ) + let maskNodeID = registry.nodeID(for: mask) + let maskNode = try XCTUnwrap( + snapshot.roots.first { $0.nodeID == maskNodeID } + ) + let ownerNode = try XCTUnwrap( + flattenedNodes(snapshot.roots[1]).first { + $0.nodeID == registry.nodeID(for: maskedView.layer) + } + ) + let relations = try XCTUnwrap(snapshot.relations) + + XCTAssertEqual(maskNode.runtimeType.name, "CAShapeLayer") + XCTAssertNil(maskNode.parentID) + XCTAssertFalse(ownerNode.children.contains { $0.nodeID == maskNodeID }) + XCTAssertTrue(relations.contains { + $0.type.rawValue == "ios.layer.mask" && + $0.sourceNodeID == ownerNode.nodeID && + $0.targetNodeID == maskNodeID + }) + } + + func testCollectorCapturesDetachedMaskLayerSubtreeWithOwnerGeometry() throws { + let registry = RuntimeNodeRegistry() + let collector = makeCollector(nodeRegistry: registry) + let window = UIWindow( + frame: CGRect(x: 0, y: 0, width: 320, height: 640) + ) + let maskedView = UIView( + frame: CGRect(x: 20, y: 40, width: 120, height: 80) + ) + let mask = CALayer() + mask.frame = CGRect(x: 10, y: 12, width: 60, height: 40) + let maskSublayer = CALayer() + maskSublayer.frame = CGRect(x: 5, y: 7, width: 20, height: 10) + mask.addSublayer(maskSublayer) + maskedView.layer.mask = mask + window.addSubview(maskedView) + + let snapshot = try collector.capture( + windows: [window], + screen: window.screen, + interfaceOrientation: "portrait", + capturedAtUnixTime: 100 + ) + let maskNode = try XCTUnwrap( + snapshot.roots.first { + $0.nodeID == registry.nodeID(for: mask) + } + ) + let maskSublayerNode = try XCTUnwrap(maskNode.children.first) + + XCTAssertEqual(maskNode.runtimeType.name, "CALayer") + XCTAssertEqual(maskNode.geometry.frameInScreen.x, 30, accuracy: 0.001) + XCTAssertEqual(maskNode.geometry.frameInScreen.y, 52, accuracy: 0.001) + XCTAssertEqual(maskSublayerNode.parentID, maskNode.nodeID) + XCTAssertEqual( + maskSublayerNode.nodeID, + registry.nodeID(for: maskSublayer) + ) + XCTAssertEqual( + maskSublayerNode.geometry.frameInScreen.x, + 35, + accuracy: 0.001 + ) + XCTAssertEqual( + maskSublayerNode.geometry.frameInScreen.y, + 59, + accuracy: 0.001 + ) + } + + func testCollectorCapturesDetachedMaskWithoutRelationProviders() throws { + let registry = RuntimeNodeRegistry() + let collector = makeCollector( + nodeRegistry: registry, + relationProviderRegistry: UIKitRuntimeNodeRelationProviderRegistry( + providers: [] + ) + ) + let window = UIWindow( + frame: CGRect(x: 0, y: 0, width: 320, height: 640) + ) + let maskedView = UIView( + frame: CGRect(x: 20, y: 40, width: 120, height: 80) + ) + let mask = CALayer() + mask.frame = maskedView.bounds + maskedView.layer.mask = mask + window.addSubview(maskedView) + + let snapshot = try collector.capture( + windows: [window], + screen: window.screen, + interfaceOrientation: "portrait", + capturedAtUnixTime: 100 + ) + + XCTAssertTrue(snapshot.roots.contains { + $0.nodeID == registry.nodeID(for: mask) + }) + XCTAssertEqual(snapshot.relations, []) + } + func testCollectorEmitsViewBackingLayerRelationsForCapturedNodes() throws { let registry = RuntimeNodeRegistry() let collector = makeCollector(nodeRegistry: registry) From e2b771d5026c0c0ac708345c0a9a610e427039b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Wed, 5 Aug 2026 17:20:56 +0800 Subject: [PATCH 5/8] feat: add iOS shape layer semantics --- .../UIKitRuntimeAttributeCollection.swift | 1 + .../UIKitRuntimeProtocolIdentifiers.swift | 3 + ...tRuntimeShapeLayerAttributeCollector.swift | 164 ++++++++++++++++ .../UIKitRuntimeNodeDetailProviderTests.swift | 181 ++++++++++++++++++ 4 files changed, 349 insertions(+) create mode 100644 Sources/AstrolabeRuntimeUIKit/UIKitRuntimeShapeLayerAttributeCollector.swift diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAttributeCollection.swift b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAttributeCollection.swift index d9d13f5..80ec179 100644 --- a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAttributeCollection.swift +++ b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAttributeCollection.swift @@ -88,6 +88,7 @@ final class UIKitRuntimeAttributeCollectorRegistry { UIKitViewAttributeCollector(), UIKitLayerAttributeCollector(), UIKitGradientLayerAttributeCollector(), + UIKitShapeLayerAttributeCollector(), UIKitAccessibilityAttributeCollector(), UIKitLabelAttributeCollector(), UIKitImageViewAttributeCollector(), diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift index c92ab70..65d8439 100644 --- a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift +++ b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift @@ -23,6 +23,7 @@ extension RuntimeAttributeCategory { static let view = iosCategory("view") static let layer = iosCategory("layer") static let gradientLayer = iosCategory("gradientLayer") + static let shapeLayer = iosCategory("shapeLayer") static let accessibility = iosCategory("accessibility") static let control = iosCategory("control") static let label = iosCategory("label") @@ -71,6 +72,8 @@ extension RuntimeAttributeIdentifier { static let gradientLayerStartPoint = iosAttribute("gradientLayer.startPoint") static let gradientLayerEndPoint = iosAttribute("gradientLayer.endPoint") static let gradientLayerType = iosAttribute("gradientLayer.type") + static let shapeLayerPath = iosAttribute("shapeLayer.path") + static let shapeLayerFillRule = iosAttribute("shapeLayer.fillRule") static let accessibilityElement = iosAttribute("accessibility.element") static let accessibilityIdentifier = iosAttribute("accessibility.identifier") static let accessibilityLabel = iosAttribute("accessibility.label") diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeShapeLayerAttributeCollector.swift b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeShapeLayerAttributeCollector.swift new file mode 100644 index 0000000..3fdb82c --- /dev/null +++ b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeShapeLayerAttributeCollector.swift @@ -0,0 +1,164 @@ +// +// UIKitRuntimeShapeLayerAttributeCollector.swift +// astrolabe-runtime-ios +// +// Created by 轩辕十四 on 2026/8/5. +// + +#if canImport(UIKit) +import AstrolabeProtocol +import CoreGraphics +import QuartzCore + +struct UIKitShapeLayerAttributeCollector: UIKitRuntimeAttributeCollecting { + let category = RuntimeAttributeCategory.shapeLayer + private let pathProjector = UIKitShapePathProjector() + + func supports(_ object: AnyObject) -> Bool { + object is CAShapeLayer + } + + func attributes( + for object: AnyObject, + context: UIKitRuntimeAttributeCollectionContext + ) -> [RuntimeAttribute] { + guard let layer = object as? CAShapeLayer else { + return [] + } + + var attributes = [ + attribute(.shapeLayerFillRule, .string(layer.fillRule.rawValue)) + ] + if let path = layer.path, + let pathValue = pathProjector.value(for: path) { + attributes.insert( + attribute(.shapeLayerPath, pathValue), + at: 0 + ) + } + return attributes + } +} + +struct UIKitShapePathProjector { + private let maximumElementCount: Int + + init(maximumElementCount: Int = 256) { + precondition(maximumElementCount > 0) + self.maximumElementCount = maximumElementCount + } + + func value(for path: CGPath) -> RuntimeAttributeValue? { + var elementCount = 0 + var elements = [RuntimeJSONValue]() + var valid = true + path.applyWithBlock { pointer in + elementCount += 1 + guard let value = elementValueIfFinite(pointer.pointee) else { + valid = false + return + } + if elements.count < maximumElementCount { + elements.append(value) + } + } + guard valid else { + return nil + } + var result: [String: RuntimeJSONValue] = [ + "coordinateSpace": .string("local"), + "elementCount": .integer(Int64(elementCount)), + "returnedElementCount": .integer(Int64(elements.count)), + "truncated": .boolean(elements.count < elementCount), + "elements": .array(elements) + ] + let bounds = path.boundingBoxOfPath + if !path.isEmpty { + guard !bounds.isNull, finite(bounds) else { + return nil + } + result["boundingBox"] = rectValue(bounds) + } + return .object(result) + } + + func pointValueIfFinite(_ point: CGPoint) -> RuntimeJSONValue? { + guard point.x.isFinite, point.y.isFinite else { + return nil + } + return pointValue(point) + } + + private func elementValueIfFinite( + _ element: CGPathElement + ) -> RuntimeJSONValue? { + switch element.type { + case .moveToPoint: + guard let point = pointValueIfFinite(element.points[0]) else { + return nil + } + return .object([ + "type": .string("moveTo"), + "point": point + ]) + case .addLineToPoint: + guard let point = pointValueIfFinite(element.points[0]) else { + return nil + } + return .object([ + "type": .string("lineTo"), + "point": point + ]) + case .addQuadCurveToPoint: + guard let controlPoint = pointValueIfFinite(element.points[0]), + let point = pointValueIfFinite(element.points[1]) else { + return nil + } + return .object([ + "type": .string("quadCurveTo"), + "controlPoint": controlPoint, + "point": point + ]) + case .addCurveToPoint: + guard let controlPoint1 = pointValueIfFinite(element.points[0]), + let controlPoint2 = pointValueIfFinite(element.points[1]), + let point = pointValueIfFinite(element.points[2]) else { + return nil + } + return .object([ + "type": .string("curveTo"), + "controlPoint1": controlPoint1, + "controlPoint2": controlPoint2, + "point": point + ]) + case .closeSubpath: + return .object(["type": .string("closeSubpath")]) + @unknown default: + preconditionFailure("Unsupported CGPath element type") + } + } + + private func finite(_ rect: CGRect) -> Bool { + rect.origin.x.isFinite && + rect.origin.y.isFinite && + rect.size.width.isFinite && + rect.size.height.isFinite + } + + private func pointValue(_ point: CGPoint) -> RuntimeJSONValue { + .object([ + "x": .number(point.x), + "y": .number(point.y) + ]) + } + + private func rectValue(_ rect: CGRect) -> RuntimeJSONValue { + .object([ + "x": .number(rect.origin.x), + "y": .number(rect.origin.y), + "width": .number(rect.size.width), + "height": .number(rect.size.height) + ]) + } +} +#endif diff --git a/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeNodeDetailProviderTests.swift b/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeNodeDetailProviderTests.swift index cdc6559..6020a46 100644 --- a/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeNodeDetailProviderTests.swift +++ b/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeNodeDetailProviderTests.swift @@ -269,6 +269,171 @@ final class UIKitRuntimeNodeDetailPayloadProviderTests: XCTestCase { ) } + func testProviderCollectsShapeLayerPathSemantics() async throws { + let registry = RuntimeNodeRegistry() + let provider = UIKitRuntimeNodeDetailProvider(nodeRegistry: registry) + let layer = CAShapeLayer() + let path = CGMutablePath() + path.move(to: CGPoint(x: 1, y: 2)) + path.addLine(to: CGPoint(x: 11, y: 2)) + path.addQuadCurve( + to: CGPoint(x: 21, y: 12), + control: CGPoint(x: 16, y: 2) + ) + path.addCurve( + to: CGPoint(x: 1, y: 22), + control1: CGPoint(x: 21, y: 17), + control2: CGPoint(x: 11, y: 22) + ) + path.closeSubpath() + layer.path = path + layer.fillRule = .evenOdd + + let detail = try await provider.nodeDetail( + for: registry.nodeID(for: layer) + ) + + XCTAssertEqual( + detail.sections.map(\.category), + [.layout, .layer, .shapeLayer] + ) + XCTAssertEqual( + value(.shapeLayerFillRule, in: detail), + .string("even-odd") + ) + let pathValue = try XCTUnwrap( + value(.shapeLayerPath, in: detail)?.objectValue + ) + XCTAssertEqual(pathValue["coordinateSpace"], .string("local")) + XCTAssertEqual(pathValue["elementCount"], .integer(5)) + XCTAssertEqual(pathValue["returnedElementCount"], .integer(5)) + XCTAssertEqual(pathValue["truncated"], .boolean(false)) + XCTAssertEqual( + pathValue["boundingBox"], + .object([ + "x": .number(1), + "y": .number(2), + "width": .number(20), + "height": .number(20) + ]) + ) + let elements = try XCTUnwrap(pathValue["elements"]?.arrayValue) + XCTAssertEqual(elements.count, 5) + XCTAssertEqual( + elements.compactMap(\.objectValue).compactMap { $0["type"] }, + [ + .string("moveTo"), + .string("lineTo"), + .string("quadCurveTo"), + .string("curveTo"), + .string("closeSubpath") + ] + ) + XCTAssertEqual( + elements[2].objectValue?["controlPoint"]?.objectValue?["x"], + .number(16) + ) + XCTAssertEqual( + elements[3].objectValue?["controlPoint2"]?.objectValue?["y"], + .number(22) + ) + } + + func testProviderOmitsNilShapePathButKeepsFillRule() async throws { + let registry = RuntimeNodeRegistry() + let provider = UIKitRuntimeNodeDetailProvider(nodeRegistry: registry) + let layer = CAShapeLayer() + + let detail = try await provider.nodeDetail( + for: registry.nodeID(for: layer) + ) + + XCTAssertNil(value(.shapeLayerPath, in: detail)) + XCTAssertEqual( + value(.shapeLayerFillRule, in: detail), + .string("non-zero") + ) + } + + func testProviderReturnsEmptyShapePathWithoutBoundingBox() async throws { + let registry = RuntimeNodeRegistry() + let provider = UIKitRuntimeNodeDetailProvider(nodeRegistry: registry) + let layer = CAShapeLayer() + layer.path = CGMutablePath() + + let detail = try await provider.nodeDetail( + for: registry.nodeID(for: layer) + ) + + let pathValue = try XCTUnwrap( + value(.shapeLayerPath, in: detail)?.objectValue + ) + XCTAssertEqual(pathValue["elementCount"], .integer(0)) + XCTAssertEqual(pathValue["returnedElementCount"], .integer(0)) + XCTAssertEqual(pathValue["truncated"], .boolean(false)) + XCTAssertEqual(pathValue["elements"], .array([])) + XCTAssertNil(pathValue["boundingBox"]) + } + + func testProviderKeepsDegenerateShapePathBoundingBox() async throws { + let registry = RuntimeNodeRegistry() + let provider = UIKitRuntimeNodeDetailProvider(nodeRegistry: registry) + let layer = CAShapeLayer() + let path = CGMutablePath() + path.move(to: CGPoint(x: 1, y: 2)) + path.addLine(to: CGPoint(x: 11, y: 2)) + layer.path = path + + let detail = try await provider.nodeDetail( + for: registry.nodeID(for: layer) + ) + + let pathValue = try XCTUnwrap( + value(.shapeLayerPath, in: detail)?.objectValue + ) + XCTAssertEqual( + pathValue["boundingBox"], + .object([ + "x": .number(1), + "y": .number(2), + "width": .number(10), + "height": .number(0) + ]) + ) + } + + func testShapePathProjectorReportsTruncation() { + let path = CGMutablePath() + path.move(to: .zero) + for index in 1 ... 300 { + path.addLine(to: CGPoint(x: CGFloat(index), y: 0)) + } + + guard case let .object(value)? = UIKitShapePathProjector().value( + for: path + ) else { + return XCTFail("Expected a bounded Shape Layer path.") + } + XCTAssertEqual(value["elementCount"], .integer(301)) + XCTAssertEqual(value["returnedElementCount"], .integer(256)) + XCTAssertEqual(value["truncated"], .boolean(true)) + } + + func testShapePathProjectorRejectsNonFinitePoint() { + let projector = UIKitShapePathProjector() + + XCTAssertNil( + projector.pointValueIfFinite( + CGPoint(x: CGFloat.nan, y: 0) + ) + ) + XCTAssertNil( + projector.pointValueIfFinite( + CGPoint(x: 0, y: CGFloat.infinity) + ) + ) + } + func testProviderDoesNotInferGradientLocations() async throws { let registry = RuntimeNodeRegistry() let provider = UIKitRuntimeNodeDetailProvider(nodeRegistry: registry) @@ -413,7 +578,23 @@ final class UIKitRuntimeNodeDetailPayloadProviderTests: XCTestCase { } } +private extension RuntimeAttributeValue { + var objectValue: [String: RuntimeJSONValue]? { + guard case let .object(value) = self else { + return nil + } + return value + } +} + private extension RuntimeJSONValue { + var arrayValue: [RuntimeJSONValue]? { + guard case let .array(value) = self else { + return nil + } + return value + } + var objectValue: [String: RuntimeJSONValue]? { guard case let .object(value) = self else { return nil From 393f58cba001d28625966993c5b0abbd18f66726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Wed, 5 Aug 2026 18:37:42 +0800 Subject: [PATCH 6/8] feat: add iOS Auto Layout relations --- .../UIKitRuntimeAttributeCollection.swift | 1 + ...tRuntimeAutoLayoutAttributeCollector.swift | 255 ++++++++++++------ .../UIKitRuntimeProtocolIdentifiers.swift | 18 ++ .../UIKitRuntimeNodeDetailProviderTests.swift | 145 +++++++++- 4 files changed, 337 insertions(+), 82 deletions(-) diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAttributeCollection.swift b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAttributeCollection.swift index 80ec179..2b77f69 100644 --- a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAttributeCollection.swift +++ b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAttributeCollection.swift @@ -85,6 +85,7 @@ final class UIKitRuntimeAttributeCollectorRegistry { private static var defaultCollectors: [any UIKitRuntimeAttributeCollecting] { [ UIKitLayoutAttributeCollector(), + UIKitNormalizedLayoutRelationAttributeCollector(), UIKitViewAttributeCollector(), UIKitLayerAttributeCollector(), UIKitGradientLayerAttributeCollector(), diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAutoLayoutAttributeCollector.swift b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAutoLayoutAttributeCollector.swift index fbac9a3..9b50504 100644 --- a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAutoLayoutAttributeCollector.swift +++ b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeAutoLayoutAttributeCollector.swift @@ -11,9 +11,179 @@ import AstrolabeRuntimeCore import AstrolabeRuntimeObjC import UIKit +private struct UIKitAutoLayoutConstraintSource { + func constraints(affecting view: UIView) -> [NSLayoutConstraint] { + var result = [NSLayoutConstraint]() + var seen = Set() + + func append(_ constraints: [NSLayoutConstraint]) { + for constraint in constraints where references(constraint, view: view) { + let identifier = ObjectIdentifier(constraint) + guard seen.insert(identifier).inserted else { + continue + } + result.append(constraint) + } + } + + var owner: UIView? = view + while let current = owner { + append(current.constraints) + owner = current.superview + } + append(view.constraintsAffectingLayout(for: .horizontal)) + append(view.constraintsAffectingLayout(for: .vertical)) + return result + } + + private func references( + _ constraint: NSLayoutConstraint, + view: UIView + ) -> Bool { + (constraint.firstItem as AnyObject?) === view || + (constraint.secondItem as AnyObject?) === view + } +} + +private struct UIKitAutoLayoutConstraintMapper { + func attributeName(_ attribute: NSLayoutConstraint.Attribute) -> String { + switch attribute { + case .notAnAttribute: return "notAnAttribute" + case .left: return "left" + case .right: return "right" + case .top: return "top" + case .bottom: return "bottom" + case .leading: return "leading" + case .trailing: return "trailing" + case .width: return "width" + case .height: return "height" + case .centerX: return "centerX" + case .centerY: return "centerY" + case .firstBaseline: return "firstBaseline" + case .lastBaseline: return "lastBaseline" + case .leftMargin: return "leftMargin" + case .rightMargin: return "rightMargin" + case .topMargin: return "topMargin" + case .bottomMargin: return "bottomMargin" + case .leadingMargin: return "leadingMargin" + case .trailingMargin: return "trailingMargin" + case .centerXWithinMargins: return "centerXWithinMargins" + case .centerYWithinMargins: return "centerYWithinMargins" + @unknown default: + return "unknown:\(attribute.rawValue)" + } + } + + func relationName(_ relation: NSLayoutConstraint.Relation) -> String { + switch relation { + case .lessThanOrEqual: return "lessThanOrEqual" + case .equal: return "equal" + case .greaterThanOrEqual: return "greaterThanOrEqual" + @unknown default: + return "unknown:\(relation.rawValue)" + } + } + + func relationKind( + _ relation: NSLayoutConstraint.Relation + ) -> RuntimeLayoutRelationKind? { + switch relation { + case .lessThanOrEqual: return .lessThanOrEqual + case .equal: return .equal + case .greaterThanOrEqual: return .greaterThanOrEqual + @unknown default: return nil + } + } +} + +@MainActor +private struct UIKitNormalizedLayoutRelationProjector { + private let mapper = UIKitAutoLayoutConstraintMapper() + + func relation( + from constraint: NSLayoutConstraint, + nodeRegistry: RuntimeNodeRegistry + ) -> RuntimeLayoutRelation? { + guard let sourceView = constraint.firstItem as? UIView, + constraint.multiplier.isFinite, + constraint.constant.isFinite, + constraint.priority.rawValue.isFinite, + let relation = mapper.relationKind(constraint.relation), + let extensions = try? RuntimeExtensionMap(values: [ + "ios.uikit.priority": .number(Double(constraint.priority.rawValue)) + ]) else { + return nil + } + let target: RuntimeLayoutAnchor? + if let secondItem = constraint.secondItem { + guard let targetView = secondItem as? UIView else { + return nil + } + target = RuntimeLayoutAnchor( + nodeID: nodeRegistry.nodeID(for: targetView), + anchor: mapper.attributeName(constraint.secondAttribute) + ) + } else { + target = nil + } + let strength = min(max(Double(constraint.priority.rawValue) / 1_000, 0), 1) + return RuntimeLayoutRelation( + identifier: nonempty(constraint.identifier), + source: RuntimeLayoutAnchor( + nodeID: nodeRegistry.nodeID(for: sourceView), + anchor: mapper.attributeName(constraint.firstAttribute) + ), + relation: relation, + target: target, + multiplier: Double(constraint.multiplier), + offset: RuntimeMeasurement( + value: Double(constraint.constant), + unit: .logical + ), + strength: strength, + active: constraint.isActive, + extensions: extensions + ) + } + + private func nonempty(_ value: String?) -> String? { + guard let value, !value.isEmpty else { + return nil + } + return value + } +} + +struct UIKitNormalizedLayoutRelationAttributeCollector: UIKitRuntimeAttributeCollecting { + let category = RuntimeAttributeCategory.commonLayout + + private let constraintSource = UIKitAutoLayoutConstraintSource() + private let projector = UIKitNormalizedLayoutRelationProjector() + + func supports(_ object: AnyObject) -> Bool { + object is UIView + } + + func attributes( + for object: AnyObject, + context: UIKitRuntimeAttributeCollectionContext + ) -> [RuntimeAttribute] { + guard let view = object as? UIView else { + return [] + } + let relations = constraintSource.constraints(affecting: view).compactMap { + projector.relation(from: $0, nodeRegistry: context.nodeRegistry) + } + return [attribute(.commonLayoutRelations, .layoutRelations(relations))] + } +} + struct UIKitAutoLayoutAttributeCollector: UIKitRuntimeAttributeCollecting { let category = RuntimeAttributeCategory.autoLayout + private let constraintSource = UIKitAutoLayoutConstraintSource() + private let mapper = UIKitAutoLayoutConstraintMapper() + func supports(_ object: AnyObject) -> Bool { object is UIView } @@ -25,7 +195,7 @@ struct UIKitAutoLayoutAttributeCollector: UIKitRuntimeAttributeCollecting { guard let view = object as? UIView else { return [] } - let constraints = affectingConstraints(for: view).compactMap { + let constraints = constraintSource.constraints(affecting: view).compactMap { runtimeConstraint($0, nodeRegistry: context.nodeRegistry) } return [ @@ -77,41 +247,6 @@ struct UIKitAutoLayoutAttributeCollector: UIKitRuntimeAttributeCollecting { ] } - private func affectingConstraints(for view: UIView) -> [NSLayoutConstraint] { - var result = [NSLayoutConstraint]() - var seen = Set() - - func append(_ constraints: [NSLayoutConstraint]) { - for constraint in constraints where references( - constraint, - view: view - ) { - let identifier = ObjectIdentifier(constraint) - guard seen.insert(identifier).inserted else { - continue - } - result.append(constraint) - } - } - - var owner: UIView? = view - while let current = owner { - append(current.constraints) - owner = current.superview - } - append(view.constraintsAffectingLayout(for: .horizontal)) - append(view.constraintsAffectingLayout(for: .vertical)) - return result - } - - private func references( - _ constraint: NSLayoutConstraint, - view: UIView - ) -> Bool { - (constraint.firstItem as AnyObject?) === view || - (constraint.secondItem as AnyObject?) === view - } - private func runtimeConstraint( _ constraint: NSLayoutConstraint, nodeRegistry: RuntimeNodeRegistry @@ -130,12 +265,12 @@ struct UIKitAutoLayoutAttributeCollector: UIKitRuntimeAttributeCollecting { "identifier": optionalString(constraint.identifier), "firstItem": firstItem, "firstAttribute": .string( - runtimeAttribute(constraint.firstAttribute) + mapper.attributeName(constraint.firstAttribute) ), - "relation": .string(runtimeRelation(constraint.relation)), + "relation": .string(mapper.relationName(constraint.relation)), "secondItem": secondItem, "secondAttribute": .string( - runtimeAttribute(constraint.secondAttribute) + mapper.attributeName(constraint.secondAttribute) ), "multiplier": .number(constraint.multiplier), "constant": .number(constraint.constant), @@ -180,48 +315,6 @@ struct UIKitAutoLayoutAttributeCollector: UIKitRuntimeAttributeCollecting { ]) } - private func runtimeAttribute( - _ attribute: NSLayoutConstraint.Attribute - ) -> String { - switch attribute { - case .notAnAttribute: return "notAnAttribute" - case .left: return "left" - case .right: return "right" - case .top: return "top" - case .bottom: return "bottom" - case .leading: return "leading" - case .trailing: return "trailing" - case .width: return "width" - case .height: return "height" - case .centerX: return "centerX" - case .centerY: return "centerY" - case .firstBaseline: return "firstBaseline" - case .lastBaseline: return "lastBaseline" - case .leftMargin: return "leftMargin" - case .rightMargin: return "rightMargin" - case .topMargin: return "topMargin" - case .bottomMargin: return "bottomMargin" - case .leadingMargin: return "leadingMargin" - case .trailingMargin: return "trailingMargin" - case .centerXWithinMargins: return "centerXWithinMargins" - case .centerYWithinMargins: return "centerYWithinMargins" - @unknown default: - return "unknown:\(attribute.rawValue)" - } - } - - private func runtimeRelation( - _ relation: NSLayoutConstraint.Relation - ) -> String { - switch relation { - case .lessThanOrEqual: return "lessThanOrEqual" - case .equal: return "equal" - case .greaterThanOrEqual: return "greaterThanOrEqual" - @unknown default: - return "unknown:\(relation.rawValue)" - } - } - private func optionalString(_ value: String?) -> RuntimeJSONValue { guard let value = nonempty(value) else { return .null diff --git a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift index 65d8439..f6ce498 100644 --- a/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift +++ b/Sources/AstrolabeRuntimeUIKit/UIKitRuntimeProtocolIdentifiers.swift @@ -19,6 +19,7 @@ extension RuntimeNamespacedIdentifier { } extension RuntimeAttributeCategory { + static let commonLayout = commonCategory("layout") static let layout = iosCategory("layout") static let view = iosCategory("view") static let layer = iosCategory("layer") @@ -36,6 +37,7 @@ extension RuntimeAttributeCategory { } extension RuntimeAttributeIdentifier { + static let commonLayoutRelations = commonAttribute("layout.relations") static let frameInParent = iosAttribute("layout.frameInParent") static let frameInScreen = iosAttribute("layout.frameInScreen") static let bounds = iosAttribute("layout.bounds") @@ -154,6 +156,14 @@ private func iosCategory(_ path: String) -> RuntimeAttributeCategory { } } +private func commonCategory(_ path: String) -> RuntimeAttributeCategory { + do { + return try RuntimeAttributeCategory(rawValue: "common.\(path)") + } catch { + preconditionFailure("Invalid built-in common attribute category: \(path)") + } +} + private func iosAttribute(_ path: String) -> RuntimeAttributeIdentifier { do { return try RuntimeAttributeIdentifier(rawValue: "ios.\(path)") @@ -162,6 +172,14 @@ private func iosAttribute(_ path: String) -> RuntimeAttributeIdentifier { } } +private func commonAttribute(_ path: String) -> RuntimeAttributeIdentifier { + do { + return try RuntimeAttributeIdentifier(rawValue: "common.\(path)") + } catch { + preconditionFailure("Invalid built-in common attribute identifier: \(path)") + } +} + private func iosNamespacedIdentifier( _ path: String ) -> RuntimeNamespacedIdentifier { diff --git a/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeNodeDetailProviderTests.swift b/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeNodeDetailProviderTests.swift index 6020a46..c71badc 100644 --- a/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeNodeDetailProviderTests.swift +++ b/Tests/AstrolabeRuntimeUIKitTests/UIKitRuntimeNodeDetailProviderTests.swift @@ -43,7 +43,16 @@ final class UIKitRuntimeNodeDetailPayloadProviderTests: XCTestCase { XCTAssertEqual( detail.sections.map(\.category), - [.layout, .view, .layer, .accessibility, .control, .button, .autoLayout] + [ + .layout, + .commonLayout, + .view, + .layer, + .accessibility, + .control, + .button, + .autoLayout + ] ) XCTAssertEqual(value(.buttonTitle, in: detail), .string("Save")) XCTAssertEqual(value(.selected, in: detail), .boolean(true)) @@ -90,6 +99,140 @@ final class UIKitRuntimeNodeDetailPayloadProviderTests: XCTestCase { ) } + func testProviderNormalizesConstantAutoLayoutRelation() async throws { + let registry = RuntimeNodeRegistry() + let provider = UIKitRuntimeNodeDetailProvider(nodeRegistry: registry) + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + let widthConstraint = view.widthAnchor.constraint(equalToConstant: 120) + widthConstraint.identifier = "content.width" + widthConstraint.priority = .defaultHigh + widthConstraint.isActive = true + let nodeID = registry.nodeID(for: view) + + let detail = try await provider.nodeDetail(for: nodeID) + + let identifier = try RuntimeAttributeIdentifier( + rawValue: "common.layout.relations" + ) + guard case let .layoutRelations(relations)? = value( + identifier, + in: detail + ) else { + return XCTFail("Expected normalized layout relations.") + } + XCTAssertEqual( + relations, + [ + RuntimeLayoutRelation( + identifier: "content.width", + source: RuntimeLayoutAnchor( + nodeID: nodeID, + anchor: "width" + ), + relation: .equal, + target: nil, + multiplier: 1, + offset: RuntimeMeasurement(value: 120, unit: .logical), + strength: 0.75, + active: true, + extensions: try RuntimeExtensionMap(values: [ + "ios.uikit.priority": .number(750) + ]) + ) + ] + ) + } + + func testProviderNormalizesAutoLayoutRelationBetweenNodes() async throws { + let registry = RuntimeNodeRegistry() + let provider = UIKitRuntimeNodeDetailProvider(nodeRegistry: registry) + let container = UIView() + let sourceView = UIView() + let targetView = UIView() + sourceView.translatesAutoresizingMaskIntoConstraints = false + targetView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(sourceView) + container.addSubview(targetView) + let widthConstraint = NSLayoutConstraint( + item: sourceView, + attribute: .width, + relatedBy: .lessThanOrEqual, + toItem: targetView, + attribute: .width, + multiplier: 0.5, + constant: 8 + ) + widthConstraint.identifier = "source.width" + widthConstraint.isActive = true + let sourceNodeID = registry.nodeID(for: sourceView) + let targetNodeID = registry.nodeID(for: targetView) + + let detail = try await provider.nodeDetail(for: sourceNodeID) + + guard case let .layoutRelations(relations)? = value( + .commonLayoutRelations, + in: detail + ) else { + return XCTFail("Expected normalized layout relations.") + } + XCTAssertEqual( + relations, + [ + RuntimeLayoutRelation( + identifier: "source.width", + source: RuntimeLayoutAnchor( + nodeID: sourceNodeID, + anchor: "width" + ), + relation: .lessThanOrEqual, + target: RuntimeLayoutAnchor( + nodeID: targetNodeID, + anchor: "width" + ), + multiplier: 0.5, + offset: RuntimeMeasurement(value: 8, unit: .logical), + strength: 1, + active: true, + extensions: try RuntimeExtensionMap(values: [ + "ios.uikit.priority": .number(1_000) + ]) + ) + ] + ) + } + + func testProviderKeepsLayoutGuideConstraintInRawExtension() async throws { + let registry = RuntimeNodeRegistry() + let provider = UIKitRuntimeNodeDetailProvider(nodeRegistry: registry) + let container = UIView() + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(view) + let guideConstraint = view.leadingAnchor.constraint( + equalTo: container.safeAreaLayoutGuide.leadingAnchor + ) + guideConstraint.identifier = "content.safeAreaLeading" + guideConstraint.isActive = true + let nodeID = registry.nodeID(for: view) + + let detail = try await provider.nodeDetail(for: nodeID) + + XCTAssertEqual( + value(.commonLayoutRelations, in: detail), + .layoutRelations([]) + ) + guard case let .array(constraints)? = value(.constraints, in: detail) else { + return XCTFail("Expected raw Auto Layout extension values.") + } + XCTAssertTrue( + constraints.compactMap(\.objectValue).contains { + $0["identifier"] == .string("content.safeAreaLeading") && + $0["secondItem"]?.objectValue?["kind"] == .string("layoutGuide") + } + ) + } + func testProviderRedactsSecureTextAndCollectsSpecializedViews() async throws { let registry = RuntimeNodeRegistry() let provider = UIKitRuntimeNodeDetailProvider(nodeRegistry: registry) From 4dc2a00f310e32389cc34e3f9daf9ad8833284c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Sun, 9 Aug 2026 20:14:10 +0800 Subject: [PATCH 7/8] chore: pin Protocol 2.1.0 --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index d2f34fb..3ae677a 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/regulusleow/astrolabe-protocol.git", - branch: "develop" + exact: "2.1.0" ) ], targets: [ From 42eef16b5ad212b056a1f2496f69a06b54a2175e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=BD=A9=E8=BE=95=E5=8D=81=E5=9B=9B?= Date: Sun, 9 Aug 2026 21:14:33 +0800 Subject: [PATCH 8/8] chore: release 2.2.0 --- README.md | 2 +- README.zh-CN.md | 2 +- Sources/AstrolabeRuntime/AstrolabeRuntime.swift | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a97da32..6b1d34d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ the Astrolabe Host during development. Linking the dynamic framework is the complete integration: the Runtime starts automatically when an eligible application process loads it. -Current package release: `2.1.0`. +Current package release: `2.2.0`. ## Requirements diff --git a/README.zh-CN.md b/README.zh-CN.md index a318231..b89c880 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -6,7 +6,7 @@ Astrolabe Runtime for iOS 在开发期间向 Astrolabe Host 暴露 UIKit 和 Cor 检查数据。链接 dynamic Framework 即完成全部接入;符合条件的 App 进程加载 Framework 后,Runtime 会自动启动。 -当前 Package 版本:`2.1.0`。 +当前 Package 版本:`2.2.0`。 ## 环境要求 diff --git a/Sources/AstrolabeRuntime/AstrolabeRuntime.swift b/Sources/AstrolabeRuntime/AstrolabeRuntime.swift index ddd8e03..5167526 100644 --- a/Sources/AstrolabeRuntime/AstrolabeRuntime.swift +++ b/Sources/AstrolabeRuntime/AstrolabeRuntime.swift @@ -9,7 +9,7 @@ import AstrolabeProtocol package enum AstrolabeRuntimeSDK { /// Release version advertised by the embedded runtime. - package static let runtimeVersion = "2.1.0" + package static let runtimeVersion = "2.2.0" package static var protocolVersion: RuntimeProtocolVersion { .v2 diff --git a/package-lock.json b/package-lock.json index bb70bb7..f996090 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astrolabe-runtime-ios", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astrolabe-runtime-ios", - "version": "2.1.0" + "version": "2.2.0" } } } diff --git a/package.json b/package.json index 6ea9bfe..1bf2ffc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "astrolabe-runtime-ios", - "version": "2.1.0", + "version": "2.2.0", "private": true, "type": "module", "scripts": {