diff --git a/README.md b/README.md index 3a512dd..c6ac7a4 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,19 @@ CefWebView(url: startUrl, controller: c); by **one `cef_host` process with one cookie jar** — they share one login. Cookie writes are therefore process-wide: `clearCookies()` / `deleteCookie()` clear the cookie for *all* views in the profile, by design. +- **One trust domain per profile.** Because a named profile is one process with + one cookie jar, sessions sharing a profile are **not isolated from each + other**. The cookie jar is common (a page in one view can read another's + cookies via `getCookies`), and registered JS channels + (`addJavaScriptChannel`) are process-global, so a page in one view can observe + a channel name another view registered. Per-message *routing* stays + per-session — a channel message is delivered only to the view whose page sent + it (`OnQuery` stamps the originating browser), so this is an information- + sharing boundary, not a message-spoofing one — but the rule is the same: + **co-locate only mutually-trusting content on one profile.** For + mutually-distrusting content (e.g. arbitrary third-party pages from different + authors), give each its own `profile`, or use the ephemeral default — each + gets its own process, cookie jar, and channel namespace. ### Secrets at rest diff --git a/example/lib/channel_probe.dart b/example/lib/channel_probe.dart new file mode 100644 index 0000000..c29e639 --- /dev/null +++ b/example/lib/channel_probe.dart @@ -0,0 +1,156 @@ +// page->host JS-channel probe — reproduces the Campus "peer edit never reaches +// the host" symptom in isolation (no Campus, no multiplayer). +// +// A Campus PEER mounts a FRESH CefWebController, registers a JS channel +// (addJavaScriptChannel), loads an HTML doc ON the first onPageStarted (session +// ready), and the page calls window..postMessage(...). Live, that never +// invokes the Dart onMessageReceived (host->page works; page->host is dead). +// This probe exercises exactly that path and reports — via document.title (an +// INDEPENDENT page->host signal, OnTitleChange, NOT the cefQuery channel under +// test) — whether window.probeHost / window.cefQuery exist and whether a direct +// cefQuery succeeds or fails. +// +// Run (cef_host must be built; CEF cached): +// FLUTTER_CEF_HOST=<.../cef_host.app/Contents/MacOS/cef_host> \ +// flutter run -d macos -t lib/channel_probe.dart +// Result: `CEF_CHANNEL_PROBE_RESULT …` stdout + /tmp/cef_channel_probe.json. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_cef/flutter_cef.dart'; + +const _resultPath = '/tmp/cef_channel_probe.json'; + +const _probeHtml = r''' + +

page→host channel probe

+ +
+'''; + +void main() => runApp(const ProbeApp()); + +class ProbeApp extends StatefulWidget { + const ProbeApp({super.key}); + @override + State createState() => _ProbeAppState(); +} + +class _ProbeAppState extends State { + // Fresh, ephemeral controller — exactly like a Campus peer mirror. + final CefWebController _c = CefWebController(); + final List _received = []; + bool _hostGot = false; + bool _loaded = false; + String _status = 'starting…'; + + @override + void initState() { + super.initState(); + _c.addJavaScriptChannel('probeHost', onMessageReceived: (m) { + _received.add(m); + _hostGot = true; + // ignore: avoid_print + print('CEF_CHANNEL_PROBE host received: $m'); + }); + _c.title.addListener(() { + // ignore: avoid_print + print('CEF_CHANNEL_PROBE title=${_c.title.value}'); + }); + // Mirror _CefSurfaceView on a peer: load the doc on the first onPageStarted + // (the initial about:blank — session is up by then), NOT before it exists. + _c.onPageStarted = (url) { + // ignore: avoid_print + print('CEF_CHANNEL_PROBE onPageStarted url=$url'); + if (!_loaded) { + _loaded = true; + _c.loadHtmlString(_probeHtml); + } + }; + _c.onPageFinished = (url) { + // ignore: avoid_print + print('CEF_CHANNEL_PROBE onPageFinished url=$url'); + }; + WidgetsBinding.instance.addPostFrameCallback((_) => _run()); + } + + Future _run() async { + setState(() => _status = 'waiting for page + channel…'); + final deadline = DateTime.now().add(const Duration(seconds: 18)); + while (!_hostGot && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 200)); + } + final out = { + 'pass': _hostGot, + 'received_count': _received.length, + 'received': _received.take(5).toList(), + }; + try { + File(_resultPath).writeAsStringSync( + const JsonEncoder.withIndent(' ').convert(out), + ); + } catch (_) {} + // ignore: avoid_print + print('CEF_CHANNEL_PROBE_RESULT ${jsonEncode(out)}'); + if (mounted) { + setState(() => _status = _hostGot + ? 'PASS — host received ${_received.length} message(s)' + : 'FAIL — host received NOTHING (page->host channel dead)'); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Text('channel probe — $_status', + style: const TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded( + child: CefWebView(url: 'about:blank', controller: _c), + ), + ], + ), + ), + ), + ); + } +} diff --git a/example/lib/channel_probe_shared.dart b/example/lib/channel_probe_shared.dart new file mode 100644 index 0000000..abb3a9c --- /dev/null +++ b/example/lib/channel_probe_shared.dart @@ -0,0 +1,170 @@ +// SHARED-HOST page->host channel probe — the single-controller channel_probe.dart +// PASSES, so the basic channel works. Campus's peer tile differs in that it runs +// on a SHARED cef_host (a named profile → one host for many sessions, per the +// #138 consolidation). This probe mounts TWO controllers on ONE named profile +// (= one shared cef_host), each registering the SAME channel name 'probeHost' +// (exactly like every Campus agent_ui uses 'campusHost'), each page posting a +// DISTINCT tag. It verifies each controller's handler receives ONLY its own tag +// — i.e. OnQuery's slot_->browser_id routing stays correct across sessions. +// +// Run: +// FLUTTER_CEF_HOST=<.../cef_host.app/Contents/MacOS/cef_host> \ +// flutter run -d macos -t lib/channel_probe_shared.dart +// Result: `CEF_SHARED_PROBE_RESULT …` + /tmp/cef_channel_probe_shared.json. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_cef/flutter_cef.dart'; + +const _profile = 'chprobe'; +const _resultPath = '/tmp/cef_channel_probe_shared.json'; + +String _html(String tag) => ''' +

session $tag

+'''; + +void main() => runApp(const SharedProbeApp()); + +class SharedProbeApp extends StatefulWidget { + const SharedProbeApp({super.key}); + @override + State createState() => _SharedProbeAppState(); +} + +class _SharedProbeAppState extends State { + final CefWebController _a = CefWebController(profile: _profile); + final CefWebController _b = CefWebController(profile: _profile); + final Set _aRecv = {}; + final Set _bRecv = {}; + bool _aLoaded = false, _bLoaded = false; + String _status = 'starting…'; + + void _wire(CefWebController c, String tag, Set recv, + bool Function() loaded, void Function() setLoaded) { + c.addJavaScriptChannel('probeHost', onMessageReceived: (m) { + recv.add(m); + // ignore: avoid_print + print('CEF_SHARED_PROBE $tag-handler received: $m'); + }); + c.title.addListener(() { + // ignore: avoid_print + print('CEF_SHARED_PROBE $tag title=${c.title.value}'); + }); + c.onPageStarted = (url) { + // ignore: avoid_print + print('CEF_SHARED_PROBE $tag onPageStarted ${url.length > 30 ? url.substring(0, 30) : url}'); + if (!loaded()) { + setLoaded(); + c.loadHtmlString(_html(tag)); + } + }; + c.onPageFinished = (url) { + // ignore: avoid_print + print('CEF_SHARED_PROBE $tag onPageFinished ${url.length > 30 ? url.substring(0, 30) : url}'); + }; + } + + @override + void initState() { + super.initState(); + _wire(_a, 'A', _aRecv, () => _aLoaded, () => _aLoaded = true); + _wire(_b, 'B', _bRecv, () => _bLoaded, () => _bLoaded = true); + WidgetsBinding.instance.addPostFrameCallback((_) => _run()); + } + + Future _run() async { + setState(() => _status = 'waiting for both sessions…'); + final deadline = DateTime.now().add(const Duration(seconds: 20)); + while ((_aRecv.isEmpty || _bRecv.isEmpty) && + DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 200)); + } + // Correct routing = A's handler got ONLY A-tags, B's got ONLY B-tags. + final aOk = _aRecv.any((m) => m.startsWith('A')) && + !_aRecv.any((m) => m.startsWith('B')); + final bOk = _bRecv.any((m) => m.startsWith('B')) && + !_bRecv.any((m) => m.startsWith('A')); + final out = { + 'pass': aOk && bOk, + 'a_handler_received': _aRecv.toList(), + 'b_handler_received': _bRecv.toList(), + 'a_ok': aOk, + 'b_ok': bOk, + }; + try { + File(_resultPath).writeAsStringSync( + const JsonEncoder.withIndent(' ').convert(out), + ); + } catch (_) {} + // ignore: avoid_print + print('CEF_SHARED_PROBE_RESULT ${jsonEncode(out)}'); + if (mounted) { + setState(() => _status = (aOk && bOk) + ? 'PASS — both sessions routed correctly' + : 'FAIL — A=$_aRecv B=$_bRecv (a_ok=$aOk b_ok=$bOk)'); + } + } + + @override + void dispose() { + _a.dispose(); + _b.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: Scaffold( + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: Text('shared-host channel probe — $_status', + style: const TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded( + child: Row( + children: [ + Expanded( + child: CefWebView( + key: const ValueKey('A'), + url: 'about:blank', + controller: _a, + profile: _profile), + ), + const VerticalDivider(width: 1), + Expanded( + child: CefWebView( + key: const ValueKey('B'), + url: 'about:blank', + controller: _b, + profile: _profile), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift b/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift index b4ea1e0..d538c66 100644 --- a/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift +++ b/packages/flutter_cef_macos/macos/Classes/CdpRelay.swift @@ -59,6 +59,12 @@ final class CdpRelay { /// Guarded by `clientLock`, which also serializes writes to it. private var clientFd: Int32 = -1 private let clientLock = NSLock() + // Per-relay SERIAL queue for client writes. sendRawToClient hops onto this so a + // stuck client (writeFrameLocked blocks up to SO_SNDTIMEO ~2s) can't stall the + // SHARED CDP reader thread — which fans delivery to every relay — and thereby + // starve sibling agent-controlled tiles on the same host. Serial = frame order + // for this client is preserved; per-relay = one wedged client is isolated. + private let clientWriteQueue = DispatchQueue(label: "flutter_cef.cdp-relay.client-write") /// In-flight connection (handler-thread) count, guarded by `stateLock`. Caps /// concurrent handshakes so a flood of half-open connections can't exhaust @@ -506,10 +512,18 @@ final class CdpRelay { } /// Write a raw (already-filtered / self-originated) JSON text frame to the client. + /// Hops onto the per-relay serial queue so the SHARED CDP reader thread is never + /// blocked by a stuck client; clientFd is re-validated under clientLock inside, so + /// a concurrent stop()/handler-close makes this a graceful no-op (or a failed + /// write that writeFrameLocked already handles). private func sendRawToClient(_ json: String) { - clientLock.lock(); defer { clientLock.unlock() } - guard clientFd >= 0 else { return } - writeFrameLocked(clientFd, opcode: 0x1, payload: Array(json.utf8)) + let payload = Array(json.utf8) + clientWriteQueue.async { [weak self] in + guard let self = self else { return } + self.clientLock.lock(); defer { self.clientLock.unlock() } + guard self.clientFd >= 0 else { return } + self.writeFrameLocked(self.clientFd, opcode: 0x1, payload: payload) + } } /// Write a server frame (unmasked). Takes clientLock itself (callers that already @@ -808,9 +822,15 @@ final class CdpRelay { // crashes the daemon). Only reached on RECONNECT, where the page is already attached // so no fresh event fires and ourBrowserContextId is already cached. filterLock.lock(); let bctx = ourBrowserContextId; filterLock.unlock() + // Playwright's CRBrowser._onAttachedToTarget asserts a NON-EMPTY + // browserContextId; synthesizing one with "" crashes the daemon. If we never + // captured the real id for this relay (the page's own attach event hadn't + // fired yet), SKIP the synthesized event rather than emit a poisoned one — the + // real Target.attachedToTarget will carry the correct id when it arrives. + guard let bctx = bctx, !bctx.isEmpty else { return } let info: [String: Any] = ["targetId": tid, "type": "page", "title": "", "url": "", "attached": true, "canAccessOpener": false, - "browserContextId": bctx ?? ""] + "browserContextId": bctx] sendClientJson(["method": "Target.attachedToTarget", "params": ["sessionId": sessionId, "targetInfo": info, "waitingForDebugger": false]]) diff --git a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift index 74332a1..2359d88 100644 --- a/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift +++ b/packages/flutter_cef_macos/macos/Classes/CefProfileHost.swift @@ -1001,6 +1001,23 @@ final class CefProfileHost { spawnedPid = 0 let died = onHostDied writeLock.unlock() + // The host is gone: tear down CDP relays (free their localhost listeners + + // clients) and FAIL any in-flight targetId waiters so enableAgentControl + // callers don't hang forever. Mirrors shutdown()'s teardown — snapshot under + // each lock, act OUTSIDE it (stop()/completions may block + take other locks). + // Idempotent: a later shutdown()/terminate finds the dicts already empty. + cdpHandlerLock.lock() + let deadRelays = Array(cdpRelays.values) + cdpRelays.removeAll() + onCdpMessage = nil + cdpHandlerLock.unlock() + for r in deadRelays { r.stop() } + targetIdLock.lock() + let strandedWaiters = pendingTargetId.values.flatMap { $0 } + pendingTargetId.removeAll() + targetIdEpoch.removeAll() + targetIdLock.unlock() + for w in strandedWaiters { w(nil) } // nil = resolution failed (host died) // Resolve the exit status + invoke onHostDied off the caller's thread: this // can be the MAIN thread (a writeAll failure in send()/sendCreate()), and // terminationStatus traps if read while the process is still running — so we @@ -1390,7 +1407,12 @@ final class CefProfileHost { let n = buf.withUnsafeMutableBytes { ptr -> Int in read(fd, ptr.baseAddress!.advanced(by: off), len - off) } - if n <= 0 { return false } + if n <= 0 { + // A signal (SIGALRM/SIGCHLD/…) interrupts the syscall: retry rather than + // treat it as a dead pipe (which would tear down the whole shared host). + if n < 0 && errno == EINTR { continue } + return false + } off += n } return true @@ -1400,7 +1422,12 @@ final class CefProfileHost { var off = 0 while off < len { let n = write(fd, buf.advanced(by: off), len - off) - if n <= 0 { return false } + if n <= 0 { + // Same EINTR resilience as readAll: a signal mid-write must not be + // mistaken for a dead pipe (matches the C++ WriteAll on the host side). + if n < 0 && errno == EINTR { continue } + return false + } off += n } return true diff --git a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift index e27bc41..cbc42d5 100644 --- a/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift +++ b/packages/flutter_cef_macos/macos/Classes/CefWebSession.swift @@ -99,6 +99,13 @@ final class CefWebSession: NSObject, FlutterTexture { // CefProfileHost.createBrowser() allocates the id. private weak var host: CefProfileHost? private(set) var browserId: UInt32 = 0 + // JS-channel names registered for this session. Buffered here so a registration + // that arrives BEFORE attach() assigns the wire browserId — which happens on a + // shared host, where createBrowser is queued (pendingCreates) — is flushed with + // a VALID browserId once attached (and re-sent on a re-home to a new host). + // Without this the addChannel op would go out with browserId=0 and the host + // couldn't bind it to this browser, so the window. shim was never injected. + private var channels: Set = [] // C1: set once when this browser delivers its first present frame. Owned/guarded by // CefProfileHost under its browsersLock (the reader flips it there) — a cheap per-frame // first-paint check that avoids a second lock on the hot paint path. @@ -168,6 +175,9 @@ final class CefWebSession: NSObject, FlutterTexture { func attach(host: CefProfileHost, browserId: UInt32) { self.host = host self.browserId = browserId + // Flush channels registered before the wire id existed (and re-send them on a + // re-home to a new host) now that sendFrame can route with a valid browserId. + for name in channels { sendFrame(Self.opAddChannel, Array(name.utf8)) } } // MARK: FlutterTexture @@ -344,7 +354,9 @@ final class CefWebSession: NSObject, FlutterTexture { } func addChannel(_ name: String) { - sendFrame(Self.opAddChannel, Array(name.utf8)) + channels.insert(name) + // Ship now only if we already have a wire id; otherwise attach() flushes it. + if browserId != 0 { sendFrame(Self.opAddChannel, Array(name.utf8)) } } func setCookie(url: String, name: String, value: String, domain: String, diff --git a/packages/flutter_cef_macos/native/cef_host/main.mm b/packages/flutter_cef_macos/native/cef_host/main.mm index 975bbfe..f5942e1 100644 --- a/packages/flutter_cef_macos/native/cef_host/main.mm +++ b/packages/flutter_cef_macos/native/cef_host/main.mm @@ -1434,12 +1434,20 @@ void DoEvalReturning(const std::shared_ptr& slot, uint32_t id, } void DoAddChannel(const std::shared_ptr& slot, const std::string& name) { if (!IsValidChannelName(name)) { - SendLog(slot->browser_id, "addJavaScriptChannel: rejected invalid name '" + - name + "' (must be a JS identifier)"); + if (slot) + SendLog(slot->browser_id, "addJavaScriptChannel: rejected invalid name '" + + name + "' (must be a JS identifier)"); return; } + // Register process-globally: OnLoadStart injects every g_channels entry into + // each freshly-loaded frame, so the shim lands on the next load. This also + // keeps the op null-safe — if `slot` is somehow absent the registration still + // takes (defense; the Swift session now buffers addChannel until attach(), so + // in practice the op carries a valid browserId and `slot` is set). g_channels.insert(name); - if (slot->browser) InjectChannelShim(slot->browser->GetMainFrame(), name); + // Inject into the registering session's CURRENT frame too, covering the case + // where the channel is registered after its page has already loaded. + if (slot && slot->browser) InjectChannelShim(slot->browser->GetMainFrame(), name); } // Cookie ops act on the GLOBAL cookie manager (= the shared profile jar), so a // login in one browser is visible to every browser sharing this profile. They @@ -1876,7 +1884,13 @@ void IpcReadLoop() { break; } case kOpAddChannel: { - if (!slot) break; + // Do NOT require `slot`: on a shared host a session's createBrowser may + // still be queued (pendingCreates) when this op arrives, and dropping it + // here is exactly why a peer/secondary session's window. shim was + // never injected (campus.emit silently dead). DoAddChannel registers the + // name in the process-global g_channels — OnLoadStart injects it into the + // frame once the browser loads — and injects into the current frame only + // if the browser already exists. std::string name(reinterpret_cast(p), plen); CefPostTask(TID_UI, base::BindOnce(&DoAddChannel, slot, name)); break; diff --git a/test/cef_web_controller_test.dart b/test/cef_web_controller_test.dart index e5e6fb0..ffe53d1 100644 --- a/test/cef_web_controller_test.dart +++ b/test/cef_web_controller_test.dart @@ -454,6 +454,26 @@ void main() { expect(got, 'hello world'); }); + test('a channel added BEFORE create() is re-registered on create() ' + '(call-order independence — the shared-host channel regression)', () async { + // On a SHARED host the session attaches LATE (createBrowser is queued), so an + // addJavaScriptChannel issued before the widget mounts reaches the host before + // the browser exists. The controller must re-register every channel on + // create() so the host binds it to the now-attached browser; if this re-send + // is dropped, the window. shim is never injected and page->host messages + // die — the exact B->A Campus regression. (Native end-to-end delivery on a + // real cef_host is covered by test/run_channel_integration.sh.) + final c = CefWebController(sessionId: 'prech'); + await c.addJavaScriptChannel('Early', onMessageReceived: (_) {}); + log.clear(); + await c.create(url: 'about:blank', width: 1, height: 1); + final reSent = log.where((m) => + m.method == 'addJavaScriptChannel' && + (m.arguments as Map)['name'] == 'Early'); + expect(reSent, isNotEmpty, + reason: 'a channel registered before create() must be re-sent on create()'); + }); + test('scroll + storage conveniences forward as JavaScript', () async { final c = CefWebController(sessionId: 'sc'); await c.scrollTo(0, 100); diff --git a/test/run_channel_integration.sh b/test/run_channel_integration.sh new file mode 100755 index 0000000..525d915 --- /dev/null +++ b/test/run_channel_integration.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# +# flutter_cef integration probes — REAL cef_host, headless, asserting. +# +# WHY THIS EXISTS: the Dart integration_test (example/integration_test/) MOCKS +# the host method channel, so it can NOT catch native channel-delivery or +# CDP-relay regressions — which is exactly how the shared-host page->host channel +# bug shipped. These probes run the example app against a REAL cef_host and assert +# the /tmp JSON result each writes. Run this before bumping a consumer's pin. +# +# Probes (example/lib/): +# channel_probe single ephemeral host: page->host JS channel delivers +# channel_probe_shared TWO sessions on ONE shared host: channel delivers + +# routes per-session (the B->A Campus regression) +# multiview_probe agent-control / CDP relay isolation on a shared host +# +# Usage: +# ./test/run_channel_integration.sh # all probes +# ./test/run_channel_integration.sh channel_probe_shared # just one +# +# Env: +# FLUTTER flutter binary (default: `flutter` on PATH) +# FLUTTER_CEF_HOST cef_host binary (default: build/cef_host, built if absent) +# +set -uo pipefail +cd "$(dirname "$0")/.." +ROOT="$PWD" +FLUTTER="${FLUTTER:-flutter}" +APP="$ROOT/example/build/macos/Build/Products/Debug/flutter_cef_example.app/Contents/MacOS/flutter_cef_example" + +# --- resolve cef_host (build an ad-hoc one if not supplied / not present) ------ +HOST="${FLUTTER_CEF_HOST:-}" +if [ -z "$HOST" ]; then + HOST="$ROOT/build/cef_host/cef_host.app/Contents/MacOS/cef_host" + if [ ! -x "$HOST" ]; then + echo ">> building ad-hoc cef_host (needs cmake + ninja)…" + ( cd packages/flutter_cef_macos && CEF_HOST_ADHOC=ON ./native/build_cef_host.sh "$ROOT/build/cef_host" ) || { + echo "!! cef_host build failed — set FLUTTER_CEF_HOST to a prebuilt binary"; exit 2; } + fi +fi +echo ">> cef_host: $HOST" + +# An ad-hoc cef_host refuses named (shared) profiles and downgrades them to +# ephemeral — which would silently turn the shared-host probes into two separate +# hosts and mask the very regression they guard. This opt-in keeps the real +# shared host for the test. (A signed release build does not need it.) +export FLUTTER_CEF_ALLOW_INSECURE_PROFILE=1 + +# probe : result-json : timeout-secs +PROBES=( + "channel_probe:/tmp/cef_channel_probe.json:30" + "channel_probe_shared:/tmp/cef_channel_probe_shared.json:40" + "multiview_probe:/tmp/cef_multiview_probe.json:75" +) + +want="${1:-}" +fails=0 +for entry in "${PROBES[@]}"; do + IFS=':' read -r name json timeout <<< "$entry" + [ -n "$want" ] && [ "$want" != "$name" ] && continue + echo "" + echo "=== probe: $name ===" + ( cd example && "$FLUTTER" build macos --debug -t "lib/$name.dart" ) >/tmp/cef_int_build.log 2>&1 || { + echo "!! build failed for $name (see /tmp/cef_int_build.log)"; fails=$((fails+1)); continue; } + pkill -9 -f flutter_cef_example 2>/dev/null; pkill -9 -f cef_host 2>/dev/null; rm -f "$json" + FLUTTER_CEF_HOST="$HOST" nohup "$APP" >"/tmp/cef_int_${name}.log" 2>&1 & + for _ in $(seq 1 "$timeout"); do [ -f "$json" ] && break; sleep 1; done + pkill -9 -f flutter_cef_example 2>/dev/null; pkill -9 -f cef_host 2>/dev/null + if [ ! -f "$json" ]; then echo "FAIL $name — no result (timeout); see /tmp/cef_int_${name}.log"; fails=$((fails+1)); continue; fi + if python3 -c "import json,sys; sys.exit(0 if json.load(open('$json')).get('pass') is True else 1)" 2>/dev/null; then + echo "PASS $name" + else + echo "FAIL $name — $(cat "$json")"; fails=$((fails+1)) + fi +done + +echo "" +if [ "$fails" -ne 0 ]; then echo "INTEGRATION FAILED ($fails probe(s))"; exit 1; fi +echo "ALL INTEGRATION PROBES PASSED"