Remote debugging for Flutter on real devices — widget-tree traversal, gesture injection, screenshots, logs and hot-reload, driven over MCP by your AI coding tool.
The Flutter client is a pure Dart package: no platform channels, no Gradle or CocoaPods edits. Add it as a git dependency, bootstrap it in main(), and your running app connects to OmniDebugLink so an AI tool can inspect and drive it on a phone, an emulator, or a colleague's device. It also brings the one capability no other platform has: hot_reload, where the AI edits your Dart code, reloads the running app, and keeps testing.
No platform channels, no per-platform setup. Dependencies are just web_socket_channel, shared_preferences and vm_service, and task handlers run on your main isolate — one language, one place to debug.
The element tree rebuilds constantly and runs 70+ levels deep, so tasks locate targets by key / text / widget_type substring (+index) and complete locate-and-act atomically in one task. Path is only an exact-address fallback.
In debug or profile builds with a reachable VM Service, hot_reload reloads sources in place — the AI edit → reload → verify loop stays inside one session.
OmniDebugLink.actionsEnabled (default true) gates every write operation. Set it false and the SDK becomes a read-only observer, announcing that mode with its hello.
pubspec.yaml you can edit — the package ships as a git dependency, not on a package registry.hot_reload: a debug or profile build with a reachable VM Service; the task is not registered in release builds.dependencies:
omnidebuglink:
git:
url: https://github.com/omnidebuglink/omnidebuglink_flutter.git
ref: v0.2.0
bootstrap() takes over the zone, catching uncaught async errors and print output for the log stream.
import 'package:omnidebuglink/omnidebuglink.dart';
void main() {
OmniDebugLink.bootstrap(
token: '<clientToken>',
appVersion: '1.2.0',
app: const MyApp(),
);
}
If your app owns its own zone, start the link after runApp and forward errors with recordLog / recordError. The relay URL is baked in.
runApp(const MyApp());
await OmniDebugLink.start('<clientToken>');
This lets get_state return the navigation stack; without it you get routes: null plus guidance instead.
MaterialApp(navigatorObservers: [OmniDebugLink.routeObserver], ...)
Launch on a device: it connects, announces its capabilities, and shows up in your account for synchronous MCP calls.
19 built-in tasks, plus conditional ones such as hot_reload. Registry changes re-announce automatically, so custom tasks are discovered without any server-side change.
| Task | What it does |
|---|---|
ui_traverse | Widget tree snapshot — a flat list by default (depth/name/key/text/rect/center, token-efficient); flat:false for nested output, 3000-node cap. |
find_objects | Search by text / key / widget_type substring; matches carry center coords and a hint to pass the same locator to an action task. |
view_component | One widget in depth: targeted properties plus renderObject info, same locators. |
wait_for | Polls every 200 ms until a key / text / widget_type / path appears; timeout returns found: false, not an error. |
screenshot | PNG via OffsetLayer.toImage, downsampling loop to fit the base64 budget. |
read_logs | Subscription buffer (no history before start): Flutter framework errors, platform-dispatched errors, debugPrint. |
get_perf | FrameTiming percentiles (p50/p95/p99) plus RSS. |
get_state | App state plus the route stack (register OmniDebugLink.routeObserver). |
prefs | Read SharedPreferences (get / list). |
All gated by actionsEnabled.
| Task | What it does |
|---|---|
ui_click | Real gesture tap (GestureBinding.handlePointerEvent) on a target located by key/text/widget_type/index/path — atomically in one call. |
tap_screen | Tap at normalized 0-1 coordinates (top-left origin). |
swipe | Drag gesture with per-frame delta and increasing timestamps (velocity tracking needs both). |
long_press | Pointer down, hold until the recognizer's own timer fires, then up. |
input_text | Write text into a field located by key/widget_type/path or the current focus. Note: text is the value to enter, not a locator. |
set_component | Targeted mutations only (no reflection in AOT): text / scroll_offset / scroll_to_end / scroll_to_start / checked. |
send_key | Soft-dispatched enter / escape / tab / space. |
prefs | Write / delete SharedPreferences with valueType coercion; the result echoes the stored type. |
hot_reload reloads sources in place — a natural fit for an AI edit → reload → verify loop. There is no hot_restart: that RPC requires a flutter run session, which standalone apps never have. Basics round out the set: echo, ping, get_stats.
Normalized coordinates are 0-1 with a top-left origin — same as Android (Unity is bottom-left). From a screenshot pixel: x=(px+0.5)/W, y=(py+0.5)/H, no flipping. All rect/center values are logical pixels, and screenshots are PNG.
| Source | Captured? |
|---|---|
| Flutter framework errors (build/layout) | yes |
| Uncaught platform-dispatched errors | yes |
debugPrint | yes, while the link is started |
Uncaught async errors / print | only inside the bootstrap() zone |
| Native logs (logcat / nslog) | no |
| Anything logged before start | no — no history |
Register your own task with a type, handler, description and payload schema; capability changes are announced automatically.
OmniDebugLink.tasks.register(
type,
handler,
description: '...',
payloadSchema: {...},
);
set_component supports only targeted operations, and --obfuscate scrambles widget type names. Debug and profile builds are the right place to debug.tap_screen on the on-screen controls.android:id; text and widget_type substrings also work, with index to disambiguate.