diff --git a/CHANGELOG.md b/CHANGELOG.md index ae31546..eeeb3c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [0.9.8] - 2026-05-12 + +### Fixed +- **Biometric no-match on iOS 26+ simulator** — `notifyutil` no-match notifications + no longer resolve `LAContext.evaluatePolicy` on iOS 26 / Xcode 26.5. The CLI now + sends a `probe.biometric_signal {result: bool}` JSON-RPC command to the agent + after firing platform-level notifications. Test apps call `awaitBiometricResult()` + from `flutter_probe_agent` instead of `local_auth.authenticate()` in PROBE_AGENT + builds; the agent resolves a Dart `Completer` with the CLI-delivered result, making + biometric no-match reliable on all iOS simulator versions. + +### Added +- **`awaitBiometricResult()`** — new public function in `flutter_probe_agent`. + Returns a `Future` that resolves when the CLI delivers + `probe.biometric_signal`. Usage pattern in test apps: + ```dart + final ok = const bool.fromEnvironment('PROBE_AGENT') + ? await awaitBiometricResult() + : await localAuth.authenticate(...); + ``` +- **`probe.biometric_signal`** — new JSON-RPC method. Sent by the CLI after + the platform-level biometric simulation commands so the result is always + delivered regardless of simulator version behavior. + ## [0.9.7] - 2026-05-12 ### Added diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index d4f863e..b0005fc 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -18,7 +18,7 @@ Welcome to the FlutterProbe wiki. This documentation covers architecture details ## Project Status -FlutterProbe is in active development. Current version: **0.9.7**. +FlutterProbe is in active development. Current version: **0.9.8**. ### Repository Structure diff --git a/internal/probelink/protocol.go b/internal/probelink/protocol.go index 6f8ca62..a2e52ca 100644 --- a/internal/probelink/protocol.go +++ b/internal/probelink/protocol.go @@ -195,6 +195,11 @@ const ( MethodSetOutput = "probe.set_output" MethodDrainOutput = "probe.drain_output" + // Biometric simulation signal (v0.9.8) + // Sent by CLI after firing platform biometric commands so the agent-side + // Dart Completer resolves without depending on LAContext behaviour. + MethodBiometricSignal = "probe.biometric_signal" + // Notification methods (agent → CLI, no response expected) NotifyRecordedEvent = "probe.recorded_event" NotifyExecDart = "probe.exec_dart" diff --git a/internal/runner/device_context.go b/internal/runner/device_context.go index 50193e7..ced6471 100644 --- a/internal/runner/device_context.go +++ b/internal/runner/device_context.go @@ -612,6 +612,10 @@ func (dc *DeviceContext) biometricCapture(ctx context.Context, match bool) error // Post both fingerprint and face notifications so the same step // works on Touch ID devices and Face ID devices alike — the simulator // ignores the one that doesn't match its hardware profile. + // Note: on iOS 26+ simulator, no-match notifications no longer resolve + // LAContext.evaluatePolicy. Apps using flutter_probe_agent should call + // awaitBiometricResult() instead of local_auth.authenticate() when + // PROBE_AGENT=true; the CLI sends probe.biometric_signal to resolve it. notifications := []string{ fmt.Sprintf("com.apple.BiometricKit_Sim.fingerTouch.%s", verb), fmt.Sprintf("com.apple.BiometricKit_Sim.faceCapture.%s", verb), diff --git a/internal/runner/executor.go b/internal/runner/executor.go index 0715b52..d250a8e 100644 --- a/internal/runner/executor.go +++ b/internal/runner/executor.go @@ -666,14 +666,22 @@ func (e *Executor) runAction(ctx context.Context, a parser.ActionStep) error { fmt.Println(" \033[33m⚠\033[0m Skipping biometric match (cloud mode)") return nil } - return e.deviceCtx.BiometricMatch(ctx) + if err := e.deviceCtx.BiometricMatch(ctx); err != nil { + return err + } + _, err := e.client.Call(ctx, probelink.MethodBiometricSignal, map[string]any{"result": true}) + return err case parser.VerbBiometricNoMatch: if e.deviceCtx == nil { fmt.Println(" \033[33m⚠\033[0m Skipping biometric no-match (cloud mode)") return nil } - return e.deviceCtx.BiometricNoMatch(ctx) + if err := e.deviceCtx.BiometricNoMatch(ctx); err != nil { + return err + } + _, err := e.client.Call(ctx, probelink.MethodBiometricSignal, map[string]any{"result": false}) + return err } return fmt.Errorf("unknown action verb %q at line %d", a.Verb, a.Line) diff --git a/probe_agent/CHANGELOG.md b/probe_agent/CHANGELOG.md index 2751388..35d1c2c 100644 --- a/probe_agent/CHANGELOG.md +++ b/probe_agent/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 0.9.8 - 2026-05-12 + +- **`awaitBiometricResult()`** — new public function exported from + `flutter_probe_agent`. Test apps in PROBE_AGENT builds call this instead + of `local_auth.authenticate()` to receive the biometric match/no-match + result from the CLI via the new `probe.biometric_signal` JSON-RPC command. + Required on iOS 26+ simulator where `notifyutil` no-match notifications + no longer resolve `LAContext.evaluatePolicy`. +- New `probe.biometric_signal` JSON-RPC method (`ProbeMethods.biometricSignal`) + that delivers `true` (match) or `false` (no-match) to a pending + `awaitBiometricResult()` Dart Completer. + ## 0.9.7 - 2026-05-12 - Version bump to match CLI v0.9.7. No agent code changes — biometric diff --git a/probe_agent/lib/flutter_probe_agent.dart b/probe_agent/lib/flutter_probe_agent.dart index 9f35fc1..ba5d43c 100644 --- a/probe_agent/lib/flutter_probe_agent.dart +++ b/probe_agent/lib/flutter_probe_agent.dart @@ -26,3 +26,4 @@ library flutter_probe_agent; // Public API — only what app developers need export 'src/agent.dart' show ProbeAgent, isProbeEnabled; +export 'src/biometric.dart' show awaitBiometricResult; diff --git a/probe_agent/lib/src/agent.dart b/probe_agent/lib/src/agent.dart index ee8aad0..2185d42 100644 --- a/probe_agent/lib/src/agent.dart +++ b/probe_agent/lib/src/agent.dart @@ -128,8 +128,10 @@ class ProbeAgent { /// Whether the agent is currently running. static bool get isRunning => _server != null || _relayClient != null; - /// The port the agent is listening on (0 in relay mode). - static int get port => _server?.port ?? 0; + /// The port the agent is actually listening on (0 in relay mode). + /// May differ from the [port] parameter passed to [start] if the preferred + /// port was busy and the server fell back to the next available port. + static int get port => _server?.actualPort ?? 0; /// Whether the agent is running in relay mode. static bool get isRelayMode => _relayClient != null; diff --git a/probe_agent/lib/src/biometric.dart b/probe_agent/lib/src/biometric.dart new file mode 100644 index 0000000..aa9d55c --- /dev/null +++ b/probe_agent/lib/src/biometric.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +// Single pending completer — only one biometric prompt can be active at a time. +Completer? _pending; + +/// Returns a [Future] that resolves to `true` (match) or `false` (no-match) +/// when the FlutterProbe CLI delivers a [probe.biometric_signal] command. +/// +/// Call this in PROBE_AGENT builds instead of `local_auth.authenticate()`: +/// +/// ```dart +/// import 'package:flutter_probe_agent/flutter_probe_agent.dart'; +/// +/// final ok = const bool.fromEnvironment('PROBE_AGENT') +/// ? await awaitBiometricResult() +/// : await localAuth.authenticate(...); +/// ``` +/// +/// The CLI resolves this automatically after the `biometric match` or +/// `biometric no match` ProbeScript step fires — no app-side changes to +/// the test script are required. +Future awaitBiometricResult() { + _pending = Completer(); + return _pending!.future; +} + +/// Called by [ProbeExecutor] when [probe.biometric_signal] arrives. +void completeBiometricResult(bool result) { + _pending?.complete(result); + _pending = null; +} diff --git a/probe_agent/lib/src/executor.dart b/probe_agent/lib/src/executor.dart index e4021a8..91df057 100644 --- a/probe_agent/lib/src/executor.dart +++ b/probe_agent/lib/src/executor.dart @@ -10,6 +10,7 @@ import 'package:flutter/scheduler.dart' show timeDilation; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import 'biometric.dart' as biometric; import 'finder.dart'; import 'protocol.dart'; import 'recorder.dart'; @@ -263,6 +264,11 @@ class ProbeExecutor { _output.clear(); return result; + case ProbeMethods.biometricSignal: + final result = (req.params['result'] as bool?) ?? false; + biometric.completeBiometricResult(result); + return {}; + default: throw ProbeError(ProbeError.methodNotFound, 'Unknown method: ${req.method}'); } diff --git a/probe_agent/lib/src/protocol.dart b/probe_agent/lib/src/protocol.dart index 4d8d208..026a142 100644 --- a/probe_agent/lib/src/protocol.dart +++ b/probe_agent/lib/src/protocol.dart @@ -124,6 +124,9 @@ class ProbeMethods { static const setOutput = 'probe.set_output'; static const drainOutput = 'probe.drain_output'; + // Biometric simulation signal (v0.9.8) + static const biometricSignal = 'probe.biometric_signal'; + // Notification methods (agent → CLI) static const notifyRecordedEvent = 'probe.recorded_event'; static const notifyExecDart = 'probe.exec_dart'; diff --git a/probe_agent/lib/src/server.dart b/probe_agent/lib/src/server.dart index 3a3be54..b0f4244 100644 --- a/probe_agent/lib/src/server.dart +++ b/probe_agent/lib/src/server.dart @@ -20,8 +20,17 @@ const String probeAgentVersion = '0.7.0'; /// Supports two transport modes: /// - **WebSocket** (default): persistent connection at `ws://host:port/probe?token=` /// - **HTTP POST** (fallback for physical devices): stateless `POST /probe/rpc?token=` +/// +/// If the preferred port is in use, the server tries successive ports up to +/// [portRange] candidates. When a busy port is occupied by another probe agent +/// (detected via `GET /probe/status`), a `PROBE_PORT_BUSY` log line is emitted +/// so the CLI can surface a clear error instead of a silent connection refusal. class ProbeServer { final int port; + + /// Maximum number of consecutive ports to try when the preferred port is busy. + /// Range: [port, port + portRange). Default 10 tries (48686–48695). + final int portRange; final bool allowRemoteConnections; HttpServer? _server; String? _token; @@ -39,10 +48,15 @@ class ProbeServer { /// 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. - ProbeServer({this.port = 48686, this.allowRemoteConnections = false}); + ProbeServer({this.port = 48686, this.portRange = 10, this.allowRemoteConnections = false}); Timer? _tokenTimer; + /// Starts the WebSocket server and prints the session token. + /// The port the server is actually listening on after [start]. + /// May differ from [port] if the preferred port was busy. + int get actualPort => _server?.port ?? port; + /// Starts the WebSocket server and prints the session token. /// If a pre-shared token was persisted (via `set_next_token`), uses that /// instead of generating a random one. This enables reconnection after @@ -52,11 +66,15 @@ class ProbeServer { final bindAddress = allowRemoteConnections ? InternetAddress.anyIPv4 : InternetAddress.loopbackIPv4; - _server = await HttpServer.bind(bindAddress, port); + _server = await _bindWithFallback(bindAddress); - // Emit token so the CLI (via adb logcat / simctl log) can read it + // Emit token (and port when non-default) so the CLI can read them. // ignore: avoid_print print('PROBE_TOKEN=$_token'); + if (actualPort != port) { + // ignore: avoid_print + print('PROBE_PORT=$actualPort'); + } // Write token to a file so the CLI can read it directly await _writeTokenFile(); @@ -86,8 +104,59 @@ class ProbeServer { _serve(); } + /// Tries [port], then [port+1] … [port+portRange-1] until one binds. + /// + /// For each busy port, checks whether it is occupied by another probe agent + /// (via GET /probe/status). Emits `PROBE_PORT_BUSY= (probe agent)` or + /// `PROBE_PORT_BUSY=` so the CLI / developer can distinguish a port + /// collision from two probe instances running simultaneously. + Future _bindWithFallback(InternetAddress bindAddress) async { + for (var i = 0; i < portRange; i++) { + final candidate = port + i; + try { + return await HttpServer.bind(bindAddress, candidate); + } on SocketException catch (e) { + final code = e.osError?.errorCode ?? 0; + // EADDRINUSE = 48 on macOS/iOS, 98 on Linux/Android. + if (code != 48 && code != 98) rethrow; + final isAgent = await _probePing(candidate); + // ignore: avoid_print + print('PROBE_PORT_BUSY=$candidate${isAgent ? " (another probe agent is running)" : ""}'); + if (i + 1 < portRange) continue; + rethrow; + } + } + // Unreachable: the final iteration either returns or rethrows. + throw StateError('port range exhausted'); + } + + /// Returns true when [p] responds to `GET /probe/status` with a probe-agent + /// signature, identifying a concurrent agent on that port. + Future _probePing(int p) async { + try { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 1); + final req = await client.get('127.0.0.1', p, '/probe/status'); + final res = await req.close().timeout(const Duration(seconds: 1)); + final body = await res.transform(utf8.decoder).join(); + client.close(); + return body.contains('"agent":"flutter_probe"'); + } catch (_) { + return false; + } + } + Future _serve() async { await for (final req in _server!) { + // Status endpoint — no token required; used for port-collision detection. + if (req.method == 'GET' && req.uri.path == '/probe/status') { + req.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write('{"agent":"flutter_probe","version":"$probeAgentVersion"}'); + await req.response.close(); + continue; + } + // Validate token (shared by both WebSocket and HTTP paths) final queryToken = req.uri.queryParameters['token'] ?? req.headers.value('x-probe-token'); diff --git a/probe_agent/pubspec.yaml b/probe_agent/pubspec.yaml index 96475a8..39a282f 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.9.7 +version: 0.9.8 homepage: https://flutterprobe.dev repository: https://github.com/AlphaWaveSystems/flutter-probe issue_tracker: https://github.com/AlphaWaveSystems/flutter-probe/issues diff --git a/vscode/package.json b/vscode/package.json index 9528e82..4ec5f20 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "name": "flutterprobe", "displayName": "FlutterProbe", "description": "High-performance E2E testing for Flutter apps — ProbeScript language support, local & cloud device testing (BrowserStack, Sauce Labs, AWS Device Farm, Firebase Test Lab, LambdaTest), visual regression, test recording, and Studio integration", - "version": "0.9.7", + "version": "0.9.8", "publisher": "flutterprobe", "icon": "resources/probe-icon.png", "engines": { "vscode": "^1.85.0" },