English | 简体中文
patchbay_flutter is Patchbay's optional Flutter adapter. Its goal is to provide stable,
discoverable, fail-closed UI debugging capabilities with minimal widget intrusion — not to
implement yet another coordinate-driven black-box automation framework. "Consumer" below means the
app integrating Patchbay.
For quick installation and a first connection, see the
repository README; for the general protocol and transport
boundaries, see ../patchbay/README.md.
| Capability surface | Public API and boundaries |
|---|---|
| Stable targets | PatchbayKey.text, weak-reference registry, mounted generation, duplicate-ID ambiguity, and stale rejection |
| Text operations | text.set / text.enter, per-operation gates, sensitive input, and side-effect descriptors |
| Semantics | Container-free normalized tree, node generations, obscured value redaction, and standard actions rejected by policy default |
| Navigation and waits | Composition-root navigation adapter, revision / redirect / timeout semantics, and ui.wait conditions |
| Capture | Optional root / target capture after N observed frames, bounded pixel diff, and chunked blob output |
| Keep awake | Consumer-injected keepAwakeDelegate, off by default, auto-released on lease expiry and host disposal |
| Inspector switch | PatchbayInspectPolicy opt-in on-device select mode: self-restoring lease, refused on non-debug builds |
| Host composition | PatchbayFlutterServiceHost merges the UI and domain catalogs/operators into one service extension and forwards core redacted audit configuration |
The logging surface lives in core patchbay and only enters this host's catalog when the consumer
explicitly injects a PatchbayArtifactService. The Widget/Render/Focus diagnostic trees are
proxied by patchbay_cli straight to the Flutter runtime extensions, without this package
duplicating the protocol.
The contracts for the Widget/Render/Semantics trees, standard actions, node generations,
redaction, and the DevTools diagnostic proxy are in
doc/ui-inspection-and-actions.md (currently in Chinese).
That document makes it explicit that tree-driven actions are the default low-intrusion path, and
semantic navigation is only a stable enhancement on top.
| Integration level | Consumer change | Capabilities |
|---|---|---|
| Host only | Start the service host at the app's composition root | identity, domain catalog/snapshot/invoke; no promise of stable widget operations |
| Runtime observation | Start the Flutter host only, without touching the widget tree | Widget/Render/Semantics summaries and standard Semantics actions |
| Optional root bridge | Wrap a root bridge once at the app's top level | Root capture and frame coordination that genuinely needs the root render context |
| Single Key | Swap the target widget's key for a PatchbayKey |
The operations that target declares in the catalog |
No page base class is required, no per-page container, no Keys on every widget, and no central target list duplicating the widget tree.
For widgets you need to drive remotely, just swap the Key:
late final PatchbayKey nameKey = PatchbayKey.text(
'profile.displayName',
operationGates: <PatchbayUiOperation, Set<String>>{
PatchbayUiOperation.textEnter: <String>{'profile.editable'},
},
);
TextField(
key: nameKey,
controller: controller,
inputFormatters: formatters,
onChanged: onChanged,
)The PatchbayKey is the target widget's only key — there is no PatchbayTextTarget or other
wrapper.
A Key stays the same kind of GlobalKey in debug, profile, and release, preserving Element/State
mounting and cross-position move semantics. Release strips only the declaration, the weak
registration, and the operator references; it must not degrade into a different kind of Key.
This is the part consumers most often end up reverse-engineering from source. Three phrases sum it up: registration happens at construction, deregistration is by weak reference, and only simultaneously mounted instances count as ambiguous.
Registration takes place in the constructor of PatchbayKey.text(...) /
PatchbayKey.capture(...), not when the widget mounts. Therefore:
- a Key that has never mounted still appears in
patchbay catalog'suiTargets, just withmounted: falseand emptyoperations— the catalog lists "declared targets", not "currently operable targets"; - in release,
declarationisnulland nothing is registered at all; the Key degrades to a plainGlobalKey; - there is no
dispose(), and none is needed: the consumer creates Keys and does not manage the registration lifecycle.
The registry holds a WeakReference to every Key. Once a Key object is garbage collected, its
entry is cleared the next time it is observed (reading the catalog, or resolving one call);
once all entries for an ID are cleared, that ID disappears from the catalog.
These are weak-reference semantics, not deterministic destruction: while a page has already been
destroyed but its Key has not yet been collected, the catalog still shows that mounted: false
record. That is not a leak, and it does not affect operations — mounted state is judged on the
spot from the Element.
| Fact | How it is determined |
|---|---|
mounted |
Read the Key's currentContext on the spot; only an existing Element counts as mounted |
generation |
One monotonic counter per ID; +1 whenever a change of Element identity is observed |
ambiguous |
Counts only simultaneously mounted instances; set only when >1 |
Several conclusions follow from this that are easy to guess wrong:
- On the first observed mount the generation is 1, not 0. An entry that has never mounted is 0.
- The counter is shared per ID, not per Key instance. When a second instance of the same ID mounts, it takes the globally next number rather than restarting from 1.
- Moving a GlobalKey across positions does not change the number. The Element is carried along
and its identity is unchanged, so the generation is unchanged — one of the reasons the Key must
retain
GlobalKeysemantics. - Duplicate entries that are not mounted do not constitute ambiguity. With three Keys sharing one ID and only one of them mounted, operations execute as usual.
- The generation only advances when observed. Three remounts between two observations still bump it by only 1; the point is "it changed", not "how many times it changed".
Write operations must carry the most recently observed generation. The stable codes for a failed resolution are:
| code | Meaning |
|---|---|
uiTargetNotFound |
No live entry exists for that ID (never declared, or the Key has been collected) |
uiTargetUnmounted |
Entries exist but none of them is mounted |
uiTargetAmbiguous |
Several instances of the same ID are mounted simultaneously |
uiGenerationStale |
The number does not match; details.currentGeneration gives the current value |
uiOperationUnavailable |
The target is mounted but does not support this operation (for example, a text target whose widget is not a TextField/EditableText, or a capture target whose render object is not already a RenderRepaintBoundary) |
After an await inside a gate, the operator re-resolves the ID, generation, and ambiguity
state, so a remount after the gate — or a newly appeared same-named instance — likewise does not
inherit this call's continuation. Late commands aimed at an old instance after a page exits are
therefore rejected stably, and never write into a same-named widget that appeared afterwards.
// ❌ a new GlobalKey on every build
@override
Widget build(BuildContext context) {
return TextField(key: PatchbayKey.text('login.phone'), controller: _controller);
}PatchbayKey is a GlobalKey, and Flutter decides between reuse and rebuild by Key instance
identity. Swapping in a new instance every frame tells Flutter "this is a different widget" every
frame, and the cost is threefold:
- State is lost — the old
Element/Stateis destroyed and rebuilt, taking input focus, scroll position, and animation progress with it; - The generation jumps on every observation — the number you just read from the catalog is
already stale by the next resolution, so write operations are stably rejected with
uiGenerationStale, which looks like "the fence is broken" when really the Key is drifting; - Registry entries pile up — every construction adds another, and only GC clears them.
The right approach is to cache the instance so it stays stable across builds:
// ✅ a State field: one Key per State instance
class _LoginFormState extends State<LoginForm> {
late final PatchbayKey _phoneKey = PatchbayKey.text('login.phone');
@override
Widget build(BuildContext context) =>
TextField(key: _phoneKey, controller: _controller);
}The same applies to StatelessWidget: the Key belongs in an enclosing State, an injected
controller, or a module-level constant table — never inside build(). This holds for every
GlobalKey and is not a Patchbay-specific rule; it is spelled out here because Patchbay's
generation fencing turns it into a stream of baffling uiGenerationStale rejections.
To confirm a widget has appeared after a page switch or an action, the route you take depends on which kind of ID you labelled it with — these are two separate identity spaces that do not interconnect:
| How it is labelled | How to check whether it is mounted |
|---|---|
PatchbayKey.text/capture('id') |
Read patchbay catalog and look at that ID's mounted / generation in uiTargets |
Semantics(identifier: 'id') |
patchbay ui wait semantics-mounted <identifier> (long poll) |
PatchbayKey only replaces the widget's key and does not also write a Semantics
identifier; conversely, ui wait semantics-mounted walks the Semantics tree and cannot see a
target labelled only with a PatchbayKey. For widgets you need to "wait for, then operate on",
label a Semantics identifier (or both).
For ui wait's full condition table and the correspondence between CLI subcommands and wire
values, see the ui wait section of the usage guide
(currently in Chinese).
When invoked through PatchbayFlutterServiceHost, the text and Semantics operators reuse the
requestId passed in by the transport; the bridge generates a local ID only when called directly
by a caller that supplied none. This keeps logs, CLI output, and invocation envelopes stably
correlated to the same request.
Two distinct semantics are supported today:
text.set— replacesTextEditingController.valuedirectly, invoking neither formatters noronChanged; suitable for preparing state;text.enter— runs the target'sinputFormattersin order, writes back to the controller, then calls the publiconChanged; suitable for simulating one Flutter-level user input.
Both prove only that the Flutter target accepted and observed the value change — never IME, soft keyboard UI, or system input method behavior.
Sensitive targets must declare sensitive: true. Such inputs are only accepted from CLI stdin,
and the catalog and results return only a length and a redacted marker, never the plaintext.
The widget and Semantics trees do not require the consumer to wrap a root. The Flutter host can read the current tree through the public binding, Inspector, and Semantics APIs; Semantics actions require the consumer to inject a policy once at the composition root, and are rejected by default.
Standard components that already carry a label, flags, and actions do not need a PatchbayKey.
A bottom tab with button + label + tap, for example, can be discovered from the Semantics
snapshot and have its original callback executed, fully reusing the widget's own exit and
switching flow.
When a widget has a stable Semantics identifier, ui.semantics.tap folds "resolve → generation
check → dispatch" into a single admission: the caller need not first read the whole tree and then
carry around a nodeId that is only valid within the current SemanticsOwner. The fence is not
loosened by this — the bridge pins the resolved generation before the gates, the post-gate
re-resolution must hit that same generation, and a remount during the await is still rejected
with uiSemanticsGenerationStale; callers may additionally pass a generation as their own
up-front fence. Multiple mounted instances of the same identifier are always rejected as
ambiguous, never picked by tree order.
For the same identifier-first flow with another public action, use
ui.semantics.actionByIdentifier / patchbay ui action <identifier> <generation> <action> [text].
Unlike the legacy tap command, its caller generation is required: the bridge checks it on the
first resolution, pins it across policy and gates, and checks it again immediately before
dispatch. It exposes only tap, focus, four-direction scroll, and setText; it never substitutes
latest or retries a write against a replacement node.
These commands share one action policy with ui.semantics.action: with no consumer policy they
neither enter the catalog nor can be dispatched. Misses, ambiguity, and stale generations all
carry details (the mounted identifier list is capped at 20 entries, plus the candidate list and
expected/current generation), and labels of obscured nodes are redacted in those details —
a rejection must be actionable without becoming a second observation surface that bypasses the
tree limits.
Anchored ui.gesture.tap|pressHold|drag|fling commands are a separate synthetic-pointer surface,
not Semantics actions. They require an identifier, a caller-supplied Semantics generation,
normalized target-local points, and an independent PatchbayGesturePolicy; without that policy
they are not published. The bridge re-resolves after gates and rejects clipping or a blocking
overlay before converting coordinates. Converted global coordinates remain invocation-local and
never enter the response. tap is the shortest fixed press-and-release: its down→up interval is
an internal constant rather than a wire parameter, its start defaults to the target centre, and
it proves a real pointer can reach and trigger the target — to drive a declared semantics tap
action (including pointer-unreachable targets) use ui.semantics.tap instead; the two paths prove
different facts.
For the detailed protocol, privacy boundaries, and staged exit conditions, see
doc/ui-inspection-and-actions.md (currently in Chinese).
When global Flutter observation is needed, the consumer may wrap a root bridge once at the top of
MaterialApp.builder:
MaterialApp.router(
builder: (context, child) => PatchbayRoot(child: child!),
)The root bridge is responsible only for capture and frame scheduling that genuinely need the root
render context. It does not handle widget/Semantics tree discovery, does not register business
pages, does not automatically turn every descendant into an operable target, and does not change
layout in release; a release build returns the original child directly, keeping no runtime
re-enable entry point.
The default path is root capture. When a partial capture is genuinely needed, swap an existing
RenderRepaintBoundary's key for PatchbayKey.capture('stable.id'); the Key only provides stable
resolution and generation fencing and will not turn an arbitrary widget into a repaint
boundary. If the target is not unique, not mounted, has a stale generation, or its render object
is not already a boundary, it fails closed.
A root capture proves only that the Flutter composition tree produced those pixels in that frame.
PlatformViews, textures, system dialogs, and other apps may be absent from the image, so results
always return the flutterSubtreeOnly, platformViewsMayBeMissing, and systemUiNotIncluded
warnings and must not pose as a full physical screen capture. Capture waits for afterFrames
Patchbay-observed Flutter frames (default 1, maximum 120) and re-checks resumed/target before the
call, after the gate await, and before encoding. The observer actively requests frames, so this
is the Nth observation after admission, not the App's natural frame number. The defaults cap the
shared wait at 5 seconds (maximum 30 seconds), each image at 16 MP and an 8 MiB PNG, and pixelRatio
at 3. The PNG goes only into the shared blob store, and the response returns dimensions, RGBA8888
pixel format, observed/requested frame counts, collection time, limits, SHA-256, TTL, and a blobId.
ui.capture.diff compares two retained Flutter capture blob IDs only when their width, height, and
decoded pixel format match. It returns changedPixels, totalPixels, and differenceRatio as
observations and never invents a pass/fail threshold. Both inputs obey the same 16 MP / 8 MiB
budgets and the three capture warnings remain attached to the result: platform views, textures,
system dialogs, and other OS-composited pixels are outside this evidence.
This is a pure Flutter package: it touches no platform channel, and it does not pull in a wakelock dependency for the sake of one debugging switch. The protocol, the bookkeeping, and the lease live in the framework; the one line that touches the platform comes from the app:
PatchbayFlutterBridge(
gates: gates,
// Android: FLAG_KEEP_SCREEN_ON; iOS: UIApplication.isIdleTimerDisabled.
keepAwakeDelegate: (bool enabled) => myPlatformChannel.setKeepAwake(enabled),
keepAwakeGates: const <String>{'my.debug.keepAwake'},
)The delegate is called only on an actual transition, never twice in a row with the same value.
Throwing is a legitimate answer: the request is refused with keepAwakeDelegateFailed rather than
recorded as a hold. Off by default — nothing is engaged until an operator asks, because holding
the screen changes the behaviour of the app being observed, and screen-off behaviour is itself
something a consumer tests. Every engagement carries a lease (10 minutes by default, 2 hours at
most), released by the framework when it runs out; PatchbayFlutterBridge.dispose() gives it back
too. Neither transport gives the app a connection lifecycle, so the lease is the only honestly
observable form of "the operator is gone".
Unlike capture / navigation, the command stays in the catalog even with nothing wired: an
operator reaches for it exactly when the UI plane has started refusing everything, and
commandNotRegistered says nothing useful at that moment — so it answers keepAwakeNotWired and
names the missing injection point instead. The response source is always appRecorded: it states
what the app asked its host to do. This package never reads the platform back and never claims the
screen is in fact lit.
A failed release does not commit the bookkeeping. Recording enabled: false while the platform
has not let go would turn the next off into an unchanged no-op that never reaches the platform
again, stranding the screen lit with no way left to ask. So the state is committed only after the
delegate succeeds: on failure the hold stays, enabled remains true, lastReleaseFailure carries
the failure type, and the lease is left armed (a lease expiry whose release fails tries again one
lease later). dispose() is synchronous and does not join the request queue, so it can land in the
middle of an in-flight engagement — both suspended points, the gate and the delegate, re-check the
disposed state on resume. Any set after teardown is refused with keepAwakeHostDisposed, and the
case where the delegate already took the hold hands it back before refusing. After disposal the
delegate never receives true again.
Navigation should not be done by adding Keys to home and settings buttons and simulating taps. The recommended approach is to inject a consumer adapter at the app's composition root, with the protocol exposing only stable destination IDs:
navigation.catalog
navigation.current
navigation.go <destination-id>
navigation.push <destination-id>
navigation.back
The term is destination rather than route because one consumer's destination might be a Router route, a Shell tab, an overlay, or a dialog. That difference exists only inside the consumer adapter; the general-purpose CLI knows nothing about paths or page implementations.
final navigation = PatchbayNavigationAdapter(
destinations: () => <PatchbayNavigationDestination>[
PatchbayNavigationDestination(
id: 'settings',
gateIds: <String>{'debug.navigation'},
go: () => shellController.select(settingsTab),
push: () => router.pushNamed('settings'),
),
],
current: () => PatchbayNavigationObservation(
revision: navigationRevision,
destinationId: settledDestinationId,
),
back: router.pop,
);Low-intrusion constraints:
- only one navigation adapter is wired at the app's top level;
- each destination is registered as a single row in the central catalog, without modifying the target page;
- Router routes use the existing router, and Shell tabs use one injected controller;
- arbitrary route strings are not accepted, and the consumer's real paths are not exposed;
- login, privacy consent, startup redirects, and business route guards are never bypassed.
A router/controller call returning only means the navigation request was issued. Only once the
observer confirms the current destination and the next frame has completed may the result be
marked uiObserved. Redirects, timeouts, backgrounding, and stale revisions must be rejected
stably.
Navigation commands are serialized and carry a navigation revision, so a late back/go cannot
operate on a new page stack. Operations that need to assert "the page is displayed" should require
the app lifecycle to be resumed; while backgrounded or with the screen off, at most a route state
change can be reported.
navigation.catalog and navigation.current are read-only commands. go, push, and back must
carry the revision the caller has just observed, with an optional timeoutMs defaulting to 5000.
A consumer callback returning only means the request has been handed to the existing
router/controller; the bridge keeps observing the settled destination and returns
outcome=arrived, source=uiObserved only after re-checking on the next frame.
The consumer's observer must publish only destinations that have already settled, and must
increase the revision monotonically whenever the settled destination changes. When a business
guard rewrites the request to a different settled destination, navigationRedirected is returned;
the other stable rejection codes are navigationTimeout, navigationLifecycleNotResumed,
navigationRevisionStale, and navigationDestinationAmbiguous. After an await in a gate, the
destination callback, ambiguity, and revision are all re-resolved.
ui.wait is a side-effect-free long-poll call with an explicit timeoutMs; it does not dress up
"started waiting" as completion. The current conditions are:
semanticsMounted/semanticsUnmounted/semanticsValue— accept only a stable, non-empty Semanticsidentifier; a duplicate identifier returnsuiSemanticsTargetAmbiguous, and obscured values cannot be read;navigationDestination— waits for a cataloged destination, optionally requiring the navigation revision to have advanced;treeRevision/frameRevision— waits for a revision strictly greater than the caller's baseline.
Successful results are uniformly outcome=observed, source=uiObserved, returning the
revision/node facts directly observable at that moment; timeouts uniformly return uiWaitTimeout
with an explicit timeout and current-revision summary. When the app is not resumed,
uiWaitLifecycleNotResumed is returned.
New operators use only public Flutter APIs, and the runtime catalog decides what a target actually supports:
| Operator | Target contract | Failure policy |
|---|---|---|
focus |
A unique focusable target | Reject if not focusable or ambiguous |
action.invoke |
A unique public Semantics/Actions action | No fallback to guessing by label, type, or coordinates |
scroll |
A unique ScrollableState | Returns ScrollMetrics before and after |
wait |
Semantics identifier, destination, tree/frame revision | Timeouts and ambiguity return stable rejections |
capture |
Root, or a unique target RenderObject | Returns warnings, blob metadata, and sha256 |
Plain ValueKeys, widget copy, runtime types, and Element paths may appear in read-only summaries,
but never automatically become stable operable targets.
A UI action proves only the direct result of a Flutter callback, controller, or RenderObject. If the action ultimately calls network, file, device SDK, or permission capabilities, the real handler must still pass the consumer's domain gates, permits, and generations.
The Flutter bridge does not acquire the consumer's domain locks and offers no shortcut that bypasses controllers. For complex business behavior, prefer registering a domain command; Keys are only for verifying UI wiring and standard widget semantics.
- release does not register the Flutter service host;
- the registry, descriptors, operators, and consumer callbacks must be unreachable;
- Key types, equality, and State preservation semantics stay unchanged;
- the root bridge passes the child straight through in release;
- the on-device inspector select mode is refused with
inspectorUnavailableon non-debug builds, and the binding flag is never written; - there is no runtime configuration that re-enables Patchbay.