From 1d02ff76e31479c5073a60ac9db875186be56e96 Mon Sep 17 00:00:00 2001 From: Patrick Bertsch Date: Sat, 2 May 2026 17:07:14 -0600 Subject: [PATCH] feat(studio,agent): mDNS auto-discovery for WiFi physical devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-sided change so Studio can find physical iOS/Android devices on the LAN without manual IP entry. Agent (flutter_probe_agent v0.7.0): - New dep: bonsoir ^5.1.10 - When started in WiFi mode (PROBE_WIFI=true), advertises itself as _flutterprobe._tcp with TXT records {version, port}. Localhost deployments skip mDNS entirely so simulator-only apps pay zero overhead. - Token is deliberately NOT in TXT records — anyone on the LAN could read it. Users still paste the token from PROBE_TOKEN logs. Studio: - New dep: github.com/grandcat/zeroconf v1.0.0 - New file studio/wifi_discovery.go owns the browse loop. Discovered devices stream out via the wifi:device-found Wails event with dedup on host:port. - New Wails methods: StartWiFiDiscovery, StopWiFiDiscovery, ConnectWiFi(host, port, token). - Frontend gets a WiFi button in the toolbar that opens a modal listing discovered devices, with a token input that fires ConnectWiFi on submit. Compatibility: Studio v0.7.0 only discovers agents v0.7.0+. Older agents continue to work over USB through the existing Connect path. --- .gitignore | 1 + CHANGELOG.md | 5 +- probe_agent/CHANGELOG.md | 12 +++ probe_agent/lib/src/mdns_advertise.dart | 58 ++++++++++ probe_agent/lib/src/server.dart | 28 +++++ probe_agent/pubspec.yaml | 8 +- studio/README.md | 12 ++- studio/app.go | 99 ++++++++++++++++- studio/frontend/index.html | 27 +++++ studio/frontend/src/main.ts | 87 +++++++++++++++ studio/frontend/src/style.css | 27 +++++ studio/frontend/wailsjs/go/main/App.d.ts | 6 ++ studio/frontend/wailsjs/go/main/App.js | 12 +++ studio/go.mod | 3 + studio/go.sum | 20 ++++ studio/wifi_discovery.go | 132 +++++++++++++++++++++++ 16 files changed, 529 insertions(+), 8 deletions(-) create mode 100644 probe_agent/lib/src/mdns_advertise.dart create mode 100644 studio/wifi_discovery.go diff --git a/.gitignore b/.gitignore index 9e2aefb..3593cb6 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ probe_agent/.dart_tool/ probe_agent/build/ probe_agent/pubspec.lock probe_agent/.packages +probe_agent/.flutter-plugins-dependencies # VS Code extension build output vscode/node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 94256c3..d0ba5c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added - **MCP device lifecycle tools** — `probe-mcp` now exposes 5 new tools that let AI agents discover and manage simulators/emulators end-to-end without leaving chat: `list_devices` (booted/connected sims, emulators, physical devices), `list_simulators` (all iOS sims including shutdown), `list_avds` (Android Virtual Device names), `start_device` (boot Android emulator by AVD name or iOS simulator by UDID, blocks until ready), `shutdown_device` (iOS simulator only). Brings the total tool count from 10 to 15. - **`device` argument on existing MCP tools** — `get_widget_tree`, `take_screenshot`, `run_script`, and `run_tests` accept an optional `device` (serial or UDID) so the agent can pin a target when multiple devices are connected. Previously the agent had to smuggle this through the undocumented `flags` string. -- **Studio: physical-device support over USB** — Studio's Connect flow now handles physical iOS (via `iproxy` tunnel + `idevicesyslog` token read) and physical Android (via `adb forward`, same path as emulators). The picker shows a `physical` tag so cabled devices are obvious next to sims and emulators. Requires `brew install libimobiledevice` for physical iOS. WiFi-attached physical devices remain a follow-up — they need mDNS auto-discovery, which is a paired agent + Studio change. +- **Studio: physical-device support over USB** — Studio's Connect flow now handles physical iOS (via `iproxy` tunnel + `idevicesyslog` token read) and physical Android (via `adb forward`, same path as emulators). The picker shows a `physical` tag so cabled devices are obvious next to sims and emulators. Requires `brew install libimobiledevice` for physical iOS. +- **Studio: WiFi physical-device discovery via mDNS** — Studio now browses for `_flutterprobe._tcp` on the LAN and lets you connect to discovered devices with one click + token paste. Requires `flutter_probe_agent` v0.7.0+ in your Flutter app. The token is intentionally NOT advertised over mDNS (anyone on the network would be able to read it) — the user pastes it from the app's `PROBE_TOKEN=...` log line. +- **`flutter_probe_agent` v0.7.0**: agent advertises itself over Bonjour/NSD when running in WiFi mode (`PROBE_WIFI=true`). New dependency: `bonsoir: ^5.1.10`. Localhost-bound agents skip mDNS entirely so simulator-only apps pay zero overhead. +- **Studio: new Wails methods** `StartWiFiDiscovery`, `StopWiFiDiscovery`, `ConnectWiFi(host, port, token)`. Backed by `github.com/grandcat/zeroconf`. ## [0.6.0] - 2026-04-26 diff --git a/probe_agent/CHANGELOG.md b/probe_agent/CHANGELOG.md index b57ce50..47b164f 100644 --- a/probe_agent/CHANGELOG.md +++ b/probe_agent/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.7.0 - 2026-05-02 + +- **mDNS auto-discovery** — when running in WiFi mode (`PROBE_WIFI=true`), the + agent now advertises itself over Bonjour/NSD as `_flutterprobe._tcp` so + Studio (and any compatible client) can discover physical devices on the LAN + without manual IP entry. The token is deliberately NOT included in TXT + records — anyone on the same network would be able to read it. The agent + still prints `PROBE_TOKEN=...` to logs as before. +- New dependency: `bonsoir: ^5.1.10`. Localhost-only deployments (no + `PROBE_WIFI`) skip mDNS bring-up entirely so apps that only test on + simulators pay zero overhead. + ## 0.6.0 - 2026-04-26 - Version bump to keep in sync with CLI v0.6.0 diff --git a/probe_agent/lib/src/mdns_advertise.dart b/probe_agent/lib/src/mdns_advertise.dart new file mode 100644 index 0000000..8e24f83 --- /dev/null +++ b/probe_agent/lib/src/mdns_advertise.dart @@ -0,0 +1,58 @@ +import 'dart:async'; + +import 'package:bonsoir/bonsoir.dart'; + +/// mDNS service type used by all FlutterProbe agents. Studio (and any other +/// discovery client) browses this name to find agents on the LAN. The token +/// is intentionally not advertised in TXT records — anyone on the same +/// network would be able to read it. +const String mdnsServiceType = '_flutterprobe._tcp'; + +/// ProbeMDNS publishes the agent over Bonjour/NSD when the agent is running +/// in WiFi mode (i.e. listening on 0.0.0.0). On localhost-only deployments +/// the agent never calls into this class, so apps that only use simulators +/// pay zero overhead. +class ProbeMDNS { + BonsoirBroadcast? _broadcast; + + /// Starts advertising the agent on the local network. Errors are logged + /// but never thrown — mDNS failure must not prevent the agent from + /// accepting direct connections. + Future start({ + required String name, + required int port, + required String agentVersion, + }) async { + try { + final service = BonsoirService( + name: name, + type: mdnsServiceType, + port: port, + attributes: { + 'version': agentVersion, + 'port': '$port', + }, + ); + final broadcast = BonsoirBroadcast(service: service); + await broadcast.ready; + await broadcast.start(); + _broadcast = broadcast; + // ignore: avoid_print + print('PROBE_MDNS=advertising as "$name" on $mdnsServiceType:$port'); + } catch (e) { + // ignore: avoid_print + print('ProbeAgent: mDNS advertise failed: $e'); + } + } + + /// Stops advertising. Safe to call when the broadcast was never started or + /// has already been stopped. + Future stop() async { + try { + await _broadcast?.stop(); + } catch (_) { + // best-effort cleanup; failure here just leaks one record until TTL + } + _broadcast = null; + } +} diff --git a/probe_agent/lib/src/server.dart b/probe_agent/lib/src/server.dart index 45ebbfe..3a3be54 100644 --- a/probe_agent/lib/src/server.dart +++ b/probe_agent/lib/src/server.dart @@ -4,8 +4,13 @@ import 'dart:io'; import 'dart:math'; import 'executor.dart'; +import 'mdns_advertise.dart'; import 'protocol.dart'; +/// Agent version used in mDNS TXT records so discovery clients can render +/// version info next to each device. Bumped together with pubspec.yaml. +const String probeAgentVersion = '0.7.0'; + /// ProbeServer is a WebSocket server that listens on localhost:48686. /// /// The probe CLI connects to this server after the app starts. @@ -26,6 +31,11 @@ class ProbeServer { /// across stateless HTTP requests within the same session. ProbeExecutor? _httpExecutor; + /// mDNS broadcaster, only used when [allowRemoteConnections] is true. + /// Studio (and any other compatible client) browses for these records to + /// discover physical devices on the LAN without manual IP entry. + ProbeMDNS? _mdns; + /// Creates a ProbeServer. /// Set [allowRemoteConnections] to true for WiFi testing (binds to 0.0.0.0 /// instead of localhost). Only use in debug/profile builds — never in release. @@ -57,6 +67,22 @@ class ProbeServer { print('PROBE_TOKEN=$_token'); }); + // Advertise on mDNS only when we're actually reachable from off-host. + // Localhost-bound agents have no one to discover them. + if (allowRemoteConnections) { + _mdns = ProbeMDNS(); + // Hostname makes a stable, recognizable label (e.g. "Patrick's iPhone"). + // Falls back to a generic name when the OS doesn't expose one. + final host = Platform.localHostname.isNotEmpty + ? Platform.localHostname + : 'flutter-probe-agent'; + await _mdns!.start( + name: host, + port: port, + agentVersion: probeAgentVersion, + ); + } + _serve(); } @@ -171,6 +197,8 @@ class ProbeServer { Future stop() async { _tokenTimer?.cancel(); _tokenTimer = null; + await _mdns?.stop(); + _mdns = null; await _server?.close(force: true); _server = null; } diff --git a/probe_agent/pubspec.yaml b/probe_agent/pubspec.yaml index 146a7c0..6fb25a4 100644 --- a/probe_agent/pubspec.yaml +++ b/probe_agent/pubspec.yaml @@ -3,7 +3,7 @@ description: >- On-device E2E test agent for FlutterProbe. Embeds in your Flutter app and executes test commands via direct widget-tree access with sub-50ms latency. -version: 0.6.0 +version: 0.7.0 homepage: https://flutterprobe.dev repository: https://github.com/AlphaWaveSystems/flutter-probe issue_tracker: https://github.com/AlphaWaveSystems/flutter-probe/issues @@ -21,6 +21,12 @@ environment: dependencies: flutter: sdk: flutter + # Used to advertise the agent over mDNS/Bonjour when running in WiFi + # mode (PROBE_WIFI=true). Studio uses this for zero-config discovery + # of physical devices on the LAN. The dependency is conditional on the + # WiFi flag at runtime — apps not using WiFi mode pay no advertising + # cost. + bonsoir: ^5.1.10 dev_dependencies: flutter_test: diff --git a/studio/README.md b/studio/README.md index 75c8e55..aea8aad 100644 --- a/studio/README.md +++ b/studio/README.md @@ -115,7 +115,7 @@ studio/ - Editor pane with Monaco + ProbeScript syntax highlighting - Device pane with ~10 FPS screenshot stream (via existing `take_screenshot` RPC — works on iOS sim, Android emu, physical iOS - over USB, physical Android over USB with zero new agent code) + over USB or WiFi, physical Android over USB or WiFi) - Tap forwarding (click in pane → coord translation → existing `tap_at` RPC) - Lint markers (live, on every edit) @@ -129,13 +129,15 @@ studio/ | Android Emulator | adb forward | Token from cache, /data/local/tmp, or logcat | | Physical iOS (USB) | iproxy tunnel | Requires `brew install libimobiledevice`; token via idevicesyslog | | Physical Android (USB) | adb forward | Same path as emulator | -| Physical iOS (WiFi) | direct | Coming via mDNS auto-discovery in a follow-up | -| Physical Android (WiFi) | direct | Coming via mDNS auto-discovery in a follow-up | +| Physical iOS (WiFi) | direct | Auto-discovered via mDNS; user pastes token from app logs | +| Physical Android (WiFi) | direct | Auto-discovered via mDNS; user pastes token from app logs | + +WiFi discovery requires `flutter_probe_agent` v0.7.0+ in your Flutter +app's pubspec, and the app must run with `--dart-define=PROBE_WIFI=true` +so the agent advertises itself as `_flutterprobe._tcp` on the LAN. ## Deferred to v0.7.x -- WiFi-attached physical devices (mDNS auto-discovery — needs an agent - package update) - Native scrcpy embed (Android, 60 FPS) - `simctl io recordVideo` H.264 stream (iOS sim, 60 FPS) - Multi-device side-by-side diff --git a/studio/app.go b/studio/app.go index 81c4c68..fc1f793 100644 --- a/studio/app.go +++ b/studio/app.go @@ -33,6 +33,7 @@ type App struct { mu sync.Mutex conn *connection // nil when disconnected deviceMgr *device.Manager + wifi *wifiDiscovery } // connection holds the active agent client and cleanup callback. @@ -50,7 +51,7 @@ type connection struct { // NewApp creates a new App. func NewApp() *App { - return &App{deviceMgr: device.NewManager()} + return &App{deviceMgr: device.NewManager(), wifi: newWiFiDiscovery()} } // startup is called once when the Wails runtime is ready. The context is @@ -61,6 +62,9 @@ func (a *App) startup(ctx context.Context) { // shutdown closes any active connection. Called by Wails on app exit. func (a *App) shutdown(_ context.Context) { + if a.wifi != nil { + a.wifi.Stop() + } a.mu.Lock() conn := a.conn a.conn = nil @@ -305,6 +309,99 @@ func (a *App) Status() ConnectionStatus { } } +// ---- WiFi discovery ---- + +// StartWiFiDiscovery begins browsing the local network for agents +// advertising _flutterprobe._tcp via mDNS. Each discovered device is +// emitted via the `wifi:device-found` event. Idempotent — calling Start +// while a browse is already running is a no-op. +// +// The agent must be flutter_probe_agent v0.7.0+ AND running with +// PROBE_WIFI=true to be discoverable. Older agents and localhost-bound +// agents do not advertise. +func (a *App) StartWiFiDiscovery() error { + if a.wifi == nil { + return fmt.Errorf("wifi discovery not initialized") + } + return a.wifi.Start(a.ctx) +} + +// StopWiFiDiscovery cancels the active mDNS browse, if any. +func (a *App) StopWiFiDiscovery() { + if a.wifi != nil { + a.wifi.Stop() + } +} + +// ConnectWiFi establishes an agent connection to a WiFi-discovered (or +// manually-entered) device by IP, port, and token. Unlike Connect, there +// is no UDID lookup, no port forwarding, and no token auto-discovery — +// the caller supplies everything because the device isn't physically +// attached. +// +// Use the values from a wifi:device-found event, plus the token the user +// reads from their app's logs (PROBE_TOKEN=...). +func (a *App) ConnectWiFi(host string, port int, token string) (ConnectionStatus, error) { + if host == "" || port == 0 || token == "" { + return ConnectionStatus{}, fmt.Errorf("host, port, and token are all required") + } + + cfg, _ := config.Load(".") + dialTimeout := 5 * time.Second + + client, err := probelink.DialWithOptions(a.ctx, probelink.DialOptions{ + Host: host, + Port: port, + Token: token, + DialTimeout: dialTimeout, + }) + if err != nil { + return ConnectionStatus{}, fmt.Errorf("dial: %w", err) + } + if err := client.Ping(a.ctx); err != nil { + _ = client.Close() + return ConnectionStatus{}, fmt.Errorf("ping: %w", err) + } + + // WiFi connections have no host-side cleanup (no iproxy, no adb forward). + // The deviceID for runner/event purposes is host:port — unique enough + // for status display and disambiguation in logs. Platform is recorded as + // iOS as a best-effort default; the agent doesn't currently advertise + // platform in TXT records. + deviceID := fmt.Sprintf("%s:%d", host, port) + streamCtx, streamCancel := context.WithCancel(context.Background()) + streamDone := make(chan struct{}) + newConn := &connection{ + client: client, + cfg: cfg, + deviceID: deviceID, + deviceName: fmt.Sprintf("WiFi %s", host), + platform: device.PlatformIOS, + streamCancel: streamCancel, + streamDone: streamDone, + deviceCtx: &runner.DeviceContext{ + Manager: a.deviceMgr, + Serial: deviceID, + Platform: device.PlatformIOS, + Port: port, + DevicePort: port, + }, + } + + a.mu.Lock() + prev := a.conn + a.conn = newConn + a.mu.Unlock() + if prev != nil { + stopConnection(prev) + } + + go a.streamLoop(streamCtx, streamDone, client) + + wailsruntime.EventsEmit(a.ctx, "connection:changed", a.Status()) + return a.Status(), nil +} + // Connect establishes an agent connection to a simulator, emulator, or // USB-attached physical device. Physical iOS uses iproxy to forward the // agent port; physical Android uses adb forward (same path as emulators). diff --git a/studio/frontend/index.html b/studio/frontend/index.html index 5c610e6..15d7315 100644 --- a/studio/frontend/index.html +++ b/studio/frontend/index.html @@ -23,6 +23,7 @@ +
@@ -85,6 +86,32 @@
+ +