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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ packages/inspector-agent/.build/
packages/inspector-agent/.swiftpm/
packages/nativescript-inspector/dist/
packages/react-native-inspector/dist/
packages/flutter-inspector/.dart_tool/
packages/flutter-inspector/pubspec.lock
docs/.vitepress/dist/
docs/.vitepress/cache/
cloud/
Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ The native side should own anything that depends on macOS frameworks, `xcrun sim
React Native in-app inspector runtime that connects to the Rust server over
WebSocket, publishes React Fiber component hierarchies with Metro source
locations, and performs best-effort debug JS/native prop edits.
- `packages/flutter-inspector/lib/simdeck_flutter_inspector.dart`
Flutter in-app inspector runtime that connects to the Rust server over
WebSocket, publishes widget/render/semantics hierarchies with debug creation
locations, and performs best-effort semantics, focus, text, and scroll actions.

## Working Rules

Expand All @@ -59,6 +63,7 @@ The native side should own anything that depends on macOS frameworks, `xcrun sim
- Keep browser-only presentation logic in `client/`.
- Keep NativeScript app runtime inspection logic in `packages/nativescript-inspector/`.
- Keep React Native app runtime inspection logic in `packages/react-native-inspector/`.
- Keep Flutter app runtime inspection logic in `packages/flutter-inspector/`.
- Prefer adding a native API endpoint before adding client-only assumptions.
- Do not add a Node or Swift dependency to solve work that already fits in Foundation/AppKit.
- When touching private API usage, keep the adaptation small and explicit and document any simulator/runtime assumptions here.
Expand Down
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ view inside the editor.
- Full simulator control & inspection using private accessibility APIs - available using `simdeck` CLI
- Real-time screen `describe` command using accessibility view tree - available in token-efficient format for agents
- CoreSimulator chrome asset rendering for device bezels
- NativeScript, React Native, UIKit and SwiftUI runtime inspector plugins to view app's view hierarchy live
- NativeScript, React Native, Flutter, UIKit and SwiftUI runtime inspector plugins to view app's view hierarchy live
- `simdeck/test` for fast JS/TS app tests that can query accessibility state and drive simulator controls.
- SimDeck Studio for sharing Simulator streams & automatic PR deployments to on-demand simulators

Expand Down Expand Up @@ -182,9 +182,10 @@ booting is unavailable.
`stream` writes an Annex B H.264 elementary stream to stdout for diagnostics or
external tools such as `ffplay`.

`describe` uses the project daemon to prefer React Native, NativeScript, or
UIKit in-app inspectors, then falls back to the built-in private CoreSimulator
accessibility bridge. Use `--format agent` or `--format compact-json` for
`describe` uses the project daemon to prefer React Native, NativeScript,
Flutter, or UIKit in-app inspectors, then falls back to the built-in private
CoreSimulator accessibility bridge. Use `--format agent` or
`--format compact-json` for
lower-token hierarchy dumps. Coordinate commands accept screen coordinates from
the accessibility tree by default; pass `--normalized` to send `0.0..1.0`
coordinates directly.
Expand Down Expand Up @@ -241,6 +242,27 @@ so the package can capture React Fiber commits. The auto entrypoint no-ops
outside development, reads `EXPO_PUBLIC_SIMDECK_PORT` when present, and
otherwise scans common SimDeck daemon ports.

## Flutter Inspector

Flutter apps can expose their widget tree, render frames, semantics metadata,
and debug widget creation locations with the Flutter inspector package:

```dart
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:simdeck_flutter_inspector/simdeck_flutter_inspector.dart';

void main() {
WidgetsFlutterBinding.ensureInitialized();

if (kDebugMode) {
startSimDeckFlutterInspector(port: 4310);
}

runApp(const App());
}
```

## VS Code

