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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions probe_agent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
58 changes: 58 additions & 0 deletions probe_agent/lib/src/mdns_advertise.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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<void> stop() async {
try {
await _broadcast?.stop();
} catch (_) {
// best-effort cleanup; failure here just leaks one record until TTL
}
_broadcast = null;
}
}
28 changes: 28 additions & 0 deletions probe_agent/lib/src/server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -171,6 +197,8 @@ class ProbeServer {
Future<void> stop() async {
_tokenTimer?.cancel();
_tokenTimer = null;
await _mdns?.stop();
_mdns = null;
await _server?.close(force: true);
_server = null;
}
Expand Down
8 changes: 7 additions & 1 deletion probe_agent/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
12 changes: 7 additions & 5 deletions studio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
99 changes: 98 additions & 1 deletion studio/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand Down
27 changes: 27 additions & 0 deletions studio/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<span class="sep"></span>
<select id="device-picker" class="select" title="Select device"></select>
<button id="btn-refresh" class="btn btn-icon-only" title="Refresh devices">↻</button>
<button id="btn-wifi" class="btn btn-icon-only" title="Discover WiFi devices">📡</button>
<button id="btn-connect" class="btn" disabled title="Connect to selected device">Connect</button>
</div>
<div class="status">
Expand Down Expand Up @@ -85,6 +86,32 @@

<div id="toast-container" aria-live="polite"></div>

<div id="wifi-overlay" class="overlay" hidden>
<div class="overlay-card">
<div class="overlay-header">
<h2>WiFi devices</h2>
<button id="btn-wifi-close" class="btn-mini" title="Close">✕</button>
</div>
<p class="dim">
Discovering Flutter apps running with <code>PROBE_WIFI=true</code> on the local network.
Requires <code>flutter_probe_agent</code> v0.7.0 or later.
</p>
<ul id="wifi-devices" class="wifi-device-list">
<li class="empty">Searching…</li>
</ul>
<div id="wifi-token-row" hidden>
<div class="wifi-selected" id="wifi-selected-label"></div>
<label for="wifi-token-input" class="dim">
Paste the agent token (from your app's <code>PROBE_TOKEN=</code> log line):
</label>
<input id="wifi-token-input" type="password" placeholder="agent token" autocomplete="off" spellcheck="false" />
<div class="wifi-actions">
<button id="btn-wifi-connect" class="btn btn-primary">Connect</button>
</div>
</div>
</div>
</div>

<div id="help-overlay" class="overlay" hidden>
<div class="overlay-card">
<div class="overlay-header">
Expand Down
Loading
Loading