Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions client/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,14 @@ export interface AccessibilityNode {
role?: string | null;
role_description?: string | null;
scroll?: Record<string, unknown> | 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;
Expand All @@ -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 {
Expand Down
10 changes: 8 additions & 2 deletions client/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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) {
Expand All @@ -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" &&
Expand Down
3 changes: 2 additions & 1 deletion client/src/app/uiState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 2 additions & 0 deletions client/src/app/uiState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];
Expand Down Expand Up @@ -115,6 +116,7 @@ export function isAccessibilitySource(
return (
value === "nativescript" ||
value === "react-native" ||
value === "swiftui" ||
value === "in-app-inspector" ||
value === "native-ax"
);
Expand Down
10 changes: 9 additions & 1 deletion client/src/features/accessibility/AccessibilityInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -726,6 +726,7 @@ function errorMessage(error: unknown): string {
const HIERARCHY_SOURCE_ORDER: AccessibilitySource[] = [
"nativescript",
"react-native",
"swiftui",
"in-app-inspector",
"native-ax",
];
Expand Down Expand Up @@ -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";
}

Expand Down Expand Up @@ -815,8 +819,12 @@ function swiftUIDescription(value: Record<string, unknown> | 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: {
Expand Down
6 changes: 6 additions & 0 deletions client/src/styles/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
15 changes: 14 additions & 1 deletion docs/api/inspector-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
7 changes: 4 additions & 3 deletions docs/api/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand All @@ -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": { ... }
}
Expand Down
4 changes: 2 additions & 2 deletions docs/inspector/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions docs/inspector/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand All @@ -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"
],
Expand All @@ -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.
Expand Down
58 changes: 57 additions & 1 deletion docs/inspector/swift.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <booted-simulator-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 <booted-simulator-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")
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"LICENSE",
"README.md",
"bin/",
"scripts/experimental/",
"scripts/postinstall.mjs",
"build/simdeck-bin",
"client/dist/",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/inspector-agent/Examples/DebugIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 14 additions & 1 deletion packages/inspector-agent/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:)`.
Expand Down
15 changes: 14 additions & 1 deletion packages/inspector-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Loading