patchbay_transport 0.3.0
patchbay_transport: ^0.3.0 copied to clipboard
Optional direct HTTP/JSON debug transport for Patchbay: explicitly started, loopback by default, short-lived bearer auth, fail-closed surface.
Changelog #
四个包随同一个 tag 定版,变更正文只在仓库根 CHANGELOG.md 维护一份。本文件由
release_prep --apply 从根表派生(已发布版本段原样拷贝,Unreleased 段不带过来),
供 pub.dev 的 Changelog tab 展示;不要手改,改根表后重跑 apply。
0.3.0 - 2026-08-17 #
发布批次:四包首次发到 pub.dev,随版依赖从 path 改成 hosted 约束——仍用 git pin 的接入方
不能只改 tag 号,两条迁移路径见本节 Changed 与发版清单第 8 节。
功能面围绕「App 不配合时也问得出话来」:保持亮屏开关与 repl 的 lifecycle 横幅(息屏即 UI 面
全拒的解法)、snapshot 字段选择与领域条件等待、widget inspector 开关、体检命令 doctor、
会话粘性、UI 目标声明对账 ui verify-manifest。协议侧补齐演进套件(serverVersion /
feature capabilities / catalog digest / 跨版本兼容 golden),工程侧补上定版脚本 release_prep
与 tag 触发的 CLI 二进制发布流水线。含行为变更:PatchbayDirectSnapshotSource 的构造签名,
迁移说明见本节 Changed。
Added #
-
跨平台「保持亮屏」开关
patchbay ui keep-awake on|off|status(ui.keepAwake.set/ui.keepAwake.status)。 设备中途息屏会把整个 UI 面带走:ui.*/navigation.*全部开始回*LifecycleNotResumed,随后系统冻结进程、CLI 只看得到appUnresponsive。Android 有不碰 App 的 外部解法(adb shell svc power stayon usb),iOS 真机没有——这条命令是长时间手动联调 iOS 真机时唯一能让设备别睡的杠杆。默认关、显式开、会话断开自动还原。 押住屏幕会改变被观察 App 的行为(息屏行为本身也是接入方 要测的东西),所以没人开口就什么都不做。两种 transport 都不给 App 连接生命周期——VM Service 扩展不知道 CLI 死没死,终端被杀也不会道别——所以每次开启都带一条租约(默认 10 分钟,上限 2 小时,
--lease-ms可显式给),到期由 App 自己释放:人还在就续租,人走了就不再续,断开和租约到期 因此是同一件事。App 销毁 debug 面时也归还。on/off是同一条协议命令的两种拼法,enabled由 敲的词决定而不是参数,off不可能被多余的 flag 变成一次开启;--lease-ms只属于on。patchbay_flutter仍是纯 Flutter 包:不转 plugin,也不引第三方 wakelock 依赖——那会改变每个 接入方 release 构建链接的东西。碰平台的那一行由接入方在组合根注入PatchbayFlutterBridge(keepAwakeDelegate: ...)(AndroidFLAG_KEEP_SCREEN_ON、iOSUIApplication.isIdleTimerDisabled),框架只拿协议、记账和租约。没接线时命令仍留在 catalog 里 ——与ui.capture/navigation.*的「没注入就不出现」相反,因为操作者伸手找它正是在屏幕刚黑、 UI 面刚开始全拒的时候,此刻回commandNotRegistered等于什么都没说;改回keepAwakeNotWired并点名缺的参数,status用wired: false报同一件事。响应
source恒为appRecorded:它说的是 App 让宿主做了什么,Patchbay 不回读平台,绝不宣称 屏幕确实亮着。delegate 抛异常是合法回答——开启时以keepAwakeDelegateFailed拒绝且不记成 hold; 释放失败时 hold 不落账、保持可重试:平台没松手就把enabled记成false,会让下一次off变成unchanged空转、再也不碰平台,屏幕永久亮着且没有补救入口。所以记账只在 delegate 成功后 才落,失败时enabled保持true、lastReleaseFailure带失败类型,再敲一次off会真的重试; 租约也不撤,到期释放失败会在一个租约之后再试(没人在场时它是唯一会重试的东西)。 后台on以keepAwakeLifecycleNotResumed拒绝并带lifecycleState(iOS 在后台设isIdleTimerDisabled无效,记下来等于记一件没发生的事),off永远允许。debug 面销毁后set以keepAwakeHostDisposed拒绝——dispose()是同步的、无法排进请求队列,可能落在一次进行中的 开启中间,此时尚无 hold 可归还,风险全在「挂起的请求随后把已销毁的宿主重新点亮」那一侧; gate 与 delegate 两个挂起点恢复后都重查销毁态,delegate 已经拿到 hold 的那种情况先归还再拒绝。doctor的 lifecycle 解法在 iOS 一侧改为指向这条命令。接法与语义见 使用指南。 -
snapshot 的字段选择与领域条件等待:
snapshot --path <dot.path>与snapshot wait <dot.path> --until exists|absent|equals [<json>]。 此前盯一个状态字段只能整树 反复拉,每轮一次完整往返;现在选择在 App 侧完成,等待也在 App 侧完成(长轮询,间隔 100ms, 第一次探测不等待,故条件已成立即刻返回)。响应新增selection: {path, found, value|miss}, 等待另带wait: {outcome, condition, timeoutMs, elapsedMs, pollIntervalMs, polls}。取到的值 原样返回(叶子或整棵子树),不重塑不汇总——会重塑的调试读没人能据以推理。寻址根是 App 交出来的快照本身,不是响应信封:协议自己盖的
schemaVersion不可寻址,否则 host 字段会冒充 App 状态。App 自己在快照里套的层级仍属路径的一部分——那是接入方的键,host 不 替谁拆包(拆了平铺的接入方就全取不到)。路径第一段就不存在时,超时拒绝的details会带availableKeys(顶层键,排序),把「路径写错」与「字段还没来」分开。打到不认识选择器的老 App 时答稳定的
snapshotSelectionUnsupportedByHost拒绝(退出码5),notice 给退路(整树snapshot,或升级 App 侧 patchbay);此前是裸transportError(退出码3),会把版本错配读成连接故障。取不到不是失败,等不到才是。
found: false退出码仍是0,并带missingKey/nullValue/notAnObject说明原因——「字段还没来」与「这条路径与快照形状矛盾」是两种答案, 合并会让写错的路径报成功,--until absent同理不吃notAnObject。等待超时以snapshotWaitTimeout拒绝(退出码5,与ui wait同口径),details带最后一次解析结果。 预算是对答案的硬顶:条件成立但拿到它的那次读取已越过预算时,答复仍是超时——超预算才拿到 的成功,调用方已经不在等它了。快照回调本身慢过预算时,details.elapsedMs会明显大于timeoutMs,这是「慢的是快照源」的读法。条件是闭合词表而非表达式语言:三条覆盖等待的全部用途,再多就是在 host 里塞进第二个没人 测过的求值器。
equals按 JSON 结构相等比较,命令行上的比较值按 JSON 字面量读(字符串要 写成'"ready"',裸词会被拒绝并把该加的引号写出来;null不接受,那是absent的事)。 等待预算--timeout-ms默认 5000、上限 2 分钟(ui.wait家族同一上限),且会自动加进 CLI 的 RPC 预算,不必另调--transport-timeout-ms。一律答复,不抛出。 非法选择器答
invalidSnapshotRequest;App 的 snapshot 回调抛错答providerProtocolViolation+details.reason: snapshotSourceFailed,只带异常类型不带消息 (consumer 的错误串是 App 数据,不跟着信封出去)。CLI 能先判的(路径语法、条件名、值形状) 在本地就以用法错误64挡下,不发请求。 -
协议新增
PatchbaySnapshotRequest/PatchbaySnapshotSelection/PatchbaySnapshotCondition/PatchbaySnapshotMiss与对应 wire 类型,patchbaySnapshotWaitCeiling/patchbaySnapshotPollInterval两个常量,以及结构化 JSON 比较patchbayJsonEquals。PatchbayServiceHost.dispatchSnapshot与PatchbayFlutterServiceHost.dispatchSnapshot接受可选的原始 wire 请求;VM Service 侧新增PatchbayServiceHost.snapshotRequestKey(request,一个 JSON 编码的对象参数)。PatchbayClient.snapshot/PatchbayDirectClient.snapshot增加可选具名参数。 -
widget inspector 开关
patchbay ui inspect on|off|status(DevTools 借用第一批)。 用 CLI 开关 Flutter 自带的设备端 widget inspector 选择模式,即 DevTools 上「圈一下看这块是什么 widget」那个。on/off是同一条协议命令ui.inspect.select的两种拼法,status是只读的ui.inspect.status。开着时点按被 inspector 吃掉、不再抵达 App,所以按sideEffect: appState声明——这是改 App 状态,不是一次观察。默认关,接入方显式 opt-in:不注入
PatchbayInspectPolicy时两条命令不进 catalog,调用得commandNotRegistered(与PatchbaySemanticsActionPolicy同一口径)。policy 声明的defaultLease就是 catalog 里ttlMs的default,maxLease是请求带了也不许超过的上限。每次启用带租约,到期自动还原。 两条传输都是请求/响应,App 侧观察不到断连,所以「断开还原」 在 App 侧只能表达成「静默还原」:租约走完没人续,桥把开关放回接手前的值;
dispose()同样还原。 续租不会把 Patchbay 自己装上去的true当成新基线。还原是有条件的——只在开关仍是 Patchbay 装的 那个值时回退,不掀 DevTools 期间别人拨的开关;显式off则照关不误。非 debug 构建如实拒绝。 overlay 由
WidgetsApp在一句assert里注入,只有 debug 成立; profile / release 下标志位写得进读得回却永不渲染。桥在动手前先判构建能力,命中即以inspectorUnavailable拒绝(details.reason为notDebugBuild/rootInspectorExcluded), 不写标志位、也不问 consumer gate。响应source恒为appRecorded:写标志位只排了一次重建, 不冒充「带 overlay 的那帧到过屏幕」。销毁竞态同样拒绝(
details.reason为hostDisposed):请求卡在 consumer gate 里等待时 host 被销毁,gate 返回后不再继续开启——否则会留下一个开着的 inspector 和一个无人持有的租约,设备从此 吞掉每一次点击。gate 恢复点重查 disposed,命中即拒绝且完全不碰 binding 标志位;销毁后再发的调用 (含只读的status)按同一 reason 拒绝。wire 新增
PatchbayInspectSelectRequestWire/PatchbayInspectStateWire与PatchbayInspectUnavailableWire/PatchbayInspectReleaseWire;patchbay_flutter公共 API 新增PatchbayInspectPolicy/PatchbayInspectBridge/PatchbayInspectorSurface(后者可注入, 用于在不切构建模式的前提下测试拒绝路径)。perf VM RPC 与 net 画像是后两批,不在本次范围内。 -
体检命令
patchbay doctor。 「连不上 / 没反应 / 命令全被拒」时一次把四件事按依赖顺序查完 ——会话目录、连接与 identity 握手、catalog、App lifecycle——每项给「现象 → 可能原因 → 建议动作」。 它自己拨号:拨不通正是它被问的那个问题,所以连接失败在它这里是一条 finding 而不是命令终止; 前一项失败时后面标skipped,会话目录判定失败时连拨都不拨。lifecycle 一项发一条只读 UI 探针 (ui.semantics.tree,maxDepth 0 / maxNodes 1),未 resumed 时报出lifecycleState并给 Android / iOS / 桌面三条解法。iOS 那条把「屏幕黑着」和「App 掉到后台」分开写:前者只能手动唤醒 (没有系统级电源命令),后者在已配对且已解锁的设备上用xcrun devicectl device process launch --device <udid> <bundle-id>就能拉回前台(真机实测)。 repl 的 lifecycle 横幅与使用指南同源同文。退出码不另立:取第一处 failed 检查项的类别(会话 / 连接
3、catalog4、lifecycle5), 即「换成普通命令撞上这一项时会拿到的那个码」;只有 warning(会话不唯一、门未开、App 没注册任何 命令)时是0。--json输出为{"doctor": {"verdict", "checks", "warnings"}},每条 check 带 稳定的check/verdict与机读details。doctor 只读:不改会话目录、不删记录、不重连。 -
活跃业务会话警示。 doctor 读一次 snapshot,扫各领域里为
true的布尔active(自顶层域起 最多五层、最多报八条),命中就打出路径原样并劝阻force-stop/kill/ 卸载——真机上强杀 正在通话或配网中的 App,代价远大于等它。这是结构化读法,CLI 不认识任何 consumer 的业务名词; 接入方把布尔active放在会话对象上即可被认出(如snapshot.call.session.active)。App 连不上 或 snapshot 读不到时这条警示照样出,措辞换成「查不出,按不安全对待」——恰恰是那一刻最容易顺手 强杀进程。 -
repl 会说出 App 未 resumed。 息屏 / 后台 / 桌面失焦时每行 UI 命令都以
*LifecycleNotResumed被拒,仅凭 code 猜不出该干什么。会话在第一条这样的拒绝之后把分平台解法打到 stderr,一个会话 只打一次(--json的 stdout 仍只有命令结果)。提示是从 App 已经给出的拒绝里读的,会话不为此 额外发命令:唯一受 lifecycle 闸管的只读命令会ensureSemantics()并催帧,等于替操作者改了被 观测的 App;doctor可以这么做(是点名要的体检),一条只是打开的会话不行。 -
CLI 公共 API 增加
PatchbayDoctorReport/PatchbayDoctorFinding/PatchbayDoctorWarning、runPatchbayDoctor与各项纯判定函数,以及dialPatchbayUnderBudget/closePatchbayQuietly(原为cli.dart私有,doctor 要用同一套拨号与静默关闭,故上提到rpc_timeout.dart)。 -
会话粘性:
patchbay sessions list|prune与patchbay session use <id>|--clear。 双设备并连 (Android + iOS 同时跑)时会话不唯一,此前每条命令都要显式敲长--session <id>。现在可以固定 一条会话,之后不带--session的命令都用它。选择是三级优先级链,不混用:显式--session最高 (且不改动固定项)→ 已固定的会话 → 唯一会话;三级都不成立时仍以sessionAmbiguous拒绝,并在 候选清单后附一句「可用session use固定」。固定项失效时 fail-closed,不回退。 被固定的记录不见了、进程已死或连不上时,命令以自己的稳定 code 失败(新增
sessionSelectionStale,另有既有的sessionStaleProcess/sessionUnreachable) 并附处置提示,不会改用目录里另一条会话——在双设备台上那意味着命令打到了另一台设备。CLI 也不 自行清掉固定项:清掉等于让下一条命令重新开始猜。sessions prune只在它删掉的记录正是被固定的 那条时才顺带取消固定。这三条命令不连 App、不读 catalog,只读写本地会话目录(
--session-dir),因此在「CLI 选不出 会话」时照样可用;它们在 repl 内不可用(那条连接已经选定)。sessions list的status是本地 判定而非一次往返:live/pending/stale,列 N 台设备不会变成 N 次连接尝试。记录里的 VM Service URI 带认证 token,列表只打印scheme://host:port,路径一律不出,--json的endpoint字段同样已打码。 -
PatchbaySessionException.hint:会话类错误可带一句处置提示,人读时跟在 stderr 的 code 之后,--json时进details.hint(与appUnresponsive的 hint 同一口径)。 -
CLI 公共 API 增加
PatchbaySessionStatus、PatchbaySessionListing、PatchbaySessionPruneResult, 以及PatchbaySessionStore.readSelection/writeSelection/clearSelection与PatchbaySessionResolver.inventory/prune/select/selection;启动器可据此自建会话面板。 -
session↔sessions互为别名拼写(session list与sessions list等价)。别名只增加拼写, 不新增命令,也不改任何既有命令名。 -
排版门禁:CI 的
dart_packagesjob 增加一步仓根dart format --output=none --set-exit-if-changed .(GitLab 与 GitHub Actions 两边同步)。此前排版没有门禁,main 自身也不统一——87 个 Dart 文件里有 17 个不合仓库 pin 的 dart_style(Dart 3.12.2)。同批已按该基准机械重排全仓,仅换行/缩进/尾逗号, 无语义改动。门禁从仓根跑一次即覆盖四包与 example,flutter_package内不重复。 -
command_codegen进codegen_drift门禁。 此前只有wire_codegen有零漂移检查,command_codegen只被单测按临时 fixture 跑过——而它恰恰是接入方直接消费的那个生成器, 输出漂移在本仓无人察觉,要等接入方升级 pin、重新生成、diff 炸开才暴露。现在仓内带一份样例 contract 与其生成物(packages/patchbay/contracts/example_commands.{json,g.dart}), GitLab 与 GitHub 两边的 codegen job 都对它跑--check,dart test里也有同一条断言。 样例本身是中性词表,不描述任何接入方的业务;它同时充当 command contract 唯一的可跑示例。这条
--check没有 cwd 约束:command_codegen生成物 header 记录的路径改为相对生成物 自身,而不是调用者当时敲的那个字符串,所以从仓根还是包目录调用都得到同一份输出。wire_codegen的老约束(必须从仓根调用,否则假漂移)未改动,两者的差异在 CI 注释、 协作约定与发版清单里写明。 -
周期性 Android emulator 冒烟(
.github/workflows/android-emulator-smoke.yml,每周一 + 手动触发): 在真实 Android 上装起 example 并跑通identity→catalog→snapshot/ui semantics tree的 CLI 往返。既有门禁全跑在 Ubuntu 上,覆盖不到「App 真的装进设备、VM Service 真的可连」这段; 它不是 PR 必过项,失败只表示平台链路有信号要查。example 的 Android 工程由 CI 临时生成,仓内 仍不带平台目录。 -
patchbay ui verify-manifest <file>:UI 目标「声明 ↔ 运行时挂载」对账。 接入方把「这个 App 应该开放哪些 UI 目标」写成一份 JSON manifest(id/kind/sensitive/destination),CLI 连上运行中的 App 与 catalog 的uiTargets对一遍,报三类偏差:declaredNotMounted、mountedNotDeclared、propertyMismatch(逐字段给declared/runtime)。纯 CLI 侧比对:不新增 wire 命令,App 侧零改动。kind的取值直接由 catalog 自己的PatchbayUiTargetKindWire解码, 不另立一份会漂移的词表。对账范围是当前挂载态,所以「未挂载」如实报成「当前未挂载」(
runtime区分absent与unmounted),不替调用方判成缺失——非常驻控件不在当前屏本来就不该挂载。destination在本版只做 过滤:manifest 里出现它时 CLI 先读一次navigation.current,只对账未 scope 和 scope 到当前屏的 条目,其余计入stats.skippedOutOfScope;逐屏自动巡检要驱动导航,不在本版内。同一 ID 同时挂载 多个实例不算偏差,但会进notices——桥对这种目标拒绝一切操作。人读输出直接列出偏差条目,
--json给三组数组 +stats。新增退出码7:对账跑完且报告里有 偏差,此时 App 侧每个请求都正常应答,因此既不是拒绝(5)也不是类型化失败(6)。manifest 读 不了或不合法时 fail-closed 退到64,稳定 codemanifestInvalid/manifestUnreadable,details.field指到具体位置(形如$.targets[2].kind);文件内容本身不进信封。读文件在拨号之前。 manifest 是本地输入,写错与设备连不连得上无关,所以离线机器上写 manifest 照样拿到文件本身的错,不会被
sessionDirectoryEmpty之类的会话错盖过——那句话是真的,但说的 不是作者此刻能改的那件事。repl 内不受影响:那条连接已经建好,这一行没有拨号可言。schema 与边界见使用指南,示例文件
docs/examples/ui-targets-manifest.json。 -
CLI 的 AOT 构建入口
packages/patchbay_cli/tool/build_cli.dart。 CLI 每条命令起一个进程, 启动开销按条计费;dart run每次都要做一遍 pub 新鲜度检查再 JIT 预热。AOT 产物两样都不付, 同机同链路对同一个 example host 实测:启动 + 一次catalog往返由dart run bin/patchbay.dart的 540 ms 降到 45 ms,纯--help由 463 ms 降到 21 ms(macOS arm64,各 8 次中位数)。产物落在 已 gitignore 的packages/patchbay_cli/build/,编一次约 1.6 秒、7 MiB,放上 PATH 即可任意目录 直跑。脚本从脚本位置而非 cwd 解析路径,仓根与包内调用等价;新 clone 缺 package config 时自己 先跑dart pub get。 -
tag 触发的 CLI 二进制发布流水线
.github/workflows/release.yml。patchbay-v*tag 推送后, 在 macOS / Linux / Windows 三个 runner 上经同一个tool/build_cli.dart编出macos-arm64/linux-x64/windows-x64产物,附checksums.txt挂到对应 GitHub Release; 产物名带版本与平台后缀。产物自带运行时,目标机器不需要 Dart SDK。Release 已存在时只补 产物不覆盖正文。首次真实运行在0.3.0tag。 -
协议演进套件:
serverVersion/ feature capabilities / catalog digest / 跨版本兼容 golden。 CLI 与 host 分开部署(CLI 从终端装,host 跟着别人发布的 App 走),已有两个接入方 pin 在不同 tag 上,「两端同版本」从来不是可依赖的前提。四件东西都是schemaVersion仍为1之内的 加字段——identity / catalog 是客户端逐键读的松读面,老客户端忽略不认识的键——不是协议 版本跳跃。设计取舍见 design.md 协议演进。serverVersion(identity):host 报出自己编译自的patchbay版本。Dart 运行时读不到 自己的pubspec.yaml,所以它是随包走的常量(patchbayPackageVersion),也因此成为发版时除 四包 manifest 与两份 README 之外还要再改的一处;release_version_parity_test.dart已把它钉死在 四包版本上——常量漂移不是印错一份文档,是全网 App 谎报自己的构建。- feature capabilities(identity
features):host 声明自己支持的能力,客户端按声明降级 而不是猜。catalogDigest由协议层无条件声明,lifecycleState由持有 lifecycle 门的 Flutter host 声明。声明侧封闭、读取侧开放:host 只能声明PatchbayFeature枚举里的名字,客户端把 它当普通字符串读,遇到没见过的名字降级成「我不用它」而不是解码失败。缺这个键(老 host)与[](声明为空)是两个答案,全链路不得抹平。 catalogDigest(catalog):commands的稳定摘要(sha256,对象键递归排序 + 条目排序), 用于回答「App 声明的能力面变没变」。只覆盖commands:uiTargets是当前挂载态,导航一下就换 一批,摘要跟着翻消费端只会学会忽略它。自带algorithm/covers,读者被告知哈希的是哪一块 而不是自己假设。协议自己写,consumer 目录里的同名键会被覆盖。读取端容忍多出来的字段,但不 容忍读不懂的条目:covers里混进本版读不懂的条目时整份覆盖按畸形处理、降级为不可复算,绝不 把那一项丢掉后接着算——丢完剩下的可能恰好就是本版认得的覆盖面,那样「只读懂一部分」会被伪装成 「全读懂了」,对着一个并非按此口径算出来的值说verified。它降级成「验不了」而非「没有摘要」, 否则上层会反过来报一条并不存在的能力失约。- 跨版本兼容 golden:
patchbay_cli/test/protocol_compat_test.dart双向钉死——新 CLI 拿 手写冻结的 v0.2.0 语料(缺上述全部字段)跑完整 doctor;老 CLI 的读法在用例里复刻后去读 当前 host 真的吐出来的东西。patchbay/test/protocol_surface_golden_test.dart另把「契约 wire 面」 与「客户端正在严格解码哪些类型」一起钉成 golden:往松读面加字段安全,往生成的XxxWire.fromJson解码面加字段会当场打断已发布的老 CLI,两者在源码里长得一模一样,golden 让 它在 diff 里现形。
-
doctor 报出 host 版本、能力与摘要核验。
connection一项打出serverVersion与features——CLI 与 host 版本错配解释掉的故障比其它任何一项都多;老 host 明说「不报自己的 patchbay 版本」, 不留空让人猜。catalog一项自己复算摘要再给catalogDigestCheck(verified/mismatched/unsupported):摘要要是消费方验不了,那就是个只能信的数字。算不动的报unsupported而不是mismatched——「我查不了」和「这是错的」是两个答案;覆盖面里有读不懂的条目时另附catalogDigestCoversUnreadable,说明同处打印的catalogDigestCovers只是能读懂的那部分、比 host 声明的窄。lifecycle一项新增lifecycleStateSource(hostReported/featureUndeclared/capabilityNotHonoured),此前三种情况一律印lifecycleState=unknown,读起来像是关于设备的结论,而它只在中间那种情况下为真。host 声明了能力 却不兑现,单列capabilityNotHonoured警告——要归档的 host bug,不是停止调试的理由,退出码仍是0。 -
定版脚本
release_prep(dart run packages/patchbay/bin/release_prep.dart)。 把定版四件套 ——四包version一致 bump、根 CHANGELOG 落款、example/pubspec.lock刷版本、兼容矩阵新行 ——加上 pub 发布链的静态门,做成两个模式:--check只读幂等、红绿即结论,--apply只改文件、 不打 tag、不推送、不发布,改完自动重跑判定并打印人工清单与按包间依赖推导出的发布顺序。硬检查是有来历的:
example/pubspec.lock是0.2.0定版漏刷的那一项,兼容矩阵行是0.2.1打完 tag 忘了回填的那一项,两项都不降级成提示。pub 侧各项按实测定级——dart pub publish --dry-run只要有一条 warning 就退 65,所以缺 README / CHANGELOG / repository、 description 不在 60–180 字符,一律按「挡发布」对待。发布开关publish_to: none单列一项, 默认不动,只有显式--apply --enable-publish才删。 -
四包各留一份
CHANGELOG.md与LICENSE。 pub.dev 每个包页的 Changelog tab 读的是包内那份, 仓根这份它看不到。包内 CHANGELOG 由release_prep --apply从本文件派生(已发布版本段原样拷贝,Unreleased段不带过来),正文仍只在本文件维护一份,不要手改包内那份。
Changed #
-
PatchbayDirectSnapshotSource改为接受一个可选位置参数(Future<Map<String, Object?>> Function([Map<String, Object?>? request])),用于把 snapshot 选择器原样交给 App 侧。自建 direct host 的接入方要改这一处:snapshot: () async => …写成snapshot: ([_]) async => …; 不改则在此处编译失败,不会静默改变行为。PatchbayDirectHost只校验选择器是不是 JSON 对象, 不解释其内容——选择器的形状是协议包的规则,传输层再解一遍就是第二个可以与 VM Service 路径 各说各话的解码器。snapshot 消息多出的request是唯一可选键,其余未知键照旧 fail-closed。 -
0.3.0起四包发布到 pub.dev(0.x语义:^0.3.0接纳0.3.x,不跨 minor)。 为此四包 互相之间的 path 依赖改成 hosted 约束(patchbay_flutter依赖patchbay: ^0.x.y),仓内解析 靠随包提交的pubspec_overrides.yaml落到工作树——两处一致由release_prep的internal-dep-constraints/local-overrides兜住,pubspec_overrides.yaml因此从.gitignore里放了出来(它不会进发布包)。对仍用 git pin 的接入方是破坏性变化:pub 不允许同一个包在一次解析里既来自 git 又来自 hosted,所以「四包全用 git ref pin」在
0.3.0上会直接版本求解失败。两条路二选一——整体改用 pub.dev 版本,或在自己仓的根 pubspec 加dependency_overrides把四包统一指回同一 git ref。 口径见 docs/release-checklist.md 第 8 节。 -
安装文档改按形态组织(
docs/guide.md安装节)。 原来只给一条dart pub global activate命令,漏掉了两件每个新用户都会踩的事:$HOME/.pub-cache/bin默认不在 PATH 上(装完了patchbay找不到);以及在接入方仓目录里dart run patchbay_cli:patchbay按当前目录所属 的包解析,拿到的是该仓 pin 的那个 tag 而不是手上的 CLI——表现为新命令「不存在」的用法错误 (退出码64),容易被误读成 CLI 有 bug。现在三种形态(Release 二进制 /pub global activate/ 仓内dart run)带耗时对比与适用场景并列,坑单独成块。同时记入:
dart pub global activate --source path每次调用都重新解析依赖,pub 把Resolving dependencies…打在 stdout 上,破坏「--json时 stdout 只有一个 JSON 文档」 的约定,下游解析器会失败——需要工作树即时生效又要读--json时,用 AOT 产物或仓内dart run,不要用 path 模式。 -
退出码一节写明判定口径(
docs/guide.md退出码)。 原来只有一句「脚本应同时读 JSON 信封」, 没点出最容易把失败读成成功的那个写法:patchbay --json … | jq …之后的$?是jq的码, patchbay 判红也照样是0。现在明确:脚本与 agent 判定结果读--json的结构化字段或 patchbay 自己的退出码;确实要在管道里拿真码,用set -o pipefail(或 bash 的${PIPESTATUS[0]}), 否则先把输出接到变量再解析。
Fixed #
- README 的项目状态与安装 tag 跟上四包
0.2.1,并新增版本一致性测试,后续四包 version、README 状态或两处 Git ref 任一漏改都会在 CI 判红;同时澄清PatchbayKey必须缓存、release 组合边界与 generation 围栏适用范围,架构图补双向请求/响应和 direct loopback 边界。 patchbay help <group>的可用性说明改为按组内各命令推导,不再对每个组一律打印「Availability is still decided by the running App catalog」。ui组因此同时说明 SDK passthrough 那一半,sessions组说明它根本不需要 App。
0.2.1 - 2026-08-14 #
诊断完备性批次:等待 App 的路径全部有超时预算并可诊断,拒绝信封不再有空 details;
文档按开源仓标准整治。含行为变更:--transport-timeout-ms 默认 60s→30s 且两传输通用,
迁移说明见本节 Changed。
Changed #
-
CLI
--transport-timeout-ms从「仅 direct 传输的 socket 预算、其他路径静默忽略」改为两条传输 通用的单次 RPC 预算,默认60000→30000;连接握手(会话发现 + identity)也纳入预算。 它与--timeout-ms是两个量,不要混用:后者是请求 App 自己等多久(ui wait、logs tail、navigation go|push|back、capture),仍随请求发到 App 侧。声明了等待预算的请求,其 RPC 预算 自动放宽成「声明的等待 + 一次往返」,所以ui wait --timeout-ms 120000与--wait的 job 长轮询 都不会被默认预算腰斩。迁移: 依赖旧的 60 秒 direct 预算、或依赖「VM Service 路径永不超时」的脚本,需要显式传
--transport-timeout-ms。direct 传输原先的timeout错误码统一成appUnresponsive(见下)。
Fixed #
-
CLI 等待 App 应答的路径原先没有超时预算(VM Service 路径完全没有,direct 只有自己那份)。 Android 真机实测:息屏后系统冻结 App 进程,对端停止应答,CLI 要等底层 socket 自己死掉(>120 秒) 才以裸
HttpException收场,看上去与「卡住」无法区分。现在每次 RPC 往返都有预算(见上), 耗尽时以退出码3和稳定 codeappUnresponsive失败,并附一句处置提示(冻结 / 息屏 / 挂起 → 亮屏解锁或检查进程;--json时在details.hint)。direct 传输自己的timeout码归一到同一个appUnresponsive,脚本对「对端不应答」只需认一个码。一条命令对不应答的对端只花一个预算,不按 RPC 段数叠加:第一次没等到应答就结束整条命令, 后面的往返不会发出。对端已死(端口无人监听)走另一条路——内核立刻拒绝,毫秒级以
transportError失败,不等预算。 -
CLI 进程在判决作出后仍可能挂住。 预算判完、
appUnresponsive也打印了,进程却不退出:被放弃的 VM Service WebSocket 握手仍注册在事件循环上,main返回后 VM 会一直等它,而冻结对端的握手永远 不会完成、也无法从调用方取消。Android 真机实测 178 秒(30 秒预算早已判完并打印),直到系统把 App 杀掉、TCP 断开才结束。现在bin/patchbay.dart在命令结果产出后冲刷 stdio 并显式exit(code)——判决即结果,进程随之结束;命令要落盘的东西(artifact、stdout 响应)在runPatchbayCli返回前 都已 await 完成。同时runPatchbayCli不再遗留被放弃的连接(迟到成功的拨号会被关掉),连接释放 本身也有上界,不会成为新的挂起点。同一场景复测:178s → 30.5s(30s 预算 + 进程启动)。 -
CLI
catalogInvocationDrift不再吞掉 host 已经给出的目录违规原因。host 目录违规时会在 invoke 应答的rejection.details.catalog里说明哪条命令名非法或重名,CLI 原先只抛一个裸码,操作者还得 再跑一次patchbay catalog才能知道刚才那次应答已经说过的话。现在原样透传到错误信封的details.catalog,并附details.command/rejection/reason;invoke 未重复时回退到 catalog 读到的那份。 -
invalidUiArguments不再是裸码。九处 UI 参数校验路径的details现在指名:missing(声明为 必填却缺席)、unexpected(命令未声明的键,只在真正执行白名单的调用点计算)、invalid(类型或 枚举取值不符),全部从 host 已经在发布的 descriptor 推导,不是另抄一份命令形状。ui.wait的 条件相关形状规则(semanticsValue要有value、revision 等待不许带 identifier)不是任何单个键 能表达的,额外由details.reason承载。只走参数名等协议词汇,调用方的值不进信封。 -
同一类的三个越界拒绝也补上 details:
invalidCaptureArguments、invalidNavigationArguments与invalidUiTreeLimits现在以details.invalid指名越界的是哪个参数,前两个还附上被越过的上界 (maxTimeoutMs/maxPixelRatio)。此前一个合理但超限的数字被拒时,调用方既不知道是哪个参数 也不知道界在哪。 -
uiLifecycleNotResumed/uiWaitLifecycleNotResumed/navigationLifecycleNotResumed/captureLifecycleNotResumed带上details.lifecycleState。此前四个码都不带 details,操作者只知道 闸关了,分不清设备睡了、窗口只是失焦、还是 App 正在退出——而三种的处置完全不同。
Added #
PatchbayLifecycleStateReader与patchbayLifecycleReaderFor/patchbayLifecycleDetails: 生命周期状态的诊断接缝,判定权仍在isAppResumed。PatchbayFlutterBridge及四个桥新增可选lifecycleState参数;不传时 reader 跟随判定接缝——默认判定读 binding,被覆写的判定如实报unknown,避免出现「拒绝说没 resumed、details 说 resumed」的自相矛盾信封。既有接入方不受影响。patchbay_cli导出patchbayDefaultRpcTimeout/patchbayRpcBudget/awaitPatchbayRpc/PatchbayTimeoutClient/patchbayAppUnresponsiveCode/patchbayAppUnresponsiveHint;PatchbayProtocolException增加details,由错误信封原样输出。
0.2.0 - 2026-08-14 #
四包同步定版(tag patchbay-v0.2.0)。含协议正确性批次、repl 会话与 ui tap 直达、
Job 资源控制、inputWasStdin 框架层收编、CLI 契约六项与 catalog 校验失败结构化上报;
双 consumer 验证(Android 真机 + macOS/iOS E2E)。升级前必读本节各迁移说明
(命令名 kebab 禁用、手写 adapter 两步迁移)。
Changed #
-
命令名语法收紧为
^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+$:每段以小写字母开头,段内只允许 字母和数字,段内连字符不再合法。canonical 命令名是这道校验的目的,不放宽。迁移:升级 pin 前先扫一遍自己 descriptor 的
name。 kebab 段名(如auth.switch-tenant) 改写成点分段(如auth.tenant.switch)。改名是破坏性的:CLI 调用、脚本和文档要同步改,旧名 调用会得到commandNotRegistered。catalog 里只要有一条非法名,整个目录就不可用(见下), 不是只跳过那一条。 -
CLI
--stdin与--args由「整体替换」改为「合并,stdin 覆盖同名键」。原先「stdin 提供全量 参数」的用法成为--args缺席时的退化情形,行为不变;stdin 内容仍必须是 JSON object。 同时新增 fail-closed:catalog 声明sensitive: true的参数若出现在--args,CLI 直接以退出码64拒发,不再依赖 App 侧兜底,错误信息不回显值。 -
CLI
--json时一切错误也输出到 stdout 的稳定 JSON 错误信封{"error":{"code":...,"details":{...}}},字段与 rejection 信封同形;人读文本仍只走 stderr。 无--json行为不变。 -
CLI
navigation go|push|back省略--revision时自动先读navigation.current再派发,结果带revisionSource。revision 围栏本身不变,读到与派发之间导航动过仍被 App 拒绝;显式--revision行为不变。 -
CLI
patchbay help <topic>接受 catalog 协议名(navigation.go、ui.semantics.tap、ui.wait) 与别名拼写(navigate/nav/wait/tap/text/semantics、ui wait <condition>)。 别名只增加拼写,不新增命令,也不改任何既有命令名或 condition 名。 -
PatchbayArtifactDownloader.chunkBytes由静态常量改为实例字段(常量更名defaultChunkBytes);CLI 按 catalog 中blob.read的limit默认值与之取小。 -
inputWasStdin由框架层收编。host 在把 arguments 交给 consumer 之前按 descriptor 的sensitive声明完成校验(任一 sensitive 参数带非空值却缺少该标记时,以sensitiveInputRequiresStdin拒绝,details.parameters列出违规参数名),随后把这个元键剥掉:domainInvoke收到的 arguments 永远不含它。plane: flutterUi的命令例外——其敏感性是目标级 而非参数级,元键仍交给patchbay_flutter的 bridge。command codegen 同步不再豁免、也不再校验 该键。catalog 是这条策略的唯一真源,读不到时带参调用 fail-closed (providerProtocolViolation/catalogUnavailable)。**迁移:手写 adapter 升级 pin 后必做两步。**① 删掉 arguments 白名单里对
inputWasStdin的 豁免(不删无害,只是死代码);② 删掉 adapter 自实现的 stdin 强制检查(不删必炸——host 剥键后该判断恒为假,所有合法的敏感调用都会被 App 侧误拒)。规范表述:host 已接管 sensitivePolicy 校验,手写 invoke 不得再依赖inputWasStdin键。用 codegen 的接入方升级 pin 后重新生成即可;停留在旧 pin 的接入方不受影响,旧 host 与旧生成代码在旧语义下自洽。
Fixed #
- CLI
--wait的终态结果在响应顶层回填jobId,与受理信封口径一致;payload.jobId保留为 App job snapshot 字段。人读摘要对终态 job 输出jobId=… terminal=true phase=…,不再吞掉 outcome。 - CLI 下载 artifact 时不再写死 64 KiB 分块:host 把
maxChunkBytes调小于该值时,原先每个blob.read都会被blobInvalidChunkLimit拒绝,下载完全不可用。 blob.read的limit与 descriptor 对齐:wire 允许缺省,host 补上 catalog 声明的同一个默认值 (PatchbayMemoryBlobStore.maxChunkBytes)。此前 descriptor 标了默认值但 wire 必填,声明与实际 不符。schemaVersion改为 host 保留字段,consumer catalog / snapshot 回调不能覆盖。- catalog 校验失败不再是未处理异常,改为结构化协议错误。此前非法命名 / 重名 / 缺名会让
handleCatalog抛StateError;异常在 VM Service 和 direct HTTP 上都变不成回复,调用方表现为 无限挂起,连带拖住依赖 catalog 的路径(CLIexec的命令解析先读 catalog)。现在整个 catalog 调用返回拒绝信封:admission: rejected+rejection.code = providerProtocolViolation,details.reason取invalidCatalogCommands/commandsNotAnArray/catalogSourceFailed。invalidCatalogCommands的details.violations逐条给出index、name和reason(invalidCommandName/duplicateCommandName/missingCommandName;没有可回显的名字时只给index),并附details.commandNamePattern;三类一次全报,不是报完第一条就停。命令名是协议 词汇不是接入方数据,直接指名。违规目录不带commands字段——静默跳过坏条目等于把接入方的 bug 藏成「App 少了个能力」。带参数的invoke同样 fail-closed(providerProtocolViolation/catalogUnavailable),details.catalog带上目录本身的违规原因。接入方 catalog 回调自己抛异常 时走同一条路(reason: catalogSourceFailed,details.error只给异常类型名,不回显消息)。 - host 严格验证 invocation wire、协议版本和
requestId;provider 返回非法信封时转换为providerProtocolViolation,不把不相关响应交给调用方。 - VM Service 与 direct 两条路径都拒绝空
requestId;invocation 同时校验 admission、rejection、payload 与 jobId 的条件不变量。 - Flutter text / Semantics operator 沿用调用方
requestId,VM Service 与 direct client 同时验证响应相关性。 retainedJobs按已结束任务计数,并在任务进入终态时立即执行淘汰。cancelAll()并行发起全部运行中 job 的取消:每个回调各自受cancellationTimeout约束,一个卡死或 抛错的回调不再阻塞后续 job,也不再中断整批取消。
Added #
ui.semantics.tap:按稳定 Semantics identifier 一步完成解析、代际校验与派发,取代ui.semantics.tree+ui.semantics.action两跳;CLI 侧为patchbay ui tap <identifier>,--generation可选。解析出的 generation 在过门前 pin 住,门后二次解析必须命中同一 generation; 未命中、多义与代际过期都是带 details 的稳定拒绝。与ui.semantics.action共用 action policy, 没有 consumer policy 时不进 catalog、不可派发。patchbay repl:一次连接内从 stdin 逐行执行 typed 命令,语法与一次性调用相同。每行结果自带exitCode,会话退出码只描述会话本身。连接类参数、--json与--stdin在会话内逐行 fail-closed; direct HTTP 传输不支持 repl(bearer token 会与命令流共用 stdin)。runPatchbayCli增加connect/replInput/output/errorOutput测试接缝参数;新增公共PatchbayReplSession、tokenizePatchbayReplLine与patchbayResponseSummary。PatchbayJobRegistry.maxRunningJobs,默认32;达到上限时同步抛出PatchbayJobCapacityExceeded,任务 body 不会启动。PatchbayJobRegistry.cancellationTimeout,默认5s;取消回调超时会保留 running 状态, 不谎报底层操作已经停止。- 没有 cancellation callback 的 job 不再被标记为 cancelled;
cancel()返回false并保留 running。 - Job registry 提供
runningJobs、settledJobs和totalJobs只读计数。 PatchbayJobCancelOutcome;cancelAll()改为返回逐 job 结果(cancelled/notCancellable/timedOut/callbackFailed/alreadySettled),不用单个结论概括全批,超时、抛错和无回调的 job 仍如实保持 running。