diff --git a/client/src/api/types.ts b/client/src/api/types.ts index 02f2d656..01a79ac3 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -81,7 +81,14 @@ export interface AccessibilityNode { role?: string | null; role_description?: string | null; scroll?: Record | null; - source?: "native-ax" | "in-app-inspector" | "nativescript" | string | null; + source?: + | "native-ax" + | "in-app-inspector" + | "nativescript" + | "react-native" + | "swiftui" + | string + | null; sourceColumn?: number | null; sourceFile?: string | null; sourceLine?: number | null; @@ -102,7 +109,8 @@ export type AccessibilitySource = | "native-ax" | "in-app-inspector" | "nativescript" - | "react-native"; + | "react-native" + | "swiftui"; export type AccessibilitySourcePreference = AccessibilitySource | "auto"; export interface AccessibilityTreeResponse { diff --git a/client/src/app/AppShell.tsx b/client/src/app/AppShell.tsx index 6b7db110..a4540848 100644 --- a/client/src/app/AppShell.tsx +++ b/client/src/app/AppShell.tsx @@ -70,7 +70,7 @@ import { const ACCESSIBILITY_REFRESH_MS = 1500; const REACT_NATIVE_ACCESSIBILITY_REFRESH_MS = 500; const DEFAULT_ACCESSIBILITY_MAX_DEPTH = 10; -const REACT_NATIVE_ACCESSIBILITY_MAX_DEPTH = 60; +const LOGICAL_INSPECTOR_MAX_DEPTH = 80; clearLegacyVolatileUiState(); @@ -420,7 +420,7 @@ export function AppShell() { maxDepth: accessibilityPreferredSource === "native-ax" ? DEFAULT_ACCESSIBILITY_MAX_DEPTH - : REACT_NATIVE_ACCESSIBILITY_MAX_DEPTH, + : LOGICAL_INSPECTOR_MAX_DEPTH, }, ); if (accessibilityRequestIdRef.current !== requestId) { @@ -443,6 +443,12 @@ export function AppShell() { accessibilityPreferredSource !== "nativescript" ) { setAccessibilityPreferredSource("nativescript"); + } else if ( + snapshot.source === "native-ax" && + availableSources.includes("swiftui") && + accessibilityPreferredSource !== "swiftui" + ) { + setAccessibilityPreferredSource("swiftui"); } if ( accessibilityPreferredSource !== "auto" && diff --git a/client/src/app/uiState.test.ts b/client/src/app/uiState.test.ts index 05f53480..37fd7c25 100644 --- a/client/src/app/uiState.test.ts +++ b/client/src/app/uiState.test.ts @@ -14,11 +14,12 @@ describe("uiState", () => { sanitizeAccessibilitySources([ "native-ax", "unknown", + "swiftui", "nativescript", "native-ax", "in-app-inspector", ]), - ).toEqual(["nativescript", "in-app-inspector", "native-ax"]); + ).toEqual(["nativescript", "swiftui", "in-app-inspector", "native-ax"]); }); it("sanitizes persisted viewport state and falls back to defaults", () => { diff --git a/client/src/app/uiState.ts b/client/src/app/uiState.ts index 5e40f383..dab79124 100644 --- a/client/src/app/uiState.ts +++ b/client/src/app/uiState.ts @@ -28,6 +28,7 @@ export const ACCESSIBILITY_SOURCE_STORAGE_KEY = "xcw-hierarchy-source"; const ACCESSIBILITY_SOURCE_ORDER: AccessibilitySource[] = [ "nativescript", "react-native", + "swiftui", "in-app-inspector", "native-ax", ]; @@ -115,6 +116,7 @@ export function isAccessibilitySource( return ( value === "nativescript" || value === "react-native" || + value === "swiftui" || value === "in-app-inspector" || value === "native-ax" ); diff --git a/client/src/features/accessibility/AccessibilityInspector.tsx b/client/src/features/accessibility/AccessibilityInspector.tsx index 54916b0f..d842a57c 100644 --- a/client/src/features/accessibility/AccessibilityInspector.tsx +++ b/client/src/features/accessibility/AccessibilityInspector.tsx @@ -726,6 +726,7 @@ function errorMessage(error: unknown): string { const HIERARCHY_SOURCE_ORDER: AccessibilitySource[] = [ "nativescript", "react-native", + "swiftui", "in-app-inspector", "native-ax", ]; @@ -754,6 +755,9 @@ function sourceLabel(source: AccessibilitySource): string { if (source === "react-native") { return "React Native"; } + if (source === "swiftui") { + return "SwiftUI"; + } return source === "in-app-inspector" ? "UIKit" : "Native AX"; } @@ -815,8 +819,12 @@ function swiftUIDescription(value: Record | null | undefined) { const flags = [ value.isHost === true ? "host" : "", value.isProbe === true ? "probe" : "", + value.isViewTreeNode === true ? "view tree" : "", ].filter(Boolean); - return [tag, tagId, flags.join(", ")].filter(Boolean).join(" / "); + const modifiers = Array.isArray(value.modifiers) + ? value.modifiers.filter((item) => typeof item === "string").join(", ") + : ""; + return [tag, tagId, flags.join(", "), modifiers].filter(Boolean).join(" / "); } function frameText(frame: { diff --git a/client/src/styles/components.css b/client/src/styles/components.css index f44a2afd..0068f9a5 100644 --- a/client/src/styles/components.css +++ b/client/src/styles/components.css @@ -426,6 +426,12 @@ color: color-mix(in srgb, #61dafb 78%, var(--text)); } +.hierarchy-source-pill.source-swiftui { + border-color: color-mix(in srgb, #ff6b9d 50%, var(--border)); + background: color-mix(in srgb, #ff6b9d 14%, transparent); + color: color-mix(in srgb, #ff6b9d 82%, var(--text)); +} + .hierarchy-source-pill.source-native-ax { border-color: color-mix(in srgb, #d7ba7d 55%, var(--border)); background: color-mix(in srgb, #d7ba7d 13%, transparent); diff --git a/docs/api/inspector-protocol.md b/docs/api/inspector-protocol.md index a870060e..bb24f75c 100644 --- a/docs/api/inspector-protocol.md +++ b/docs/api/inspector-protocol.md @@ -248,7 +248,20 @@ Evaluates a small UIKit script against a view. Used by the browser inspector to ## SwiftUI -SwiftUI's value tree is not publicly enumerable at runtime. The agent therefore exposes SwiftUI in two ways: +For SwiftUI apps you control, attach the root publisher to the top of your scene: + +```swift +WindowGroup { + ContentView() + .simDeckPublishSwiftUIViewTree("ContentView", id: "app.root") +} +``` + +The agent reflects the current SwiftUI value/body tree and publishes it as the `swiftui` hierarchy source. `View.getHierarchy` returns that tree by default; pass `"source": "uikit"` to inspect the backing hosting views instead. + +This is a debug aid built on Swift reflection. It can show the declared view/body structure, including custom subviews, containers, labels, modifier names, active conditional branches, and `ForEach` rows whose data and content builder are available through SwiftUI's public API. Private/custom containers may still be opaque when they do not expose a child view value or content builder. + +The agent also exposes SwiftUI in the raw UIKit tree: 1. **Automatic detection.** UIKit bridge or hosting views whose runtime classes contain `SwiftUI` or `UIHosting` are reported with `swiftUI.isHost` or `swiftUI.isProbe` markers. 2. **Source-level tags.** Apps can tag SwiftUI views with `View.simDeckInspectorTag(_:id:metadata:)` from the Swift agent. Tagged views appear as lightweight probe `UIView`s with `swiftUI.isProbe = true`. diff --git a/docs/api/rest.md b/docs/api/rest.md index ab4a9142..08509292 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -307,13 +307,14 @@ Returns the rendered bezel as a PNG. Cache headers are set to `no-cache, no-stor ### `GET /api/simulators/{udid}/accessibility-tree` -Returns the current accessibility tree. The server merges three sources: NativeScript, Swift in-app agent (UIKit), and accessibility tree. Query parameters: +Returns the current accessibility tree. The server merges framework inspectors, the Swift in-app agent, and the native accessibility tree. Query parameters: | `source` | Behaviour | | ---------------------------- | ------------------------------------------------------------------------------------------------------ | | `auto` _(default)_ / unset | Use the most accurate source available, falling back to AX. | | `nativescript` / `ns` | Force the NativeScript logical tree if a NativeScript inspector is connected for the foreground app. | | `react-native` / `rn` | Force the React Native component tree if a React Native inspector is connected for the foreground app. | +| `swiftui` / `swift-ui` | Force the published SwiftUI logical tree if the Swift agent root publisher is installed in the app. | | `uikit` / `in-app-inspector` | Force the raw UIKit hierarchy from the in-app inspector agent (NativeScript or Swift). | | `native-ax` / `ax` | Always use the native accessibility snapshot. | @@ -327,8 +328,8 @@ The response always includes: ```json { "roots": [...], - "source": "nativescript|react-native|in-app-inspector|native-ax", - "availableSources": ["nativescript", "react-native", "in-app-inspector", "native-ax"], + "source": "nativescript|react-native|swiftui|in-app-inspector|native-ax", + "availableSources": ["nativescript", "react-native", "swiftui", "in-app-inspector", "native-ax"], "fallbackReason": "...", "inspector": { ... } } diff --git a/docs/inspector/accessibility.md b/docs/inspector/accessibility.md index 702a48b4..ba04befa 100644 --- a/docs/inspector/accessibility.md +++ b/docs/inspector/accessibility.md @@ -12,11 +12,11 @@ It reports anything the app publishes through the accessibility tree: It does **not** see: -- SwiftUI value-tree internals. +- SwiftUI value-tree internals unless the app links the Swift agent and attaches the SwiftUI root publisher. - NativeScript logical tree nodes. - UIView properties that aren't part of the accessibility surface. -For those, you need to link the [Swift in-app agent](/inspector/swift) or use the [NativeScript runtime inspector](/inspector/nativescript). +For those, you need to link the [Swift in-app agent](/inspector/swift), attach the SwiftUI root publisher, or use the [NativeScript runtime inspector](/inspector/nativescript). ## When AX is the right call diff --git a/docs/inspector/index.md b/docs/inspector/index.md index 611ec6a6..c17a2153 100644 --- a/docs/inspector/index.md +++ b/docs/inspector/index.md @@ -20,6 +20,7 @@ The HTTP API picks the most specific source available, falls back to the next on | `auto` _(default)_ / unset | Use the most accurate source available, falling back to AX. | | `nativescript` / `ns` | Force the NativeScript logical tree if a NativeScript inspector is connected for the foreground app. | | `react-native` / `rn` | Force the React Native component tree if a React Native inspector is connected for the foreground app. | +| `swiftui` / `swift-ui` | Force the published SwiftUI logical tree if the Swift agent root publisher is installed in the app. | | `uikit` / `in-app-inspector` | Force the raw UIKit hierarchy from any in-app inspector (NativeScript or Swift agent). | | `native-ax` / `ax` | Always use the native accessibility snapshot. | @@ -33,10 +34,11 @@ Every accessibility tree response includes: ```json { - "source": "nativescript|react-native|in-app-inspector|native-ax", + "source": "nativescript|react-native|swiftui|in-app-inspector|native-ax", "availableSources": [ "nativescript", "react-native", + "swiftui", "in-app-inspector", "native-ax" ], @@ -49,7 +51,7 @@ Every accessibility tree response includes: ## Choosing the right inspector -- **You own the iOS app and write Swift / Objective-C.** Link the [Swift in-app agent](/inspector/swift). It exposes the most semantic data — UIView properties, SwiftUI probes, custom actions — and lets the browser client edit values in place. +- **You own the iOS app and write Swift / Objective-C.** Link the [Swift in-app agent](/inspector/swift). It exposes the most semantic data — UIView properties, SwiftUI view trees/probes, custom actions — and lets the browser client edit values in place. - **You ship a NativeScript app.** Use the [NativeScript runtime inspector](/inspector/nativescript). It connects outbound to the SimDeck server and publishes both the NativeScript logical tree and the underlying UIKit hierarchy. - **You ship a React Native app.** Use the [React Native runtime inspector](/inspector/react-native). It connects outbound to the SimDeck server and publishes the React component tree with dev-mode source locations. - **You can't link anything into the app.** Stick with [AX snapshot](/inspector/accessibility). It only sees what the iOS accessibility stack exposes, but it works for every app. diff --git a/docs/inspector/swift.md b/docs/inspector/swift.md index 7651e25b..494a3dd2 100644 --- a/docs/inspector/swift.md +++ b/docs/inspector/swift.md @@ -78,9 +78,65 @@ printf '{"id":2,"method":"View.getHierarchy","params":{"maxDepth":4}}\n' | nc 12 For the full envelope shape and method list, see the [Inspector Protocol](/api/inspector-protocol). +## SwiftUI view tree + +For SwiftUI apps you control, attach the root publisher to the top of your scene. The agent reflects the current SwiftUI value/body tree and publishes it as the `swiftui` hierarchy source while keeping the raw UIKit host tree available as `uikit`. + +```swift +WindowGroup { + ContentView() + .simDeckPublishSwiftUIViewTree("ContentView", id: "app.root") +} +``` + +`View.getHierarchy` returns the published SwiftUI tree by default. Pass `"source": "uikit"` to inspect the backing hosting views instead. + +This is a debug aid built on Swift reflection. It can show the declared view/body structure, including custom subviews, containers, labels, modifier names, active conditional branches, and `ForEach` rows whose data and content builder are available through SwiftUI's public API. Private/custom containers may still be opaque when they do not expose a child view value or content builder. + +## Experimental SwiftUI preview runner + +The repo includes a hacky local preview runner that extracts a `#Preview { ... }` block from a Swift file, builds it into a versioned iOS Simulator dylib, and asks a tiny installed host app to `dlopen` it. The host is rebuilt and reinstalled with `--rebuild-host`; cached runs send the new dylib over a localhost TCP reload socket so the simulator does not need a new app install. + +```sh +npm run preview:swiftui -- \ + --udid \ + --file Sources/MyFeature/MyView.swift \ + --preview "Default" \ + --watch +``` + +If `--udid` is omitted, the first booted simulator is used. Extra local source files can be passed with repeated `--extra-swift` flags, and raw compiler flags can be passed with repeated `--swiftc-arg` flags. + +Useful speed flags: + +- `--skip-codesign` skips ad-hoc signing for simulator reload dylibs. This worked in local simulator smoke tests and removed roughly 170-205ms. +- `--split-compile` caches the preview source without its `#Preview` blocks as a testable Swift module. When only the preview body changes, reloads compile a tiny wrapper and link against the cached object. +- `--profile` prints reload-stage timings so changes can be compared without Instruments. + +For a closer Xcode-compatible path, point the runner at the app workspace/project and scheme: + +```sh +npm run preview:swiftui -- \ + --workspace MyApp.xcworkspace \ + --scheme MyApp \ + --configuration Debug \ + --udid \ + --file MyApp/Features/Profile/ProfileView.swift \ + --preview "Default" \ + --watch +``` + +In that mode the runner asks `xcodebuild` for the target build settings, does one warm app build, copies the app bundle's resources/frameworks into the preview host, and links reload dylibs against the target's Xcode-built debug dylib. Reloads then compile the edited preview source plus a tiny wrapper instead of rebuilding and reinstalling the whole app target. For the fastest loop after a warm build, pass `--skip-xcode-build --skip-codesign --split-compile`. + +The host listens on local TCP ports `47440-47455`. The preferred protocol streams the dylib bytes directly into the app's Documents directory and waits for a tiny `OK` acknowledgement. If that fails, the runner falls back to copying into the app container and notifying via TCP path reload, then to `simctl openurl`. + +When `--skip-xcode-build` is used, the runner also reuses a cached Xcode build context under `--build-root` after the first successful settings lookup. Delete that build root or run without `--skip-xcode-build` after changing schemes, destinations, package resolution, or major build settings. + +This is intentionally still not full Xcode Preview compatibility. It is best for simulator-debuggable app targets with Swift modules and a `.debug.dylib` available in DerivedData. Project dependencies and assets are reused from the warm Xcode build, but complex preview setup, generated sources that changed after the warm build, or build systems with unusual output layouts may still need `--extra-swift`, `--swiftc-arg`, or another warm `xcodebuild`. + ## SwiftUI tagging -The agent automatically reports SwiftUI hosting and bridge `UIView`s, but SwiftUI's value tree is not publicly enumerable at runtime. To make specific SwiftUI elements addressable, tag them in source: +The agent also reports SwiftUI hosting and bridge `UIView`s in the UIKit tree. To make specific SwiftUI elements addressable in the raw UIKit hierarchy, tag them in source: ```swift Text("Continue") diff --git a/package.json b/package.json index f5b59c71..3b840ca7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "LICENSE", "README.md", "bin/", + "scripts/experimental/", "scripts/postinstall.mjs", "build/simdeck-bin", "client/dist/", @@ -50,6 +51,7 @@ "test:integration:js-api:verbose": "SIMDECK_INTEGRATION_SHOW_SIMULATOR=1 node scripts/integration/js-api.mjs", "ci": "npm run lint && npm run build && npm run test && npm run package:vscode-extension", "dev": "npm run build:cli && node scripts/dev.mjs", + "preview:swiftui": "node scripts/experimental/swiftui-preview.mjs", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs", diff --git a/packages/inspector-agent/Examples/DebugIntegration.swift b/packages/inspector-agent/Examples/DebugIntegration.swift index 149243c9..f2794e92 100644 --- a/packages/inspector-agent/Examples/DebugIntegration.swift +++ b/packages/inspector-agent/Examples/DebugIntegration.swift @@ -18,6 +18,7 @@ struct TaggedSwiftUIExample: View { .simDeckInspectorTag("pay-button", id: "checkout.pay") } .simDeckInspectorTag("checkout-screen", id: "checkout.screen") + .simDeckPublishSwiftUIViewTree("TaggedSwiftUIExample", id: "checkout.screen") } } #endif diff --git a/packages/inspector-agent/PROTOCOL.md b/packages/inspector-agent/PROTOCOL.md index 45aa99a2..665f0e09 100644 --- a/packages/inspector-agent/PROTOCOL.md +++ b/packages/inspector-agent/PROTOCOL.md @@ -249,7 +249,20 @@ should reject unsafe property names and coerce structured UIKit values such as ## SwiftUI -SwiftUI's value tree is not publicly enumerable at runtime. The agent therefore exposes SwiftUI in two ways: +For SwiftUI apps you control, attach the root publisher to the top of your scene: + +```swift +WindowGroup { + ContentView() + .simDeckPublishSwiftUIViewTree("ContentView", id: "app.root") +} +``` + +The agent reflects the current SwiftUI value/body tree and publishes it as the `swiftui` hierarchy source. `View.getHierarchy` returns that tree by default; pass `"source": "uikit"` to inspect the backing hosting views instead. + +This is a debug aid built on Swift reflection. It can show the declared view/body structure, including custom subviews, containers, labels, modifier names, active conditional branches, and `ForEach` rows whose data and content builder are available through SwiftUI's public API. Private/custom containers may still be opaque when they do not expose a child view value or content builder. + +The agent also exposes SwiftUI in the raw UIKit tree: - Automatic detection of UIKit bridge/hosting views whose runtime classes include `SwiftUI` or `UIHosting`. - Optional source-level tags using `View.simDeckInspectorTag(_:id:metadata:)`. diff --git a/packages/inspector-agent/README.md b/packages/inspector-agent/README.md index 5534200f..252b52f1 100644 --- a/packages/inspector-agent/README.md +++ b/packages/inspector-agent/README.md @@ -66,7 +66,20 @@ See `PROTOCOL.md` for the full method list. ## SwiftUI -The agent automatically reports SwiftUI hosting/bridge UIViews. SwiftUI's value tree is not publicly enumerable, so meaningful SwiftUI nodes should be tagged in source: +For SwiftUI apps you control, attach the root publisher to the top of your scene: + +```swift +WindowGroup { + ContentView() + .simDeckPublishSwiftUIViewTree("ContentView", id: "app.root") +} +``` + +The agent reflects the current SwiftUI value/body tree and publishes it as the `swiftui` hierarchy source. `View.getHierarchy` returns that tree by default; pass `"source": "uikit"` to inspect the backing hosting views instead. + +This is a debug aid built on Swift reflection. It can show the declared view/body structure, including custom subviews, containers, labels, modifier names, active conditional branches, and `ForEach` rows whose data and content builder are available through SwiftUI's public API. Private/custom containers may still be opaque when they do not expose a child view value or content builder. + +The agent also reports SwiftUI hosting/bridge UIViews in the UIKit tree. To make specific SwiftUI elements addressable in that raw UIKit hierarchy, tag them in source: ```swift Text("Continue") diff --git a/packages/inspector-agent/Sources/SimDeckInspectorAgent/SwiftUIViewTreeSnapshotter.swift b/packages/inspector-agent/Sources/SimDeckInspectorAgent/SwiftUIViewTreeSnapshotter.swift new file mode 100644 index 00000000..3b62c1e2 --- /dev/null +++ b/packages/inspector-agent/Sources/SimDeckInspectorAgent/SwiftUIViewTreeSnapshotter.swift @@ -0,0 +1,483 @@ +#if canImport(SwiftUI) +import SwiftUI +import UIKit + +@available(iOS 13.0, *) +public extension View { + func simDeckPublishSwiftUIViewTree( + _ name: String? = nil, + id: String? = nil, + metadata: [String: String] = [:], + maxDepth: Int = 80 + ) -> some View { + modifier( + SimDeckSwiftUIViewTreePublisher( + rootView: self, + name: name, + id: id, + metadata: metadata, + maxDepth: maxDepth + ) + ) + } +} + +@available(iOS 13.0, *) +public extension SimDeckInspectorAgent { + func publishSwiftUIViewTree( + _ rootView: Root, + name: String? = nil, + id: String? = nil, + metadata: [String: String] = [:], + maxDepth: Int = 80 + ) throws { + let snapshot = SwiftUIViewTreeSnapshotter().snapshot( + rootView, + name: name, + id: id, + metadata: metadata, + maxDepth: maxDepth + ) + let data = try JSONEncoder.simDeckInspector.encode(snapshot) + guard let json = String(data: data, encoding: .utf8) else { + throw InspectorFailure.actionFailed("Unable to encode SwiftUI hierarchy snapshot.") + } + try publishHierarchySnapshot(source: "swiftui", snapshotJSON: json) + } +} + +@available(iOS 13.0, *) +private struct SimDeckSwiftUIViewTreePublisher: ViewModifier { + var rootView: Root + var name: String? + var id: String? + var metadata: [String: String] + var maxDepth: Int + + func body(content: Content) -> some View { + content.background( + SimDeckSwiftUIViewTreePublisherRepresentable( + rootView: rootView, + name: name, + id: id, + metadata: metadata, + maxDepth: maxDepth + ) + .frame(width: 0, height: 0) + .allowsHitTesting(false) + ) + } +} + +@available(iOS 13.0, *) +private struct SimDeckSwiftUIViewTreePublisherRepresentable: UIViewRepresentable { + var rootView: Root + var name: String? + var id: String? + var metadata: [String: String] + var maxDepth: Int + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.isUserInteractionEnabled = false + view.isAccessibilityElement = false + view.backgroundColor = .clear + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + try? SimDeckInspectorAgent.shared.publishSwiftUIViewTree( + rootView, + name: name, + id: id, + metadata: metadata, + maxDepth: maxDepth + ) + } +} + +private struct InspectorSwiftUIViewHierarchySnapshot: Codable, Equatable { + var protocolVersion: String + var capturedAt: String + var processIdentifier: Int32 + var bundleIdentifier: String? + var displayScale: Double + var coordinateSpace: String + var source: String + var roots: [InspectorSwiftUIViewNode] +} + +private struct InspectorSwiftUIViewNode: Codable, Equatable { + var id: String + var type: String + var title: String + var role: String + var AXLabel: String? + var AXIdentifier: String? + var AXUniqueId: String + var source: String + var swiftUI: InspectorSwiftUIInfo + var children: [InspectorSwiftUIViewNode] +} + +@available(iOS 13.0, *) +private protocol SimDeckSwiftUIExpandable { + func simDeckSwiftUIChildren( + parentPath: String, + depth: Int, + maxDepth: Int + ) -> [InspectorSwiftUIViewNode] +} + +@available(iOS 13.0, *) +extension ForEach: SimDeckSwiftUIExpandable where Content: View { + fileprivate func simDeckSwiftUIChildren( + parentPath: String, + depth: Int, + maxDepth: Int + ) -> [InspectorSwiftUIViewNode] { + data.enumerated().map { index, element in + SwiftUIViewTreeSnapshotter.makeViewNode( + content(element), + path: "\(parentPath).row-\(index)", + depth: depth, + maxDepth: maxDepth + ) + } + } +} + +@available(iOS 13.0, *) +private struct SwiftUIViewTreeSnapshotter { + func snapshot( + _ rootView: Root, + name: String?, + id: String?, + metadata: [String: String], + maxDepth: Int + ) -> InspectorSwiftUIViewHierarchySnapshot { + let root = Self.makeViewNode( + rootView, + explicitName: name, + explicitId: id, + metadata: metadata, + path: "root", + depth: 0, + maxDepth: max(0, maxDepth) + ) + + return InspectorSwiftUIViewHierarchySnapshot( + protocolVersion: InspectorProtocol.version, + capturedAt: ISO8601DateFormatter().string(from: Date()), + processIdentifier: ProcessInfo.processInfo.processIdentifier, + bundleIdentifier: Bundle.main.bundleIdentifier, + displayScale: Double(UIScreen.main.scale), + coordinateSpace: "screen-points", + source: "swiftui", + roots: [root] + ) + } + + fileprivate static func makeViewNode( + _ view: V, + explicitName: String? = nil, + explicitId: String? = nil, + metadata: [String: String] = [:], + path: String, + depth: Int, + maxDepth: Int + ) -> InspectorSwiftUIViewNode { + if let collapsed = modifiedContentNode( + view, + explicitName: explicitName, + explicitId: explicitId, + metadata: metadata, + path: path, + depth: depth, + maxDepth: maxDepth + ) { + return collapsed + } + + let rawType = String(reflecting: V.self) + let displayType = displayTypeName(rawType) + let children: [InspectorSwiftUIViewNode] + if depth >= maxDepth { + children = [] + } else if let expandable = view as? SimDeckSwiftUIExpandable { + children = expandable.simDeckSwiftUIChildren( + parentPath: path, + depth: depth + 1, + maxDepth: maxDepth + ) + } else if V.Body.self != Never.self { + children = [ + makeAnyNode( + view.body, + label: "body", + path: "\(path).body", + depth: depth + 1, + maxDepth: maxDepth + ), + ].compactMap { $0 } + } else { + children = reflectedChildren( + of: view, + parentPath: path, + depth: depth + 1, + maxDepth: maxDepth + ) + } + + let directText = extractedText(from: view) + let semanticText = directText ?? childSemanticText(children) + let title = clean(explicitName) ?? semanticText ?? displayType + let nodeId = "swiftui:\(clean(explicitId) ?? path)" + + return InspectorSwiftUIViewNode( + id: nodeId, + type: displayType, + title: title, + role: "SwiftUI View", + AXLabel: semanticText, + AXIdentifier: clean(explicitId), + AXUniqueId: nodeId, + source: "swiftui", + swiftUI: InspectorSwiftUIInfo( + isHost: false, + isProbe: false, + tag: clean(explicitName), + tagId: clean(explicitId), + metadata: metadata, + isViewTreeNode: true, + valueType: rawType, + bodyType: String(reflecting: V.Body.self), + path: path, + modifiers: nil + ), + children: children + ) + } + + private static func modifiedContentNode( + _ view: V, + explicitName: String?, + explicitId: String?, + metadata: [String: String], + path: String, + depth: Int, + maxDepth: Int + ) -> InspectorSwiftUIViewNode? { + guard String(reflecting: V.self).contains("ModifiedContent<") else { + return nil + } + + let mirror = Mirror(reflecting: view) + let content = mirror.children.first { $0.label == "content" }?.value + let modifier = mirror.children.first { $0.label == "modifier" }?.value + guard var node = content.flatMap({ + makeAnyNode( + $0, + label: "content", + path: path, + depth: depth, + maxDepth: maxDepth + ) + }) else { + return nil + } + + if let explicitName = clean(explicitName) { + node.title = explicitName + node.swiftUI.tag = explicitName + } + if let explicitId = clean(explicitId) { + node.id = "swiftui:\(explicitId)" + node.AXIdentifier = explicitId + node.AXUniqueId = node.id + node.swiftUI.tagId = explicitId + } + if !metadata.isEmpty { + node.swiftUI.metadata = metadata + } + if let modifier { + node.swiftUI.modifiers = (node.swiftUI.modifiers ?? []) + [ + displayTypeName(String(reflecting: type(of: modifier))), + ] + } + return node + } + + private static func reflectedChildren( + of value: Any, + parentPath: String, + depth: Int, + maxDepth: Int + ) -> [InspectorSwiftUIViewNode] { + Mirror(reflecting: value).children.enumerated().flatMap { index, child in + let label = child.label ?? String(index) + return nodesFromReflectedValue( + child.value, + label: label, + path: "\(parentPath).\(pathComponent(label, fallback: index))", + depth: depth, + maxDepth: maxDepth + ) + } + } + + private static func nodesFromReflectedValue( + _ value: Any, + label: String, + path: String, + depth: Int, + maxDepth: Int + ) -> [InspectorSwiftUIViewNode] { + if label == "action" || label == "modifier" || label == "root" { + return [] + } + + if let node = makeAnyNode(value, label: label, path: path, depth: depth, maxDepth: maxDepth) { + return [node] + } + + let mirror = Mirror(reflecting: value) + if mirror.displayStyle == .tuple + || mirror.displayStyle == .optional + || mirror.displayStyle == .enum + || label == "_tree" + { + return mirror.children.enumerated().flatMap { index, child in + nodesFromReflectedValue( + child.value, + label: child.label ?? String(index), + path: "\(path).\(pathComponent(child.label, fallback: index))", + depth: depth, + maxDepth: maxDepth + ) + } + } + + let rawType = String(reflecting: type(of: value)) + if rawType.contains("TupleView<") || rawType.contains("Tree<") { + return mirror.children.enumerated().flatMap { index, child in + nodesFromReflectedValue( + child.value, + label: child.label ?? String(index), + path: "\(path).\(pathComponent(child.label, fallback: index))", + depth: depth, + maxDepth: maxDepth + ) + } + } + + if label == "content" || label == "label" || label == "value" || label.hasPrefix(".") { + return mirror.children.enumerated().flatMap { index, child in + nodesFromReflectedValue( + child.value, + label: child.label ?? String(index), + path: "\(path).\(pathComponent(child.label, fallback: index))", + depth: depth, + maxDepth: maxDepth + ) + } + } + + return [] + } + + private static func makeAnyNode( + _ value: Any, + label: String?, + path: String, + depth: Int, + maxDepth: Int + ) -> InspectorSwiftUIViewNode? { + guard let view = value as? any View else { + return nil + } + return view.simDeckSwiftUIViewTreeNode( + path: path, + depth: depth, + maxDepth: maxDepth + ) + } + + private static func childSemanticText(_ children: [InspectorSwiftUIViewNode]) -> String? { + let labels = children.compactMap { clean($0.AXLabel ?? $0.title) } + guard labels.count == 1 else { + return nil + } + return labels.first + } + + private static func extractedText(from value: Any) -> String? { + guard displayTypeName(String(reflecting: type(of: value))) == "Text" else { + return nil + } + return firstStringValue(in: value, preferredLabels: ["verbatim", "key"], depth: 0) + } + + private static func firstStringValue( + in value: Any, + preferredLabels: Set, + depth: Int + ) -> String? { + if depth > 8 { + return nil + } + let mirror = Mirror(reflecting: value) + for child in mirror.children { + if let label = child.label, preferredLabels.contains(label), let text = child.value as? String { + return clean(text) + } + } + for child in mirror.children { + if let text = firstStringValue( + in: child.value, + preferredLabels: preferredLabels, + depth: depth + 1 + ) { + return text + } + } + return nil + } + + private static func pathComponent(_ label: String?, fallback: Int) -> String { + let raw = label ?? String(fallback) + let filtered = raw.map { character -> Character in + character.isLetter || character.isNumber ? character : "-" + } + let value = String(filtered).trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return value.isEmpty ? String(fallback) : value + } + + private static func displayTypeName(_ rawType: String) -> String { + let base = rawType.split(separator: "<", maxSplits: 1).first.map(String.init) ?? rawType + let name = base.split(separator: ".").last.map(String.init) ?? base + return name.trimmingCharacters(in: CharacterSet(charactersIn: "_")) + } + + private static func clean(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed?.isEmpty == false ? trimmed : nil + } +} + +@available(iOS 13.0, *) +private extension View { + func simDeckSwiftUIViewTreeNode( + path: String, + depth: Int, + maxDepth: Int + ) -> InspectorSwiftUIViewNode { + SwiftUIViewTreeSnapshotter.makeViewNode( + self, + path: path, + depth: depth, + maxDepth: maxDepth + ) + } +} +#endif diff --git a/packages/inspector-agent/Sources/SimDeckInspectorAgent/ViewModels.swift b/packages/inspector-agent/Sources/SimDeckInspectorAgent/ViewModels.swift index c8e742cd..7d87ae84 100644 --- a/packages/inspector-agent/Sources/SimDeckInspectorAgent/ViewModels.swift +++ b/packages/inspector-agent/Sources/SimDeckInspectorAgent/ViewModels.swift @@ -53,6 +53,35 @@ public struct InspectorSwiftUIInfo: Codable, Equatable { public var tag: String? public var tagId: String? public var metadata: [String: String] + public var isViewTreeNode: Bool? + public var valueType: String? + public var bodyType: String? + public var path: String? + public var modifiers: [String]? + + public init( + isHost: Bool, + isProbe: Bool, + tag: String? = nil, + tagId: String? = nil, + metadata: [String: String] = [:], + isViewTreeNode: Bool? = nil, + valueType: String? = nil, + bodyType: String? = nil, + path: String? = nil, + modifiers: [String]? = nil + ) { + self.isHost = isHost + self.isProbe = isProbe + self.tag = tag + self.tagId = tagId + self.metadata = metadata + self.isViewTreeNode = isViewTreeNode + self.valueType = valueType + self.bodyType = bodyType + self.path = path + self.modifiers = modifiers + } } public struct InspectorScrollInfo: Codable, Equatable { diff --git a/scripts/experimental/swiftui-preview.mjs b/scripts/experimental/swiftui-preview.mjs new file mode 100755 index 00000000..5a3f9c8a --- /dev/null +++ b/scripts/experimental/swiftui-preview.mjs @@ -0,0 +1,1438 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import crypto from "node:crypto"; +import path from "node:path"; +import process from "node:process"; +import net from "node:net"; +import { spawnSync } from "node:child_process"; + +const DEFAULT_BUNDLE_ID = "dev.simdeck.PreviewHost"; +const DEFAULT_MIN_IOS = "15.0"; +const RELOAD_PORT_START = 47440; +const RELOAD_PORT_LIMIT = 16; +const RELOAD_PROTOCOL_PREFIX = "SIMDECK_PREVIEW_RELOAD "; +const RELOAD_BYTES_PROTOCOL_PREFIX = "SIMDECK_PREVIEW_RELOAD_B64 "; + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help || !args.file) { + printUsage(); + process.exit(args.help ? 0 : 2); + } + + const sourceFile = path.resolve(String(args.file)); + const udid = String(args.udid ?? findBootedSimulatorUDID()); + if (!udid) { + throw new Error( + "No simulator UDID supplied and no booted simulator was found.", + ); + } + + const buildRoot = path.resolve( + String(args.buildRoot ?? path.join(".simdeck-preview", "build")), + ); + const bundleId = String(args.bundleId ?? DEFAULT_BUNDLE_ID); + const minIos = String(args.minIos ?? DEFAULT_MIN_IOS); + const targetArch = String( + args.arch ?? (process.arch === "arm64" ? "arm64" : "x86_64"), + ); + const sdkPath = runText("xcrun", [ + "--sdk", + "iphonesimulator", + "--show-sdk-path", + ]).trim(); + const context = { + buildRoot, + bundleId, + minIos, + sdkPath, + target: `${targetArch}-apple-ios${minIos}-simulator`, + udid, + }; + fs.mkdirSync(buildRoot, { recursive: true }); + const xcode = resolveXcodeContext(args, context); + + const host = buildHostApp(context, Boolean(args.rebuildHost)); + const shouldInstallHost = + host.rebuilt || Boolean(xcode && !args.skipXcodeBuild); + if (xcode && shouldInstallHost) { + overlayXcodeAppBundle(host.appPath, xcode); + } + installAndLaunchHost(context, host.appPath, shouldInstallHost); + await reloadPreview(context, sourceFile, args, xcode); + + if (args.watch) { + console.log(`[simdeck-preview] watching ${sourceFile}`); + watchFiles( + [ + sourceFile, + ...arrayArg(args.extraSwift).map((item) => path.resolve(item)), + ], + () => { + reloadPreview(context, sourceFile, args, xcode).catch((error) => { + console.error(`[simdeck-preview] reload failed: ${error.message}`); + }); + }, + ); + process.stdin.resume(); + } +} + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--help" || arg === "-h") { + args.help = true; + } else if (arg === "--watch" || arg === "-w") { + args.watch = true; + } else if (arg === "--rebuild-host") { + args.rebuildHost = true; + } else if (arg === "--skip-xcode-build") { + args.skipXcodeBuild = true; + } else if (arg === "--skip-codesign") { + args.skipCodesign = true; + } else if (arg === "--profile") { + args.profile = true; + } else if (arg === "--split-compile") { + args.splitCompile = true; + } else if (arg.startsWith("--")) { + const key = arg + .slice(2) + .replace(/-([a-z])/g, (_, char) => char.toUpperCase()); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + throw new Error(`Missing value for ${arg}.`); + } + index += 1; + if (key === "extraSwift" || key === "swiftcArg") { + args[key] = [...arrayArg(args[key]), next]; + } else { + args[key] = next; + } + } else if (!args.file) { + args.file = arg; + } else { + args.extraSwift = [...arrayArg(args.extraSwift), arg]; + } + } + return args; +} + +function printUsage() { + console.log(`Usage: + node scripts/experimental/swiftui-preview.mjs --udid --file [options] + +Options: + --preview Select a #Preview block. Defaults to the first one. + --watch, -w Recompile and dlopen a new payload after file changes. + --extra-swift Include another Swift file. Can be repeated. + --swiftc-arg Pass an extra argument to swiftc. Can be repeated. + --workspace Use an Xcode workspace for compatibility mode. + --project Use an Xcode project for compatibility mode. + --scheme Xcode scheme to build for compatibility mode. + --configuration Xcode configuration. Default: Debug. + --derived-data-path DerivedData path for Xcode builds. + --skip-xcode-build Reuse existing Xcode build artifacts. + --skip-codesign Do not ad-hoc sign reload dylibs. + --profile Print reload-stage timings. + --split-compile Cache the preview source as a testable Swift module. + --bundle-id Host bundle id. Default: ${DEFAULT_BUNDLE_ID} + --build-root Build/cache directory. Default: .simdeck-preview/build + --rebuild-host Rebuild and reinstall the stable host app. + +This is intentionally experimental. It extracts simple #Preview { ... } bodies, +builds them into a versioned simulator dylib, and asks a tiny host app to dlopen +the new dylib without reinstalling the host. + +When --workspace/--project and --scheme are supplied, the runner builds the real +app target once, copies its resources/frameworks into the host, and links reload +dylibs against the target's Xcode-built debug dylib. That is the faster Xcode-ish +path: reloads compile the edited preview source plus a tiny wrapper, not the full +app target.`); +} + +function buildHostApp(context, rebuildHost) { + const appPath = path.join(context.buildRoot, "SimDeckPreviewHost.app"); + const executable = "SimDeckPreviewHost"; + const executablePath = path.join(appPath, executable); + if (!rebuildHost && fs.existsSync(executablePath)) { + return { appPath, rebuilt: false }; + } + + fs.rmSync(appPath, { recursive: true, force: true }); + fs.mkdirSync(appPath, { recursive: true }); + fs.writeFileSync( + path.join(appPath, "Info.plist"), + ` + + + + CFBundleDevelopmentRegionen + CFBundleExecutable${executable} + CFBundleIdentifier${context.bundleId} + CFBundleInfoDictionaryVersion6.0 + CFBundleNameSimDeckPreview + CFBundlePackageTypeAPPL + CFBundleShortVersionString1.0 + CFBundleVersion1 + LSRequiresIPhoneOS + MinimumOSVersion${context.minIos} + UIDeviceFamily12 + CFBundleURLTypes + + + CFBundleURLNameSimDeckPreview + CFBundleURLSchemessimdeck-preview + + + + +`, + ); + + const mainPath = path.join(context.buildRoot, "SimDeckPreviewHost.swift"); + fs.writeFileSync(mainPath, hostSource()); + run("xcrun", [ + "--sdk", + "iphonesimulator", + "swiftc", + "-target", + context.target, + "-sdk", + context.sdkPath, + "-parse-as-library", + "-Onone", + "-framework", + "SwiftUI", + "-framework", + "UIKit", + mainPath, + "-o", + executablePath, + ]); + console.log(`[simdeck-preview] built host ${appPath}`); + return { appPath, rebuilt: true }; +} + +function installAndLaunchHost(context, appPath, shouldInstall) { + if (!shouldInstall) { + return; + } + run("xcrun", ["simctl", "install", context.udid, appPath]); + run("xcrun", ["simctl", "launch", context.udid, context.bundleId], { + allowFailure: true, + }); +} + +async function reloadPreview(context, sourceFile, args, xcode) { + const started = Date.now(); + const timings = []; + const timed = (label, action) => { + const stageStarted = Date.now(); + const result = action(); + timings.push([label, Date.now() - stageStarted]); + return result; + }; + const timedAsync = async (label, action) => { + const stageStarted = Date.now(); + const result = await action(); + timings.push([label, Date.now() - stageStarted]); + return result; + }; + const source = timed("read source", () => + fs.readFileSync(sourceFile, "utf8"), + ); + const previews = timed("extract previews", () => extractPreviews(source)); + if (previews.length === 0) { + throw new Error(`No #Preview blocks found in ${sourceFile}.`); + } + const preview = timed("select preview", () => + selectPreview(previews, args.preview), + ); + const stamp = `${Date.now()}-${process.pid}`; + const payloadDir = path.join(context.buildRoot, "payloads", stamp); + timed("prepare payload dir", () => + fs.mkdirSync(payloadDir, { recursive: true }), + ); + + const sanitizedPath = path.join(payloadDir, "PreviewSource.swift"); + const wrapperPath = path.join(payloadDir, "SimDeckPreviewPayload.swift"); + const dylibPath = path.join( + payloadDir, + `SimDeckPreviewPayload-${stamp}.dylib`, + ); + const sanitizedSource = timed("sanitize source", () => + sanitizePreviewSource( + removeRanges( + source, + previews.map((item) => item.range), + ), + xcode?.moduleName, + ), + ); + const sourceModule = args.splitCompile + ? timed("resolve source cache", () => + previewSourceModule(context, sanitizedSource, args, xcode), + ) + : null; + timed("write generated swift", () => { + fs.writeFileSync(sanitizedPath, sanitizedSource); + fs.writeFileSync( + wrapperPath, + payloadWrapperSource( + preview.body, + xcode?.moduleName, + sourceModule?.moduleName, + ), + ); + }); + + if (sourceModule) { + timed("swiftc source module", () => + compilePreviewSourceModule( + context, + sanitizedPath, + sourceModule, + args, + xcode, + ), + ); + } + + const compileArgs = sourceModule + ? splitWrapperCompileArgs( + context, + stamp, + wrapperPath, + dylibPath, + sourceModule, + args, + xcode, + sourceFile, + ) + : monolithicCompileArgs( + context, + stamp, + sanitizedPath, + wrapperPath, + dylibPath, + args, + xcode, + sourceFile, + ); + timed("swiftc emit dylib", () => run("xcrun", compileArgs)); + if (!args.skipCodesign) { + timed("codesign", () => + run("codesign", ["-s", "-", "-f", dylibPath], { allowFailure: true }), + ); + } + + const sentBytes = await timedAsync("tcp reload bytes", () => + sendTcpReloadBytes(dylibPath), + ); + if ( + !sentBytes && + !(await timedAsync("tcp reload path", () => + sendTcpReloadPath(context, dylibPath), + )) + ) { + const containerPath = timed("simctl get container", () => + appContainerPath(context), + ); + const documentsDir = path.join(containerPath, "Documents"); + fs.mkdirSync(documentsDir, { recursive: true }); + const installedDylibPath = path.join( + documentsDir, + path.basename(dylibPath), + ); + timed("copy dylib", () => fs.copyFileSync(dylibPath, installedDylibPath)); + const reloadUrl = `simdeck-preview://reload?path=${encodeURIComponent(installedDylibPath)}`; + timed("simctl openurl", () => + run("xcrun", ["simctl", "openurl", context.udid, reloadUrl]), + ); + } + + const elapsed = Date.now() - started; + const label = preview.name ? `"${preview.name}"` : `#${preview.index + 1}`; + console.log(`[simdeck-preview] reloaded preview ${label} in ${elapsed}ms`); + console.log( + `[simdeck-preview] SimDeck UI should show bundle ${context.bundleId} on ${context.udid}`, + ); + if (args.profile) { + const summary = timings.map(([label, ms]) => `${label}=${ms}ms`).join(" "); + console.log(`[simdeck-preview] profile ${summary}`); + } +} + +function monolithicCompileArgs( + context, + stamp, + sanitizedPath, + wrapperPath, + dylibPath, + args, + xcode, + sourceFile, +) { + return [ + "--sdk", + "iphonesimulator", + "swiftc", + "-target", + context.target, + "-sdk", + context.sdkPath, + "-parse-as-library", + "-Onone", + "-emit-library", + "-module-name", + `SimDeckPreviewPayload_${stamp.replaceAll("-", "_")}`, + "-framework", + "SwiftUI", + "-framework", + "UIKit", + ...xcodeSwiftcArgs(xcode), + sanitizedPath, + ...arrayArg(args.extraSwift).map((item) => path.resolve(item)), + wrapperPath, + ...xcodeObjectFiles(xcode, sourceFile), + ...arrayArg(args.swiftcArg), + "-o", + dylibPath, + ]; +} + +function splitWrapperCompileArgs( + context, + stamp, + wrapperPath, + dylibPath, + sourceModule, + args, + xcode, + sourceFile, +) { + return [ + "--sdk", + "iphonesimulator", + "swiftc", + "-target", + context.target, + "-sdk", + context.sdkPath, + "-parse-as-library", + "-Onone", + "-emit-library", + "-module-name", + `SimDeckPreviewPayload_${stamp.replaceAll("-", "_")}`, + "-I", + sourceModule.directory, + "-framework", + "SwiftUI", + "-framework", + "UIKit", + ...xcodeSwiftcArgs(xcode), + wrapperPath, + sourceModule.objectPath, + ...xcodeObjectFiles(xcode, sourceFile), + ...arrayArg(args.swiftcArg), + "-o", + dylibPath, + ]; +} + +function previewSourceModule(context, sanitizedSource, args, xcode) { + const extraSwift = arrayArg(args.extraSwift); + if (extraSwift.length > 0) { + console.warn( + "[simdeck-preview] warning: --split-compile currently falls back when --extra-swift is used.", + ); + return null; + } + const swiftcArgs = [...xcodeSwiftcArgs(xcode), ...arrayArg(args.swiftcArg)]; + const hash = crypto + .createHash("sha256") + .update(context.target) + .update("\0") + .update(context.sdkPath) + .update("\0") + .update(xcode?.moduleName ?? "") + .update("\0") + .update(swiftcArgs.join("\0")) + .update("\0") + .update(sanitizedSource) + .digest("hex") + .slice(0, 16); + const directory = path.join(context.buildRoot, "source-cache", hash); + return { + directory, + moduleName: `SimDeckPreviewSource_${hash}`, + modulePath: path.join( + directory, + `SimDeckPreviewSource_${hash}.swiftmodule`, + ), + objectPath: path.join(directory, `SimDeckPreviewSource_${hash}.o`), + }; +} + +function compilePreviewSourceModule( + context, + sanitizedPath, + sourceModule, + args, + xcode, +) { + if ( + fs.existsSync(sourceModule.objectPath) && + fs.existsSync(sourceModule.modulePath) + ) { + return; + } + fs.mkdirSync(sourceModule.directory, { recursive: true }); + run("xcrun", [ + "--sdk", + "iphonesimulator", + "swiftc", + "-target", + context.target, + "-sdk", + context.sdkPath, + "-parse-as-library", + "-Onone", + "-enable-testing", + "-emit-module", + "-emit-module-path", + sourceModule.modulePath, + "-emit-object", + "-module-name", + sourceModule.moduleName, + "-framework", + "SwiftUI", + "-framework", + "UIKit", + ...xcodeSwiftcArgs(xcode), + sanitizedPath, + ...arrayArg(args.swiftcArg), + "-o", + sourceModule.objectPath, + ]); +} + +async function sendTcpReloadPath(context, dylibPath) { + const containerPath = appContainerPath(context); + const documentsDir = path.join(containerPath, "Documents"); + fs.mkdirSync(documentsDir, { recursive: true }); + const installedDylibPath = path.join(documentsDir, path.basename(dylibPath)); + fs.copyFileSync(dylibPath, installedDylibPath); + for (let offset = 0; offset < RELOAD_PORT_LIMIT; offset += 1) { + const port = RELOAD_PORT_START + offset; + if (await sendTcpReloadToPort(port, installedDylibPath)) { + return true; + } + } + return false; +} + +async function sendTcpReloadBytes(dylibPath) { + const base64 = fs.readFileSync(dylibPath).toString("base64"); + const payload = `${RELOAD_BYTES_PROTOCOL_PREFIX}${path.basename(dylibPath)} ${base64}\n`; + for (let offset = 0; offset < RELOAD_PORT_LIMIT; offset += 1) { + const port = RELOAD_PORT_START + offset; + if (await sendTcpMessageToPort(port, payload)) { + return true; + } + } + return false; +} + +function sendTcpReloadToPort(port, dylibPath) { + return sendTcpMessageToPort(port, `${RELOAD_PROTOCOL_PREFIX}${dylibPath}\n`); +} + +function sendTcpMessageToPort(port, message) { + return new Promise((resolve) => { + const socket = net.createConnection({ host: "127.0.0.1", port }); + let settled = false; + let response = ""; + const settle = (value) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + socket.destroy(); + resolve(value); + }; + const timer = setTimeout(() => { + settle(false); + }, 500); + socket.once("connect", () => { + socket.end(message); + }); + socket.on("data", (data) => { + response += data.toString("utf8"); + if (response.trim().startsWith("OK")) { + settle(true); + } else if (response.trim().startsWith("ERROR")) { + settle(false); + } + }); + socket.once("error", () => { + settle(false); + }); + socket.once("close", () => { + settle(response.trim().startsWith("OK")); + }); + }); +} + +function appContainerPath(context) { + if (context.containerPath) { + return context.containerPath; + } + context.containerPath = runText("xcrun", [ + "simctl", + "get_app_container", + context.udid, + context.bundleId, + "data", + ]).trim(); + return context.containerPath; +} + +function resolveXcodeContext(args, context) { + if (!args.workspace && !args.project && !args.scheme) { + return null; + } + if (!args.scheme) { + throw new Error( + "--scheme is required when using --workspace or --project.", + ); + } + + const derivedDataPath = path.resolve( + String(args.derivedDataPath ?? path.join(context.buildRoot, "DerivedData")), + ); + const cacheIdentity = xcodeCacheIdentity(args, derivedDataPath); + if (args.skipXcodeBuild) { + const cached = readCachedXcodeContext(context, cacheIdentity); + if (cached) { + return cached; + } + } + const buildArgs = xcodebuildBaseArgs(args, derivedDataPath); + if (!args.skipXcodeBuild) { + const started = Date.now(); + run("xcodebuild", [ + ...buildArgs, + "build", + "CODE_SIGNING_ALLOWED=NO", + "ENABLE_TESTABILITY=YES", + ]); + console.log( + `[simdeck-preview] xcodebuild warm build completed in ${Date.now() - started}ms`, + ); + } + + const settings = readXcodeBuildSettings(buildArgs, args.target); + const buildSettings = settings.buildSettings ?? settings; + const arch = context.target.split("-")[0]; + const appPath = findBuiltAppPath(buildSettings); + const debugDylibPath = findXcodeDebugDylib(appPath, buildSettings); + const xcode = { + appPath, + arch, + buildSettings, + debugDylibPath, + moduleName: + buildSettings.PRODUCT_MODULE_NAME || buildSettings.SWIFT_MODULE_NAME, + objectFiles: debugDylibPath + ? [] + : collectXcodeObjectFiles(buildSettings, arch), + }; + writeCachedXcodeContext(context, cacheIdentity, xcode); + return xcode; +} + +function xcodeContextCachePath(context) { + return path.join(context.buildRoot, "xcode-context-cache.json"); +} + +function xcodeCacheIdentity(args, derivedDataPath) { + return { + configuration: String(args.configuration ?? "Debug"), + derivedDataPath, + destination: String(args.destination ?? "generic/platform=iOS Simulator"), + project: args.project ? path.resolve(String(args.project)) : "", + scheme: String(args.scheme), + target: String(args.target ?? ""), + workspace: args.workspace ? path.resolve(String(args.workspace)) : "", + }; +} + +function readCachedXcodeContext(context, identity) { + const cachePath = xcodeContextCachePath(context); + if (!fs.existsSync(cachePath)) { + return null; + } + try { + const cached = JSON.parse(fs.readFileSync(cachePath, "utf8")); + if (JSON.stringify(cached.identity) !== JSON.stringify(identity)) { + return null; + } + const xcode = cached.xcode; + if (!xcode?.appPath || !fs.existsSync(xcode.appPath)) { + return null; + } + if (xcode.debugDylibPath && !fs.existsSync(xcode.debugDylibPath)) { + return null; + } + return xcode; + } catch { + return null; + } +} + +function writeCachedXcodeContext(context, identity, xcode) { + fs.writeFileSync( + xcodeContextCachePath(context), + JSON.stringify({ identity, xcode }, null, 2), + ); +} + +function xcodebuildBaseArgs(args, derivedDataPath) { + const buildArgs = []; + if (args.workspace) { + buildArgs.push("-workspace", path.resolve(String(args.workspace))); + } else if (args.project) { + buildArgs.push("-project", path.resolve(String(args.project))); + } + buildArgs.push("-scheme", String(args.scheme)); + buildArgs.push("-configuration", String(args.configuration ?? "Debug")); + buildArgs.push( + "-destination", + String(args.destination ?? "generic/platform=iOS Simulator"), + ); + buildArgs.push("-derivedDataPath", derivedDataPath); + return buildArgs; +} + +function readXcodeBuildSettings(buildArgs, targetName) { + const output = runText("xcodebuild", [ + ...buildArgs, + "-showBuildSettings", + "-json", + ]); + const settings = JSON.parse(output); + if (!Array.isArray(settings) || settings.length === 0) { + throw new Error( + "xcodebuild -showBuildSettings returned no target settings.", + ); + } + if (targetName) { + const match = settings.find((item) => item.target === targetName); + if (match) { + return match; + } + } + return ( + settings.find((item) => item.buildSettings?.PRODUCT_MODULE_NAME) ?? + settings[0] + ); +} + +function findBuiltAppPath(settings) { + const wrapperName = settings.WRAPPER_NAME; + const candidates = [ + settings.TARGET_BUILD_DIR && wrapperName + ? path.join(settings.TARGET_BUILD_DIR, wrapperName) + : "", + settings.CODESIGNING_FOLDER_PATH, + settings.BUILT_PRODUCTS_DIR && wrapperName + ? path.join(settings.BUILT_PRODUCTS_DIR, wrapperName) + : "", + ].filter(Boolean); + const appPath = candidates.find( + (candidate) => candidate.endsWith(".app") && fs.existsSync(candidate), + ); + if (!appPath) { + throw new Error( + `Unable to locate built .app for Xcode target. Tried: ${candidates.join(", ")}`, + ); + } + return appPath; +} + +function findXcodeDebugDylib(appPath, settings) { + const moduleName = settings.PRODUCT_MODULE_NAME || settings.SWIFT_MODULE_NAME; + const candidates = [ + moduleName ? path.join(appPath, `${moduleName}.debug.dylib`) : "", + ...fs + .readdirSync(appPath) + .filter((entry) => entry.endsWith(".debug.dylib")) + .map((entry) => path.join(appPath, entry)), + ].filter(Boolean); + return candidates.find((candidate) => fs.existsSync(candidate)) ?? ""; +} + +function overlayXcodeAppBundle(hostAppPath, xcode) { + const sourceApp = xcode.appPath; + for (const entry of fs.readdirSync(sourceApp, { withFileTypes: true })) { + if ( + entry.name === "Info.plist" || + entry.name === "_CodeSignature" || + entry.name === "PkgInfo" || + entry.name.endsWith(".app") || + entry.name === path.basename(sourceApp, ".app") + ) { + continue; + } + copyRecursive( + path.join(sourceApp, entry.name), + path.join(hostAppPath, entry.name), + ); + } + console.log( + `[simdeck-preview] overlaid resources/frameworks from ${sourceApp}`, + ); +} + +function copyRecursive(source, destination) { + fs.rmSync(destination, { recursive: true, force: true }); + fs.cpSync(source, destination, { recursive: true, dereference: false }); +} + +function xcodeSwiftcArgs(xcode) { + if (!xcode) { + return []; + } + const settings = xcode.buildSettings; + const args = []; + pushSearchArgs(args, "-I", [ + settings.SWIFT_INCLUDE_PATHS, + settings.BUILT_PRODUCTS_DIR, + settings.TARGET_BUILD_DIR, + settings.CONFIGURATION_BUILD_DIR, + ]); + pushSearchArgs(args, "-F", [ + settings.FRAMEWORK_SEARCH_PATHS, + settings.BUILT_PRODUCTS_DIR, + settings.TARGET_BUILD_DIR, + settings.CONFIGURATION_BUILD_DIR, + ]); + pushSearchArgs(args, "-L", [ + settings.LIBRARY_SEARCH_PATHS, + settings.BUILT_PRODUCTS_DIR, + settings.TARGET_BUILD_DIR, + settings.CONFIGURATION_BUILD_DIR, + ]); + for (const condition of splitBuildSetting( + settings.SWIFT_ACTIVE_COMPILATION_CONDITIONS, + )) { + if (condition && condition !== "$(inherited)") { + args.push("-D", condition); + } + } + const bridgingHeader = settings.SWIFT_OBJC_BRIDGING_HEADER; + if (bridgingHeader && fs.existsSync(bridgingHeader)) { + args.push("-import-objc-header", bridgingHeader); + } + const moduleCache = + settings.CLANG_MODULE_CACHE_PATH || settings.MODULE_CACHE_DIR; + if (moduleCache) { + args.push("-module-cache-path", moduleCache); + } + args.push(...splitBuildSetting(settings.OTHER_SWIFT_FLAGS)); + if (xcode.debugDylibPath) { + args.push( + "-Xlinker", + "-rpath", + "-Xlinker", + "@executable_path", + "-Xlinker", + "-rpath", + "-Xlinker", + "@executable_path/Frameworks", + ); + } + return args.filter((item) => item !== "$(inherited)"); +} + +function xcodeObjectFiles(xcode, editedSourceFile) { + if (!xcode) { + return []; + } + if (xcode.debugDylibPath) { + return [xcode.debugDylibPath]; + } + const editedBaseName = path.basename( + editedSourceFile, + path.extname(editedSourceFile), + ); + return xcode.objectFiles.filter((file) => { + const name = path.basename(file, ".o"); + return name !== editedBaseName && !name.startsWith(`${editedBaseName}.`); + }); +} + +function collectXcodeObjectFiles(settings, arch) { + const roots = [ + settings.OBJECT_FILE_DIR_normal, + settings.TARGET_TEMP_DIR, + settings.OBJECT_FILE_DIR, + ].filter(Boolean); + const files = new Set(); + for (const root of roots) { + if (fs.existsSync(root)) { + for (const file of walkFiles(root)) { + if (file.endsWith(".o") && objectFileMatchesArchPath(file, arch)) { + files.add(file); + } + } + } + } + const list = [...files].filter( + (file) => !file.includes("SimDeckPreviewPayload"), + ); + if (list.length === 0) { + console.warn( + "[simdeck-preview] warning: no Xcode object files found; compatibility will be limited.", + ); + } else { + console.log( + `[simdeck-preview] found ${list.length} Xcode object files for fallback linking`, + ); + } + return list; +} + +function objectFileMatchesArchPath(file, arch) { + const normalized = file.split(path.sep).join("/"); + return ( + normalized.includes(`/Objects-normal/${arch}/`) || + !normalized.includes("/Objects-normal/") + ); +} + +function* walkFiles(root) { + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const fullPath = path.join(root, entry.name); + if (entry.isDirectory()) { + yield* walkFiles(fullPath); + } else if (entry.isFile()) { + yield fullPath; + } + } +} + +function pushSearchArgs(args, flag, values) { + for (const value of values) { + for (const item of splitBuildSetting(value)) { + if (item && item !== "$(inherited)" && fs.existsSync(item)) { + args.push(flag, item); + } + } + } +} + +function findBootedSimulatorUDID() { + const data = JSON.parse( + runText("xcrun", ["simctl", "list", "devices", "booted", "-j"]), + ); + for (const runtimes of Object.values(data.devices ?? {})) { + for (const device of runtimes) { + if (device.state === "Booted") { + return device.udid; + } + } + } + return ""; +} + +function extractPreviews(source) { + const previews = []; + let searchFrom = 0; + while (true) { + const macroIndex = source.indexOf("#Preview", searchFrom); + if (macroIndex < 0) { + break; + } + let cursor = macroIndex + "#Preview".length; + cursor = skipWhitespace(source, cursor); + let name = ""; + if (source[cursor] === "(") { + const parens = readBalanced(source, cursor, "(", ")"); + name = firstStringLiteral(parens.text) ?? ""; + cursor = skipWhitespace(source, parens.end + 1); + } + if (source[cursor] !== "{") { + searchFrom = cursor + 1; + continue; + } + const body = readBalanced(source, cursor, "{", "}"); + previews.push({ + body: body.text.trim(), + index: previews.length, + name, + range: [macroIndex, body.end + 1], + }); + searchFrom = body.end + 1; + } + return previews; +} + +function selectPreview(previews, selector) { + if (selector == null || selector === "") { + return previews[0]; + } + const numeric = Number(selector); + if (Number.isInteger(numeric)) { + const match = previews[numeric - 1] ?? previews[numeric]; + if (match) { + return match; + } + } + const named = previews.find((item) => item.name === selector); + if (!named) { + throw new Error( + `No preview matched ${JSON.stringify(selector)}. Available: ${previews + .map((item) => item.name || `#${item.index + 1}`) + .join(", ")}`, + ); + } + return named; +} + +function removeRanges(source, ranges) { + let output = ""; + let cursor = 0; + for (const [start, end] of ranges.sort((a, b) => a[0] - b[0])) { + output += source.slice(cursor, start); + cursor = end; + } + output += source.slice(cursor); + return output; +} + +function sanitizePreviewSource(source, moduleName) { + const stripped = source.replace( + /(^|\n)(\s*)@main\b/g, + "$1$2// @main stripped by SimDeck preview runner", + ); + return xcodeImportPrelude(moduleName) + stripped; +} + +function xcodeImportPrelude(moduleName) { + if (!moduleName) { + return ""; + } + return `#if DEBUG +@testable import ${moduleName} +#else +import ${moduleName} +#endif + +`; +} + +function testableImportPrelude(moduleName) { + if (!moduleName) { + return ""; + } + return `@testable import ${moduleName} + +`; +} + +function readBalanced(source, start, open, close) { + let depth = 0; + let textStart = start + 1; + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (char === '"' || char === "'") { + index = skipQuoted(source, index); + continue; + } + if (char === "/" && source[index + 1] === "/") { + index = skipLineComment(source, index); + continue; + } + if (char === "/" && source[index + 1] === "*") { + index = skipBlockComment(source, index); + continue; + } + if (char === open) { + depth += 1; + } else if (char === close) { + depth -= 1; + if (depth === 0) { + return { end: index, text: source.slice(textStart, index) }; + } + } + } + throw new Error(`Unbalanced ${open}${close} block near offset ${start}.`); +} + +function firstStringLiteral(text) { + const match = text.match(/"((?:\\"|[^"])*)"/); + return match ? match[1].replace(/\\"/g, '"') : null; +} + +function skipWhitespace(source, index) { + while (/\s/.test(source[index] ?? "")) { + index += 1; + } + return index; +} + +function skipQuoted(source, start) { + const quote = source[start]; + for (let index = start + 1; index < source.length; index += 1) { + if (source[index] === "\\") { + index += 1; + continue; + } + if (source[index] === quote) { + return index; + } + } + return source.length - 1; +} + +function skipLineComment(source, start) { + const next = source.indexOf("\n", start + 2); + return next < 0 ? source.length - 1 : next; +} + +function skipBlockComment(source, start) { + const next = source.indexOf("*/", start + 2); + return next < 0 ? source.length - 1 : next + 1; +} + +function watchFiles(files, callback) { + let timer = null; + for (const file of files) { + fs.watchFile(file, { interval: 250 }, () => { + clearTimeout(timer); + timer = setTimeout(callback, 120); + }); + } +} + +function payloadWrapperSource(previewBody, moduleName, sourceModuleName) { + return `import SwiftUI +import UIKit +${sourceModuleName ? "" : xcodeImportPrelude(moduleName)} +${testableImportPrelude(sourceModuleName)} + +@_cdecl("simdeck_make_preview_view_controller") +public func simdeck_make_preview_view_controller() -> UnsafeMutableRawPointer { + let rootView = AnyView({ +${indent(previewBody, 8)} + }()) + let controller = UIHostingController(rootView: rootView) + controller.view.backgroundColor = .systemBackground + return Unmanaged.passRetained(controller).toOpaque() +} +`; +} + +function hostSource() { + return `import Darwin +import Foundation +import Network +import SwiftUI +import UIKit + +private typealias PreviewFactory = @convention(c) () -> UnsafeMutableRawPointer +private let simDeckPreviewReloadPortStart: UInt16 = ${RELOAD_PORT_START} +private let simDeckPreviewReloadPortLimit: UInt16 = ${RELOAD_PORT_LIMIT} +private let simDeckPreviewReloadPrefix = "${RELOAD_PROTOCOL_PREFIX}" +private let simDeckPreviewReloadBytesPrefix = "${RELOAD_BYTES_PROTOCOL_PREFIX}" + +@MainActor +final class PreviewStore: ObservableObject { + @Published var controller: UIViewController? + @Published var status = "Waiting for SimDeck preview dylib..." + private var handles: [UnsafeMutableRawPointer] = [] + private var listener: NWListener? + + init() { + startReloadListener() + } + + @discardableResult + func load(path rawPath: String) -> Bool { + let path = rawPath.removingPercentEncoding ?? rawPath + guard FileManager.default.fileExists(atPath: path) else { + status = "Missing dylib: \\(path)" + return false + } + guard let handle = dlopen(path, RTLD_NOW | RTLD_LOCAL) else { + status = String(cString: dlerror()) + return false + } + guard let symbol = dlsym(handle, "simdeck_make_preview_view_controller") else { + status = "Missing simdeck_make_preview_view_controller" + return false + } + let factory = unsafeBitCast(symbol, to: PreviewFactory.self) + let pointer = factory() + let nextController = Unmanaged.fromOpaque(pointer).takeRetainedValue() + nextController.view.backgroundColor = .systemBackground + handles.append(handle) + controller = nextController + status = "Loaded \\((path as NSString).lastPathComponent)" + return true + } + + private func startReloadListener() { + for offset in 0.. ContainerViewController { + let container = ContainerViewController() + container.set(controller) + return container + } + + func updateUIViewController(_ uiViewController: ContainerViewController, context: Context) { + uiViewController.set(controller) + } +} + +final class ContainerViewController: UIViewController { + private var current: UIViewController? + + func set(_ next: UIViewController) { + guard current !== next else { + return + } + current?.willMove(toParent: nil) + current?.view.removeFromSuperview() + current?.removeFromParent() + + addChild(next) + next.view.frame = view.bounds + next.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(next.view) + next.didMove(toParent: self) + current = next + } +} + +@main +struct SimDeckPreviewHostApp: App { + var body: some Scene { + WindowGroup { + PreviewHostRoot() + } + } +} +`; +} + +function indent(text, spaces) { + const prefix = " ".repeat(spaces); + return text + .split("\n") + .map((line) => `${prefix}${line}`) + .join("\n"); +} + +function arrayArg(value) { + if (value == null) { + return []; + } + return Array.isArray(value) ? value : [value]; +} + +function splitBuildSetting(value) { + if (!value) { + return []; + } + const text = String(value); + const items = []; + let current = ""; + let quote = ""; + for (let index = 0; index < text.length; index += 1) { + const char = text[index]; + if (quote) { + if (char === "\\") { + current += text[index + 1] ?? ""; + index += 1; + } else if (char === quote) { + quote = ""; + } else { + current += char; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + } else if (/\s/.test(char)) { + if (current) { + items.push(current); + current = ""; + } + } else { + current += char; + } + } + if (current) { + items.push(current); + } + return items; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: process.cwd(), + encoding: "utf8", + stdio: options.capture ? "pipe" : "inherit", + }); + if (result.status !== 0 && !options.allowFailure) { + const details = options.capture + ? `\n${result.stderr || result.stdout}` + : ""; + throw new Error(`${command} ${args.join(" ")} failed.${details}`); + } + return result; +} + +function runText(command, args) { + const result = run(command, args, { capture: true }); + return result.stdout; +} diff --git a/server/src/api/routes.rs b/server/src/api/routes.rs index 27d76732..c974fb1b 100644 --- a/server/src/api/routes.rs +++ b/server/src/api/routes.rs @@ -316,6 +316,7 @@ const CONNECTED_INSPECTOR_HIERARCHY_TIMEOUT: Duration = Duration::from_secs(8); const SOURCE_NATIVE_AX: &str = "native-ax"; const SOURCE_NATIVE_SCRIPT: &str = "nativescript"; const SOURCE_REACT_NATIVE: &str = "react-native"; +const SOURCE_SWIFTUI: &str = "swiftui"; const SOURCE_UIKIT: &str = "in-app-inspector"; pub fn router(state: AppState) -> Router { @@ -1156,6 +1157,7 @@ async fn accessibility_tree_value( AccessibilityHierarchySource::Auto => InAppHierarchySource::Automatic, AccessibilityHierarchySource::NativeScript => InAppHierarchySource::Automatic, AccessibilityHierarchySource::ReactNative => InAppHierarchySource::Automatic, + AccessibilityHierarchySource::SwiftUI => InAppHierarchySource::Automatic, AccessibilityHierarchySource::UIKit => InAppHierarchySource::UIKit, AccessibilityHierarchySource::NativeAX => unreachable!(), }; @@ -1182,6 +1184,10 @@ async fn accessibility_tree_value( && snapshot_source != Some(SOURCE_REACT_NATIVE) { Some("React Native hierarchy is not published by the app.".to_owned()) + } else if requested_source == AccessibilityHierarchySource::SwiftUI + && snapshot_source != Some(SOURCE_SWIFTUI) + { + Some("SwiftUI hierarchy is not published by the app.".to_owned()) } else { None }; @@ -2064,6 +2070,7 @@ enum AccessibilityHierarchySource { Auto, NativeScript, ReactNative, + SwiftUI, UIKit, NativeAX, } @@ -2074,6 +2081,7 @@ impl AccessibilityHierarchySource { "" | "auto" => Ok(Self::Auto), "nativescript" | "ns" => Ok(Self::NativeScript), "react-native" | "reactnative" | "rn" => Ok(Self::ReactNative), + "swiftui" | "swift-ui" => Ok(Self::SwiftUI), "uikit" | "in-app-inspector" => Ok(Self::UIKit), "ax" | "native-ax" | "native-accessibility" => Ok(Self::NativeAX), source => Err(AppError::bad_request(format!( @@ -2298,11 +2306,18 @@ fn inspector_session_score(session: &InspectorSession) -> u8 { if session .available_sources .iter() - .any(|source| source == SOURCE_UIKIT) + .any(|source| source == SOURCE_SWIFTUI) { return 2; } - 3 + if session + .available_sources + .iter() + .any(|source| source == SOURCE_UIKIT) + { + return 3; + } + 4 } async fn frontmost_process_identifier(state: &AppState, udid: &str) -> Result, String> { @@ -2341,7 +2356,7 @@ async fn run_in_app_inspector_hierarchy( .get("source") .and_then(Value::as_str) .unwrap_or(SOURCE_UIKIT); - if source == SOURCE_NATIVE_SCRIPT || source == SOURCE_REACT_NATIVE { + if framework_source(source) { return Ok(json_value!({ "roots": hierarchy.get("roots").cloned().unwrap_or_else(|| Value::Array(Vec::new())), "source": source, @@ -2411,6 +2426,7 @@ fn inspector_available_sources(info: &Value) -> Vec { match app_hierarchy_source { SOURCE_NATIVE_SCRIPT => sources.push(SOURCE_NATIVE_SCRIPT.to_owned()), SOURCE_REACT_NATIVE => push_unique_source(&mut sources, SOURCE_REACT_NATIVE), + SOURCE_SWIFTUI => push_unique_source(&mut sources, SOURCE_SWIFTUI), _ => {} } } @@ -2447,6 +2463,13 @@ fn available_sources_for_session(session: &InspectorSession) -> Vec { { push_unique_source(&mut sources, SOURCE_REACT_NATIVE); } + if session + .available_sources + .iter() + .any(|source| source == SOURCE_SWIFTUI) + { + push_unique_source(&mut sources, SOURCE_SWIFTUI); + } if session .available_sources .iter() @@ -2522,7 +2545,7 @@ fn root_process_identifier(snapshot: &Value) -> Option { } fn framework_source(source: &str) -> bool { - source == SOURCE_NATIVE_SCRIPT || source == SOURCE_REACT_NATIVE + source == SOURCE_NATIVE_SCRIPT || source == SOURCE_REACT_NATIVE || source == SOURCE_SWIFTUI } fn attach_available_sources(snapshot: Value, available_sources: &[String]) -> Value {