Install the `nativescript.simdeck-vscode` extension from the VS Code Marketplace, then
Expand Down
4 changes: 4 additions & 0 deletions client/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ export interface AccessibilityNode {
enabled?: boolean | null;
frame?: AccessibilityFrame | null;
frameInScreen?: AccessibilityFrame | null;
flutter?: Record<string, unknown> | null;
help?: string | null;
imageName?: string | null;
inspectorId?: string | null;
Expand All @@ -178,11 +179,13 @@ export interface AccessibilityNode {
role?: string | null;
role_description?: string | null;
scroll?: Record<string, unknown> | null;
semantics?: Record<string, unknown> | null;
source?:
| "native-ax"
| "in-app-inspector"
| "nativescript"
| "react-native"
| "flutter"
| "swiftui"
| string
| null;
Expand All @@ -207,6 +210,7 @@ export type AccessibilitySource =
| "in-app-inspector"
| "nativescript"
| "react-native"
| "flutter"
| "swiftui";
export type AccessibilitySourcePreference = AccessibilitySource | "auto";

Expand Down
11 changes: 9 additions & 2 deletions client/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,10 @@ import {

const ACCESSIBILITY_REFRESH_MS = 1500;
const REACT_NATIVE_ACCESSIBILITY_REFRESH_MS = 500;
const FLUTTER_ACCESSIBILITY_REFRESH_MS = 1000;
const DEFAULT_ACCESSIBILITY_MAX_DEPTH = 10;
const LOGICAL_INSPECTOR_MAX_DEPTH = 80;
const FLUTTER_INSPECTOR_MAX_DEPTH = 48;
const AUTH_REQUIRED_MESSAGE = "SimDeck API access token is required.";
const LOCAL_STREAM_DEFAULTS: StreamConfig = {
encoder: "auto",
Expand Down Expand Up @@ -913,7 +915,9 @@ export function AppShell({
maxDepth:
accessibilityPreferredSource === "native-ax"
? DEFAULT_ACCESSIBILITY_MAX_DEPTH
: LOGICAL_INSPECTOR_MAX_DEPTH,
: accessibilityPreferredSource === "flutter"
? FLUTTER_INSPECTOR_MAX_DEPTH
: LOGICAL_INSPECTOR_MAX_DEPTH,
},
);
if (accessibilityRequestIdRef.current !== requestId) {
Expand Down Expand Up @@ -993,7 +997,10 @@ export function AppShell({
accessibilityPreferredSource === "react-native" ||
accessibilitySource === "react-native"
? REACT_NATIVE_ACCESSIBILITY_REFRESH_MS
: ACCESSIBILITY_REFRESH_MS;
: accessibilityPreferredSource === "flutter" ||
accessibilitySource === "flutter"
? FLUTTER_ACCESSIBILITY_REFRESH_MS
: ACCESSIBILITY_REFRESH_MS;
const interval = window.setInterval(() => {
void loadAccessibilityTree();
}, refreshMs);
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 @@ -34,6 +34,7 @@ export const TOUCH_OVERLAY_VISIBLE_STORAGE_KEY = "xcw-touch-overlay-visible";
const ACCESSIBILITY_SOURCE_ORDER: AccessibilitySource[] = [
"nativescript",
"react-native",
"flutter",
"swiftui",
"in-app-inspector",
"native-ax",
Expand Down Expand Up @@ -158,6 +159,7 @@ export function isAccessibilitySource(
return (
value === "nativescript" ||
value === "react-native" ||
value === "flutter" ||
value === "swiftui" ||
value === "in-app-inspector" ||
value === "native-ax"
Expand Down
20 changes: 18 additions & 2 deletions client/src/features/accessibility/AccessibilityInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,15 @@ export function AccessibilityInspector({

const tree = buildAccessibilityTree(roots);
const storedExpandedIds =
source === "react-native"
source === "react-native" || source === "flutter"
? []
: readStoredStringArray(expandedStorageKey(udid));
setExpandedIds(
storedExpandedIds.length > 0
? new Set(storedExpandedIds)
: defaultExpandedAccessibilityIds(
tree,
source === "react-native" ? 2 : 10,
source === "react-native" || source === "flutter" ? 2 : 10,
),
);
expandedInitializedKeyRef.current = expansionKey;
Expand Down Expand Up @@ -467,6 +467,7 @@ function NodeDetails({
["Module", node.moduleName ?? ""],
["NativeScript", nativeScriptDescription(node.nativeScript)],
["React Native", reactNativeDescription(node.reactNative)],
["Flutter", flutterDescription(node.flutter)],
["UIKit Class", node.className ?? ""],
["Last JS", lastUIKitScriptText(node)],
["Value", node.AXValue ?? ""],
Expand Down Expand Up @@ -727,6 +728,7 @@ function errorMessage(error: unknown): string {
const HIERARCHY_SOURCE_ORDER: AccessibilitySource[] = [
"nativescript",
"react-native",
"flutter",
"swiftui",
"in-app-inspector",
"native-ax",
Expand Down Expand Up @@ -756,6 +758,9 @@ function sourceLabel(source: AccessibilitySource): string {
if (source === "react-native") {
return "React Native";
}
if (source === "flutter") {
return "Flutter";
}
if (source === "swiftui") {
return "SwiftUI";
}
Expand Down Expand Up @@ -795,6 +800,17 @@ function reactNativeDescription(
return [tag, testID, nativeID].filter(Boolean).join(" / ");
}

function flutterDescription(value: Record<string, unknown> | null | undefined) {
if (!value) {
return "";
}
const widgetType =
typeof value.widgetType === "string" ? value.widgetType : "";
const stateType = typeof value.stateType === "string" ? value.stateType : "";
const key = typeof value.key === "string" ? value.key : "";
return [widgetType, stateType, key].filter(Boolean).join(" / ");
}

function lastUIKitScriptText(node: AccessibilityNode): string {
const direct = stringRecordValue(node.uikitScript, "script");
if (direct) {
Expand Down
78 changes: 78 additions & 0 deletions client/src/features/accessibility/accessibilityTree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,52 @@ describe("buildAccessibilityTree", () => {
expect(tree[0].node.type).toBe("Text");
expect(tree[0].chain.map((node) => node.type)).toEqual(["Wrap", "RCTView"]);
});

it("compacts one-child Flutter layout wrappers but keeps app components", () => {
const roots: AccessibilityNode[] = [
{
source: "flutter",
type: "InspectorDemoHome",
title: "InspectorDemoHome",
sourceLocation: { file: "/tmp/demo/lib/main.dart" },
children: [
{
source: "flutter",
type: "Padding",
title: "Padding",
sourceLocation: { file: "/tmp/demo/lib/main.dart" },
flutter: { transparent: true },
children: [
{
source: "flutter",
type: "Center",
title: "Center",
sourceLocation: { file: "/tmp/demo/lib/main.dart" },
flutter: { transparent: true },
children: [
{
source: "flutter",
type: "Text",
title: "Continue",
AXLabel: "Continue",
},
],
},
],
},
],
},
];

const tree = buildAccessibilityTree(roots);

expect(tree[0].node.type).toBe("InspectorDemoHome");
expect(tree[0].children[0].node.type).toBe("Text");
expect(tree[0].children[0].chain.map((node) => node.type)).toEqual([
"Padding",
"Center",
]);
});
});

describe("findAccessibilityItemAtPoint", () => {
Expand Down Expand Up @@ -247,4 +293,36 @@ describe("findAccessibilityItemAtPoint", () => {
expect(item?.node.type).toBe("Label");
expect(item?.id).toBe("0.0");
});

it("ignores transparent Flutter overlays that cover selectable content", () => {
const roots: AccessibilityNode[] = [
{
source: "flutter",
type: "Stack",
frame: { x: 0, y: 0, width: 400, height: 800 },
flutter: { transparent: true },
children: [
{
source: "flutter",
type: "FilledButton",
title: "Continue",
AXLabel: "Continue",
frame: { x: 100, y: 300, width: 200, height: 60 },
},
{
source: "flutter",
type: "Listener",
title: "Listener",
frame: { x: 0, y: 0, width: 400, height: 800 },
flutter: { transparent: true },
},
],
},
];

const item = findAccessibilityItemAtPoint(roots, { x: 0.5, y: 0.4125 });

expect(item?.node.type).toBe("FilledButton");
expect(item?.id).toBe("0.0");
});
});
Loading
Loading