Skip to content

Commit e204386

Browse files
authored
Add Flutter runtime inspector support (#25)
* Add Flutter runtime inspector support * Fix Flutter inspector hierarchy refresh * Flatten Flutter inspector wrapper nodes * Preserve published inspector framework sources
1 parent e1622ac commit e204386

33 files changed

Lines changed: 2073 additions & 50 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ packages/inspector-agent/.build/
99
packages/inspector-agent/.swiftpm/
1010
packages/nativescript-inspector/dist/
1111
packages/react-native-inspector/dist/
12+
packages/flutter-inspector/.dart_tool/
13+
packages/flutter-inspector/pubspec.lock
1214
docs/.vitepress/dist/
1315
docs/.vitepress/cache/
1416
cloud/

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ The native side should own anything that depends on macOS frameworks, `xcrun sim
5151
React Native in-app inspector runtime that connects to the Rust server over
5252
WebSocket, publishes React Fiber component hierarchies with Metro source
5353
locations, and performs best-effort debug JS/native prop edits.
54+
- `packages/flutter-inspector/lib/simdeck_flutter_inspector.dart`
55+
Flutter in-app inspector runtime that connects to the Rust server over
56+
WebSocket, publishes widget/render/semantics hierarchies with debug creation
57+
locations, and performs best-effort semantics, focus, text, and scroll actions.
5458

5559
## Working Rules
5660

@@ -59,6 +63,7 @@ The native side should own anything that depends on macOS frameworks, `xcrun sim
5963
- Keep browser-only presentation logic in `client/`.
6064
- Keep NativeScript app runtime inspection logic in `packages/nativescript-inspector/`.
6165
- Keep React Native app runtime inspection logic in `packages/react-native-inspector/`.
66+
- Keep Flutter app runtime inspection logic in `packages/flutter-inspector/`.
6267
- Prefer adding a native API endpoint before adding client-only assumptions.
6368
- Do not add a Node or Swift dependency to solve work that already fits in Foundation/AppKit.
6469
- When touching private API usage, keep the adaptation small and explicit and document any simulator/runtime assumptions here.

README.md

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ view inside the editor.
3939
- Full simulator control & inspection using private accessibility APIs - available using `simdeck` CLI
4040
- Real-time screen `describe` command using accessibility view tree - available in token-efficient format for agents
4141
- CoreSimulator chrome asset rendering for device bezels
42-
- NativeScript, React Native, UIKit and SwiftUI runtime inspector plugins to view app's view hierarchy live
42+
- NativeScript, React Native, Flutter, UIKit and SwiftUI runtime inspector plugins to view app's view hierarchy live
4343
- `simdeck/test` for fast JS/TS app tests that can query accessibility state and drive simulator controls.
4444
- SimDeck Studio for sharing Simulator streams & automatic PR deployments to on-demand simulators
4545

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

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

245+
## Flutter Inspector
246+
247+
Flutter apps can expose their widget tree, render frames, semantics metadata,
248+
and debug widget creation locations with the Flutter inspector package:
249+
250+
```dart
251+
import 'package:flutter/foundation.dart';
252+
import 'package:flutter/widgets.dart';
253+
import 'package:simdeck_flutter_inspector/simdeck_flutter_inspector.dart';
254+
255+
void main() {
256+
WidgetsFlutterBinding.ensureInitialized();
257+
258+
if (kDebugMode) {
259+
startSimDeckFlutterInspector(port: 4310);
260+
}
261+
262+
runApp(const App());
263+
}
264+
```
265+
244266
## VS Code
245267

246268
Install the `nativescript.simdeck-vscode` extension from the VS Code Marketplace, then

client/src/api/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ export interface AccessibilityNode {
164164
enabled?: boolean | null;
165165
frame?: AccessibilityFrame | null;
166166
frameInScreen?: AccessibilityFrame | null;
167+
flutter?: Record<string, unknown> | null;
167168
help?: string | null;
168169
imageName?: string | null;
169170
inspectorId?: string | null;
@@ -178,11 +179,13 @@ export interface AccessibilityNode {
178179
role?: string | null;
179180
role_description?: string | null;
180181
scroll?: Record<string, unknown> | null;
182+
semantics?: Record<string, unknown> | null;
181183
source?:
182184
| "native-ax"
183185
| "in-app-inspector"
184186
| "nativescript"
185187
| "react-native"
188+
| "flutter"
186189
| "swiftui"
187190
| string
188191
| null;
@@ -207,6 +210,7 @@ export type AccessibilitySource =
207210
| "in-app-inspector"
208211
| "nativescript"
209212
| "react-native"
213+
| "flutter"
210214
| "swiftui";
211215
export type AccessibilitySourcePreference = AccessibilitySource | "auto";
212216

client/src/app/AppShell.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,10 @@ import {
106106

107107
const ACCESSIBILITY_REFRESH_MS = 1500;
108108
const REACT_NATIVE_ACCESSIBILITY_REFRESH_MS = 500;
109+
const FLUTTER_ACCESSIBILITY_REFRESH_MS = 1000;
109110
const DEFAULT_ACCESSIBILITY_MAX_DEPTH = 10;
110111
const LOGICAL_INSPECTOR_MAX_DEPTH = 80;
112+
const FLUTTER_INSPECTOR_MAX_DEPTH = 48;
111113
const AUTH_REQUIRED_MESSAGE = "SimDeck API access token is required.";
112114
const LOCAL_STREAM_DEFAULTS: StreamConfig = {
113115
encoder: "auto",
@@ -913,7 +915,9 @@ export function AppShell({
913915
maxDepth:
914916
accessibilityPreferredSource === "native-ax"
915917
? DEFAULT_ACCESSIBILITY_MAX_DEPTH
916-
: LOGICAL_INSPECTOR_MAX_DEPTH,
918+
: accessibilityPreferredSource === "flutter"
919+
? FLUTTER_INSPECTOR_MAX_DEPTH
920+
: LOGICAL_INSPECTOR_MAX_DEPTH,
917921
},
918922
);
919923
if (accessibilityRequestIdRef.current !== requestId) {
@@ -993,7 +997,10 @@ export function AppShell({
993997
accessibilityPreferredSource === "react-native" ||
994998
accessibilitySource === "react-native"
995999
? REACT_NATIVE_ACCESSIBILITY_REFRESH_MS
996-
: ACCESSIBILITY_REFRESH_MS;
1000+
: accessibilityPreferredSource === "flutter" ||
1001+
accessibilitySource === "flutter"
1002+
? FLUTTER_ACCESSIBILITY_REFRESH_MS
1003+
: ACCESSIBILITY_REFRESH_MS;
9971004
const interval = window.setInterval(() => {
9981005
void loadAccessibilityTree();
9991006
}, refreshMs);

client/src/app/uiState.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const TOUCH_OVERLAY_VISIBLE_STORAGE_KEY = "xcw-touch-overlay-visible";
3434
const ACCESSIBILITY_SOURCE_ORDER: AccessibilitySource[] = [
3535
"nativescript",
3636
"react-native",
37+
"flutter",
3738
"swiftui",
3839
"in-app-inspector",
3940
"native-ax",
@@ -158,6 +159,7 @@ export function isAccessibilitySource(
158159
return (
159160
value === "nativescript" ||
160161
value === "react-native" ||
162+
value === "flutter" ||
161163
value === "swiftui" ||
162164
value === "in-app-inspector" ||
163165
value === "native-ax"

client/src/features/accessibility/AccessibilityInspector.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,15 +146,15 @@ export function AccessibilityInspector({
146146

147147
const tree = buildAccessibilityTree(roots);
148148
const storedExpandedIds =
149-
source === "react-native"
149+
source === "react-native" || source === "flutter"
150150
? []
151151
: readStoredStringArray(expandedStorageKey(udid));
152152
setExpandedIds(
153153
storedExpandedIds.length > 0
154154
? new Set(storedExpandedIds)
155155
: defaultExpandedAccessibilityIds(
156156
tree,
157-
source === "react-native" ? 2 : 10,
157+
source === "react-native" || source === "flutter" ? 2 : 10,
158158
),
159159
);
160160
expandedInitializedKeyRef.current = expansionKey;
@@ -467,6 +467,7 @@ function NodeDetails({
467467
["Module", node.moduleName ?? ""],
468468
["NativeScript", nativeScriptDescription(node.nativeScript)],
469469
["React Native", reactNativeDescription(node.reactNative)],
470+
["Flutter", flutterDescription(node.flutter)],
470471
["UIKit Class", node.className ?? ""],
471472
["Last JS", lastUIKitScriptText(node)],
472473
["Value", node.AXValue ?? ""],
@@ -727,6 +728,7 @@ function errorMessage(error: unknown): string {
727728
const HIERARCHY_SOURCE_ORDER: AccessibilitySource[] = [
728729
"nativescript",
729730
"react-native",
731+
"flutter",
730732
"swiftui",
731733
"in-app-inspector",
732734
"native-ax",
@@ -756,6 +758,9 @@ function sourceLabel(source: AccessibilitySource): string {
756758
if (source === "react-native") {
757759
return "React Native";
758760
}
761+
if (source === "flutter") {
762+
return "Flutter";
763+
}
759764
if (source === "swiftui") {
760765
return "SwiftUI";
761766
}
@@ -795,6 +800,17 @@ function reactNativeDescription(
795800
return [tag, testID, nativeID].filter(Boolean).join(" / ");
796801
}
797802

803+
function flutterDescription(value: Record<string, unknown> | null | undefined) {
804+
if (!value) {
805+
return "";
806+
}
807+
const widgetType =
808+
typeof value.widgetType === "string" ? value.widgetType : "";
809+
const stateType = typeof value.stateType === "string" ? value.stateType : "";
810+
const key = typeof value.key === "string" ? value.key : "";
811+
return [widgetType, stateType, key].filter(Boolean).join(" / ");
812+
}
813+
798814
function lastUIKitScriptText(node: AccessibilityNode): string {
799815
const direct = stringRecordValue(node.uikitScript, "script");
800816
if (direct) {

client/src/features/accessibility/accessibilityTree.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,52 @@ describe("buildAccessibilityTree", () => {
114114
expect(tree[0].node.type).toBe("Text");
115115
expect(tree[0].chain.map((node) => node.type)).toEqual(["Wrap", "RCTView"]);
116116
});
117+
118+
it("compacts one-child Flutter layout wrappers but keeps app components", () => {
119+
const roots: AccessibilityNode[] = [
120+
{
121+
source: "flutter",
122+
type: "InspectorDemoHome",
123+
title: "InspectorDemoHome",
124+
sourceLocation: { file: "/tmp/demo/lib/main.dart" },
125+
children: [
126+
{
127+
source: "flutter",
128+
type: "Padding",
129+
title: "Padding",
130+
sourceLocation: { file: "/tmp/demo/lib/main.dart" },
131+
flutter: { transparent: true },
132+
children: [
133+
{
134+
source: "flutter",
135+
type: "Center",
136+
title: "Center",
137+
sourceLocation: { file: "/tmp/demo/lib/main.dart" },
138+
flutter: { transparent: true },
139+
children: [
140+
{
141+
source: "flutter",
142+
type: "Text",
143+
title: "Continue",
144+
AXLabel: "Continue",
145+
},
146+
],
147+
},
148+
],
149+
},
150+
],
151+
},
152+
];
153+
154+
const tree = buildAccessibilityTree(roots);
155+
156+
expect(tree[0].node.type).toBe("InspectorDemoHome");
157+
expect(tree[0].children[0].node.type).toBe("Text");
158+
expect(tree[0].children[0].chain.map((node) => node.type)).toEqual([
159+
"Padding",
160+
"Center",
161+
]);
162+
});
117163
});
118164

119165
describe("findAccessibilityItemAtPoint", () => {
@@ -247,4 +293,36 @@ describe("findAccessibilityItemAtPoint", () => {
247293
expect(item?.node.type).toBe("Label");
248294
expect(item?.id).toBe("0.0");
249295
});
296+
297+
it("ignores transparent Flutter overlays that cover selectable content", () => {
298+
const roots: AccessibilityNode[] = [
299+
{
300+
source: "flutter",
301+
type: "Stack",
302+
frame: { x: 0, y: 0, width: 400, height: 800 },
303+
flutter: { transparent: true },
304+
children: [
305+
{
306+
source: "flutter",
307+
type: "FilledButton",
308+
title: "Continue",
309+
AXLabel: "Continue",
310+
frame: { x: 100, y: 300, width: 200, height: 60 },
311+
},
312+
{
313+
source: "flutter",
314+
type: "Listener",
315+
title: "Listener",
316+
frame: { x: 0, y: 0, width: 400, height: 800 },
317+
flutter: { transparent: true },
318+
},
319+
],
320+
},
321+
];
322+
323+
const item = findAccessibilityItemAtPoint(roots, { x: 0.5, y: 0.4125 });
324+
325+
expect(item?.node.type).toBe("FilledButton");
326+
expect(item?.id).toBe("0.0");
327+
});
250328
});

0 commit comments

Comments
 (0)