BongoCat Documentation

BongoCat — A cross-platform interactive desktop pet that brings fun to your desktop!

RAW Doc

Technical Documentation: ayangweb/BongoCat

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (`README.md`)

text
[](./LICENSE)
[](https://github.com/ayangweb/BongoCat/releases/latest)
[](https://github.com/ayangweb/BongoCat/releases)



[](https://trendshift.io/developers/8507)
[](https://trendshift.io/repositories/14605)
<a href="https://hellogithub.com/repository/7d23863fd4be47b39e816193ded385c9" target="_blank">
  
    
    
    
  
</a>
macOS Windows

BongoCat is a desktop companion for Windows and macOS. A Live2D cat lives on your screen, its eyes
and paws follow your mouse, and it reacts to every key, mouse button and gamepad button you press.
Bring your own models, move the window wherever you like, and it stays out of your way until you
want it again.

Inspired by Bongo-Cat-Mver by
MMmmmoko.

Features

  • Runs on macOS and Windows.
  • Matches the right motion to every key, mouse button or gamepad button you press.
  • Bring your own Live2D models and make the cat your own.
  • Fully open source, public code, and no collection of user data.
  • Works offline with no network access, so your privacy is protected.

More models

You can browse and download more cat models, or share your own, in
Awesome-BongoCat.

Community

QQ Group 1 QQ Group 2

Support

Every bit of recognition is appreciated. Donations are welcome, but never expected — thank you for
loving the cat ❤️

Contributing

Contributions are very welcome. Please read the contributing guide.

Star history

License

BongoCat is open source under the Apache License 2.0 license.


Overview (`docs/benchmark/README.md`)

Benchmark Records

本目录保存可重复的性能测试方法和结果。每份记录至少包含:

  • 构建 commit 和 release/debug 配置
  • 操作系统、CPU、GPU、内存和显示器/DPI
  • 模型、窗口尺寸、目标 FPS 和输入脚本
  • 预热、样本数、测量工具和原始数据位置
  • p50/p95/p99、误差来源和结论

macOS Metal overlay 的第一个单机 draw-time diagnostic baseline 见
macos-overlay-frame-timing-90e0aa7.md
及其 raw CSV。它只验证当前 preview 的有界计时器,未覆盖发布性能矩阵。


Overview (`spikes/gpui-settings/README.md`)

GPUI Settings Spike

This is an isolated Phase 0 probe for gpui = 0.2.2. It intentionally does not
belong to the legacy Tauri workspace and does not implement product state,
Live2D, input capture, or the native overlay.

Run it from this directory:

text
cargo run

For a repeatable lifecycle smoke test:

text
BONGOCAT_SPIKE_AUTO_QUIT_MS=1500 cargo run --locked

Successful output includes window opened, a first-frame line with a positive
platform scale_factor, runtime snapshot revision=1, runtime stopped, and
stopped. Window initialization failure exits non-zero after the runtime
shutdown hook completes, so automated smoke tests cannot pass on an error log
alone.

The synthetic runtime bridge uses a bounded typed command channel. The UI reads
revisioned snapshots through a GPUI task, while application shutdown waits for
the runtime acknowledgement. Run its contract test with:

text
cargo test --locked

Launch with --runtime-error-probe to exercise non-blocking loading, typed
failure, retry, and revision recovery. The first refresh resolves to revision
1, the second reports runtime probe failed, and the retry resolves to revision
2; each read waits three seconds on the GPUI background executor rather than blocking the UI
thread.

The Reset command exercises GPUI's public tooltip API and a project-owned modal
dialog. The dialog traps Tab/Shift-Tab between Cancel and Reset and supports
Enter/Space activation and Escape dismissal. It remains a Phase 0 interaction
probe, not a product settings reset implementation.

This probe is visual-first like the product: it does not maintain a
project-owned accessibility tree, adapter or action channel, and it does not
depend on accesskit, accesskit_macos or accesskit_windows (ADR-0054). Its
CI smoke therefore checks window lifecycle, the typed runtime bridge and the
visible UI probes, not an auxiliary-technology tree.

On macOS, launch with --menu-probe to verify the native Application, Edit,
and Window menu structure. The probe dispatches Select All, Cut, and Paste via
the installed NSMenu items on later AppKit run-loop turns, then checks the
focused GPUI text input and clipboard without logging their contents. This does
not validate the later NSStatusItem menu-bar service.

Launch with --tooltip-probe to send synthetic native mouse-move messages
through the platform window and GPUI input path. The probe finds the Reset
hitbox, waits for GPUI's 500 ms tooltip delay, verifies that the tooltip is
built, then moves out and verifies hover cleanup. It does not replace a physical
pointer.

Build the release binary and collect the macOS Phase 0 performance probe with:

text
./scripts/package-macos.sh
./scripts/benchmark-macos.sh

Raw CSV files and a summary are written below target/benchmark/. First-frame
time starts at Rust main, so it excludes dyld and process creation. Idle CPU
is the lifetime average reported by macOS ps; RSS is sampled in KiB after a
five-second warmup. The binary increment compares the unsigned release
executable with an empty Rust executable built by the same toolchain using
opt-level=2 and thin LTO.

Build an ad-hoc-signed macOS application bundle and launch it through
LaunchServices:

text
./scripts/package-macos.sh
open -W "target/package/BongoCat GPUI Spike.app" --args --auto-quit-ms 6000 --menu-probe --tooltip-probe

The ad-hoc signature only validates local bundle integrity. It is not a
Developer ID signature or notarization result.

The spike is successful only when the window opens, renders text without
clipping, and closes cleanly on the target machine. It is not evidence that the
production runtime, UI, or overlay lifecycle is complete.

This probe uses GPUI's default precompiled shader path. On macOS, install the
optional Metal Toolchain before building:

text
xcodebuild -downloadComponent MetalToolchain

The validated component version is 17F109.


Overview (`spikes/overlay-windows/README.md`)

Windows Native Overlay Spike

This Phase 0 crate owns the independent Windows overlay boundary used by the
GPUI coexistence probe. It creates a thread-confined Win32 popup and a separate
D3D11 + DXGI + DirectComposition renderer without accessing GPUI renderer
internals.

Hypothesis

A Rust-owned HWND with a premultiplied-alpha composition swap chain can live
beside a GPUI settings window, clear/present transparently, transition between
visible and hidden states, and release GPU/DirectComposition resources before
destroying the window.

The spike attempts a hardware D3D11 device first and falls back to WARP so CI
can test ownership and composition APIs without claiming physical-GPU
performance. Windows ARM64 is compile-checked only; Cubism R5 still blocks an
ARM64 release.

Boundaries

  • The crate returns project-local strings and reports; Win32/COM types stay
    private.
  • The owner is deliberately !Send/!Sync and asserts its creation thread.
  • unsafe is limited to documented Win32, D3D11, DXGI and DirectComposition
    calls.
  • Renderer teardown detaches the composition graph and flushes D3D11 before
    the HWND owner is dropped.
  • This does not render Live2D, validate physical-GPU performance, recover a
    removed device, or prove leak freedom beyond the measured process handle
    contract.

Validation

Portable and cross-target checks:

text
cargo fmt --manifest-path spikes/overlay-windows/Cargo.toml -- --check
cargo clippy --manifest-path spikes/overlay-windows/Cargo.toml --locked --target x86_64-pc-windows-msvc --all-targets -- -D warnings
cargo check --manifest-path spikes/overlay-windows/Cargo.toml --locked --target aarch64-pc-windows-msvc

The windows-latest CI job runs the GPUI coexistence executable in three
modes:

text
--auto-quit-ms 1800
--simulate-overlay-init-failure --auto-quit-ms 900
--windows-overlay-cycles 100

The normal path must report two transparent presents and ordered GPU/HWND
teardown; the GPUI quit path explicitly releases the overlay owner before
posting the platform quit message. The injected failure must leave the GPUI
settings window alive with a degraded status. The cycle probe warms
process-global graphics state, then
requires 100 full create/show/present/hide/drop cycles without process handle
growth beyond a fixed four-handle tolerance. The standalone cycle process owns
a thread-confined COM apartment, and CI terminates any individual smoke process
that does not exit within 30 seconds.


Overview (`tools/cubism-bindgen/README.md`)

Cubism Core Binding Generator

This offline tool fixes the raw-binding generation contract for Cubism 5 SDK
for Native R5. It does not download the SDK, accept a license, link Cubism
Core, or implement product behavior.

The committed fixture header and expected bindings are synthetic BongoCat test
data. They verify the symbol allowlist, three R5 desktop target configurations,
the C ABI, deterministic output, and generated-file drift. They are not copied
from the Cubism SDK. Windows x86 and native Windows ARM64 are outside the
final target set; Windows on ARM runs the x64 build. R5 has no matching
desktop Windows ARM64 Core to bind.

Fixture check

text
cargo run --manifest-path tools/cubism-bindgen/Cargo.toml --locked -- check-fixtures

Expected bindings are generated files and must not be edited manually. After
reviewing an intentional generator or synthetic-header change, refresh them
with:

text
cargo run --manifest-path tools/cubism-bindgen/Cargo.toml --locked -- refresh-fixtures

Licensed R5 header

Only a maintainer who has legally acquired the pinned R5 SDK may run real
generation. Use the header SHA-256 reported by tools/inspect-cubism-sdk.py:

text
cargo run --manifest-path tools/cubism-bindgen/Cargo.toml --locked -- \
  generate \
  --header /absolute/path/outside/repository/Live2DCubismCore.h \
  --expected-header-sha256 <64-hex-digest> \
  --target aarch64-apple-darwin \
  --output-directory /absolute/new/path/outside/repository/cubism-bindings-aarch64

The tool refuses repository-local licensed headers and output, refuses an
existing output directory, and emits only bindings.rs plus
provenance.json. The provenance records header/output/config hashes,
bindgen, libclang, Rust edition, and target configuration without recording
the local SDK path.

The approved minimal R5 developer baseline under vendor/cubism/5-r.5 and the
real generated bindings may be committed for local development and ABI evidence.
The official SDK ZIP, extracted SDK, and unreviewed generated output must remain
outside the repository unless the maintainer records a later permission decision.
Review of new generated output must happen in the controlled external directory and cover:

  1. header hash against the independently verified R5 SDK report;
  2. exact generated symbol inventory and target calling convention;
  3. a second generation with the recorded bindgen/libclang configuration;
  4. diff and SHA-256 equality between both outputs;
  5. compile/link/ABI smoke against the matching official Core artifact.

Overview (`tools/cubism-core-probe/README.md`)

Cubism Core Probe

This standalone Rust tool validates a locally supplied Cubism Core and generated raw
bindings without committing licensed SDK material. It checks the fixed Core/Moc
versions, aligned Moc and Model lifecycle, parameter/part/drawable arrays, enhanced
rendering blend/offscreen arrays, masks, geometry, canvas data, and repeated teardown.

The tool intentionally has no default binding or library path. Prepare the official
header, generated binding, and matching Core outside the repository, then compile with
explicit paths:

text
BONGOCAT_CUBISM_BINDINGS=/external/bindings.rs \
RUSTFLAGS='-L native=/external/core -l dylib=Live2DCubismCore' \
cargo build --manifest-path tools/cubism-core-probe/Cargo.toml --release \
  --target-dir /external/probe-target

Run one or more absolute Moc paths as id=/absolute/path.moc3; use repeated lifecycle
cycles for the Phase 0 evidence:

text
DYLD_LIBRARY_PATH=/external/core \
/external/probe-target/release/bongocat-cubism-core-probe --cycles 100 \
  standard=/absolute/path/demomodel.moc3

On Windows, link the matching x64 import library and place the official DLL next to the
probe executable or on its controlled DLL search path. Native Windows ARM64 is outside
the final target set after ADR-0033, so Windows on ARM runs the x64 probe/build; this
probe must not use the UWP artifact.

The approved minimal developer baseline may commit the official header and reviewed
real generated bindings under vendor/cubism/5-r.5 and bongocat-live2d. The SDK
ZIP, extracted SDK, additional Core binaries, and probe target directory remain
outside the repository unless a later permission decision says otherwise. Probe output
proves Core ABI and model-array access only; it
does not prove Framework behavior, GPU rendering, publication rights, or another target's
ABI.


TODO (`docs/TODO.md`)

TODO

Phase 4:设置与模型管理

  • P4-WINDOW-MODE(issue #1130):Windows 普通窗口、可配置纯色背景、OBS 窗口捕获与抠图、旧 v1 配置读取、禁用颜色预览和统一客户区等比例缩放已实现。依赖 ADR-0057、ADR-0054;协议见 ADR-0089。退出条件:配置/UI/runtime/HWND/D3D11 回归、just check、Windows product smoke、OBS BitBlt/WGC/抠图验收,以及 Windows 10 和 125/150/200% 跨屏实机验证。当前 Windows 11 OBS 与自动化验收已有证据,缺少的 Windows 10、跨屏与设置布局验收保持未勾选;macOS 不在本任务范围。证据见 docs/benchmark/windows-window-mode-2026-10-08.md。
  • P4-MOTION-OVERLAP(issue #1107):默认关闭的动作叠加开关、旧 v1 文档读取、独立计时/淡出/停止和按启动顺序求值已实现,标准模型跨组重复资源重播有自动化回归。依赖 ADR-0061、ADR-0012(动作音效);协议见 ADR-0088。退出条件:workspace 与独立 config-store contract、just check、双平台 CI smoke,以及 Windows/macOS 实机多动作叠加、跨组重播和 800×600/DPI/Retina 目视验收。尚缺的平台实机证据保持未勾选。
  • P4-NESTED-MODEL-IMPORT(issue #1105):嵌套模型发现、单模型自动继续、多模型复用选择弹框、模式选择及顺序导入已实现。依赖 ADR-0037、ADR-0055、ADR-0059;协议与扫描边界见 ADR-0087。退出条件:自动化扫描/服务/GPUI 交互回归、just check、macOS smoke,以及 Windows/macOS 实机文件夹选择与拖入、800×600 和 DPI/Retina 目视验收。缺少的平台实机证据仍保持未勾选,不由另一平台编译或 headless 测试代替。

Changelog Conventions (`docs/changelog-conventions.md`)

更新日志书写规范

文档性质:规范性约定。 本文只约束 CHANGELOG.md 与 CHANGELOG.zh-CN.md 的分段标题
怎么写。目标架构仍以 docs/technical-design.md 和 docs/adr/ 为准。

UI 文案的书写规范另见 docs/localization-copy-conventions.md;该文 §1.2 明确把
CHANGELOG 排除在自己的适用范围之外,所以两套规范互不覆盖。

适用范围:CHANGELOG.md 与 CHANGELOG.zh-CN.md 的版本条目内的 ### 分段标题。
版本标题(## <version> - <date>)不受本文约束。

1. 分段标题只能取自固定词表

规则:一个分段标题由一个 emoji 加一个固定文案组成,英文侧和中文侧各自只能从词表里取。

发布时 just release-notes 会把两份 changelog 的同一条目拼成同一份文档(GitHub
release 正文和 App 内更新窗口读的是同一个文件),读者会在同一段文字里连续看到中英文。
如果分段标题是临时想出来的,就会出现"中文条目有这一节、英文条目没有"或"同一节两种
叫法",而读者无从判断这是漏译还是本来就不同。

因此词表按 emoji 索引:emoji 是这一节的标识,两侧文案是它的固定写法。

Emoji 英文 中文
⚠️ Upgrade Notice 升级说明
✨ Features 新功能
🐛 Bug Fixes 问题修复
⚡️ Performance 性能优化
🔐 Security 安全
⬆️ Dependencies 依赖更新
🗑️ Removals 移除
🎨 Interface 界面
🌍 Localization 本地化
⚙️ Configuration 配置
🧪 Testing 测试
♻️ Refactoring 重构
🔧 Maintenance 维护
📝 Documentation 文档
⏪ Reverts 回退
💻 Support Changes 支持范围变化

不需要某一节就整节省略,不要保留空标题,也不要为了凑齐词表而新增空节。

1.1 emoji 的变体选择符不作要求

⚠️(U+26A0 U+FE0F)与 ⚠(U+26A0)在不同编辑器里都会被写出来,U+FE0F 只决定字形
怎么画,不决定它是哪一节。校验比较前会去掉 U+FE0F,所以两种写法都算对;不要为了
"统一"去批量改写某一侧的 emoji。

1.2 新增一节

需要词表里没有的分段时,在词表里加一行,同时给出英文和中文,再在两份 changelog 里使用。
不要只在一侧新增后靠人工翻译——那正是本规范要消除的情况。

1.3 下载链接、模型库与赞助不进 changelog

发布说明不只有 changelog 条目。bongocat-packaging 给每个语言块生成一个
## Changelog / ## 更新日志 标题,并在块末尾生成一节下载链接、一节模型库
(Awesome-BongoCat)和一节赞助(见 §1.4),再与两份 changelog 的条目合成同一份文件。
不要把这些内容写进 CHANGELOG.md 或 CHANGELOG.zh-CN.md:

  • 它们的每一项事实都由打包工具已经持有的值生成——产物名来自 ReleaseTarget::download_asset,
    下载地址来自 release_asset_url,版本号来自同一个 CARGO_PKG_VERSION;手写一份就多出一处
    要在每次发版时人工核对的地方。
  • 它们每个版本都一样,进 changelog 等于每次发版改两个文件重述一遍不变的文字。
  • 词表管的是 changelog 条目内的 ### 分段。生成块用的是 ##,因此不需要在词表里
    加行,加了反而会让门禁去校验一份根本不由人写的文案。

要改文案、换赞助或加赞助商,改 crates/bongocat-packaging/src/main.rs 里的
APPENDIX_ENGLISH、APPENDIX_CHINESE 和 RELEASE_SPONSORS,两份 changelog 不动。

1.4 生成块的形状

--extract-release-notes 产出的文件是「## Changelog → 条目 → 生成块 → --- → ## 更新日志
→ 条目 → 生成块」。每个语言块内,changelog 条目在前、生成块在后:条目通常以「⚠️ 升级说明」
开头,而 2.0.0 这类版本要求先卸载旧版本再装新版本,读者应当先读到这句再点到下载链接。

条目自己只写 ### 分段,而生成块的三节都是 ##。所以条目前面必须由工具补一个
## Changelog / ## 更新日志
:没有它,条目会顶着文档开头,它的 ### 看上去像是属于
前一个块,而读者分不清哪一半是人写的、哪一半是发版时生成的。这个 ## 同样不要写进
changelog 文件——门禁会把粘进去的写法拦下来。

生成块只用 App 内更新窗口能安全渲染的 Markdown:普通链接和列表项,不含图片和内联 HTML
(crates/bongocat-ui/src/update_markdown 会拒绝解释两者)。链接目标一律 https,
因为窗口只把 https 渲染成可点击控件,其余 scheme 会被降级为纯文本。

1.5 怎么强制

tools/tests/test_release_changelog_contract.py 的 ChangelogSectionVocabularyTests
逐条断言:

  • 两侧每个 ### 标题的 emoji 在词表内,且文案与词表逐字相同;
  • 两侧的 emoji 序列完全一致且顺序相同(漏译一侧会失败);
  • 词表自身没有重复项。

ReleaseNotesContractTests 另外断言两份 changelog 都没有写 §1.3 的生成块标题——有人把它
粘进 changelog 就会失败,因为发布后那几行会各出现两次。

sh
python3 -m unittest tools.tests.test_release_changelog_contract

因此这条规则不依赖 review 记忆:写错、漏译或在词表外自造一节都会在门禁上变红。
python3 -m unittest discover -s tools/tests 会一并跑到它。

2. 版本标题

版本标题是 ## <version> - <date>。just release-notes 按
[workspace.package].version(即 just version 打印的值)去 changelog 里找对应条目,
找不到就让发布失败。因此发版时必须把 ## Unreleased 改成 ## <version> - <date>。
详见 docs/adr/0033-build-packaging-and-release-toolchain.md。

3. 发布提交:标题固定为 `chore: release v`

规则:打 tag 的那个 commit,标题必须逐字为 chore: release v<version>,其中
${version} 就是 tag 去掉 v 后的部分。

text
chore: release v2.0.0

理由:

  • 历史读起来和发布页一致。 git log 里一眼能看出哪个 commit 是发版点,不必先知道
    版本号再去找 tag。
  • 版本升级是可检索的。 git log --grep='^chore: release' 就能列出所有发版点。
  • 它把「发版」和「改代码」分成两类。 发版 commit 只装版本相关的改动(版本号、
    changelog 标题),功能改动留在各自的 feat/fix 里。Conventional Commits 的
    chore 正是这个用途。

3.1 怎么强制

.github/workflows/release.yml 的「Check the tag against the single product version
source」步骤在构建之前就断言这件事,所以标题写错只需几秒就失败,而不是等三个平台
白跑二十分钟:

text
::error::the commit tagged v2.0.0 is titled 'fix: 某个功能'; a release commit must be titled 'chore: release v2.0.0'

这个断言和同一步里既有的 tag/版本号一致性断言是同一个守卫:只有
GITHUB_REF_TYPE == 'tag' 时才生效,因此用 workflow_dispatch 手动跑不会误伤。

3.2 发版时的动作顺序

sh
# 1. 版本号只有一个来源,改它:[workspace.package].version
# 2. 两份 changelog 的 `## Unreleased` 改成 `## 2.0.0 - <date>`
# 3. 提交,标题逐字为 `chore: release v2.0.0`
git commit -m "chore: release v2.0.0"
# 4. 打 tag 并推送,流水线按 tag 触发
git tag -a v2.0.0 -m "Release 2.0.0"
git push origin master && git push origin v2.0.0

改完第 2 步先本地验一次,避免构建跑完才发现发布说明取不到:

sh
just release-notes release-notes.md

Localization Copy Conventions (`docs/localization-copy-conventions.md`)

本地化文案书写规范

文档性质:规范性约定。 本文只约束 crates/bongocat-i18n/locales/*.json 里的 UI 文案
怎么写。目标架构仍以 docs/technical-design.md 和 docs/adr/ 为准;
catalog 的结构、key 命名与占位符规则见
docs/adr/0012-json-localization.md。

适用范围:crates/bongocat-i18n/locales/ 下的全部语言资源。新增文案一律遵守,
不因语言而异。

1. 省略号:一律三个点

规则:省略号写成单个 …(U+2026),一个字符,视觉上是三个点。

写法 点数 是否允许
…(U+2026 ×1) 3 ✅ 唯一允许的写法
……(U+2026 ×2) 6 ❌ 中文排版习惯,不采用
...(ASCII 句点 ×3) 3 ❌ 拉丁写法,不采用

理由:

  • 同一份 catalog 的同一个 key 由中英文共用,写法必须与语言无关。…… 只在中文排版里
    成立、... 只在拉丁文里成立,任何一种都会让另一语言看起来是错的。
  • … 是单个字符、占一个码位,中英文字体都能正确渲染,宽度也稳定;…… 是两个字符,
    在非中文字体下还可能被拆成两个独立字形。
  • 文案里没有需要区分"三个点"和"六个点"的语义,长度差异纯属排版习惯。

示例(crates/bongocat-i18n/locales/zh-CN.json):

json
{
  "models": {
    "import": {
      "step": {
        "choosing": "正在打开文件选择器…",
        "importing": "正在导入模型…",
        "capturing": "正在截取封面…"
      }
    }
  }
}

英文侧同样是一个 …,不写 ...:

json
{
  "models": {
    "import": {
      "step": {
        "choosing": "Opening the file picker…",
        "importing": "Importing model…",
        "capturing": "Capturing cover…"
      }
    }
  }
}

1.1 怎么强制

tools/validate-locales.py 会遍历每个 locale 的每个值:一旦出现连续两个及以上的 …
或连续两个及以上的 ASCII .,校验直接失败并报出 key 与实际写法,例如:

text
error: zh-CN: models.import.step.importing spells an ellipsis as '……'; use a single '…' (U+2026) in every locale

因此这条规则不依赖 review 记忆:写错就会在门禁上变红。新增/修改文案后本地跑一次
python3 tools/validate-locales.py 即可。

1.2 例外

  • 文档、源码注释、CHANGELOG、memory 记录属于自然语言,不受本规范约束。但引用 UI 文案时
    照抄 catalog 里的写法
    ,否则文档与界面会不一致。
  • 代码里的 ..、...(Rust 语法、路径、范围表达式)不属于文案。

Product Runtime (`docs/product-runtime.md`)

BongoCat Product Runtime

The repository root is the formal workspace and the only product build entry. The previous
implementation is retained only in the remote pre-refactor-tauri branch; master carries the
current code rather than a historical implementation.

Build Environments

Direct Cargo commands from the repository root use the Development environment by default; the
selection is compiled into the artifact. The formal just dev, just test, and just check
recipes also use Development for the workspace. Run product commands from the repository root:

text
cargo run -p bongocat-app --release

Enable the production feature for a standalone Production build:

text
cargo run -p bongocat-app --release --features production

The environment is a mutually exclusive compile-time feature, not a runtime environment variable;
the legacy BONGOCAT_BUILD_ENV name is ignored.

This is the current formal visible product entry on macOS and Windows. It loads the selected bundled
preset (standard by default), starts the product runtime and platform input producer, and displays
the transparent Metal or D3D11 overlay. Closing the settings window leaves the runtime, input, and
overlay active and destroys the settings GPUI window; the next open creates a fresh settings entity
from the current runtime snapshot. The process-local settings navigation memory restores the last
top-level sidebar page until the application restarts. Update windows also destroy their window on
close while their worker continues independently. Explicit Windows quit first stops and joins every
BongoCat-owned runtime, input, audio, renderer, GPU, and overlay owner. The platform adapter then
terminates the process without dropping the final GPUI window, because GPUI 0.2.2 synchronously
re-enters its borrowed AsyncApp from WM_DESTROY. This final-step workaround must be removed when
a fixed GPUI revision is adopted.
The application stays active until an explicit Quit command by default. --run-seconds <seconds>
with a positive value is reserved for bounded smoke and diagnostic runs; 0 is the explicit spelling
of the normal unbounded lifetime used by platform startup-item registrations.

The cross-platform product smoke closes and reopens the settings window, verifies that a fresh entity
receives the current snapshot, and checks that the process-local sidebar page is restored:

text
cargo run -p bongocat-app --release -- --run-seconds 4 --settings-window-smoke

On macOS, grant Input Monitoring permission to the launching terminal for global keyboard and
mouse-button animation. Permission denial is reported as a degraded input state and does not
prevent the model from appearing. On Windows, the product uses a dedicated hidden Raw Input owner
window, periodically reconciles locally pressed candidates with GetAsyncKeyState, and resets input
on device, session, power, queue, and service lifecycle changes. Physical PixPin, Win+L, UAC,
administrator-boundary, and long-running input tests remain release evidence tasks.

Production must be selected at build time. The packaging entry point rejects an unknown
--environment value before invoking Cargo and translates the selection into the app's Cargo
feature:

text
just build

just build forwards its arguments to crates/bongocat-packaging, which is the only place that
decides how the product is compiled and packaged. It compiles the release binary with the immutable
production feature where selected, writes build provenance, and hands the executable to
cargo-packager, which owns the macOS .app layout, Info.plist generation and the Windows NSIS
installer. Both local developers and CI run this same entry point, so there is one code path and one
configuration:

text
just build                                            # Production, host target, all artifacts
just build --target x86_64-apple-darwin               # explicit target (macOS can cross-build both)
just build --environment development --formats app     # Development .app only, for smoke tests
just version                                          # the resolved product version

The product version has one source of truth: [workspace.package].version in the root Cargo.toml.
All workspace crates inherit it, including bongocat-packaging, so Cargo itself resolves the
value before the packager runs; the settings window, system menu, and update runtime read the
compiled CARGO_PKG_VERSION. Cargo.lock records the resolved workspace versions as generated
metadata; a release changes the root manifest once and lets Cargo refresh the lockfile. The Windows
executable resource derives its file, product, and display versions from that value, and
cargo-packager writes CFBundleShortVersionString into the bundle from the same configured
version. The release workflow refuses to publish a tag that disagrees with just version.

On macOS just build produces target/package/BongoCat.app and
target/package/BongoCat-<version>-<arch>.dmg, and prints both absolute paths. The disk image
contains the signed bundle plus an /Applications drop link. On Windows it produces the x64 NSIS
current-user installer at target/package/BongoCat_<version>_x64.exe.

Build provenance is written as path-free JSON with the source commit, Cargo.lock SHA-256,
Rust toolchain, target, profile, feature set, and build environment. The macOS package includes
Contents/Resources/build-provenance.json; the Windows installer payload does too.

Windows x64 packaging is a current-user NSIS install. cargo-packager obtains its own NSIS
toolchain, so no release workstation state is required, and the installer requests no administrator
privileges and writes only HKCU uninstall metadata plus its own product directory. The installer is
signed only when a signing identity or signing command is configured; without one the release
workflow reports the unsigned installer as unsuitable for a stable release.
The wizard opens with a language picker covering exactly the application languages (Simplified and
Traditional Chinese, English, Arabic, Vietnamese, Brazilian Portuguese, Korean, French); it preselects the
system language and falls back to English. The choice only changes the installer and uninstaller
text, never the application language setting. NSIS stores it in HKCU, so a machine that already has
BongoCat installed reuses the stored language without showing the picker, and silent updates never
show it. cargo-packager embeds the installer messages for every language except Vietnamese, whose
strings live in crates/bongocat-packaging/installer/Vietnamese.nsh. Windows install,
upgrade, uninstall, and rollback smoke remain release gates.

Preset models are product resources, not user data. macOS loads them from
BongoCat.app/Contents/Resources/models; Windows packages must place them under
resources/models beside bongocat-app.exe. An unpackaged development binary falls back to the
repository resource tree only when that product-relative location is absent.

The application does not expose a runtime environment switch. Both environments use the same
schema and relative layout under separate development/ and production/ roots.
The formal startup API always resolves that root from the compiled environment. Process-level tests
that require an isolated temporary layout must explicitly enable storage-test-injection; the
feature is absent from the default CLI/API and is rejected at compile time for Production builds.

Verification

text
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features --exclude bongocat-app -- -D warnings
cargo clippy -p bongocat-app --all-targets --features storage-test-injection -- -D warnings
cargo clippy -p bongocat-app --all-targets --features production -- -D warnings
cargo test --workspace
cargo check --workspace --release

Live2D Diagnostic Preview

bongocat-overlay remains a diagnostic executable for selecting presets, injecting deterministic
input, and exercising model switching. It is not the product entry. The final positional argument
is the preview duration in seconds. The interactive and fixed-duration preview is currently
available on macOS:

text
cargo run -p bongocat-overlay --release -- standard 30
cargo run -p bongocat-overlay --release -- keyboard 30
cargo run -p bongocat-overlay --release -- gamepad 30

On macOS, the completion line includes frame_timing=Some(...). Its draw_*_us
values are nearest-rank percentiles over the interval surrounding each Metal
draw call, including the initial draw. missed_deadlines is the number of
full preview-loop iterations that reached the next 60 FPS deadline before the
loop could sleep. The collector retains at most 4,096 samples and reports
additional samples as samples_dropped; it intentionally excludes input,
runtime handoff, and sleep. Windows' switch-only preview reports
frame_timing=None until it has an equivalent paced diagnostic path.

On Windows or macOS, exercise transactional GPU model replacement with all three presets by running
100 measured standard -> keyboard -> gamepad -> standard cycles (300 reported generations):

text
cargo run -p bongocat-overlay --release -- standard 0 --switch-cycles 100

The switch probe first injects one invalid texture preparation and requires the current CPU model,
input bindings, and GPU generation to remain active and drawable. Rejected candidate generations
may create a gap, but committed generations must remain strictly monotonic. Every valid generation
performs a non-transparent frame readback. Before the requested Windows measurement interval, the
probe runs at least 100 equivalent warmup cycles and waits for driver workers to settle. The warmup
high-water mark is a snapshot, not a hard ceiling: process-global D3D11, DXGI, and thread-pool
workers can be created after the settle window and then persist for the life of the process, so the
probe accepts a settled count within two threads of the mark while still rejecting the per-switch
growth that scales with the measured switch count. It also rejects more than four additional
handles or DXGI local-memory growth, and the preview reports the warmup mark and the settled count
so the remaining allowance stays visible. The macOS probe rejects Metal allocation growth between
warmed-up standard baselines.

By default the preview applies deterministic, model-specific input through the product runtime so
hand, pointer, head, and eye changes exercise per-frame Cubism evaluation and GPU buffer updates.
To use the formal macOS listen-only CGEventTap and gilrs/IOHID producers for keyboard, mouse and
gamepad input instead, grant Input Monitoring permission to the launching terminal and run:

text
cargo run -p bongocat-overlay --release -- standard 30 --interactive

The interactive path uses the same typed runtime input state as the deterministic preview and
stops the platform producer before the runtime and Metal overlay. It seeds the current global
cursor position at startup and then coalesces cursor movement through an independent latest-value
transport; pointer, head, and eye parameters use the active display's logical viewport. The pinned
gilrs fork now exposes bounded backend queue/epoch recovery and an explicit bounded shutdown
acknowledgement; BongoCat maps those results into its own diagnostics, while physical gamepad
focus, device, and long-lifecycle evidence remains a release gate. The product entry now owns this
runtime/input/render lifecycle on both launch platforms. GPUI settings coexistence, installed-model
selection, physical gamepad validation, and remaining lifecycle evidence are separate work items.

Cubism model evaluation and Metal GPU ownership are separated by the platform-independent
bongocat-render contract. The single runtime worker owns the mutable Cubism model and publishes
immutable resource/frame pairs through its latest-frame transport; the overlay resolves drawables,
masks, and textures with strong resource IDs and never receives the mutable Cubism model.

The same worker now owns typed motion playback. Application::start_motion resolves a validated
model3 group/index, applies motion3 linear, Bezier, stepped, or inverse-stepped curves using monotonic
elapsed time, and publishes the resulting immutable drawable frame. A product motion advances for one
cycle and then remains the current motion layer at the clip's fully evaluated terminal sample; later
frames restore Core defaults and reapply that final motion contribution instead of snapping back to
idle. The held sample retains the resource's natural fade weights. Completion is derived from elapsed
monotonic time even when no frame was delivered, so a hidden or sleeping overlay still releases the
completed layer's priority before the next command. The completed layer no longer reserves priority,
so the next motion request may replace or restart it.
Runtime snapshots expose only the active motion identity, priority, originating command sequence,
and optional first stop command sequence. Explicit stop also matches a completed held motion,
preserves the first frame, multiplies curve weights by the model3 sine fade-out, and clears the motion
only after the fade completes; duplicate stops cannot restart it. A different old motion ID cannot
stop a newer run, while a same-ID replay is the current run targeted by a later same-ID stop.
PartOpacity curves follow the official Framework part-opacity sink and remain separate from weighted
parameter samples. Each frame restores the fresh-model part opacity values before applying the active
motion, preventing stop or replacement from leaving stale part visibility. Model3 Parameter groups
drive R5-compatible Model curves: EyeBlink multiplies matching parameter curves, LipSync adds to
them, and both update unmatched group parameters with the motion fade. Model Opacity travels in the
immutable render snapshot and is multiplied only in the final D3D11/Metal color pass, leaving mask
generation unchanged.
Product input is applied after motion curves so an actual pressed key or button remains authoritative
for hand parameters.

The Model behavior setting model.allow_motion_overlap enables independent motion
layers ordered by accepted start, oldest first (ADR-0088). Replays move their
matching layer to the newest position, so older held terminal poses cannot mask
newer starts from another group. Existing documents
and fresh installations use replacement playback until the setting is enabled.
The runtime publishes active_motions and derives active_motion from the latest
surviving start. Each stop and UserData occurrence belongs to one motion identity;
model commit and shutdown clear all layers. Audio remains one ordered stream.

Accepted motions now publish an ordered side effect to the independent bongocat-audio worker.
The worker uses rodio with only playback and FLAC enabled, owns one voice, and resolves no model
metadata itself. A new motion, explicit stop, disabled audio setting, successful model switch, or
shutdown stops the old voice. Missing/corrupt audio, output failure, or queue pressure is retained as
anonymous runtime diagnostics and never fails motion or rendering. Motion3 UserData crossings use
the effective runtime playback mode, so a one-shot run of a looping asset emits its start timestamp
only once; crossings remain monotonic, de-duplicated, and bounded.

Expression playback uses Application::set_expression with the model3 expression name. Every
declared exp3 resource is parsed and cached during model preparation; Add, Multiply, and Overwrite
parameters use the file's sine fade times. Replacing an expression fades the immediately previous
layer out while the new layer fades in, keeping at most two layers. Once its fade-in completes, the
newest expression's full weight is pinned until another valid expression replaces it, a model commit
succeeds, or shutdown begins; a test-clock rollback cannot restart that completed fade-in, and
expressions do not auto-clear to idle. An invalid request leaves the active expression unchanged. The
per-frame order is parameter/part-opacity defaults, motion, expression, typed product input, then
Cubism Core update.

model.random_behavior.mode and model.random_behavior.interval_seconds form one typed runtime
setting. The mode is a single enumeration whose off value is the gate: when it selects a kind, the
worker waits one complete interval on its injected monotonic clock and uniformly selects from the active
model's declared behaviors of that kind, each item equally weighted
(motions_and_expressions is one pool, not a 50/50 split between the two kinds). A successful model
commit, a settings change, or a re-enable re-anchors the schedule; a long pause does not emit a
catch-up burst. Automatic motion uses Idle priority and cannot replace a live Normal/Force
product motion, while expressions follow the normal latest-expression replacement rule. The
scheduler is disabled during a pending model commit and shutdown, and a model that declares no behavior
of the selected kind is a
no-op rather than a fallback to the other kind. Automatic behavior and shutdown requests share an
admission gate: the request records
shutdown immediately and starts its bounded wait, while an already-admitted action finishes before
the worker drains shutdown state and later actions are skipped. Runtime event sequences for automatic
playback are separate from the bongocat-audio command sequence allocator; overflow recovery retains
model-preparation commands so accepted audio preparation cannot leave a model commit pending. A
hidden overlay's non-zero motion fade is considered settled from the injected clock even when no
frame was delivered. The GPUI Model behavior page exposes the mode dropdown and the whole-second interval
through the revision-checked settings service; the interval row is disabled while the mode is
off but keeps its saved value, and the dropdown itself stays operable so the behavior can be turned back
on.

bongocat-live2d-playback owns the SDK-independent motion3/exp3 byte parser and numeric
curve/blend evaluation. bongocat-live2d-render prepares model-package RenderResources and
owns the shared key-image inventory/overlay resolver; bongocat-live2d writes the resulting
motion/expression values into Core. Runtime remains the sole owner of active playback identity,
priority, stop/crossfade timing, and model-generation cleanup.

The fixed-version Cubism Core, header, generated bindings, and preset model development baseline are
committed under vendor/cubism/5-r.5 and resources/models. Builds do not download SDK artifacts.
Their provenance and release gates are documented in
docs/adr/0011-progressive-implementation-release-gates.md and the Phase 0 Cubism records.

Model package parsing is also SDK-independent. bongocat-model prepares and validates package
metadata before a typed command transfers an opaque committed model to the runtime. The runtime
creates and evaluates Cubism state; GPU resource upload remains a separate renderer stage.

Model imports are copied into a unique staging directory under the current build environment's
models/ root. The importer rejects symbolic links and unsupported filesystem entries, reapplies
package limits while copying, flushes every file, validates the staged package again, and commits it
with a same-root directory rename. An existing model ID is never overwritten, and a failed import
removes only the staging directory owned by that operation.

The user-model catalog is rebuilt deterministically from the environment's installed directories;
no separate database can drift from disk. A writer lock under locks/ serializes import, catalog,
load, delete, and startup recovery. Corrupt packages remain visible as per-model diagnostics, while
well-formed abandoned import/delete directories are removed on the next start. Product code can
activate only an opaque CommittedModel issued by either the environment store after commit/load
or the bundled read-only preset catalog, and it must replace the active model before deleting it.


Technical Design (`docs/technical-design.md`)

Technical Design

状态:架构决策稿,Phase 0 证据补齐与 Phase 1 渐进实现并行
最后更新:2026-09-25
首发平台:Windows 10 1903+、macOS 12+
后续平台:Linux(首发后评估)

BongoCat 采用单一 Rust 应用。GPUI 负责设置界面,Rust 平台模块直接负责输入、窗口、系统集成和 GPU 渲染。

1. 设计结论

text
Rust 2024 edition application
├── GPUI                         设置、模型管理、快捷键和诊断 UI
├── Product Runtime              状态、输入语义、动画和配置协调
├── Live2D Runtime               模型、动作、表情、物理和音效
├── Windows Backend              windows-rs、Raw Input、gilrs/WGI、D3D11
├── macOS Backend                objc2、CGEventTap、gilrs/IOHID、Metal
└── Shared Assets / Fixtures     模型、schema、本地化和测试数据

主要决策:

  • BongoCat 自有应用代码统一使用 Rust。
  • Windows 只发布 x86_64-pc-windows-msvc,不构建或发布 x86 与原生 ARM64;Windows on ARM 通过系统 x64 仿真运行该构建。
  • GPUI 只负责常规设置 UI,不承担模型渲染。
  • 模型窗口由 Rust 平台模块直接创建和管理,与 GPUI 设置窗口共享同一应用生命周期。
  • Windows 模型窗口支持透明桌宠与普通窗口模式(ADR-0089)。普通窗口使用系统标题栏、等比例
    resize 边框、最小化/关闭按钮和不透明 D3D11 HWND swap chain,支持窗口捕获。背景为用户选择的
    RGB 纯色,默认绿色;模型自带背景和绘制层次保持不变。模式与背景通过既有 revision-checked
    overlay command 持久化,缺失新字段的 v1 数据仍按桌宠默认启动,macOS 不应用这两项。
    普通窗口暂停点击穿透、悬停/空闲隐藏、不透明度及圆角裁剪,不改写桌宠偏好。关闭隐藏模型窗口,
    不提前销毁 renderer 的 HWND;客户区几何用于 renderer 和持久化,最小化不写入 shell 的占位坐标。
    Windows 两种呈现共用客户区尺寸规则,保留未取整的模型画布宽高。数字及手势缩放按当前 DPI、
    100% 时 350 逻辑像素的基准宽度计算绝对尺寸;恢复、模式和模型切换保留客户区宽度,再按
    画布推导高度。尺寸上下限统一作用于比例,不分别截断两轴;系统边框在最后计算。原生尺寸
    变更及 DPI 建议矩形同样经过该规则,避免系统标题栏最小宽度再单独改动一轴。renderer 跟随
    实测客户区尺寸,最小化期间更新恢复几何;边框拖动回写的百分比再次应用时不叠加缩放。
    只有实际改变客户区尺寸的系统 resize 操作回写缩放;移动窗口(包括跨屏 DPI 调整)、未改变
    尺寸或取消的 resize 不回写缩放,不把恢复的窗口几何误当作新缩放比例。
    模型切换在原 HWND 上 prepare/validate/commit,失败保留当前可捕获模型;改变颜色、缩放和置顶
    同样不替换捕获目标。模式切换需要重建对应呈现资源。
  • Windows 使用 D3D11,macOS 使用 Metal;首发不为了未来 Linux 强行统一 GPU backend。
  • Windows 键鼠输入优先评估成熟输入库;若没有方案能同时满足事件边缘完整性、系统状态校正、
    Raw Input 设备语义和已确认的生命周期复位不变量,则直接使用 windows-rs 实现最小适配层,
    从架构上避免 issue #47 的永久卡键。
  • macOS 输入优先评估成熟输入库;若没有方案能同时满足 listen-only CGEventTap、TCC 权限状态、
    tap 恢复和左右修饰键状态校正,则直接使用 objc2 封装最小适配层。
  • 双平台手柄 backend 固定使用 ayangweb/gilrs 精确 commit:Windows 为 WGI,macOS 为 IOHID;
    bongocat-platform 只保留强类型事件/axis、连接 generation、Reset 重播和产品阈值适配,所有
    驱动、mapping 与 backend 生命周期修复进入 fork(ADR-0066)。
  • 官方 Cubism Core 是预编译厂商二进制,是“应用代码纯 Rust”的唯一 FFI 例外;BongoCat 业务逻辑不得进入 SDK bridge。
  • Linux 不进入首发范围,但共享业务 crate 不得依赖 Win32/AppKit 类型,不得故意封死后续 backend。

2. “纯 Rust”的定义

本项目中的“纯 Rust”定义为:

  • 应用入口、UI、状态、动画、输入、配置、模型管理、窗口、渲染和系统服务的产品边界均由本
    Rust workspace 组装和拥有;实现可以使用经过评审的第三方 Rust crate。
  • UI、运行时和平台服务均由同一个 Rust workspace 构建和管理,第三方依赖不得形成第二套业务
    runtime 或绕过既定 command/event/snapshot 边界。
  • 允许通过窄 FFI 调用官方 Cubism Core 平台二进制,因为 .moc3 运行依赖厂商 SDK。
  • 操作系统 API、GPU driver 和系统 framework 不属于应用语言范围。

因此对外准确表述应为“BongoCat 应用代码使用 Rust 重写”,而不是“最终二进制完全不含任何非 Rust 代码”。

3. 目标与非目标

3.1 目标

  • 使用 Rust 实现完整的桌面应用、设置 UI 和实时运行链路。
  • 使用一套 Rust 业务实现覆盖 Windows 和 macOS。
  • 保持现有模型、动作、表情和用户资源格式尽可能兼容;配置使用全新的配置 schema 和命名。
  • 修复输入事件丢失导致的永久卡键,包括 issue #47 的截图快捷键场景。
  • 设置界面具备一致、清晰、可主题化的桌面体验。
  • 模型窗口具备低延迟、透明、置顶、穿透、多显示器和高 DPI/Retina 支持。
  • 所有后台服务具有明确的 start、stop、restart 和 shutdown 生命周期。
  • 使用 fixture 验证跨平台行为,而不是只依靠人工观察。

3.2 非目标

  • 首发不支持 Linux,不承诺 Wayland 全局输入能力。
  • 不提供进程内插件 ABI。
  • 不要求 Windows/macOS 像素完全一致。
  • 不把 GPUI fork、Zed 私有 UI crate 或第三方输入库变成业务 API。
  • 历史版本只作为行为与模型资源参考;BongoCat 不读取或导入旧 Tauri/Pinia 配置。
  • 不在技术 spike 通过前完整迁移所有产品功能。

4. 设计原则

原则 设计约束
单一状态所有者 runtime 独占输入、动画、配置和模型可变状态
实时链路内聚 输入直接进入 runtime,模型求值后生成不可变渲染快照
输入最终一致 按键边沿、系统状态校正和生命周期复位共同维护 pressed state
UI 与渲染分离 GPUI 负责设置,独立 overlay renderer 负责 Live2D
平台能力显式 系统 API 封装在平台模块,业务 crate 不接触平台 handle
先复用后自研 依次评估现有代码、标准库、平台能力、已安装及成熟第三方方案,再写最小自有实现
配置可恢复 v1 schema、环境隔离、验证、备份和原子提交均可测试
行为可重复 fixture、规范化状态快照和平台 smoke test 共同验收

5. GPUI 决策

5.1 为什么选择 GPUI

GPUI 用于设置窗口、模型管理、快捷键编辑、权限状态、更新和诊断:

  • Rust 原生、GPU 加速,适合视觉统一的桌面工具界面。
  • Windows 后端使用 Win32、DirectWrite、D3D11/DirectComposition,macOS 使用 AppKit/Metal。
  • retained/immediate 混合模型适合设置表单、列表、实时预览和自定义控件。
  • 已有目标桌面平台 backend,并为后续 Linux 留出路径。
  • Zed 的实际产品规模证明其能够支撑复杂桌面 UI。

5.2 GPUI 边界

GPUI 仍是 pre-1.0,公共渲染 API 也没有稳定的 Windows/macOS 外部 Live2D 纹理合成路径。因此:

  • 正式 workspace 只直接依赖 crates.io 的 gpui-kit = "=0.7.3" 并提交 Cargo.lock;GPUI Kit
    是唯一直接 GPUI 依赖,其 suite 内五个 package 都从同一 registry 版本解析。禁止另行声明或替换
    gpui、platform、component 和 assets。
  • v0.7.0 是首个同时发布 SettingGroup::variant() 与 Popover::arrow() 的 release,此前为拿到
    这两项能力而固定的 longbridge/gpui-kit git revision 已删除,deny.toml 不再放行该仓库。
    GPUI Kit 仍通过 crates.io 的 gpui-pre 0.3.9 同步包提供元数据对应 Zed gpui 0.2.2 的整套
    GPUI crate,并把这套家族精确 pin 在 =0.3.9。
  • 不自动跟随 Zed main,不直接依赖 Zed 应用内部 UI crate。
  • 以 gpui_kit::component 提供的主题和基础组件为 design system 基础;窗口使用其官方
    Root,图标使用 gpui_kit::assets,项目只保留领域适配、产品 token 覆盖和组件库未覆盖
    的薄业务控件。应用统一调用 gpui_kit::init,平台入口只从 gpui_kit::platform 获取。
  • GPUI Entity 只保存视图状态;真实输入、动画、配置和模型状态由 runtime 管理。
  • 正式 bongocat-ui-protocol 定义设置 command/snapshot 协议,bongocat-ui 只负责 GPUI 视图与
    presentation policy;bongocat-app 的有界 service worker 独占配置写入和 runtime command,
    UI executor 不执行阻塞 I/O。
  • SettingsSnapshot.revision 是 UI 快照排序用的单调版本,能够因 runtime、平台状态、诊断或
    catalog 变化推进;可编辑配置另携带可选 config_revision,仅由当前环境的持久化配置版本
    驱动。修改配置的 command 必须携带 expected_config_revision,在任何 config/runtime 写入
    前执行 compare-and-swap,避免输入诊断等无关变化制造假冲突。
  • appearance.theme 通过独立 SettingsTheme 投影和 revision-checked typed command 修改,由
    Application owner 原子持久化,不进入 runtime。GPUI 收到新 snapshot 后调用组件库公开的
    Theme::change 即时更新内容;选择在快照往返前先乐观应用(system 在 macOS 上先清应用级
    覆盖再解析,见 ADR-0048 修正);light/dark 同时请求匹配的原生窗口外观,system 清除
    覆盖并仅在该模式下响应系统外观通知。Entity 只缓存已经应用的显示模式以避免重复刷新,不成为
    配置事实来源。
  • 原生表面(窗口框、系统弹框、右键菜单、托盘菜单、文件选择框)的主题由 bongocat-platform
    的 theme 模块独占(ADR-0048)
    ,UI 层不再直接调用平台外观 API。该模块只接受已解析的
    AppTheme(System 不往下传),并按平台能力矩阵落地:macOS 用进程级
    NSApplication.appearance,一处覆盖窗口框、弹框、菜单和面板;Windows 用
    DWMWA_USE_IMMERSIVE_DARK_MODE 覆盖窗口框——该属性一旦显式写入便不再自动跟随系统,因此
    偏好为 system 时(平台层收到的是 None,System 不往下传)以 gpui 同源的
    UISettings 查询实时重推导并写入该属性;其余表面接受跟随系统主题,由进程在创建任何
    窗口前调用一次未文档化的 SetPreferredAppMode(AllowDark) 使其生效。system 的解析口径统一
    在平台层(macOS 查 NSApplication.effectiveAppearance)且全 UI crate 只有一处解析入口,
    render 路径与 smoke 断言共用它——不读 gpui 的窗口外观缓存,因为该缓存只在延迟触发的
    appearance_changed 中更新;也不复用 gpui 的外观名映射,因为它不识别
    AccessibilityHighContrastDarkAqua,会把开启「提高对比度」的暗色系统判成浅色。主题失败一律
    降级为该表面保持系统外观,不报错、不改配置、不阻止启动。
  • appearance.language 只接受 system、zh-CN、zh-TW、en-US、ar-SA、vi-VN、pt-BR、ko-KR 和 fr-FR 九个当前 v1 值,默认 system。
    平台 adapter 在启动时读取系统首选 locale,并按 primary subtag 归类(RFC 4647 language-subtag
    fallback):ar、ar-EG、ar-SA 等阿拉伯语 locale 归为 ar-SA,vi 与 vi-VN 归为 vi-VN,
    pt 与 pt-PT 归为 pt-BR,ko 归为 ko-KR,fr、fr-CA 等法语 locale 归为 fr-FR,简繁之外的英语 locale 归为 en-US。zh 是
    primary subtag 唯一无法判定的情形:简繁是不同书写体系而非地区变体,因此 zh-Hant 与 TW/HK/MO
    归为 zh-TW,其余 zh 标签归为 zh-CN。
    其它 locale 也回退 en-US,不把解析结果写回配置。UI 通过独立 SettingsLanguage、revision-checked typed
    command 和 GPUI Kit Select 修改并立即刷新窗口标题、导航和当前可见文案。
    未知持久化值直接拒绝,不增加 alias、开发中间版本兼容或旧配置导入。
    语言下拉里每种语言用自身文字书写(endonym),因此看不懂当前窗口语言的用户仍能找回自己的。
  • 启动项等系统能力通过 UI 自有的 typed platform snapshot 显示,由 settings service worker
    读取和显式变更;外部状态变化递增 settings revision。读取失败只形成可重试状态,写入失败
    不改变 config/runtime,Development macOS 的 unsupported 状态不允许发出变更 command。
  • 输入诊断通过 UI 自有的计数型 snapshot 投影 captured/reconciled/fallback/reset、sequence 和可靠队列
    transport 指标;不得包含具体按键、原始事件或时间戳。即使 queue full 等 transport-only 变化
    未推进 runtime revision,settings service 也必须观察投影变化并推进 settings revision。
  • 设置 UI 采用 visual-first 契约(ADR-0054):项目只定义并绘制用户可见、可操作的内容。
    BongoCat 不维护项目自有 AccessKit tree、native bridge、辅助技术 action、隐藏 label/action
    或只为辅助技术存在的文案;gpui-kit 传递提供的默认语义不作为项目 UI contract。
  • 可见控件、键盘导航、focus、loading/error 和 value 共用同一份 UI snapshot;实现和 smoke
    只为可见行为建立断言,不复制辅助语义状态。
  • GPUI 不加载 Cubism、不持有模型 GPU 资源、不驱动模型帧循环。
  • 全局快捷键使用配置边界编译出的 CompiledShortcuts,由操作系统注册(Windows
    RegisterHotKey、macOS RegisterEventHotKey,见 ADR-0044),单一 owner 线程持有平台
    manager 并把共享 ShortcutTable 增量映射为真实注册;只有匹配当前 active model 的
    motion/expression target 才能转成 typed runtime command。只有物理按下边沿才分发:
    两个平台都会在组合键被按住期间按系统重复节奏继续投递 pressed 事件,重复事件必须被丢弃
    而不是再次触发目标,因此一次按住永远只产生一次动作。应用级 target 必须经 coordinator 的
    配置 revision-aware
    command 执行,平台层不得直接改 overlay 或设置状态。配置提交后通过共享 ShortcutTable
    原子替换 compiled bindings,owner 线程在下一轮轮询读取新表;快捷键注册与输入服务生命周期
    解耦,替换不涉及输入管线的 pressed set。增量映射以 chord 为身份:仍留在表内的 chord 复用既有
    注册(平台会拒绝重复注册,或产生重复的注册),只把其 target 更新为新表的目标;离开表的 chord
    必须注销并从 owner 的记录中移除,否则同一组合键再次进入表时不会被重新注册。跨模型复用同一
    组合键是合法配置,模型切换正是靠这一步把共享 chord 从旧模型改指新模型,否则它们会被下游的
    active-model 判定当作非活动模型丢弃。应用级 target 通过有界
    typed handoff 进入 settings service;显隐、镜像、穿透、置顶以及三个模型输入忽略开关由唯一
    Application owner 按当前配置 revision 持久化,后三个 target 分别切换 model.ignore_pointer、
    model.ignore_keyboard 和 model.ignore_gamepad,不停止平台输入采集。open_settings 交给
    GPUI coordinator,避免平台线程直接触碰 UI 生命周期。open_settings 通过线程安全的一次性请求位
    交给 GPUI frame source,后者在
    owner 线程切换设置窗口可见性:窗口已显示时关闭并销毁,两个平台都走同一套 GPUI close 路径;
    窗口不存在时才创建并显示。设置窗口的当前侧边栏页面由 app coordinator 持有的 process-local
    SettingsNavigationMemory 记忆,重建窗口时恢复,应用重启后回到 Appearance。forwarder 必须支持
    有界停止与 join。
  • 快捷键页面由两个带标题的 group 组成,每个 group 的第一行是它自己的门禁开关:启用窗口快捷键
    (shortcuts.commands_enabled,默认 true)与 启用模型行为快捷键
    (shortcuts.model_behaviors_enabled,默认 false)。两个门禁彼此独立,各自只决定对应的一半是否
    进入活动的 CompiledShortcuts:都不清空、不改写配置中的绑定,因此重新打开时无需重录即可恢复
    全部已校验绑定。门禁的唯一实现点是 ShortcutConfig::active_bindings——"此刻生效的绑定"的唯一
    投影,模型侧的活动模型过滤也在同一处,应用层与平台层不得再判一次。门禁变更必须经
    revision-checked settings command 原子持久化并替换共享 shortcut table。
    两个开关都是正向字段的直出:可见 UI 和 settings command 读同一个布尔值,全链路不存在取反;
    开关只带标题、不带描述(标题已经表达了它的作用),因此两个分组第一行都是纯标题行。
    门禁关闭时该作用域的行在 UI 上置灰且不可交互(捕获与清除都不再可用,tab 跳过),
    mutator 用同一谓词守卫;这是「开关门禁」统一禁用绑定规则(ADR-0053,并经 ADR-0054
    收窄为“可见行与 mutator 同源”)的落地。
  • shortcuts.model_behaviors_enabled 默认关闭是对旧版行为的刻意收窄:旧版在模型加载完成后无条件为
    每个 motion 和 expression 自动分配一整层 primary + [Shift/Alt] + 数字/字母 组合键,用户没有
    拒绝的机会。BongoCat 按维护者决定移植这套自动分配,但把"是否让这些组合键真正生效"交给用户。
    该开关只作用于 motion/expression 绑定,不得清空或改写配置中的绑定,也不得连带禁用
    open_settings、overlay 显隐以及三个模型输入忽略开关等应用级快捷键——应用级快捷键有它自己的开关。
  • model.behavior_names 是每个模型每个行为一行的自定义显示名,behavior_id 与快捷键绑定、随机播放
    勾选共用同一种拼法。设置窗口把它解析成 SettingsModelBehaviorName(只含已命名的行、只含当前模型),
    shortcut_rows 优先用它作为行标签,未命名的行保持「动作 N / 表情 N」。空白名字不落盘,清空字段等于
    删除这一行;删除模型时一并清掉它的名字。窗口侧的限长与配置侧一致
    (MODEL_BEHAVIOR_NAME_MAXIMUM_CHARS),因此窗口不会送出配置会拒绝的值。改名在行内完成(ADR-0085
    §4):行名称后面有一个编辑图标(提示就是「重命名」一词,且是唯一入口),点击它把名称就地换成文本框;
    字段自身不带控件与说明文案——Enter 保存、Esc 放弃、焦点被别处拿走视为保存,字段仍是原标签时不发送
    请求。一次只有一个字段打开,打开另一行的编辑会先提交已打开的那份编辑。录制/播放/清除三个控件的位置与
    tab 序号保持不变,改名面不新增 tab stop。
  • 模型行为快捷键的自动分配与旧版同构:模型激活时(prepare_model / select_model)按声明顺序
    遍历该模型的 motion 与 expression,依次填入 [primary]、[primary, Shift]、[primary, Alt]、
    [primary, Shift, Alt] 四层、每层先数字后字母的组合键,共 144 个名额;primary 在 macOS 是
    Command、其它平台是 Control。编号对每个模型独立从第一个名额开始——占用范围是"应用级 command 绑定
    • 该模型自身的绑定",其它模型已占用的组合键不计入,因为同一时刻只有当前模型的绑定生效。已有绑定
      的行为永不重写,因此重复激活是幂等的,只有用户尚未录制的行为会被补上;同一作用域内已被占用的组合键
      跳过而非重用,否则 shortcuts.conflict 会让整份配置失效。分配结果随选中模型同一次 commit 落盘,
      且不依赖 shortcuts.model_behaviors_enabled——绑定在快捷键页面始终可见,门禁关闭时该分组行置灰
      不可改(ADR-0053),是否进入平台匹配表则由同一分组第一行的 启用模型行为快捷键 开关决定。
  • 只有当前模型的绑定进入平台编译表。配置按模型保存绑定且跨模型允许复用同一组合键,所以
    shortcuts.conflict 是作用域内的判定(命令内唯一、同一模型内唯一、模型绑定不得与命令冲突),
    整份配置本身不是一张无歧义的表:active_shortcuts 先按当前模型投影再编译。prepare_model 与
    select_model 都在激活成功后重建该表,因此被离开的模型立即停止触发它独占的组合键,被切到的模型
    无需重启即可用自己的组合键,非活动模型的绑定也不再占用全局热键。
  • system.show_status_icon 通过独立的 revision-checked settings command 修改。settings worker
    以有界 request/reply bridge 请求平台主线程隐藏或显示状态图标,平台成功后才由 Application owner
    原子提交配置;配置提交失败时必须把图标恢复为旧状态。macOS/Windows 共用 tray-icon 0.26.1 托盘
    owner 与直接依赖的 muda 0.21.2 菜单 owner:TrayIcon、一个 popup 菜单根、菜单项 receiver 和强类型
    事件队列在整个运行期保持存活,set_visible 只改变平台表示(macOS 移除 NSStatusItem,Windows 保留
    注册并设置隐藏),重新显示不创建第二套业务状态或菜单 owner。托盘和 overlay 右键复用同一棵菜单树,
    由 muda 从 overlay 的真实 HWND/NSView 弹出,不借用托盘隐藏窗口。
    正式启动不创建或显示设置窗口,设置窗口、单实例唤醒和 application reopen 仍提供恢复入口;
    平台失败只返回稳定匿名 settings error。
  • system.show_taskbar_icon 只控制 Windows 模型窗口的任务栏按钮,默认 false,不改变窗口
    可见性,也不映射为 macOS Dock 图标。默认关闭是刻意的:模型窗口是无标题栏的 WS_POPUP,
    它在任务栏里的按钮没有用户可操作的对应窗口,而设置窗口才是可用的任务栏入口,因此默认
    不额外占用一个任务栏位置。设置窗口始终保留 GPUI 创建时带的任务栏按钮与原生标题栏:
    给它套上 WS_EX_TOOLWINDOW 会连标题栏一起换成工具窗口的短标题栏,因此产品不写它的
    扩展样式。该偏好在桌宠模式切换模型窗口的 WS_EX_APPWINDOW/WS_EX_TOOLWINDOW;普通窗口模式
    固定使用 WS_EX_APPWINDOW,以保留普通标题栏和可恢复的任务栏入口,原偏好不被重写。
    settings worker 通过独立的有界 request/reply bridge 请求主线程在 overlay 会话上应用并回读,
    平台成功后才由 Application owner 按 expected revision 原子提交;配置提交失败时恢复旧值。
    启动必须把当前 v1 值写进 overlay 会话选项,模型切换重建 HWND 时沿用会话记录的应用值,
    平台失败只返回稳定匿名 settings error。
  • system.show_dock_icon 控制 macOS 仅剩猫窗口时是否显示程序坞图标,默认 false,
    独立于 Windows 的 show_taskbar_icon。设置或更新窗口打开期间(包括最小化)保持 Regular
    并显示 Dock 图标;全部关闭后按后台偏好使用 Regular 或 Accessory(ADR-0054)。
    Application 主线程复用 GPUI 的窗口列表计算实际模式,打开普通窗口前启用 Dock,关闭回调中
    重新计算;开窗失败恢复后台偏好。切换偏好不关闭、隐藏或重建已有窗口,不枚举 AppKit 窗口
    或自行恢复焦点。配置仍先在主线程应用实际模式并回读,再原子提交;配置失败恢复旧偏好,
    返回稳定匿名 settings error。启动应用后台偏好排在 overlay 初始化之后。
  • 关闭设置窗口不影响 runtime、输入、音频、frame source 和 overlay,它们继续由 app
    coordinator 持有。设置和更新窗口的普通 close 都销毁各自的 GPUI 窗口;设置窗口下次打开时创建新的
    Entity,更新窗口也按原有行为重新创建。两个平台共用 GPUI close 路径,平台差异不改变窗口生命周期。
    设置侧边栏当前页面由 app coordinator 持有的 process-local SettingsNavigationMemory 记录:
    SettingPage::title_suffix 在当前页渲染时回报页面,新的设置窗口用该 index 作为
    Settings::default_selected_index;应用重启后重新从 Appearance 开始。系统菜单的 presentation 轮询
    同样不得每秒重建完整快照:它先读取只返回 revision 的命令(ReadSnapshotRevision,只比较 service
    已持有的状态),仅在 revision 变化时才读取完整快照,完整快照中的模型目录扫描因此不再随 20 Hz
    轮询反复执行;CGPreflightListenEventAccess 这类 TCC 查询由 snapshot clock 按上限 1 s 缓存,
    只有显示用途的值不再要求每次快照都访问系统。Windows GPUI 0.2.2 的最终 WM_DESTROY 同步重入
    兼容退出路径仍只用于显式 Quit 后的产品 owner 关闭阶段;普通设置/更新窗口销毁必须由双平台原生
    smoke 验证。Windows 显式退出先请求 frame source 停止并停止输入生产者,确认 frame source 已退出后
    再 shutdown/join runtime、配置和音频 owner,最后释放 renderer/GPU 与 overlay;该兼容措施不得提前
    终止业务 shutdown;升级到修复此回调的固定 GPUI 版本后必须移除,并恢复正常 GPUI 析构门禁。
  • 正式 executable 无参数启动时持续运行到显式 Quit 或系统终止;正数 --run-seconds 只用于
    有界 smoke/诊断,不能成为安装包或 Finder/Explorer 启动的隐式退出条件。所有正常退出仍须
    进入同一 shutdown coordinator。
  • Windows 的 GPUI 平台循环独占线程消息派发;宿主产品调用的 overlay tick 不得再次 pump
    Win32 队列,并且必须在没有持有 GPUI App/Window borrow 时执行。独立 preview 的
    run_for 循环才负责主动 pump。这一约束防止 D3D11 tick 同步派发设置窗口消息后重入
    AsyncApp。
  • 平台服务不返回 GPUI 类型,避免框架扩散到业务模块。
  • GPUI 升级必须单独提交,附变更说明、双平台构建和 UI smoke test。

所有新的 crates.io 直接依赖同样先选择引入时最新的非 yanked 稳定版,再精确 pin 并提交 lockfile。只有 Rust toolchain、目标平台、许可证或已验证的安全边界不兼容时才允许暂缓,且必须留下可复核的版本差异和解除条件;不能用旧版本回避正常的 API 迁移。传递依赖在上游约束允许的范围内保持最新,不 fork 上游只为修改版本号。

Phase 0 必须验证输入法、文本编辑、缩放、UI/主题、窗口重开、托盘应用生命周期,以及 GPUI 设置窗口与独立 overlay 共存。ADR-0011 允许已通过自动化契约的模块进入正式 workspace;未解决的问题继续阻塞对应完整功能或 stable 发布,并必须在进入完整 UI 实现前解决并记录。

6. 总体架构

text
GPUI settings window
                              |
                       Commands / Snapshots
                              |
                     App coordinator/service
                              |
Platform input ---> Runtime thread ---> Model/Animation state
     |                    |                    |
     |                    +--> Config service |
     |                    +--> Audio service  |
     |                                         v
     +--> reconciliation                 Render snapshot
                                                   |
                                      Native overlay window
                                      D3D11 / Metal renderer

6.1 模块责任

  • app:应用入口、服务装配、生命周期、单实例和 shutdown coordinator。
  • runtime:唯一业务状态所有者,处理输入、快捷键、动画选择和模型命令。
  • ui:显示 runtime snapshot,发送显式 command,不直接修改业务字段。
  • platform:窗口、输入、托盘、权限、显示器、启动项、文件和更新。
  • model:模型包解析、路径安全、资源索引和只读预置模型目录;不持有用户 store 的写入生命周期。
  • model-store:用户模型 store、writer lock、staging/提交/删除、目录与 BongoCatMver 导入、键名归一化和用户侧封面覆盖。
  • live2d-playback:motion3/exp3 字节解析、曲线/fade/loop/UserData 与 expression 混合的纯数值求值;不持有 Cubism Core 或 GPU 资源。
  • live2d-render:将 CommittedModel 的纹理、背景和键位图资源准备为 RenderResources,并提供同源的键位图清单与 overlay 解析;不持有 Cubism Core 或 GPU handle。
  • live2d:Cubism Core 生命周期、Core parameter/part 写入、motion/expression 到 Core 的适配和不可变 RenderSnapshot 生产。
  • audio:motion 音效的有序 command、FLAC 解码、唯一 voice、输出设备和 shutdown。
  • render:不可变 render snapshot 和 renderer contract。
  • config:环境隔离、当前 v1 schema、验证、备份和原子提交。

6.2 依赖方向

text
ui protocol <------- app -------> runtime <------- platform adapters
                    |                 |
                    v                 v
             model-store ---------> model
                    ^                 |
                    |                 v
              user model data     live2d-render ---------> render contract
                                      ^                 ^
                                      |                 |
                                      +------ live2d ---+
                                      |
                                      +------ live2d-playback

                         D3D11 renderer / Metal renderer

业务 crate 不得导入 Win32、Objective-C、GPUI 或 GPU handle。平台实现可以依赖业务定义的 command/event 类型。runtime 可直接使用 live2d-playback 的 clip/evaluation 类型和 live2d-render 的键位图解析 contract;两层都不读取 runtime command、Core snapshot 或 GPU 资源。

7. 仓库布局

text
BongoCat/
  Cargo.toml                  正式 workspace 根
  Cargo.lock
  rust-toolchain.toml
  resources/                  随产品打包的模型与产品图标
  crates/
    bongocat-app/             入口、装配和 shutdown
    bongocat-input/           平台无关输入协议、producer 和 latest-value transport
    bongocat-ui-protocol/     设置/更新 DTO、typed command、reply 和 bounded client
    bongocat-runtime/         状态、输入 reducer、模型投影、动画和命令
    bongocat-config/          schema、环境隔离和原子存储
    bongocat-storage/         用户私有存储原语:权限、私有目录、原子替换
    bongocat-model/           模型包解析、只读资源索引和预置模型目录
    bongocat-model-store/     用户模型持久化、导入事务、Mver 转换和封面覆盖
    bongocat-live2d/          Cubism Core 边界与 Core-coupled model adapter
    bongocat-live2d-playback/ motion3/exp3 纯解析、曲线求值和 expression 混合
    bongocat-live2d-render/  模型资源准备、键位图清单和 overlay 解析
    bongocat-audio/           motion 音效队列、解码与设备 owner
    bongocat-render/          render snapshot/contract
    bongocat-ui/              GPUI 设置界面和 design system
    bongocat-update/          发布清单读取、版本/target 判定、载荷验签与安装策略
    bongocat-platform/        Windows/macOS 平台服务
  shared/
    config/                   JSON 配置 schema、命名与存储契约
    behavior/                 输入、动画和快捷键规范
    fixtures/                 输入序列、预期状态和模型样本
    resources/                模型、图标和本地化
  tools/                      不随应用发布的验证与考古工具
  docs/
    adr/
    benchmark/
    migration/                历史参考,不进入生产配置路径

crate 是编译和责任边界,不是动态库。首期不为目录美观建立空 crate;只有依赖方向或测试隔离确实需要时才拆分。

bongocat-ui-protocol 只承载设置/更新服务的进程内强类型 contract:snapshot、command、reply、
operation control、state handle 和 bounded client/endpoint。它不依赖 GPUI、OS API、配置、平台、
本地化或 update 库;bongocat-ui 负责把这些 contract 映射为 GPUI view,app service 负责把
config/platform/update producer 映射为 protocol 类型。debounce、语言显示文案、更新窗口轮询节奏和
文件选择后的展示判断留在 UI/适配器,不进入跨层 contract。

正式 workspace 位于仓库根目录,是仓库中唯一的产品构建入口。重构前实现仅在远端
pre-refactor-tauri 分支中保留,当前工作树不包含其源码、资源或构建入口;master 承载当前代码,
不作为旧实现参考。该路径安排不改变 crate 边界或产品架构。

8. Runtime 与并发

8.1 状态所有权

单一 runtime 线程拥有 AppState、InputState、AnimationState 和当前模型控制状态。其他线程不能通过共享可变引用修改它们。bongocat-input 只拥有平台无关的事件协议、可靠输入 hand-off、latest-value transport 和匿名诊断;InputState、输入到模型的投影以及 worker/shutdown 生命周期仍由 bongocat-runtime 独占。

text
Input producers ---- reliable edge queue -----+
UI commands -------- reliable command queue --+--> runtime tick
Cursor motion ------- latest-value slot -------+        |
Gamepad axes -------- latest-value slot -------+        +--> UI snapshot
                                                        +--> Render snapshot
  • Key/button down/up、设备连接和 command 必须可靠、有序;溢出是可观测错误,不能静默丢弃。
  • 可靠队列溢出时必须清空无法证明顺序的缓存,并在队首注入 Reset;原始失败 item 返回 producer,溢出、恢复和被清理 item 数量进入诊断 snapshot。
  • 鼠标移动和摇杆轴可以合并为最新值,不能阻塞边沿事件。
  • input.mouse.force_move 打开时,平台 adapter 不发布绝对光标位置,而是把设备报告的相对位移
    累加成一个位置再发布(CursorMotionAccumulator):以前台应用捕获指针、把光标钉在一处时,
    绝对位置不再变化但设备仍在报告每次移动,按绝对位置跟随的模型会停住。累加器以绝对位置为种子,
    在第一个采样和 viewport 变化时重新对齐,并夹在该 viewport 内;开启与关闭、以及输入 Reset
    都清空累加值。累加在平台线程完成而不是 runtime:CursorProducer 是 latest-value 通道,跨两次
    发布之间的位移必须在发布前合并,否则被合并掉的采样会连同位移一起丢失。累加值仍走同一条
    latest-value 通道和同一套平滑,因此 runtime、renderer 和 ModelSettings 都不知道这个开关存在。
  • runtime 在独立 latest-value 通道之后按可注入单调时钟平滑光标位置;以 60 FPS 为基准每帧
    保留 0.75 的剩余距离,并在逻辑坐标距离小于 0.5 时收敛到目标。首个样本和显示器
    viewport 变化直接对齐目标,避免跨显示器插值使用错误坐标系;renderer 只消费平滑后的参数。
  • 手柄 axis latest-value 以 {device_id, connection_generation, axis} 为 key 并限制总 key 数;共享 runtime transport 为每个 device id 的每次连接分配单调 generation,平台将同一 connection 通过可靠 GamepadConnected/GamepadDisconnected 事件和 axis key 传递。断开只清理该 connection 的 pressed/axis,不重置其他输入;键鼠状态校正不扫描 gamepad pressed state。旧 generation 的迟到边沿和 axis 样本必须计数并拒绝。
  • Runtime 在 latest-value 消费后统一应用 GamepadAxisSettings 的 stick/trigger dead-zone;gilrs
    adapter 关闭默认 jitter/dead-zone filter 和环境 mapping,只负责 fork mapping 后的完整范围值、
    trigger 连续值与无效值诊断,不把设备默认 dead-zone 写入共享协议。轴值随后以
    ModelInputSnapshot 的不可变字段投影给 renderer。
  • 手柄六个轴按 {device_id, connection_generation, axis} 分别取最新样本,不先挑一个
    connection 再只投影它的六个值;多手柄同时连接时后动的那只在它动过的轴上立即生效,回中
    读到 0(ADR-0082,与重构前合并全部手柄的同名轴一致)。
  • CatParamStickShowLeftHand/CatParamStickShowRightHand 是模型声明的摇杆可见性,由
    apply_model_input 写入:该侧摇杆偏离中心(死区之后)或摇杆键按下即为 1。同一个条件也
    让该侧 hand-down 落下,但只在模型声明了该参数时——把 ADR-0042 的「缺图不动爪」从按键图
    片推广到摇杆美术,使键盘/鼠标模型上的摇杆完全惰性。三个事实仍互相独立:*Stick*Down 只
    表示摇杆键,CatParamStickL*/R* 只表示位置(ADR-0082)。
  • 手柄连接状态的事实来源只有 runtime 的 InputState:RuntimeSnapshot::input 的
    connected_gamepad_count 是上层唯一可读的形式(ADR-0071)。产品 frame source 每帧已经读取
    该 snapshot,因此在「无手柄 ↔ 至少一个手柄」跨帧变化时向 settings service 发送一次无载荷
    GamepadConnectionChanged;第一帧只建立基线,发送失败保留待发状态并在下帧重试。
    settings service 处理时重新读取 runtime 计数,重复、迟到与补发的通知因此落在同一结果上。
    它是产品行为,因此走 Application::select_model 的同一条路径(配置 revision-checked 提交、
    行为快捷键分配、失败回滚、selected_model 持久化与两阶段 commit),不新增第二条切换路径,
    也不在 platform callback、UI executor 或 GPUI Entity 内加载模型。
  • 平台 input worker 通过独立 latest diagnostics producer 发布项目稳定计数;该通道不占用可靠
    command/input edge 队列,Windows service tick 与 macOS run-loop slice 都刷新 live snapshot。
    callback 原子计数、worker 恢复计数和 cursor latest 统计在平台 owner 内合并后进入
    RuntimeSnapshot::platform_input;停止输入时须先发布包含 clean_shutdown 的最终快照,再停止 runtime。
  • 动画和延迟使用单调时钟 Instant,持久化时间才使用墙上时钟。
  • Runtime 可接收 typed Tick command,在使用注入单调时钟的 contract/fixture 或受控
    coordinator 场景显式驱动一次评估;生产环境仍由 runtime worker 的定时等待负责周期 tick,
    UI 和平台 adapter 不直接调用 renderer。
  • overlay.maximum_fps 是 15..=240 的 runtime-owned 强类型设置,通过 revision-checked settings
    command 持久化并进入 RuntimeSnapshot。它同时决定 runtime 周期评估、GPUI owner 调度的产品
    overlay frame source 和独立 overlay run loop 的下一帧间隔;变更无需重启。间隔是相对帧截止
    时间
    的等待,不是帧完成后的延时:一帧的开销由等待吸收,因此只要单帧工作在间隔内完成,实际
    节拍就等于设置值——按「完成后起算」会让实际节拍变成 interval + work,设置越高偏差越大。
    命令驱动的求值可以提前于截止时间出帧以压低输入延迟,这种帧不消耗周期槽位,因此周期节拍不变,
    被呈现的帧率仍由 frame source 自己的节拍决定。renderer 仍只消费
    不可变 RenderSnapshot,不读取 config 或 GPUI 状态。overlay 隐藏时,runtime 周期等待和产品
    frame source 统一降至 100 ms;可靠 command 仍会立即唤醒 runtime,产品重新显示的轮询延迟
    上限为 100 ms。
  • motion_start 触发的动作只播放一个循环:到达 clip 声明时长后,动作进入 completed 状态并
    保留该次完整求值产生的最终参数、part opacity 与 model opacity,作为当前 motion layer 在后续
    每帧默认值恢复后继续重放;它不会继续推进,也不会自动退回 idle。这里的“最终样本”包含
    model3/curve 自然 fade 的权重,完成语义不会绕过自然淡出而暴露更早的原始曲线端点。clip 的
    Meta.Loop 不改变这一点(它描述资源如何制作,不描述产品如何触发),否则动作会永远停在循环中。
    completion 由注入单调时钟和 clip duration 推导,不依赖下一帧是否已投递,因此 overlay 隐藏、
    睡眠或大时间步之后的命令不会仍把动作误判为 in-flight。completed motion 不再占用 priority
    保留位,下一次请求可替换或重新播放它;显式 motion_stop 仍可让最终姿态按资源 fade 退出。
    同一动作在同一 priority 上仍在播放时,重复请求被忽略(R5 motion queue 的 equal-priority
    规则),因此按键重复和连按既不会重启 clip 也不会重放 motion 音效。预览播放同样只播放一个
    循环并保持最终姿态,但每次请求都重新开始。显式停止的非零 fade 即使在 overlay 隐藏、没有
    下一帧时,也会按注入单调时钟在 fade duration 结束后视为 settled,避免过期 motion 阻塞随机行为。
  • model.allow_motion_overlap 默认关闭,旧 v1 文档缺少字段时按 false 读取。
    打开后不同 motion identity 独立保留 clip、单调开始时间、完成状态、stop fade 与 UserData 游标;
    共享参数按接纳启动的先后顺序求值,后启动层用资源自己的淡入淡出权重覆盖前层;重播匹配
    identity 会移到末尾,幂等重复和 stop 不改变顺序。顺序来自插入次序而不是时间戳,因此同一
    时钟采样内的启动也有确定次序,旧层终态不会永久遮住其他动作组的新播放。
    优先级只仲裁匹配 identity,重复产品触发保持幂等,预览只重启匹配层。
    关闭开关立即只保留最近启动的存活层;成功模型 commit 和 shutdown 清除全部层。
    snapshot 的 active_motions 显示完整集合,active_motion 派生为最近启动的存活层;
    UserData 保留来源 identity。随机动作仍让位于任何未结束的手动动作,音效沿用单路有序
    worker(ADR-0088)。
  • motion_stop 只作用于匹配的当前动作,包括已完成并保持最终姿态的 motion。非零
    FadeOutTime 在 runtime snapshot 中保留
    active identity 和首次 stop command sequence,renderer 以正弦权重淡出并在结束帧后
    清理;重复 stop 不重启计时,零时长立即清理。不同 motion identity 的旧 stop 不影响新动作;
    同一 ID 重播后仍是当前 run,之后到达的同名 stop 有意停止该 run。
  • model.random_behavior.mode 不为 off 时,runtime 以可注入单调时钟按
    model.random_behavior.interval_seconds 从当前模型声明的对应类别列表中均匀选择
    一个行为(每个声明项等权)。mode 先收窄候选集合再抽取,因此选中项一定属于用户允许的类别;
    模型没有声明所选类别的行为时保持无操作,不退回另一类。第一次选择等待一个完整间隔;成功模型切换、
    设置变更和重新启用都会重锚定时器,长暂停只产生一次选择而不追赶补发。自动 motion 使用 Idle
    priority,不能替换正在进行
    的 Normal/Force 产品 motion;随机 expression 继续遵守最新 expression 替换语义,模型没有行为
    时保持无操作。待处理模型 commit 或 shutdown 时不选择新行为;自动行为与 shutdown request
    通过同一 admission gate 排序。shutdown request 只记录状态并立即开始有界等待,已被接纳的动作
    会在 worker 进入 shutdown drain 前完成,后续请求不会插入新的自动副作用。自动行为的 runtime
    event sequence 与 audio command sequence 分离,所有生产 audio command 使用
    MotionAudioClient 的独立序号分配器,并在同一 publish lock 下完成分配与入队。随机选择器使用
    runtime 内部 seed,测试可以通过固定 seed 和单调时间得到同一序列。
  • 用户主动触发的 expression command 除了更新当前 active_expression,还写入 runtime snapshot 的
    user_expression_memory:该次选择时的模型身份、表情名与 command sequence。随机行为走
    renderer 而不是 command 队列,因此不会产生这条记录。
  • model.toggle_repeated_expression 打开时,SetExpression 请求的表达式等于当前
    active_expression 时该命令变成「关闭」而不是「重新应用」:runtime 让所有表达式图层开始淡出、
    不压入新图层,active_expression 置空,并且不写 user_expression_memory。关闭动作因此不会
    进入 model.last_expressions,记住的仍然是用户最后选择穿上的那张脸。判定放在 runtime 而不是
    Application,因为两个触发源(设置窗口的播放按钮与快捷键)都收敛为这一条 command,只有 runtime
    知道当前生效的是哪个表情;模型切换会先清空 active_expression,被延迟到 commit 之后执行的
    「恢复上次表情」因此永远不会被读成重复触发。该记录描述「用户选过什么」而不是
    「屏幕上是什么」,所以它不随模型切换清除——切换会清掉 active_expression,而做出这次选择的
    模型仍需要把记录写进配置。Application 按模型身份把它并入
    model.last_expressions;模型成为当前模型时,若 model.remember_last_expression 打开且该模型
    有记录,Application 发送一次 SetExpression,runtime 在该模型 commit 后应用。记录只由
    SetExpression 产生,因此它是「用户主动」的唯一判据,不需要额外的来源标记。
  • render snapshot 不含锁和平台对象,通过双缓冲或 latest-value channel 交给渲染线程。
  • ModelSettings 是 runtime 的强类型模型交互设置:mirror 只影响不可变
    RenderSnapshot::mirror_horizontal 的水平变换,mirror_pointer_tracking_horizontal 只反转
    指针的 X/Z 产品参数,mirror_pointer_tracking_vertical 只反转指针的 Y 产品参数;两者相互独立,
    都默认 false。ignore_pointer 跳过所有指针参数覆盖;ignore_keyboard 和
    ignore_gamepad 只在模型输入投影中屏蔽对应来源的按键/按钮/手柄轴贡献,不停止可靠采集、
    pressed-state 恢复、诊断或快捷键注册。六项通过 SetModelSettings command 和
    revisioned snapshot 传播,renderer 不读取配置;三个输入门禁还可由对应的 application
    shortcut target 经 settings service 切换,持久化后仍只改变模型输入投影。
  • GPUI 通过 command/snapshot 边界交互,不直接持有 runtime mutex。
  • GPUI 拥有平台主事件循环;应用 coordinator 在该主线程调度 overlay tick,GPUI
    Entity 不持有 renderer、render snapshot 或 frame-loop 状态。
  • shutdown 顺序:阻止新的 frame tick -> 停止输入 -> 确认 frame source 已退出 -> runtime
    drain/停止 -> 保存配置 -> 停止音频并 join -> 释放 renderer/GPU -> 销毁 overlay -> 关闭 GPUI。

8.2 输入事件

rust
enum InputEvent {
    KeyDown { key: PhysicalKey, at: Instant, repeat: bool },
    KeyUp { key: PhysicalKey, at: Instant },
    MouseDown { button: MouseButton, at: Instant },
    MouseUp { button: MouseButton, at: Instant },
    CursorMoved { position: PhysicalPoint, at: Instant },
    DeviceConnected { id: DeviceId, at: Instant },
    DeviceDisconnected { id: DeviceId, at: Instant },
    Reset { reason: InputResetReason, at: Instant },
}

应用优先保存物理键身份;字符、布局和显示名称属于映射层。左右 Ctrl/Alt/Shift/Meta 必须可区分。

9. 输入可靠性

9.1 issue #47

PixPin、Win+L 或其他系统级快捷键可能让应用收到按下边沿,却收不到对应的释放边沿。输入系统不能把事件流视为永远完整,必须通过系统状态查询和生命周期复位保证 pressed state 最终一致。系统查询集中在输入服务中,renderer 不直接读取键盘状态。

9.2 Windows

  1. 使用 windows-rs 调用 RegisterRawInputDevices,后台窗口接收 WM_INPUT。
  2. 从 scan code、extended flag 和 RI_KEY_BREAK 建立物理键边沿。
  3. runtime 维护 pressed set,但事件流不是唯一事实来源。
  4. 校正使用单调时钟周期调度;默认每 250 ms 查询一次,并要求同一个 key 连续 2 次快照缺失才确认释放,避免单次系统查询异常误清除。时钟回退不得推进调度游标。
  5. 对 pressed set 中确认释放的键生成内部 KeyUp。
  6. 会话锁定、桌面切换、睡眠、设备移除、服务重启和队列异常时发送 Reset;这些生命周期复位不等待确认阈值。
  7. 必要时用 WH_KEYBOARD_LL 补充合成事件,但 hook 不得覆盖 Raw Input 物理状态。
  8. RegisterHotKey 只处理应用快捷键;冲突必须反馈 UI 并保留旧绑定。
  9. 手柄由 ayangweb/gilrs 固定 commit 的 WGI backend 提供。adapter 将位置名、连接/断开和按钮
    送入可靠序列,将 stick/trigger 送入带 connection generation 的 latest-values;每 tick 最多
    drain 256 个 event,产品最多活动 4 个手柄。gilrs 默认 dead-zone、force feedback 和环境 mapping
    关闭,D-pad 使用 gilrs 自带转换;backend 构造失败只禁用手柄并计数,不能停止键鼠服务。Windows
    WGI 的 bounded join/错误 acknowledgement、hidden/unfocused Raw Input window 与 click-through
    overlay 下的实机投递仍是发布门禁;初始 held-state snapshot 也必须由 backend 提供。

Windows 的 WM_QUERYENDSESSION 与已确认的 WM_ENDSESSION 只记录系统终止请求并立即返回;
GPUI owner 在下一帧进入既有 shutdown coordinator,Win32 callback 不阻塞或析构 runtime/GPU 资源。

该方案不承诺安全桌面交付每个释放事件,而是保证丢事件不会产生永久卡键。
清除一个已按下 control 的路径是且只有三条:可靠 KeyUp、系统状态校正和生命周期 Reset。
经过的时间不构成释放:用户仍按住的按键与丢失的松开事件在时长上无法区分,用时长区分会把一帧
变慢变成一个猫不再相信的按键。因此没有按键释放超时配置,runtime 也不维护基于单调时钟的按键
期限或与之相关的匿名计数。

Windows 验收覆盖 PixPin Ctrl+Alt+A、Win+L、PrintScreen、UAC、管理员/非管理员进程、睡眠唤醒、多键连按和队列压力。

9.3 macOS

  • 使用 listen-only CGEventTap,不经过 GPUI 响应链。
  • 区分 Input Monitoring 的 unknown、denied、granted、restart-required 状态。
  • 全局输入监听只请求 Input Monitoring:CGPreflightListenEventAccess 只读查询,权限请求只由用户
    发起的明确设置操作触发。设置窗口不安装项目 AccessKit bridge,也不需要、不得请求 Accessibility
    trust;Input Monitoring 状态与输入服务运行状态分别投影,授权变化不等于 tap 已重启(ADR-0024)。
    可见 settings 窗口最多每秒一次读取只读 preflight 并通过 revisioned snapshot 刷新该状态;轮询不发起
    TCC request、不重试或重启 event tap,窗口释放后停止。
  • PlatformInputDiagnostics 以稳定 service_status 和单调 service_start_attempts 公开输入服务
    的 not-started/running/permission-denied/backend-unavailable/failed/stopped 状态,不携带平台错误文本。
    平台 owner 每次产品启动只尝试一次;权限拒绝或 backend 启动失败不阻止 overlay/runtime,settings
    health 进入 degraded 并以匿名状态进入 revisioned snapshot,不在后台循环请求权限或重启服务。
  • 设置窗口只展示用户可操作的设置,导航分两级(ADR-0066)。一级页面按用户任务固定为
    Appearance & language、Model library、Model behavior、Model window、Input & interaction、
    Shortcuts、App & system 七个业务分类,About 作为设置菜单中的最后一个普通页面。Appearance & language
    直接展示主题与语言,不再重复同名分组;Model library 单独展示模型卡片,Model behavior 单独展示模型镜像、动作音效,以及随机播放内容的模式下拉(关闭 / 仅表情 / 仅动作 / 表情和动作,ADR-0072)和它控制的间隔,模型行为快捷键仍留在 Shortcuts;Input & interaction 按 Mouse、Keyboard、
    Gamepad 分组;Gamepad 分组末尾是「连接或断开手柄时自动切换模型」门禁开关及其
    连接/断开两个模型下拉(ADR-0071);App & system 按 Startup & desktop、Updates、Logging 分组。Updates、Logging 与 Model window 的设置项只保留标题和控件,
    不显示重复描述。Model window 继续按 Window behavior、Window appearance、Window performance 分组;
    「按住修饰键进行交互」的录入控件是 Window behavior 分组的最后一行(ADR-0080)。它排最后不是随意的:
    这一行是对另外两行的修饰而不是它们之中的一行,而任何线性位置都无法让它同时挨着那两行——鼠标穿透在
    第三行、鼠标悬停时隐藏在第六行,中间还隔着「保持在屏幕内」。放在整组之后,它读作对上方设置的一条附注;
    撤销型修饰出现在被修饰项之后,而不是之前(之前会读成「这个开关启用了它们」)。这一行是 Model window 页
    唯一带描述的设置项,符合 ADR-0066「描述只在标题与控件说不清时出现」:描述逐字写出被暂停的两项设置在页面上的
    标题且与页面顺序一致(先「鼠标穿透」,后「鼠标悬停时隐藏」);控件只显示已录入的键,标题也只说明
    键的用途,没有说明它影响哪两项、以及松开会不会有事。描述同时承担邻接可能引起的误读——它就在行内,
    紧贴着它不影响的那几行。该描述同时进入搜索索引,因此在任意页面搜索「穿透」
    或「click-through」都能找到这一行。该行不跟随任何
    开关门禁,因为它同时被「鼠标穿透」与「鼠标悬停时隐藏」两个开关绑定,而门禁规则绑定的是「一个开关与其下方
    的设置项」;该行只在结构性编辑阻塞时禁用,取值保留到任一开关打开时生效。About 的
    操作行使用标准设置项:产品信息/手动检查更新、隐私安全的软件信息复制、项目主页、问题反馈和打开
    application-owned 日志目录;日志路径不进入 SettingsSnapshot,由 settings service 持有并校验。
    软件信息复制写入的是一个 JSON 对象而不是本地化文案:字段名稳定,不随界面语言变化,报告方可以检索、
    比较和引用。字段只包含 build 与进程事实——产品版本、build environment、Cubism Core 版本、平台与
    架构、操作系统版本与 build(由 bongocat-platform 在复制时向系统查询,因此不会像 revisioned
    snapshot 里的值一样过期)、界面语言、runtime health 与最近一次 render error code、input service
    状态、平台输入能力名与其是否具备(input_capability + input_capability_available)、已连接
    手柄数、input release 校正计数、当前模型来源与导入模型的就绪/无效计数(只计数与固定词表,不含
    模型 id、标题或目录)、设置窗口的显示缩放与逻辑尺寸(复制当刻从 Window 读取,因此布局问题
    可以被复现);不含任何路径、模型名、日志正文、快捷键或用户配置值。系统无法自报版本时该字段
    缺省而不是填占位值。
    输入能力不是一个跨平台同形的 granted/denied/unsupported:macOS 门在 TCC Input Monitoring,
    Windows 门在提权令牌(TokenElevation,高完整性前台窗口存在时 Raw Input 才会继续送达,
    ADR-0032),因此契约用「平台自己的能力名 + 本进程是否具备」表达,两平台都真、都有用。
    早期实现用 macOS 语汇的 input_monitoring_permission,在 Windows 上报 unsupported,
    读起来像「该平台没有权限概念」而实际是「门在提权上且当前未提权」,已随本字段改为能力模型。
    仍未采集的是 GPU adapter 名称与渲染后端:它需要 Metal/DXGI 枚举(新的 objc2-metal 依赖或新的
    windows feature)与无法在本机验证的 unsafe FFI,不属于本字段的最小闭环;渲染问题先靠
    runtime_error_code 与 cubism_core_version 定位,adapter 名称是下一个独立条目。
    About 的描述只在标题和按钮说不清时才出现:产品信息行显示产品版本,不带 build environment
    (Development/Production 是同一份二进制、不同数据根,只对更新通道有意义,该值仍留在 snapshot
    与复制的软件信息报告里),软件信息行说明复制内容;项目主页、问题反馈与日志目录三行都不显示
    描述(标题与按钮已经说清各自做什么)。About 的操作按钮
    以「检查更新」为唯一实心 primary,「反馈问题」用 danger 描边,复制、项目主页与日志目录三行
    保持组件库默认按钮,使颜色本身始终携带含义。
    About 不再展示法律与隐私正文。页面与分组标题同时作为设置搜索关键词,
    旧页面名保留为只搜索的别名;模型库还索引当前模型显示名。About 位于同一菜单的最后一项。
    配置损坏在 settings window 出现前完成 fallback,不显示配置恢复横幅、按钮或重启提示。
    原 Diagnostics 页面已移除,输入可靠性计数、runtime/renderer 状态和 build 标识只留在
    app-owned 日志和匿名 diagnostics export 里:周期性刷新只服务于界面上仍在显示的数字。
    OpenConfigBackupLocation 与 ExportDiagnostics 仍是 settings service 的强类型排障
    command,不从常规 UI 触发;About 的日志动作只打开应用自有目录,不暴露诊断内容。Development、Production
    和 smoke 的设置窗口使用同一套可见页面、分组和按钮组成;smoke 只改变驱动方式,不额外显示“重置偏好设置”等产品窗口没有的控件。
  • 产品启动时以只读 CGPreflightListenEventAccess 检查 Input Monitoring,缺失时用原生系统弹框
    引导用户。检查非阻塞:主线程完成窗口、菜单栏等正常初始化后,由专用 worker 线程执行检查与提示,
    提示未应答或被关闭不影响任何产品窗口的显示与使用(ADR-0032「非阻塞执行修正」)。提示不写配置、
    不写 window state、不缓存「稍后」,每次启动重新读取平台真实状态;提示本身不调用 TCC request
    (ADR-0032)。实现必须使用 rfd 无父窗口的异步消息框:同步路径会在调用线程上构造
    PolicyManager/FocusManager 并触碰共享 NSApplication,而 gpui_macos 的 MacPlatform::run
    需要该实例是自带 platform ivar 的 GPUIApplication 子类(ADR-0032「macOS 弹框实现修正」)。
  • macOS 的「授权/去设置」动作在 ADR-0078 之后是 permission-flow 的 INPUT_MONITORING 引导
    面板:它把用户送到「系统设置 → 隐私与安全性 → 输入监控」并显示拖拽授权引导。该动作前先执行
    tccutil reset ListenEvent com.ayangweb.bongo-cat,因此每次进入流程都从「未授权」开始。面板的
    语言跟随产品语言(CFBundleLocalizations + 本应用域的 AppleLanguages),拖拽目标取自当前
    可执行文件所在的 bundle。permission-flow 的 Swift 静态库带来两项构建前提:链接它的每个包都要
    在自己的 build script 里加 Swift 运行时 rpath,打包要把 PermissionFlow_PermissionFlow.bundle
    放进 Contents/Resources。以上细节与未完成项见 ADR-0078。
  • 监听 tap 被系统禁用、超时和 session 变化,并自动重建。
  • listen-only tap 必须创建在 kCGHIDEventTap 的 kCGHeadInsertEventTap 位置(与 rdev 的 listen 一致),不得使用 session 层 tail append。实测 macOS 26.5.2:session tail 位置收不到右 Shift 的释放 FlagsChanged,且重复按下事件 flags 逐字节相同;同一台机器的 HID head 位置能收到全部修饰键的完整 press/release 对。tap 是 listen-only,只观察不修改、不吞事件;HID 层事件流跨用户会话可见,锁屏/快速用户切换仍依赖既有 session 生命周期 Reset 清空 pressed state。
  • FlagsChanged 的 down/up 方向必须在 callback 中冻结,按优先级依次使用:事件 flags 相对上一个 FlagsChanged 事件的设备位跳变(flags 低 8 位中每个物理修饰键有独立 bit,左右天然区分,同侧兄弟键按住时家族 flag 不清零也不影响)、家族 flag 位跳变(rdev LAST_FLAGS 同思路)、按 callback 记录的前一边沿交替。HID head tap 下设备位跳变覆盖全部常规修饰键事件;session tail 上观察到的右 Shift 事件缺失/不变序列由交替回退兜底。decoder 状态不属于 runtime pressed state,并随任何 Reset 清空,周期校正强制释放候选时必须同步清除 decoder 记录。不得等到 consumer drain 时用较新的全局状态反推旧事件,无法识别的修饰键必须触发可观测 Reset。
  • CapsLock 是唯一例外,必须单独处理而不是纳入上述交替规则。实测 macOS 27.0 arm64、同机 listen-only kCGHIDEventTap head tap:N 次物理点按恰好投递 N 个 FlagsChanged,物理松开不投递任何事件。AlphaShift 位是驱动在按下时翻转的锁存位,不是物理边沿;Apple 的无状态 Caps Lock 掩码 NX_ALPHASHIFT_STATELESS_MASK (0x01000000) 与 NX_DEVICE_ALPHASHIFT_STATELESS_MASK (0x00000080) 在事件 flags 中始终为 0。锁存键没有 kCGEventKeyUp,因此该键的物理释放在平台层不可表达,任何解码都无法恢复。平台按产品既定的「短暂触发」语义处理:FlagsChanged 到达即成为按下候选,候选存活 100 ms 后由 adapter 合成唯一的释放边沿,并通过 set_pressed(57, false) 重新锚定 decoder,使下一次点按重新解码为按下。该 100 ms 是 Caps Lock 行为的一部分,不是可配置项,也不是已移除的 input.keyboard.release_fallback_timeout_ms:后者要回答「用户是否仍按着」这个真实释放问题,本键没有可保留的按压状态。其余修饰键一律不得纳入该定时释放。
  • 对键盘和鼠标 pressed set 分别使用 CGEventSourceKeyState、CGEventSourceButtonState 校正;右侧修饰键键码(54/60/61/62)在按住时也返回 false,必须同时查询其家族主键码(55/56/58/59)作为状态来源;保留 0–31 号 mouse button 身份,按统一的 250 ms/连续 2 次缺失策略确认释放,睡眠、锁屏、权限变化和 tap 重启时直接复位。CapsLock 的 CGEventSourceKeyState(CombinedSessionState, 57) 返回的是锁存状态而非物理键状态,因此周期校正无法代替该键自己的释放边沿;这正是 CapsLock 必须在 adapter 内单独定时的原因。
  • 手柄由 ayangweb/gilrs 固定 commit 的 IOHID backend 提供;不再由 BongoCat 枚举
    GCExtendedGamepad 或管理 GameController background policy。连接分配新的项目 generation,按钮/
    连接边沿进入可靠队列,axis 进入固定容量 keyed latest-values;全局 Reset 后以相同 connection
    重播 gilrs 已缓存的 current held state,断开后的旧 generation 不得作用于重连设备。backend
    必须另外提供 authoritative initial snapshot、reset epoch 和 lossless release 证明后,才能把
    held-state/replay contract 视为完成。当前 runtime 不对 gamepad 做键盘式 reconcile;若 backend
    release 丢失,必须由 fork 提供 authoritative snapshot 或 health-triggered Reset。
  • callback 只做映射和入队,不执行模型、文件或 UI 工作。

10. 平台实现

10.1 Windows

  • 应用:GPUI/Win32 主事件循环,单实例使用 named mutex + 唤醒消息。
  • Overlay:透明桌宠用 Win32 无边框 popup,普通窗口使用系统标题栏与 resize 边框(ADR-0089)。
    无已保存 bounds 时以 350px 作为 100% 的默认逻辑宽度,再应用当前 DPI 与缩放;高度按
    Cubism Core 返回的模型 Canvas 宽高比推导。恢复保存位置时保留客户区宽度并校正高度。
    只有呈现模式切换重建窗口资源;桌宠模式切换模型仍重建窗口,普通窗口切换模型保留 HWND。
    scale/opacity、圆角和屏幕约束设置在现有窗口上更新。桌宠非 click-through 模式的
    客户区支持拖动,click-through 仍返回 HTTRANSPARENT。桌宠右键拖动缩放窗口:位移越过
    3px 后按 (dx + dy) * 0.5 改 overlay.scale_percent(钳制在 25–400),窗口左上角
    不动,尺寸逐帧经 IDXGISwapChain1::ResizeBuffers 与就地重建的 render target、staging
    纹理、mask target 生效(不重建窗口、不重载模型纹理),松手后缩放写回配置(ADR-0057)。
    keep_inside_screen 开启时,窗口必须完整落在所有显示器矩形(EnumDisplayMonitors +
    MONITORINFO.rcMonitor)的并集内,因此允许覆盖任务栏,负坐标保持有效;跨显示器摆放只要不越过
    桌面边界就不纠正。创建、缩放/资源设置重建和模型重建立即收敛一个不可用的放置(窗口大于显示器时保留
    尺寸并把原点贴到显示器原点);拖动后的收敛由 frame tick 驱动的延迟约束完成:窗口静止累计 1 秒后
    才移回显示器内,期间任何被观测到的位移都重新计时,因此跨显示器拖拽不会被打断。约束缓存最多
    500ms 复用一次放置检查,显示器变化(含拔掉外接屏)在静止窗口下也会被重新评估并纠正。关闭时
    不执行该收敛,但完全离开现存显示器的持久化 bounds 仍按 state 恢复规则回退。
  • Renderer:D3D11 + DXGI;桌宠使用 DirectComposition/DWM 与预乘 alpha,普通窗口使用
    不透明 HWND blt swap chain,同时支持 OBS BitBlt 与 Windows Graphics Capture。
  • DPI:Per-Monitor-V2,处理 WM_DPICHANGED、显示器热插拔和负坐标。
  • 输入:Raw Input、状态校正、可选低级 hook、gilrs/WGI 手柄。
  • 产品图标:bongocat-app 在构建期把 BongoCat 自有 .ico 编译进 Windows executable,用于窗口、
    任务栏和文件身份;它不再是托盘图标来源,托盘也不会回退到系统通用应用图标。
  • 托盘:tray-icon 0.26.1 拥有 TrayIcon 和固定 GUID,直接依赖的 muda 0.21.2 拥有一个由托盘和
    overlay 右键共用的 popup 根。Windows 状态图标从 BongoCat 自有
    resources/icons/tray-windows.png 解码;托盘隐藏点击恢复和 overlay 右键入口均由该唯一菜单 owner
    管理,overlay 弹出使用自身 HWND,不借用托盘隐藏窗口。共用菜单根包含设置入口、模型窗口操作分组(显隐、
    穿透、置顶、鼠标移入隐藏)、可用的更新入口和退出。
  • 启动项:统一由 auto-launch 0.6.0 提供双平台后端。Windows 写当前用户 HKCU Run 并同步
    StartupApproved\Run 启用标记;macOS 写 ~/Library/LaunchAgents/{app_name}.plist
    (RunAtLoad)。Production 使用该后端;命令固定为当前 executable 加 --run-seconds 0,
    默认不要求管理员权限或 TCC 授权(ADR-0043)。该能力只属于已发布的产品:开发构建的可执行文件是
    构建产物而非已安装应用,所以 bongocat-app 按构建环境判定,开发构建直接汇报
    Unsupported(BuildEnvironment)、不触碰平台,设置里的启动项整行按统一禁用样式置灰,并保留可见的 unsupported_build 文案(ADR-0051)。
  • 启动权限:产品启动时以自身进程令牌的 TokenElevation 判断是否已提权,未提权时用 rfd 的
    原生系统弹框说明「属性 → 兼容性 → 勾选以管理员身份运行此程序」路径,并提供定位当前
    executable 的操作。检查非阻塞:主线程完成窗口、菜单栏等正常初始化后,由专用 worker 线程
    执行检查与提示,提示未应答或被关闭不影响任何产品窗口(ADR-0032「非阻塞执行修正」)。
    产品不原地提权、不写 HKCU/HKLM、不注册 service,提示也不持久化任何状态;
    每次启动重新读取真实令牌状态,已提权则完全不提示(ADR-0032)。

10.2 macOS

  • 应用:GPUI/AppKit 主事件循环,平台 UI 操作固定在 main thread。
  • 产品图标:.app 的 Info.plist 以 CFBundleIconFile 指向随 bundle 签名封装的 BongoCat 自有
    .icns;该键与 bundle 的其余生成键由打包工具写入,仓库只保留 macos/Info.plist overlay
    (LSMultipleInstancesProhibited、NSPrincipalClass),打包入口在签名前验证元数据与资源存在。
  • Overlay:通过 objc2 创建透明 nonactivating NSPanel;无已保存 bounds 时以
    350px 作为 100% 的默认逻辑宽度,高度按 Cubism Core 返回的当前模型
    Canvas 宽高比自适应,两者再应用缩放设置;已保存 bounds 优先,允许通过窗口背景拖动。
    右键拖动缩放窗口:位移越过 3px 后按 (dx + dy) * 0.5 改 overlay.scale_percent
    (钳制在 25–400),窗口顶边不动(AppKit frame 原点在左下角,因此修正 origin.y),尺寸
    逐帧经 setFrame:display: 与重设的 drawable size 生效,mask 纹理随 drawable 尺寸重建,
    松手后缩放写回配置(ADR-0057)。
    always_on_top 开启时使用高于 macOS 菜单栏的 AppKit status window level(NSStatusWindowLevel,
    25),关闭时恢复 normal window level。菜单栏自身在 NSMainMenuWindowLevel(24)并压住同层级的应用
    窗口,因此处于该层级的模型窗口被拖进菜单栏区域后那部分不可见(issue #1155);25 同时仍高于程序坞
    (20),并低于 AppKit 给 NSMenu 弹出菜单的 NSPopUpMenuWindowLevel(101),所以托盘菜单与模型
    窗口右键菜单依然显示在模型窗口之上。配置的 runtime snapshot 变化和任何 overlay 重建都必须立即重放
    当前层级,不能由其他路径覆盖。
    keep_inside_screen 开启时,窗口必须完整落在所有 NSScreen 的 frame(含菜单栏与程序坞
    占用条)并集内,因此允许覆盖系统区域,负坐标保持有效;跨显示器摆放只要不越过桌面边界就不纠正。
    需要纠正时选择与窗口交叠面积最大的 screen,完全无交叠时选择中心距离最近的 screen,再收敛原点。
    创建、缩放/资源设置重建和模型重建立即收敛一个不可用的放置(窗口大于显示器时保留尺寸并把原点贴到
    显示器原点);拖动后的收敛由 frame tick 驱动的延迟约束完成:窗口静止累计 1 秒后才移回显示器内,
    期间任何被观测到的位移都重新计时,因此跨显示器拖拽不会被打断。约束缓存最多 500ms 复用一次
    放置检查,显示器变化(含拔掉外接屏)在静止窗口下也会被重新评估并纠正。关闭时不执行该收敛,但
    完全离开现存显示器的持久化 bounds 仍按 state 恢复规则回退。
  • Renderer:Metal + CAMetalLayer,drawable size 跟随 backing scale。
  • Spaces:按配置设置 collection behavior 和 full-screen auxiliary。
  • 输入:CGEventTap、状态校正、gilrs/IOHID 手柄。
  • 菜单栏:tray-icon 0.26.1 在 macOS 主线程拥有 NSStatusItem,同一份直接依赖的
    muda 0.21.2 owner 持有一个由托盘和 overlay 右键共用的 popup 根;状态图标使用 BongoCat 自有
    resources/icons/tray-macos.png 并作为 template image。overlay 右键通过 content NSView 在同一主线程
    调用同一棵菜单树,不借用托盘状态项。登录启动在 macOS 13+ Production .app 使用
    SMAppService.mainAppService。macOS 12 和 Development 构建明确报告 capability unsupported,不回退到
    废弃 API 或自行写 LaunchAgent。
  • 发布:Hardened Runtime、签名、notarization 和 TCC 权限说明。

平台 unsafe 必须集中在小型 wrapper,写明安全不变量并有 smoke test。业务和 UI crate 默认禁止 unsafe_code。

11. Live2D 与渲染

模型资源中的 resources/background.png 作为独立背景资产随渲染资源提交,在 drawable
之前绘制;背景缺失时保持透明 overlay,背景文件损坏则拒绝该模型提交。按键图片从
resources/left-keys 和 resources/right-keys 按目录绑定,当前按下键优先使用精确文件名;
HID 功能键 F1-F24 缺少专属 F1.png…F24.png 时回退到该模型共享的 Fn.png,左右修饰键同理回退到
Control/Shift/Alt/Meta;没有匹配资源时不绘制按键层,也不产生任何按键动作(见下)。Fn 是这张
共享功能键图的名字,不是 Fn 键
:旧版输入层把"模型没逐键画图"的 F<数字> 重写成 Fn,两个预置键盘
模型出厂的共享图就在这个主干下,所以它既不改名也不迁移。左右修饰键的精确名是两侧各自的
canonical 名(AltLeft/AltRight、ControlLeft/ControlRight、ShiftLeft/ShiftRight、
MetaLeft/MetaRight),Alt/Control/Shift/Meta 只作为两侧共用的家族图;AltGr 是只对右 Alt
生效的旧名(见 ADR-0038),Return 是主 Enter 的旧拼写(见 ADR-0039),Function 是地球键的旧名
(见 ADR-0049)——三者都排在最具体名之后,用于兼容没有经过导入归一化的包。小键盘(HID 0x53…0x63)的
精确名是 NumLock 与 Mver 词汇表的 Kp*,其中在主键盘上重复的七个键回退到主键盘的键位图
(Kp1…Kp9、Kp0 → Num1…Num9、Num0,KpEnter → Enter,KpDivide → Slash);
NumLock、KpMultiply、KpMinus、KpPlus、KpDecimal 在主键盘上没有对应键,缺图时不绘制。
小键盘整块归左手,因为可复用的数字、Enter 和 Slash 图只存在于 left-keys(见 ADR-0040)。键位词表
覆盖标准 104/105 布局、小键盘与 Apple 地球键的全部按键,范围是
0x04..=0x65 ∪ {0x67} ∪ 0x68..=0x73 ∪ 0xe0..=0xe7 ∪ {0xff03}(八个修饰键 usage;0x66
Power 两边都不产出)。这是词表覆盖的上界,不等于"两个 adapter 实际能产出的集合":IntlHash
0x32 与 F21–F24 0x70..=0x73 目前两个平台都产不出,仍然命名。命名不以预置模型当前是否有图、
也不以今天有没有硬件能按
为前提:它是与模型作者的契约,模型提供 Dot.png、Minus.png、
Delete.png 等任何键位图都必须在不改产品代码的前提下生效。反过来,"某个 adapter 能产出某个 usage"
必须有该 adapter 自己的断言支撑,不得从这张并集推断——Apps 0x65 就曾在这个并集里躺了很久,而
两个平台都没有映射,于是 Apps.png 永远画不出来(见 ADR-0041 修订与 TODO 第 90 项);hand 归属
覆盖同一集合(含修饰键块,见 ADR-0041 事实 4 修订),因为 InputState::model_snapshot 会丢弃没有
hand 归属的按键,且该集合由契约测试遍历而不是由实现恰好用到的区间决定。

地球键是这套词表里唯一不在 HID Keyboard/Keypad 页的键:它的 usage 是 Apple 厂商页 0xFF 的 0x03
(KeyboardFn)折叠成 0xff00 | usage,即 bongocat-render::GLOBE_KEY_USAGE = 0xff03。任何
0x04..=0xe7 式区间都覆盖不到它,所以命名、绑定和两处契约并集都必须显式写出。它的精确名是 Globe,
旧名 Function 作为末位别名;它与功能键的候选列表完全不相交(见 ADR-0049)。平台可达性:macOS 经
CGEvent keycode 63(kVK_Function)以 FlagsChanged + MaskSecondaryFn 到达,Windows 的 Fn 键由
键盘固件处理、Raw Input 从不报告,所以没有 Windows 映射。同理,F13–F24 在 Windows 侧没有可依据的扫描码
(不猜值)、在 macOS 侧 Carbon 没有 kVK_F21…kVK_F24,0x70..=0x73 属于"命名到位但两个平台都不可达"。

只有模型确实提供对应键位图时,按键才产生动作(见 ADR-0042):bongocat-app 在激活模型时用
bongocat-live2d-render::KeyImageInventory 读取该模型的键位图清单(与渲染侧加载共用同一次目录扫描和同一套
候选回退),并只把清单里能画出来的键写进 InputBindings。写入 runtime 的每模型绑定因此是
"静态 hand 表 ∩ 该模型的键位图":缺图的按键不进入 InputState::model_snapshot,既不驱动
CatParamLeftHandDown/CatParamRightHandDown,也不产生按键层,所以按键层与爪部反馈永远一致。
判断在 runtime 之前完成,renderer 仍只消费不可变 RenderSnapshot,不决定动作;该规则只覆盖键位图,
鼠标指针/按键不属于键位图资源。功能键的范围、名字和左右手归属由
bongocat-render 的同一张 HID 表给出(0x3a..=0x45 与 0x68..=0x73 两段,中间是 PrintScreen
至方向键和数字键盘),避免按键图片解析和 runtime 绑定各自定义;没有 hand 归属的按键不产生按键层。

手柄按键走同一条通道,词表是产品自己的(见 ADR-0070)。bongocat_render::KeyPress 携带的是
带标签的 KeyIdentity(Keyboard(hid_usage) 或 Gamepad(GamepadButton)),不是裸 usage:手柄按钮
没有 HID Keyboard/Keypad usage,两族共用一个数字空间会让一类按键被当成另一类解析。每个按钮的图片名
就是 GamepadButton 变体名本身(GamepadButton::key_image_name),16 个按钮一一对应、互不重名,
因此类型和美术词表不会漂移。名字是产品词汇,不是 backend 词汇:旧 Tauri 输入层用
format!("{:?}", Button) 派生文件名,产物里 LB/RB 叫 LeftTrigger/RightTrigger、LT/RT 叫
LeftTrigger2/RightTrigger2、摇杆键叫 LeftThumb/RightThumb、方向键叫 DPad*;预置 gamepad
模型已按产品名重命名(LeftShoulder/RightShoulder、LeftTrigger/RightTrigger、
LeftStick/RightStick、Dpad*),导入路径在模型 store 把旧名改写为产品名
(bongocat-model-store::key_names)。手柄词表没有运行时旧名候选:LeftTrigger 在旧词表里已经是
肩键的名字,任何别名表都会把肩键或扳机键解析成对方的图。左右手归属同样由模型自己的目录决定——
left-keys 里的图画在左爪上、right-keys 里的画在右爪上,这与旧实现的判定一致,也让 Mver 转换按
lefthand/righthand 落盘的结果直接可用。缺图的按钮保持惰性(ADR-0042);两个摇杆键另外驱动
StickLeftDown/StickRightDown,摇杆本身另有可见性参数并按下述规则带动爪子(ADR-0082)。

按键层画在哪个四边形上由按键图自己的尺寸决定,不是恒定等于画布四边形(见 ADR-0037 修订)。
ModelBounds::from_canvas 给出模型画布在模型空间的四边形,背景就画在它上面;按键图则先经过
bongocat-render 的 legacy_key_overlay_bounds:BongoCatMver 把按键图画在窗口原点、一图素对一窗口
像素,而模型画布居中画在同一个窗口里,因此按键图比画布左移 (窗口宽 − 按键图宽) / 2 图素。窗口宽取
该图自身较大的一边——按键图就是画布在窗口里的尺寸,窗口不会比它窄,作者不按画布尺寸设窗口时用的是
包含它的正方形——所以按键图宽 ≥ 高时偏移为 0,画布四边形原样返回,出厂预置模型与所有宽按键图模型
都不动;只有比宽更窄的按键图(例如 issue #1129 的 986×1000 对 1000×1000 窗口)才左移。
这条规则在双平台渲染器里对每张按键图各建一个四边形缓冲,背景仍用画布四边形,因此旧包与新包、
resources 里任何分辨率与宽高比的按键图都落在同一套几何里,不需要重写已转换的模型资源。

列表,双平台渲染器都按这个顺序绘制且都没有深度附件或深度模板,因此列表里最后一项就是最上层;
z 序完全由列表顺序决定,渲染器没有额外状态。model.show_all_pressed_keys 决定这份列表的内容:

  • 关闭(默认,兼容模式):InputState::model_snapshot_with_filter 对每只手只保留一个"最后按下且仍
    有效"的按键,左右手各至多一个,按左手先、右手后写入列表。
  • 打开(叠放模式):每一个仍按住、且模型为它提供了键位图的键都进列表,按
    PressedRecord::pressed_at 与可靠输入队列的 pressed_sequence 从旧到新排序。单调时钟是毫秒精度,
    快速连击会落在同一毫秒内,只有 sequence 能给出全序。

两种模式都不改变 pressed state、释放路径和 CatParamLeftHandDown/CatParamRightHandDown:只要有任意
一个绑定到该手的键仍按住,该手就是按下的。resolve_key_overlays 只做"按键 → 资产"的解析,不再按手
折叠;两个按住的键解析到同一张图时(Fn 家族回退、小键盘复用主键区图)只画一次,先按下的那个占住
底层。列表容量固定为 KeyPressSet::CAPACITY,溢出时丢最早按下的那几个。

11.1 Cubism 边界

text
Official Cubism Core binary
          |
   raw Rust sys bindings
          |
  Moc / Model safe wrappers
          |
model evaluation + render snapshot
  • Core 二进制按平台分发,版本、hash、来源和许可证记录在构建清单中。
  • 当前验证基线固定为 Cubism Native 5-r.5 / Core 06.00.0001;升级必须重跑
    header/binding provenance、目标 ABI、三个预置 Moc、offscreen/enhanced rendering
    fixture 和双 renderer 门禁。
  • raw binding 只由精确锁定的离线生成工具从 hash 固定的官方 header 生成;生成配置、target ABI、libclang 版本和输出 hash 必须进入 provenance,禁止手改生成代码。
  • 维护者提供并批准用于开发的固定基线已存入 vendor/cubism/5-r.5,包括
    Core、官方 header 和由该 header 生成的 target binding。公开发布前另行核对
    attribution 与再分发清单;该发布工作不阻塞本地功能实现和开发提交。
  • 原始指针不离开 safe wrapper;Moc 必须比 Model 活得更久。
  • Core 全局日志 callback 只在 callback 生命周期内有界复制至多 512 bytes,并以容量 128 的
    非阻塞队列交给专用 Rust worker;callback 不获取 writer mutex、不转义/格式化文本、不执行文件 I/O。
    worker 通过 application/Core 共用的文本 writer 写入当天的 cubism-core-YYYY-MM-DD.log;Core callback
    不提供可信级别,因此原始 message 固定按 debug 过滤,实际 Core/renderer 失败仍由 app owner 以
    error/warn stable code 记录。全局 callback-slot 竞争、队列满、停止后的迟到 callback 都只增加
    匿名 dropped 计数。关闭时先注销 Core callback,拒绝新记录,再排空已接收记录并 join worker;
    Core message 与日志路径不进入 runtime、UI 或 diagnostics export。
  • 不把未经验证的新纯 Rust Cubism 兼容 crate 作为生产基础。
  • .model3.json、motion、expression、physics 和 pose 兼容性由 fixture 验证。
  • motion3/exp3 的字节解析、曲线/fade/loop/UserData 求值和 expression Add/Multiply/Overwrite
    混合由 bongocat-live2d-playback 纯数值完成;bongocat-live2d-render 从 CommittedModel
    读取并准备 RenderResources,bongocat-live2d 再做 Core ID/range/part 校验并把结果写入 Core。
  • 每帧从 Core 默认 parameter 和模型初始化时捕获的 part opacity 开始,依次应用 motion、
    expression、类型化产品输入、自动 EyeBlink/参考 Breath、physics3(已声明时),最后调用 Core
    update;这与固定 Mver 参考实现的更新顺序一致,使 neutral pointer input 不会抹掉角度呼吸,
    physics 也能看到产品输入。自动 Breath 使用 Mver 的固定 Cubism Framework 参数集合
    ParamAngleX/Y/Z、ParamBodyAngleX 和 ParamBreath,按各目标的 offset/peak/cycle 计算,
    再以 current + value * 0.5 的加法贡献写入并按 Core 范围 clamp;不能把 Breath 目标向当前值
    插值,否则鼠标角度会被削弱。它不要求 model3 存在 Breath 组。model3 首个
    Parameter/Breath 组仍可声明最多 64 个额外 ID,并保留既有的 model-range blend 语义;
    固定 ID 不重复驱动,缺少该组也不影响
    固定参考目标。声明的 physics3 由 bongocat-model 做有界解析、由 bongocat-live2d 按 R5
    结构执行固定步进、惯性、延迟和输出插值;旧版资源省略 Meta.Fps 时使用当前单调 frame delta,
    不臆造固定频率,但显式提供的 FPS 仍必须是有限正数;未知输入/输出安全跳过,pose 仍未实现。
    physics3 设置项的 Output 可以为空数组:官方 schema 只要求该键存在而不约束长度,编辑器
    导出的模型普遍带有「模拟了粒子但不写回任何参数」的设置项,求值器按同一语义跳过它的写回而
    不因此拒绝整个包(ADR-0077);输入项非空和至少两个顶点仍然是硬要求。不得把
    有符号正弦直接钳到单边参数范围,也不得按满量程绝对覆盖参数。completed motion 仍以 clip
    声明时长对应的完整求值样本参与这一步,因此默认值逐帧恢复不会抹掉动作最终姿态;该样本保留
    自然结束 fade 的权重,显式停止的外层正弦权重再与 motion 原有权重路径相乘。PartOpacity
    motion curve 遵循 R5 Framework 语义,按 curve ID 写入 Core part-opacity sink,和普通 parameter
    curve 使用独立的目标表与权重路径;每帧先恢复初始 part opacity,因此 stop、替换或不含该 part
    的后继动作不会残留旧值。model3 Groups 由模型索引保留并校验;motion Model target 中
    EyeBlink 对匹配的 Parameter curve 做乘法、LipSync 做加法,对未被 Parameter curve
    覆盖的首个同名 Parameter group(最多 64 个 ID)使用 motion fade 插值。Opacity 作为
    独立 model opacity 进入 RenderSnapshot,只在 renderer 的模型颜色合成中与 drawable opacity
    相乘,不参与 mask 生成;窗口 presentation opacity 不进入逐 drawable alpha,而是在模型、背景、
    按键和 mask 完成合成后由平台最终 surface 统一施加,并保持到后续 motion opacity curve 更新或模型切换。
    expression 的 Add/Multiply/Overwrite 和正弦淡入淡出由 bongocat-live2d-playback 纯函数计算;
    替换期间最多保留上一层与当前层,淡入完成后将当前层权重锁定为 1.0,稳定后只保留并持续
    应用最新 expression,直到被下一次有效 expression、成功的模型切换或 shutdown 清理;测试时钟
    回退不会重新启动已完成的淡入。内存和每帧成本保持有界。产品输入与 reference Breath/physics
    的组合顺序遵循本节前述 Mver 顺序。
  • 模型加载采用 prepare/commit/rollback,失败时保留当前可用模型。
  • 模型切换是 CPU/GPU 两阶段提交:runtime 先保留旧 active model/bindings,准备新的
    Cubism generation,并随候选 RenderSnapshot 发布一次性强类型 commit token;平台
    renderer 只有在纹理、mesh、mask 和 pipeline 资源全部准备成功后才能确认。runtime
    收到匹配 token 的确认后才替换产品事实状态;renderer 拒绝时双方继续使用旧模型。
  • 普通 render frame 使用独立单调 transport sequence,可以按 latest-value 合并;模型
    commit feedback 使用不可覆盖的可靠单槽并且同时只允许一个候选 generation,不能被
    普通帧、cursor 或输入事件合并。等待 GPU 确认期间可靠输入仍由旧 bindings 消费。

官方 Cubism Framework 的动作、physics3 等逻辑必须在 Phase 0 验证 Rust 实现的兼容性;当前
physics3 只覆盖已验证的 v3 结构与 R5 数值路径,pose、完整黑盒轨迹和未覆盖的平台/资源边界仍
须形成 go/no-go ADR,不能绕过该门槛扩大实现范围。

11.2 Renderer

GPUI renderer 与 Live2D renderer 完全分离:

text
RenderSnapshot
├── model opacity / horizontal mirror / drawables / offscreens / order / opacity / masks
├── color + alpha blend / multiply + screen color
├── vertex / uv / index buffers
├── texture ids
└── transform / viewport
        ├── Windows D3D11 renderer
        └── macOS Metal renderer

renderer 负责遮罩、混合、裁剪、纹理上传、dirty flag、present 和 GPU 生命周期;不读取配置、不决定动作、不访问 GPUI entity。模型、背景和按键先在 premultiplied render target 中按模型 alpha 完成合成;窗口呈现 opacity 不再乘入每个 drawable,而是在合成完成后由 Windows DirectComposition visual effect 或 macOS NSPanel 对最终 surface 统一施加一次,避免多层 Live2D 互相重复衰减。Core 的 double_sided 必须同时驱动 D3D11/Metal 的背面剔除状态。水平镜像会反转三角形 winding,因此单面 drawable 在镜像时必须改用相反的剔除面;D3D11 与 Metal 都以 counter-clockwise 为正面,背景和按键 overlay 不剔除。

Cubism Core 在每次 UpdateModel 后的 drawable dynamic flags 必须随 RenderSnapshot 一起复制,
并在 ResetDrawableDynamicFlags 前完成读取。v1 中 drawable index topology 在同一 model generation
内不可变,backend 检测到变化即拒绝该 snapshot;Windows 与 macOS 只在
vertex_positions_changed 时重写对应 vertex buffer,render order、visibility、opacity 与颜色仍以
同一帧 snapshot 更新 CPU-side draw state。

所有 v1 PNG RGBA 贴图(Cubism texture、背景与按键 overlay)遵循 ADR-0063 固定的
encoded-space 兼容契约:Windows 使用 R8G8B8A8_UNORM texture view 和
B8G8R8A8_UNORM composition/render target,macOS 使用 RGBA8Unorm texture view 和
BGRA8Unorm drawable。两端 shader 都不得加入 sRGB decode 或 encode;normal、additive 与
multiplicative blend 使用相同的 premultiplied 输入。clipping mask 只携带 alpha,保持
UNORM,避免对 coverage 作颜色空间转换。

该契约是为兼容历史 Mver 观感而作的产品选择,不是对实际 Mver 二进制来源链或跨平台像素
一致性的既成证明。精确 Mver build provenance、背景/按键上传语义和目标硬件 readback 仍是
TODO 的验收门禁。若未来要切换到物理 linear-light 合成,必须同时修改两端的 texture view、
composition attachment、shader 和跨平台像素 contract,不能只改一个平台。v1 不解释或转换
嵌入 ICC/wide-gamut profile;模型导入将此类颜色管理作为明确的后续能力,而不是让平台默认
行为决定结果。系统级显示色彩管理仍存在平台差异:macOS Core Animation 与 Windows
DirectComposition 对最终 surface 的显示转换不同,因此同一组 encoded 值在广色域显示器上的
绝对观感仍可能不同;这属于合成器行为,不由 renderer 消除。

窗口圆角是 overlay 窗口自身的形状属性,与模型、动作和输入无关。overlay.corner_radius_percent
按窗口宽高的百分比给出四个角的椭圆半径:N% 表示水平半轴为窗口宽度的 N%、垂直半轴为窗口
高度的 N%,与旧版 CSS border-radius: N% 相同,因此同一个数值在非正方形窗口上产生的水平
圆角比垂直圆角更大;0 保持直角,50 时四条弧线相接、内容被裁剪为窗口的内切椭圆。renderer
在片元着色器中按 drawable 像素位置求该椭圆的 coverage,并把它乘进每次绘制的 alpha,因此圆角
只改变窗口边缘的合成结果,不改变 RenderSnapshot、模型资源、绘制顺序或 blend 模式。该值只
作用于 overlay 窗口;GPUI 设置窗口和其他产品窗口保持各自的平台边框。Windows 圆角与屏幕范围约束
在现有窗口上更新,普通窗口模式暂停圆角裁剪;macOS 改变这两项仍重建原生窗口资源。缩放通过
现有窗口与 renderer 原地调整,不透明度则更新最终 surface 的 presentation alpha,二者都不应
因为设置变化替换 HWND/NSPanel。

指针悬停隐藏是 overlay 窗口的临时呈现状态,不是窗口可见性。overlay.hide_on_pointer_hover
开启时,指针进入 overlay 窗口矩形并停留 overlay.hide_on_pointer_hover_delay_seconds 之后,owner 把
窗口的呈现 alpha 淡到 0 并强制指针穿透;指针离开窗口矩形后按同样的时长延迟淡回
opacity_percent,并把穿透恢复为 overlay.click_through。窗口本身既不隐藏也不销毁,
runtime 的 overlay visibility 不受影响,frame source 继续按 overlay.maximum_fps 出帧,shutdown 顺序不变。隐藏期间
穿透强制为开,因此不可见的 overlay 不会吞掉本该落到下层窗口的点击。

淡入淡出复刻旧版 CSS transition-opacity-300 的 300 ms,但按「从过渡起点起算的绝对经过时间」
求值,而不是按帧累加:同样的时间点在不同帧率下得到同一个 alpha,60 FPS 与 240 FPS 的采样结果
一致。首版按线性插值,不复刻旧版 CSS 的 transition-timing-function:该曲线来自样式框架默认值、
未在旧版代码中显式声明,因此不视为已冻结的行为。命中测试用指针采样与窗口矩形的半开区间比较
(x >= left && x < left + width,纵向同理),因此相邻显示器共享的边界像素只会落在其中一个窗口内。
命中测试读采样里指针自身的位置,而不是模型跟随的位置:input.mouse.force_move 打开时后者由
相对位移累加而来(见 ADR-0081),是对指针位置的估计而非指针位置本身,用它做命中测试会让同一个
手势因为鼠标走到窗口的路径不同而得到不同结果。两个位置都由平台适配器在同一条采样里发布。
平台采样与窗口矩形的坐标空间不同时必须在 owner 内换算,不允许把平台坐标泄漏给 runtime 或 renderer。
圆角窗口的透明角落仍算在窗口矩形内,与旧版一致。

指针采样缺失、或平台输入服务不在 Running 状态时一律视为「不在窗口内」。这条降级规则保证指针
链路失效时 overlay 最坏情况是保持可见,而不会永久停在全透明且穿透的状态。悬停隐藏与
opacity_percent 共同决定最终 alpha。改变不透明度只更新现有 surface 的 presentation alpha;开关和
延迟本身也在 frame tick 内原地生效,不触发原生窗口重建。

「按住修饰键以恢复交互」是这两个临时状态之上的一个开关,而不是第三个状态机。
overlay.hold_modifier_to_interact 设置后,平台 owner 在每次 frame tick 内把它与 runtime snapshot
里的 input.pressed_modifiers 相与:按住期间悬停隐藏按「关闭」处理(状态机复位、alpha 淡回
opacity_percent),并且最终穿透判定从 click_through 收窄为 click_through && !held。因此
一只配置成「鼠标穿透 + 悬停隐藏」的猫仍然可以被抓住拖动,不必先关掉设置再打开。松开键后两者
立即恢复,指针通常仍停在窗口内,因此悬停延迟重新开始计时而不是恢复一个已经过期的截止时间。
该字段只读不改:落盘配置、runtime 设置与 overlay options 都保持用户原值,忘记哪个键也不会在
下一次按住时改变行为。

input.pressed_modifiers 是 runtime pressed set 在八个 HID 修饰键词汇表(0xE0..=0xE7)上的投影,
左右两侧各占一位。它存在的理由是现有两条读法都答不了这个问题:snapshot 只有按键计数,而模型投影
会丢掉当前模型没有对应按键图的键——修饰键恰好就是模型不会提供的键。因此设置页的录入控件也读它,
而不是读 GPUI 的按键事件:GPUI 把修饰键报告为不带键身份的布尔标志变化(macOS 上按修饰键根本不
产生按键事件,Windows 上左右 Shift 的虚拟键码落在同一分支),既分不出左右,也没有可供录入的
按键事件。控件因此是「按住」语义而非「点按」语义,这也正好是设置本身要求用户做的动作。

无操作隐藏与悬停隐藏是同一呈现通道上的第二个临时状态。overlay.hide_on_idle 开启且
overlay.hide_on_idle_delay_seconds 内没有任何新输入时,overlay 把 alpha 淡到 0 并强制指针穿透;
任意新输入重新开始计时并淡回 opacity_percent。它的「活动」判定只读 runtime snapshot 里的三个输入
计数器:输入事件序号、指针采样时间戳、手柄轴已发布样本数。三者任一变化都算一次新活动;鼠标停在
原地不产生新采样,因此不会把闲置状态续期。平台 owner 不自己监听全局输入,也不新增平台 API。
两个状态机的 alpha 相乘、穿透取或,因此任意一个生效都不会让 overlay 停在透明但吞掉点击的状态。
输入服务不在 Running 状态时无操作隐藏同样关闭,避免输入链路失效时永久隐藏窗口。窗口、frame
loop 与 runtime overlay visibility 都不受影响,shutdown 顺序不变。

原生 overlay 窗口创建后默认保持隐藏。平台 owner 只有在对应 renderer 已成功完成至少一次
非空帧 draw/present 后才允许首次显示;启动、隐藏后重显、设置导致的窗口重建和模型切换重建
都遵守同一顺序。首帧提交或验证失败时窗口保持隐藏,模型准备失败仍保留当前可用窗口与模型,
不得暴露未初始化 swapchain/drawable 造成黑框或不透明闪烁。

overlay 隐藏时不连续消费或 present 普通动画帧,latest data frame 继续在 transport 中合并;
可靠 model commit 被消费时淘汰早于候选 generation 的旧 data frame,避免重显回退 GPU owner。
带 model commit token 的可靠控制帧仍由 100 ms 上限的隐藏 tick 单独消费:候选 GPU owner
完成一次隐藏 draw/present 验证后回报 prepared 并替换旧 owner,失败则回报 rejected 并保留旧
owner。后续重显必须先同步当前 latest frame、再次成功 present,再显示已提交的候选窗口。

renderer 的模型资源准备结果通过稳定项目 error code 回到 runtime,不返回 GPU handle、
平台对象或任意字符串协议。候选准备失败只结束对应模型 command,不得终止 frame loop、
清空当前 GPU model 或让 runtime 提前宣布候选模型 active。

临时 presentation unavailable(例如 macOS CAMetalLayer 未交付 drawable)不是模型或设备
准备失败:frame source 必须以单调、有限的退避延迟重试,成功 present 后重置;不得把重复临时
不可用写成 failure 日志或停止 frame source。候选模型的可靠 commit token 必须保留至实际首帧
成功,不能因临时 drawable 缺失被拒绝。未知 D3D11 present/device-loss 仍按明确 HRESULT 恢复策略
处理,不能笼统归类为临时成功。

Linux 阶段再决定增加 Vulkan/OpenGL backend,或基于数据迁移到 wgpu。首发优先保证 Windows/macOS 透明窗口的确定性。

11.3 Motion 音效

  • bongocat-audio 使用精确锁定的 rodio 0.23.0,只启用 output playback 与 FLAC;
    rodio/CPAL 类型不进入 runtime、model、UI 或 renderer 公共接口。
  • runtime 只在 motion priority 与资源解析均成功后,通过固定容量有序队列非阻塞发布
    强类型 Prepare/ActivatePrepared/Play/Stop。解码、文件 I/O 和设备创建全部由独立 worker 执行。
  • renderer 与 audio 对候选模型并行 prepare;audio worker 将去重 sound 解码为 immutable PCM,二者完成
    或 audio 已稳定降级后才 commit 模型。ActivatePrepared 只保留活动模型 cache;output stream 的惰性打开由
    audio owner 执行且不能阻塞 model 或 motion。已活动模型的 motion 在同一 runtime command 中发布缓存 Play
    并启动 motion;不得等待 mixer position、设备回调或轮询音频诊断。audio owner 优先使用 512-frame buffer,设备拒绝时回退默认 sink。
  • 同时最多一个 motion voice。新动作(包括没有 sound 的动作)、显式停止、禁用音效、
    成功模型 commit 和 shutdown 都停止旧 voice;被 priority 拒绝或加载失败的动作不改变
    当前 voice。
  • model3 sound 相对路径必须先通过模型包规范化与包根约束,再由 runtime 组合为本地路径;
    audio backend 不解析 model3,也不接受 URL 或未验证的模型引用。
  • 文件、解码、输出设备和队列故障只更新匿名诊断,不改变 motion、input 或 render 状态。
    满载恢复丢弃不可信 backlog 并停止 voice,禁止阻塞输入边沿。
  • motion3 UserData 由 Live2D evaluator 保留并按单调 elapsed 的 (previous, current] 区间
    产生强类型 occurrence;循环跨越不重复,时间回退不重放,单 tick 最多发布 256 项并
    对跳过数量计数,避免睡眠恢复后的无界分配。

12. 配置、模型与安全

应用身份:

text
com.ayangweb.bongo-cat

构建产物携带不可变的 Development 或 Production 环境。环境由构建入口显式选择,运行时参数、环境变量和设置项均不能切换。两个环境使用相同 schema 和相对目录结构,只改变数据根目录:

正式 app 使用 Cargo feature 选择环境:默认不启用 production,即 Development;Production
build/package 必须显式启用 bongocat-app/production。packaging 继续校验 --environment 只接受
development/production,并只在 Production 时给子 Cargo 命令添加该 feature。运行时不读取
BONGOCAT_BUILD_ENV 或其他环境变量。正式 Application::start 只以编译期环境调用当前平台 path
resolver,不接受外部 StorageLayout、根目录或生产路径覆盖;隔离临时根注入只存在于显式
storage-test-injection Development 测试产物,Production 与该 feature 的组合在编译期失败,
默认产品 CLI 和 API 均不包含该入口。

平台 Development Production
Windows %APPDATA%\com.ayangweb.bongo-cat\development\ %APPDATA%\com.ayangweb.bongo-cat\production\
macOS ~/Library/Application Support/com.ayangweb.bongo-cat/development/ ~/Library/Application Support/com.ayangweb.bongo-cat/production/

每个根目录包含 config.json、window-state.json、models/、model-overrides/、backups/、logs/、updates/ 和 locks/。model-overrides/ 是预置模型用户侧内容的命名空间(每张替换封面一个 /resources/cover.png),因为预置包位于 app 包内、不可写;它与 models/ 一样只被设置页与 app 层写入。锁、单实例命名、更新 channel 和诊断同样按环境隔离;任何环境不得读取、写入或 fallback 到另一个环境。updates/ 是保留给更新的环境私有命名空间:当前 cargo-packager-updater 使用进程临时文件/目录下载和暂存载荷,不落在该目录下,因此 updates/ 当前无写入方,仅作为环境形状契约的一部分保留。StorageLayout 只描述这些用户数据路径;安装器使用平台 InstallationLayout 描述 product files root,不能从用户数据根推导或操作安装目录。

预置模型属于 product files:macOS 从 BongoCat.app/Contents/Resources/models 解析,Windows 从
bongocat-app.exe 同级 resources/models 解析。仅未打包的开发二进制可在该相对路径缺失时回退到
仓库 resources/models;安装产物不得依赖源码树、当前工作目录或用户数据目录。

要求:

  • BongoCat 配置从全新 schema 开始,不读取、不探测、不导入旧 Tauri/Pinia store。

  • JSON key 使用 snake_case,字段按当前领域语义命名,不提供旧字段 alias。

  • 初始版本是全新的,当前完整配置统一使用 schema_version: 1。v1 直接包含完整的
    model.selected_model: { id, source }、用户导入模型的元数据列表 model.imported_models 与内置
    模型的列表 model.built_in_models。imported 记录包含稳定唯一的 id、可编辑 title 与必填的
    input_mode(standard、keyboard 或 gamepad);built-in 记录只包含 id 与 title,其模式
    由构建拥有的稳定 id 派生。普通包在 staging 提交前通过 left-keys/right-keys 资源判定模式,
    判定失败直接拒绝导入;不从标题或路径生成模式。两个元数据列表各自判重:列表内 id 不得重复,
    title 去除首尾空白后不得为空。built_in_models 为空表示所有内置模型都还用构建给的名字,
    它没有任何导入、删除或裁剪路径;input_mode 不参与 { id, source } 身份。输入配置使用
    input.gamepad.stick_dead_zone 和 input.gamepad.trigger_dead_zone;两个 dead-zone 都必须是
    [0, 1) 的有限数。模型随机播放由 model.random_behavior.mode 与
    model.random_behavior.interval_seconds 成对表达:mode 是 off(默认)、expressions、
    motions 或 motions_and_expressions 的单一枚举,关闭是它的一个取值而不是独立布尔门禁;后者为
    [1, 3600] 秒且默认 30。两者直接进入当前 v1,不读取旧字段。model.gamepad_auto_switch 直接包含当前 v1:门禁
    enabled 默认 false,connected_model 与 disconnected_model 是完整 ModelIdentity 或
    null,null 是默认值并表示「上次在该输入族上使用过的模型」;该「上次使用」是
    Application 的会话状态而不是配置字段(ADR-0071)。model.toggle_repeated_expression 直接包含当前 v1 且默认 false,带 #[serde(default)]:
    旧文档没有这个键时读出 false。model.remember_last_expression 与
    model.last_expressions 直接包含当前 v1:门禁默认 false,列表每个元素是一个
    { model: ModelIdentity, expression } 记录且同一模型最多一条,列表默认为 []。
    记录与门禁分离:记录只由用户主动触发的 expression command 产生,随机行为播放的
    expression 不写入;门禁只决定模型成为当前模型时是否播放自己那条记录,不决定是否记录,
    因此关闭再打开恢复的是用户原本选过的表情。没有记录、或记录中的表情已不在模型包内时
    模型保持自己的默认表情。删除导入模型时该模型自己的记录在同一次提交中清除,
    model 语义详见 shared/config/contract.md。overlay visibility 属于 runtime 会话状态,
    不写入 config;设置页使用
    settings.overlay.hide_model_window.label 将其投影为“隐藏模型窗口”开关,开关选中表示已隐藏,默认未选中。

  • 开发期间不读取或转换任何早期中间结构,不实现 schema migration、字段 alias 或版本兼容
    分支。新增字段直接更新当前 v1 的 Rust 类型、JSON Schema、默认值和 fixture。解析入口保留显式
    版本检查并拒绝非 v1 数据;首次正式发布后的后续版本再以该发布版为基线单独设计迁移链。

  • 写入使用同目录临时文件、flush、原子替换和提交后验证;替换前把当前配置封装为带格式版本、
    墙上时间、源 schema 和 revision 的环境内备份。每个环境只管理 config-*.json 自有命名空间,
    按持久排序键保留最新 8 份且总计不超过 8 MiB;系统时钟回退不得让新备份被误删,未知文件
    不参与清理。备份写入或收敛失败时保留当前配置;原子替换后的重读、typed validation、revision
    或值比较失败时逐字节恢复替换前配置,固定 temp 不得残留,后续启动必须可以重新尝试提交。

  • 正式配置提交先以只创建方式写入并 flush 固定的同目录 config.json.tmp,再使用平台原子替换
    提交 config.json。启动在同一 writer lock 内先处理残留 temp:有效 current 优先并把有效 temp
    归档为 stale;current 缺失或损坏时才提升有效 temp;无效 temp 单独归档且不覆盖 current;非 v1
    schema temp 原样保留并明确报错。stale/invalid 归档共用每环境最多 4 份、总计 8 MiB 的自有
    命名空间,未知文件不参与清理。仅启动恢复以 10 ms 间隔重试 writer lock 最多 1 秒,普通提交
    仍立即报告竞争;app 只保留不含路径、原始字节或 I/O 文本的匿名恢复动作。

  • 当前配置属于 v1 但 parse/validate 失败时,只在同一 writer lock 内从新到旧检查自有备份,
    同时验证 envelope 格式、源 schema、源 revision 和完整 typed config。找到第一份有效备份后,
    将损坏原文写入 config-corrupt-*.bin quarantine,原子写回该配置并重新读取验证。
    没有有效备份时,直接 quarantine 损坏原文、原子写入并验证当前 v1 默认配置。两条 fallback
    都返回普通可用配置,没有 RecoveryRequired、recovery-only settings window、snapshot 恢复字段、
    RestoreDefaultConfiguration command 或“恢复后必须重启”流程。非 v1 schema 仍按当前 v1
    的严格版本入口报告 unsupported,禁止自动转换、downgrade 或覆盖未知格式。

  • 配置写入将权限/只读文件系统、存储空间/配额不足和 temp 目标占用分类为稳定的匿名失败原因;
    settings 只显示可操作的项目文案,不泄漏路径或操作系统原始错误。写入只清理由当前调用成功创建的
    temp;若固定 temp 已被文件、目录、符号链接或并发创建占用,则保留该条目和 current 并明确失败。
    权限与磁盘满在 temp 创建前后都必须可受控注入,验证失败不改变当前 snapshot/revision。

  • OpenConfigBackupLocation 是强类型 command,打开当前构建环境的 backups/;自 Diagnostics 页面
    移除后它没有 UI 入口,只由 settings service、隔离 smoke 与排障路径使用;
    路径只由 Application 从正式 StorageLayout 派生并传给 platform adapter,不进入 command、
    snapshot、错误或 GPUI Entity。platform adapter 先验证绝对目录并 canonicalize,再通过 opener
    crate 交给系统默认程序;成功只返回当前 snapshot 且不推进 revision,失败只返回
    BackupLocationOpenFailed。

  • config.json 只包含用户设置;窗口布局写入 window-state.json,pressed state、权限结果和模型解析缓存不持久化。

  • window-state.json 使用独立 v1 schema,保存设置窗口的逻辑坐标、尺寸与 maximized 状态,以及 overlay
    的坐标与尺寸;不读取或转换开发期间出现过的其他结构。坐标支持多显示器负值并设有有限范围,
    设置窗口尺寸限制为 640x480..16384x16384,overlay 尺寸限制为
    64x64..16384x16384。缺失、损坏、越界、未知字段或读取失败只回退到鼠标当前所在显示器
    居中的默认尺寸,不得阻塞 config 或 runtime 启动;非 v1 window state 回退显示且不被覆盖。

  • window state 通过环境内独立的 window-state.writer.lock 和原子替换提交,提交后重读 typed window state,失败恢复
    替换前 bytes;它不进入 config revision、backup 或 quarantine。GPUI bounds observer 对连续变化
    合并 150 ms 后通知 settings worker,overlay frame source 仅在几何真正变化时通知同一 worker;
    正常 shutdown 前仍强制 flush。配置提交、模型切换和窗口重建必须保留另一窗口已保存的几何,
    窗口完全离开当前显示器时回退到鼠标当前所在显示器居中,fullscreen 不持久化。

  • 模型导入防止路径穿越、符号链接逃逸、压缩炸弹和覆盖现有用户数据。

  • 模型来源只有一种:用户通过文件夹选择器或窗口拖放提供的文件夹,就地读取后复制进 store 自己的
    staging。压缩包来源与它的解压边界已随实现一并移除(ADR-0036 已撤回,见该 ADR 的撤回说明);要恢复
    时先与维护者确认。

  • 嵌套模型文件夹由 settings worker 调用 model store 只读发现:复用包的深度与条目预算,
    拒绝链接、路径逃逸和包含目标 store 的来源,按相对路径排序。识别模型根后停止向资源目录
    继续发现;普通包复用包验证与输入模式分类,Mver 源保留整组模式。单个嵌套模型自动进入
    原导入流程;多个模型复用 GPUI Kit Dialog/Checkbox 展示所选文件夹名称与相对路径,默认
    全选、空选禁止确认。选中来源逐个重新检测,按需选择模式,再执行已有导入与封面截取,
    当前模型结束后才继续下一项。失败通知后继续独立来源;取消导入或关闭任一选择弹框清空
    剩余队列。队列只存 UI 临时草稿,不进入持久化配置(ADR-0087)。

  • 导入在提交前把包内 resources/left-keys 与 resources/right-keys 下的旧键位名归一化到产品
    词汇表:Alt.png → AltLeft.png、AltGr.png → AltRight.png(ADR-0038)。这一步只作用在
    ModelStore 自己的 staging 上,因此用户选中的源目录始终是只读输入;改名不改变文件数与字节数,
    ModelImportProgress 的计数仍然真实。canonical 文件已存在时保留它、旧名文件原样留下,不猜
    作者意图。Mver 转换路径不做这一步——它的输出已经按产品词汇表命名。

  • 第二种来源是 BongoCatMver 模型(ADR-0037),同样按内容识别,且需要两条独立证据:根
    config.json 能解析成 legacy 的 section 形状,且它命名的模式里至少有一个在
    <资源根>/<模式>/cat_model/ 下恰好带一个 .model3.json。缺任一条就回退到普通包导入并
    由那条路径报错,检测本身不报错。

  • 解析 legacy config.json 的严格程度必须与它自己的读取端一致:该应用用 JsonCpp
    CharReaderBuilder 读这个文件,而它的默认设置含 allowComments,所以模型作者用 // 或
    /* */ 注释标注键位表是格式本身接受的内容,不是损坏。因此读取前先剔除字符串字面量之外的
    注释(字符串里的 / 是数据,\" 不结束字面量),再做严格 JSON 解析。放宽只到注释为止:
    注释移除不能把不是 config 的文件变成 config,也不能破坏多字节字符——否则检测又会静默回退,
    用户拿到的仍是「模型包无效」这种与模型无关的解释。

  • 普通包的模式在 staging 复制、键名归一化和第二次 PreparedModel 校验之后、原子 rename
    之前判定:任一手出现手柄专用键名(DPad*、*Trigger*、South/East/West/North、
    Start/Select 等)判 gamepad;否则有 right-keys 判 keyboard;否则有 left-keys 判
    standard;两类都没有则以 InvalidPackage 拒绝。该结果由 store API 返回并写入 config,
    不在 UI 每次渲染时重新扫描。手工复制进 store 的异常目录若没有 metadata,只有在仍能通过
    同一资源判定时才临时投影模式;无效目录不显示模式 Badge。

  • 一个 Mver 源产出多个 BongoCat 模型:每种输入模式各自转换成一个包,各自分配随机 UUID
    v4 存储键与元数据记录,因此三种模式是模型列表里三个可独立启用/改名/删除的条目。每个转换
    模型把所选 legacy source section 的模式写入 model.imported_models[].input_mode;模式事实来自
    source section,不从转换后目录形状或标题反推。每个模式独立提交——某个模式的键位图损坏不
    影响已转换成功的模式,与"这几种模式彼此独立"的事实一致。

  • 转换只把合成后的包写进 ModelStore 自己的 staging,并与目录复制共用同一个 PreparedModel
    校验与单次 rename 提交尾部,因此不可能绕过包校验,也不存在第二个临时位置。

  • 键位图合成是"最小画布上的 Porter-Duff over":画布取两层较小的宽高、两层锚在原点,因此过大
    的一层被裁切而非缩放;模式没有 keyboard/ 图集时按字节安装 paw 图而不合成;某个绑定缺 paw
    或配套键帽时只跳过该绑定。输出文件名沿用产品自己的词汇表——键盘键是 bongocat-live2d-render 从
    HID usage 解析出的名字,手柄键是 GamepadButton::key_image_name(ADR-0070);gamepad 的键位表用
    历史 XInput 按钮序号(这是 Mver 资源词表,不是当前手柄 backend API),映射到产品自己的十六个按钮
    名:0–3 面部键、4/5 肩键、6/7 模拟扳机、8/9 菜单键、10/11 摇杆键、12–15 方向键;无法命名的控制码
    不产出图片也不报错。

  • 合成图按 lossless 方式重编码(oxipng,只开库入口):位深/颜色类型/调色板/灰度缩减保持解码
    后像素不变,optimize_alpha 只改写全透明像素的颜色通道。有损量化库因许可证(GPL)被排除;
    Zopfli 后端实测多 5% 体积换 15 倍时间,不采用。重编码失败写回普通编码结果,不让转换失败。

  • 转换逐字节复制模式的 Live2D 包,唯一例外是 motion 条目里指向源中不存在文件的 Sound
    引用:以「源实际携带的文件集合」判定悬空,丢弃该 Sound 键,其余内容保持不变。这沿用上文
    「少一张键位图不等于这个模型不可用」的决定——从 Cubism 官方示例包改装的模型会留下示例的
    Sound 名,而 Mver 实际从自己的每模式音效列表播放,丢掉这个名字不损失任何东西,保留它则让
    整个模式过不了包校验。只丢弃「缺失」的引用:无法规范化的引用(越出包根、绝对路径)保持
    原样交由包校验报错,不因为转换而悄悄抹掉一条路径安全发现。没有悬空引用时 entry 逐字节不变,
    因此转换不会顺手重写第三方模型文件。

  • Mver 源的检测与转换只在 settings service worker 执行,UI executor 不做阻塞文件或模型解析;
    跨模型进度在应用层折叠(累计已完成模型的文件数与字节数、stage 取最大值),因此用户看到的是
    不倒退且终值等于各模型总和的一条序列,而不是卡在第一个模型的终值。标题为
    「用户标题 · 本地化模式名」,模式名在截断之后拼接,长来源名不能挤掉三个模型之间唯一的区别。
    诊断上只新增 store 码 SourceConversionFailed(映射到既有
    ModelImportSourceUnsupported),没有新增用户可见错误码。

  • 模型包在反序列化或图片解码前执行 JSON 字节/深度、单文件/整包字节、文件数、目录深度
    和纹理尺寸上限。model ID、资源路径、model3 数组索引、任意 JSON bytes 和 PNG header/
    dimensions 具有可收缩 property contract;接受的资源路径必须规范化为幂等、相对且只含
    normal component 的包内引用,任何随机输入不得 panic、越界索引或绕过分配上限。

  • model ID 是跨 Windows/macOS 可移植的 ASCII store key:禁止前导/尾随点、路径分隔符及
    Windows 保留设备 stem(含带扩展名的 CON、PRN、AUX、NUL、COM1..9、
    LPT1..9),不得让同一 installed identity 在目标平台解析为设备或隐藏路径。

  • 任意外部目录只能产生待导入的 PreparedModel;只有当前环境 ModelStore 完成复制、复验和
    原子提交后签发的 InstalledModel 才能进入 runtime 激活 command。

  • 应用装配时打开并持有只读 PresetModelCatalog;设置与模型管理只消费预置目录和当前
    环境 ModelStore 的合并目录,不在 UI executor 临时扫描或解析模型文件。

  • installed 目录扫描逐条目降级:ModelStore::list 只在 store 根目录本身不可读或 writer
    lock 竞争时失败,单个条目绝不使整表不可用。文件管理器与操作系统元数据(.DS_Store、
    .localized、Thumbs.db、desktop.ini、AppleDouble ._*)按文件名忽略,既不计入
    目录也不计入诊断;其余不是「自有模型目录」的条目(非常规目录、符号链接、非 UTF-8 名称、
    非可移植 model_id 的目录名)静默跳过并计入 InstalledModelCatalog::skipped_entries。
    带合法 model_id 但包校验失败的目录仍签发 Invalid 条目,保持可见。过滤完全在 store
    内部完成:skipped_entries 不进入 settings snapshot,也不产生任何用户可见文案或无障
    碍输出,用户只看到可用的模型集合;符号链接始终不被跟随。

  • 标准预置模型 standard 是始终可用的默认回退:目录身份 (origin, model_id) 在数据结构
    上区分预置模型与用户导入的自定义模型,所有自定义模型不可用时仍能回退到 standard。

  • 应用启动时执行模型恢复:优先激活配置中完整的 model.selected_model: { id, source };该模型缺失或
    不可用时记录匿名回退事件、把 standard 内置模型持久化为修正后的选择并激活它,恢复失败不阻塞启动。
    未配置选择时同样默认激活 standard。启动还会在 operational 状态下清理指向已不存在模型目录的
    model.imported_models 元数据记录;
    目录存在但内容无效的记录保留,由合并目录的稳定诊断码呈现。启动期清理失败的记录留待
    下次启动重试,不影响其他模型或应用整体。

  • 模型导入 command 携带标题与文件选择来源(文件夹选择器选中的目录,或设置窗口拖入的单个目录);
    标题只是显示名称,不参与身份——settings service worker 在导入前用随机 UUID v4(uuid 1.28.0,
    精确 pin)生成当前 store 内唯一的可移植存储 ID,因此重复导入同一目录不会覆盖已有模型,
    显示名称可以随时编辑,用户也无需发明任何 ID。导入成功后把标题和模式写入 model.imported_models
    元数据:Mver 写入实际选中的 source mode;普通包在 staging 通过 left-keys/right-keys 资源
    判定为 standard/keyboard/gamepad,判定失败直接拒绝导入,不写入 store 或 config。标题
    取导入 command 携带的值,页面在选中来源时按文件夹自身的名字预填,导入后可在模型卡片里
    改名;空白值依次降级为该名字和模型 ID,超长标题截断到元数据上限;元数据提交失败按导入
    失败报告且已导入目录保留。改名只更新 title,保留 input_mode;删除模型在 store 删除成功
    后同步移除对应元数据记录。

  • 模型目录身份是 { id, source }。同一 id 的 built-in 与 imported 条目都保留,
    后续选择 command 必须携带 source,不得以静默覆盖解决冲突。Models 页面的顺序由
    Application::model_catalog 一处决定,分两半:built-in 在前,按 MverInputMode::ALL 的
    模式顺序(标准 → 键盘 → 手柄,不是 id 字母序——三个 id 的字母序恰好是它的倒序);
    imported 在后,按 config.model.imported_models 的记录位置,也就是导入顺序,因此新导入的
    模型落在页面末尾且此后不再移动。没有元数据记录、只存在于 store 根目录的包排在该半区末尾并
    按 id 排序;id 在两半区都出现时 built-in 一定在前,因为整个 built-in 半区都排在前面。

  • 模型改名与换封面对两个 source 完全一致(见 ADR-0047)。imported_models[].title 与
    built_in_models[].title 是可编辑显示名;imported 记录还保存导入时确定的 input_mode,
    built-in 模式由稳定 built-in id 派生。封面在 imported source 上是包内文件
    resources/cover.png,在 built-in source 上是用户侧的
    <data>/model-overrides//resources/cover.png(app 包不可写,见 ADR-0047 决策 2),两边都由
    bongocat-model 的 package_cover_path 推出布局,分别由 bongocat-model-store 的
    ModelStore::replace_cover 与 PresetCoverStore::replace_cover 做同目录原子替换。导入成功后,每个新导入模型的封面会被换成
    该模型自己渲染的一帧(见 ADR-0055):settings worker 只把模型与它在协议里的身份排队,
    GPUI 线程在永不显示的原生窗口里渲染、读回、裁切并编码,再经 ReplaceModelCover 写回同一位置,
    因此转换输出的占位封面只在捕获失败时保留。预置模型只多一件事是禁止的:删除。它的包永远是
    product files,改名与封面只是用户侧的记录,包本身从不被写入。

  • settings 快照把页面需要、但不属于配置的模型事实一并投影:每个模型条目携带 input_mode、自己的
    包目录与包内封面路径(包内没有封面时为 None),页面因此只显示模型自己的图、显示已解析的
    模式并可直接打开模型文件夹,而不自行推导任何路径或扫描资源目录。模式 Badge 是展示元数据,
    不在 snapshot 层改变 runtime 输入绑定。

  • 模型选择 command 携带完整复合身份。应用先校验并加载候选,再以 expected revision
    持久化选择并启动 runtime/renderer 两阶段提交;候选被拒绝时 runtime 保留旧模型,应用
    使用刚取得的 config revision 原子恢复旧选择。配置写入失败时不得发送激活 command。

  • 合并目录通过强类型 settings snapshot 投影来源、可用状态、资源计数和稳定诊断码;无效
    模型继续可见,但用户路径、底层 I/O 文本和模型内容不得进入 UI snapshot 或日志。

  • 模型导入 command 只由 settings service worker 执行复制和复验;成功后刷新合并目录,但
    不隐式激活模型或修改选择配置。失败只返回稳定、
    可操作且不含用户路径的导入错误码。settings snapshot revision 同时观察 runtime 变化并为
    catalog-only 变化递增,禁止返回内容已变但 revision 未变的快照。settings client 为每次导入
    分配跨 clone 单调递增的强类型 operation ID;operation 只公开 prepare/copy/validate/commit
    stage、已复制文件数和字节数,不携带用户路径,并通过共享原子取消令牌在 service worker
    阻塞于分块复制时仍可取消。cancel 在原子 rename 提交前生效并清理 staging,
    final result 携带同一 operation ID;后续 Models 页面只消费该契约,不自行执行文件 I/O。

  • 模型来源进入设置 UI 有两个等价入口:原生文件夹选择器 pick_model_folder,以及设置窗口根视图
    接收的 GPUI ExternalPaths 文件拖放。两平台的选择器都用 rfd 0.17.2 的 pick_folder(macOS
    AsyncFileDialog,Windows FileDialog)。macOS 只在 AppKit 主线程且已有窗口可作为 sheet parent
    时创建 AsyncFileDialog,缺少 sheet parent 时返回 BackendUnavailable,不回退同步 runModal;
    Windows 在专用 worker 的 STA 中调用 FileDialog。两平台都只向上返回 Selected(PathBuf) /
    Cancelled 和稳定无路径错误码,Rust 侧重新检查绝对、存在与类型(必须是目录)并 canonicalize;
    选择层不判定所选目录里是不是可用的模型包,真正的包解析/复制仍只由 settings worker 执行。
    rfd 将取消与后端失败都表示为 None,当前 adapter 按取消处理;对话框取消不是错误,错误不得
    携带系统文本或用户路径。

  • 文件拖放是窗口级手势:根视图和临时蒙层都注册 ExternalPaths 的 drag/drop handler,另由
    paint-phase 的 window listener 接收 FileDropEvent::Exited,因此指针已经移出 viewport 时也能立即
    清理蒙层。一次只接受一个路径;多选在蒙层阶段显示拒绝状态,松手后只产生稳定的
    ModelImportDropInvalid 通知。单路径若不是目录、已不存在或无法 canonicalize,则由后台复验拒绝并
    通过同一稳定错误码报告。拖入路径先由 bongocat-platform::validate_model_folder 在 background
    executor 复验并 canonicalize,再进入既有 InspectModelSource、转换选择、导入、取消和封面截取
    流程;UI executor 不遍历、复制或解析模型文件。

  • 剪贴板 adapter 通过私有 arboard 3.6.1 边界只接受最多 1 MiB、无内嵌 NUL 的纯文本;无文本
    返回空选项,过大、无效、拒绝或系统故障只返回稳定匿名错误,绝不记录文本。macOS 调用必须
    在 AppKit 主线程和 autorelease pool 内,Windows 的打开、写入和关闭由 arboard 的 RAII
    guard 在同一线程完成。依赖类型、系统错误和剪贴板内容都不离开 adapter;底层读取在项目
    大小校验前可能已物化系统提供的完整文本,这是当前第三方文本 API 的已知边界。

  • Models 页面在 GPUI Entity 中只保存视图状态——导入草稿(来源目录、由来源名派生的标题、导入状态
    与本次导入的 baseline,运行中的 operation monitor 就在该状态里)、删除确认、编辑草稿、每行的
    焦点句柄,以及文件拖入期间的临时 Ready/Busy/InvalidSelection 蒙层状态——不持有任何路径文案:
    导入卡片只显示匿名的 choosing/validating-source/importing/capturing 步骤;validating-source 是文件夹
    选择器与拖放在取得路径后共用的“读取模型文件夹”阶段,不进入 snapshot、状态文案或日志。导入进行中以
    100 ms UI timer 重新渲染卡片,读的只有 operation ID 与取消状态(不读文件数/字节数),cancel 直接
    设置共享原子令牌;失败与取消都回到上传提示并只经 Notification 报告,不提供页面内 retry。GPUI
    executor 不遍历、解析或复制模型文件,成功只刷新 catalog,不隐式切换 active model。

  • Models 卡片的模式信息只来自 SettingsModelEntry.input_mode:固定 GPUI Kit Badge 作为封面右上角的
    覆盖层容器,承载无图标的中性 secondary Tag;三种模式使用相同的主题底色,文字在两种语言中始终可见。
    它不创建新行,不加入 Tab 顺序,也不在 UI 线程重新扫描资源。Badge 的上游 primitive 本身不承载文字,
    因此可见 Tag 才是模式标签。模式 Badge 是配置/来源类别的展示,不改变 renderer 或 runtime 的输入绑定。

  • 模型删除 command 同样携带 (origin, model_id);preset 永不可删。installed 模型始终可删,
    包括当前 runtime active 或配置所选的那个:删除前先按同一 typed selection 路径切回标准预置,
    切换失败即中止删除,绝不在 runtime 仍持有该包时移除文件。删除本身仍以 rename 后删除事务退休;
    同 ID preset 不得阻止删除 installed 副本。成功刷新 catalog,并把回退后的选择一并反映到快照。

  • 更新的 manifest 获取、版本比较、下载、验签、安装与重启由 cargo-packager-updater 0.2.3 承担;
    bongocat-update 只保留 BongoCat 侧策略与诊断契约,不自行实现传输或替换层。信任模型为
    detached minisign 签名:发行载荷必须由发布私钥签名,客户端以编译进构建的公钥校验,
    私钥不得进入源码、产物或配置。签名端与打包端是同一个 crate(cargo_packager::sign),
    因此签名器与验签器不会各自演进。ADR-0021、ADR-0022、ADR-0025 与 ADR-0026 定义的 detached
    清单签名、manifest v1 schema、单调 release_sequence 防降级与旧自研 manifest scheduler 已由 ADR-0029 取代;
    ADR-0029 的库选择与 zipsign 归档内嵌签名模型已由 ADR-0034 取代,均不再有效。

  • 更新源由不可变 ReleaseConfiguration 描述:发行仓库、构建期 channel、target triple、二进制名与
    macOS bundle 名全部是编译期常量,任何用户配置、CLI 或运行时输入都不能改变它们。Development
    构建的 channel 不允许联网或安装,check 与 install 在发出任何请求前即失败关闭;Production
    构建从公开发行渠道更新。更新 channel 按环境隔离是 AGENTS.md §10 的约束,独立于各 ADR。

  • 签名公钥缺失时失败关闭:RELEASE_SIGNING_KEY 已内嵌发布公钥(key ID
    DF5E2C9D255DD85E);缺失、空串或纯空白时 runtime 在发出任何请求前即返回
    update_signature_key_missing,绝不静默接受未签名载荷。UpdateRuntime::is_available() 仅在
    production channel 与有效公钥同时具备时为真,系统菜单据此决定是否在托盘根中创建「检查更新」入口;该
    门禁产出的是稳定、无路径的错误码,而不是库错误。

  • 发行清单是一份共享 manifest(latest.json),形状为库的 static 形状:顶层 version、
    可选的 notes 加一个 platforms 映射,键为 <os>-<arch>,每项含 url、signature、format;
    runtime 从 releases/latest/download/latest.json 读取。平台键拼写必须与库一致(macos 而非
    darwin,aarch64 而非 arm64)。crates/bongocat-packaging 每个 target 写一份 fragment
    (<os>-<arch>.json,文件名即平台键),发布前由同一个工具的 --merge-manifests 合并成共享
    manifest——manifest 的形状与资产名由一处拥有,工作流只调用工具。发布漏掉某个平台键时,
    runtime 命中 update_no_matching_asset。notes 是发布说明,由 --release-notes <file> 写入,
    上限 32 KiB,超长在字符边界截断并追加可见标记;它随同一次请求到达客户端,不需要第二次网络调用。
    说明的内容不是两次 tag 之间的提交摘要,而是该版本在双语 changelog 里的条目:发布工作流先用
    --extract-release-notes <file> 从 CHANGELOG.md 与 CHANGELOG.zh-CN.md 各取出产品版本对应的
    同一条目,按「英文正文 → --- → 中文正文」合成一份文件,再把这一份同时喂给 manifest 与
    gh release create --notes-file,所以发布页和更新窗口渲染的是同一段文本。条目按 Markdown 二级
    标题匹配而不是按版本号搜索文本,因此正文里提到的版本、### 子标题和围栏代码块里的示例都不会
    被误当条目;版本在 changelog 里没有条目时提取直接失败,而不是发出一份描述别的版本的说明。

  • 更新载荷:macOS 为已完成的 .app 打包成的 BongoCat-<version>-<arch>.app.tar.gz,
    架构段是 aarch64 / x64(归档根必须是 BongoCat.app/,库会丢弃根条目再装到 bundle 路径);
    Windows 复用已发布的 NSIS 安装器 BongoCat_<version>_x64.exe。载荷名不带 target triple:
    该文件只可能出现在它所属的那个平台上,重复平台后缀对读者和 updater 都没有信息量,而扩展名
    已经足以区分它和 Windows 安装器。.dmg 不是更新载荷——它是人工安装路径。
    签名是打包流程的最后一步:minisign 签名覆盖发布时的确切字节,签名后不得改名、重压缩或 strip。

  • 传输与安装由库承担,启用 Rustls 与 HTTPS;库的 reqwest 后端不启用。安装位置:macOS 由库从
    运行中的可执行文件推导其所属 .app 并整包替换;Windows 由库运行下载到的安装器
    (install_mode = Quiet → NSIS /S 静默、/R 重启)后自行退出进程,因此
    UpdateOutcome::Installed 只在 macOS 可观测。安装根为 $LOCALAPPDATA\Programs\BongoCat,
    用户可写,不需要提权。

  • 库的错误、配置与平台类型不得扩散为项目公共 API。cargo_packager_updater::Error 在
    bongocat-update 边界内映射为 14 个稳定错误码,未识别的变体降级为 update_internal_failed;
    诊断导出只消费这些码与匿名聚合计数,不含任何库类型或动态平台文本。

  • 打包入口 crates/bongocat-packaging 产出并签名更新资产:每个 target 的载荷、其 .sig 与
    manifest fragment;发布前再用同一个工具的 --merge-manifests 把 fragment 合并成共享
    latest.json。签名密钥通过 SIGNING_PRIVATE_KEY 注入,未设置即跳过签名(本地构建);
    release workflow 反过来断言发布构建一定签过名,未配置密钥时直接失败,而不是发出一批无人能
    更新的产物。

  • 更新管线由 bongocat-app::ApplicationUpdateService 独占:一条独立线程持有 UpdateRuntime,
    是唯一触碰网络、下载与安装的组件。它不复用设置服务循环——设置循环是串行阻塞的,一次百 MB
    级传输会让所有设置读写排队。命令通道有界且非阻塞;状态经 UpdateStateHandle 覆盖发布,窗口
    按 250 ms 轮询,因此窗口关闭、隐藏或渲染慢都不会反压 worker。操作属于 worker,不属于窗口,
    所以关闭窗口不取消任何操作。

  • 更新窗口(bongocat-ui::update_window)是单例,入口是系统菜单「检查更新」与设置页 About。
    它渲染 Unavailable / Idle / Checking / UpToDate / Available / Downloading /
    Verifying / Installing / Installed / Failed 十种状态。Downloading / Verifying /
    Installing 三段可观测靠拆分库调用实现
    :runtime 用 download_extended + install 两次调用,
    因为库的 download_and_install 把验签与安装合成一次调用。失败带明确的 stage,下载阶段的错误
    再按 code 区分为传输失败与验签失败。

  • 更新窗口按内容定高,下限 WINDOW_MIN_HEIGHT、上限 WINDOW_MAX_HEIGHT(与改版前同一高度):
    十个状态里有一半只渲染一行文字,固定 460px 会让窗口大半是空的。测量的间距与内边距都取自该帧
    自身的 bounds,不写死常量,调整 p_4 或 gap 不会让窗口高度与内容脱钩。窗口测量在窗口过矮时
    仍然准确
    :内容列 flex_shrink_0 不收缩、空隙塌成 0,所以两处 gap 都还是那两处 gap,于是
    「先跳到上限再回落」的两步收敛变成一步到位。说明区(notes)按内容取高、以常量封顶,
    这是测量能收敛的前提:若让它跟着窗口长,窗口变高 → 说明区变高 → 窗口再变高会互相喂给。
    平台会把内容高度取整到整像素,所以「已经在目标高度」允许半像素误差,否则窗口会每帧重问一个
    平台给不出的高度。

  • 更新窗口不允许拖拽边缘改变尺寸(is_resizable: false,macOS 去掉
    NSWindowStyleMaskResizable、Windows 去掉 WS_THICKFRAME/WS_MAXIMIZEBOX):窗口高度就是
    内容高度,允许拖拽等于让用户选一个下一次状态变化就会撤销的高度。窗口没有 resize 手柄,因此
    也不需要「用户拖过就接管高度」这条分支。

  • 开窗时先按内容定高并画完,再显示:内容定高只能在布局后才知道,而窗口未显示时拿不到帧
    (macOS 只为「遮挡状态判定为在屏幕上」的窗口跑 display link,也就是只发帧给它),所以等待
    「已经画对了再显示」会死等。做法是隐藏状态下手动布局一次测出目标高度,resize 之后再排一个
    foreground executor 任务把新高度同步回来、重画并 activate_window。应用 resize 本身也是同一
    个 executor 上的任务,因此后排的任务一定排在它后面——靠顺序保证,不轮询也不超时。

  • 更新内容(notes)来自 manifest,是不可信输入。渲染交给 gpui-kit 的 TextView
    (CommonMark + GFM,含删除线、任务列表与表格),bongocat-ui::update_markdown 只持有策略,
    通过 MarkdownExtensions 插件在 gpui-kit 自己的节点处理之前认领(claim)节点:
    NoRemoteImage 认领 Node::Image 与 Node::ImageReference 并只显示 alt 文本,
    LiteralHtml 在 inline 与 block 两个位置各注册一次、认领 Node::Html 并按字面文本显示,
    RefusedLink 认领目标未通过 HTTPS 检查的 Node::Link 并把标签降为普通文本。不存在可注入的
    markup 层;图片与 HTML 里的 URL 都不会成为请求。输入在解析前截断至 32 KiB,并把块容器标记数
    限在 512——每层 CommonMark 嵌套至少对应一个容器标记,因此该上限同时限定了树深,而 TextView
    按层递归遍历,树深即渲染栈深。链接引用([a][ref])的目标在 gpui-kit 内部才解析,
    RefusedLink 无法在此判定,仍由只接受 HTTPS 的平台 opener 兜底。ADR-0069 记录了这条依赖:
    插件先于内建处理被询问,一旦上游改变顺序,认领就不再是拒绝。

  • 更新协议由 bongocat-ui-protocol 拥有,bongocat-ui 只负责窗口与展示策略;两者不依赖
    bongocat-update。bongocat-app 做穷尽映射(stage 与 14 个错误码),新增一项会让映射编译失败,
    直到它被赋予用户可见含义。

  • 安装后是否需要重启进程是平台事实:macOS 由库整包替换 .app,运行中的进程此后执行已删除的
    文件(预设模型目录是惰性读盘的),因此安装成功后自动重启——先按 §5.3 的顺序完成产品 shutdown,
    再以新进程启动新构建;Windows 由安装器 /R 重启,Installed 在该平台不可观测。
    重启不用 exec 替换进程镜像:它不结束进程、PID 不变,而 shutdown 已移除托盘 NSStatusItem,
    替换后的镜像创建的 status item 不会被布局,菜单栏图标因此消失(ADR-0035 §5)。

  • 自动检查由 GPUI 侧调度(开关与间隔只有设置服务读得到):新配置的
    updates.check_automatically 默认为 false,不会在启动时主动检查;用户打开后,
    启动后等 10 秒首次检查,之后按 updates.check_interval_hours 等待下一次检查;该整小时字段默认 24、范围为
    1..=8760,并随当前 v1 配置持久化。调度器以最近一次实际派发为期限锚点,并以低成本设置轮询
    重新读取间隔,因此修改间隔会重排下一次期限(缩短后若已到期则立即检查)。发现可用更新且窗口未
    打开时打开更新窗口。关闭自动检查不会改写已保存间隔;手动检查不受该字段影响。

  • 传输有 30 分钟上限:transport 自身无超时,不设界会让 worker 永久阻塞;取值覆盖整条载荷传输。
    下载不支持取消——库没有 abort 钩子,真取消只能自研下载与验签,而自研验证层已被 ADR-0029/0034
    删除,因此不提供取消按钮而不是提供一个假的。

  • 真实签名公钥注入、真实发布链路验证、操作系统包签名验证与失败启动恢复仍未完成;在这些证据齐备前
    不得声称更新功能或 stable 发布完成。ADR-0034 记录了换实现新引入的能力损失(归档完整性校验、
    per-platform 资产匹配、公钥轮换窗)与待验证项;ADR-0035 记录了 worker、窗口、发布说明与
    "安装后协调 shutdown 而非安装前"这一处需要维护者复核的取舍。

  • 日志不记录真实按键序列、剪贴板内容、用户路径、配置/模型正文、URL、密钥/凭据或动态 OS 错误文本。

  • 匿名 diagnostics export 由 settings service 的强类型 command 触发(自 Diagnostics 页面移除后无 UI
    入口,只由隔离 smoke 与排障路径使用),在当前环境 logs 目录以同目录
    原子替换写出固定格式的 JSON。导出只包含稳定错误码、匿名聚合计数、模型来源计数和 revision;
    不包含模型 ID、用户路径、按键值、原始配置/事件内容、时间戳或动态平台错误文本。

  • application 与 Cubism Core 共用一套 UTF-8 单行文本 writer:每条记录依次包含 UTC 时间、级别、
    module、闭合 stable code、固定人类可读 message 和经过转义/截断的允许上下文;不写 JSONL,也不接受
    动态 OS 错误或用户资源正文作为日志协议。application 按 UTC 日写
    application-YYYY-MM-DD.log,Core 写 cubism-core-YYYY-MM-DD.log;单个文件达到 1 MiB 后切分
    编号文件,两类日志合计最多 8 MiB/32 个文件。

  • 当前 v1 logging.level 接受 error/warn/info/debug/trace 并默认 info;
    logging.retention_days 接受 1..=30 并默认 7。日志先按日切换、再按单文件大小轮转,不提供
    rotation mode 配置;设置变更后立即更新共享过滤/retention controller 并清理允许删除的旧文件。
    error 用于关键失败,warn 用于降级/fallback/可恢复异常,info 用于重要业务状态,
    debug/trace 用于低频开发诊断;逐输入、逐帧、下载进度与 temporary presentation unavailable
    不逐次记录。清理或写入失败只增加匿名 dropped/pruned 计数,不删除当前配置或模型数据。

  • writer 初始化后同步创建环境隔离的运行标记;标记只含 v1 schema 与固定 phase:running、
    shutting_down 或 panicked。下一次启动将旧标记匿名分类为 forced/unknown、shutdown interrupted
    或 panic 后立刻覆盖为新的 running,避免重复恢复循环;只有 runtime、音频与配置相关 owner
    完成正常 shutdown 后才删除。process panic hook 使用非阻塞写入追加固定
    application/panicked 文本 code,不读取 payload、源码位置、backtrace 或用户路径;
    Development-only 隔离 smoke 必须以同一 release 可执行文件产生真实 panic=abort,验证日志脱敏、
    配置字节不变、重启分类和标记清理;默认产品 CLI/API 不暴露该测试入口。

  • 匿名 diagnostics export 的摘要只读取 app-owned writer 和 Cubism Core 的匿名
    written/dropped/rotated/pruned/active-bytes/retained-files/retained-bytes 统计,并另输出二者
    retained-bytes/files 的饱和聚合,不读取或复制 Core message。可预览的
    application lifecycle 历史仅能按 ADR-0027 从已知 .log 文件严格提取闭合 code,再重写为不含
    原始时间戳/上下文的 application-events.log,与该摘要组成当前环境的私有 preview bundle;Core
    历史内容不属于该 bundle。

初始字段命名和数据分类见 shared/config/contract.md,环境和 Bundle ID 决策见 ADR-0008。

继续兼容:

text
.model3.json  .moc3  .motion3.json  .exp3.json
.physics3.json  .pose3.json  .cdi3.json
texture_*.png  audio files
resources/left-keys  resources/right-keys
resources/background.png  resources/cover.png

新增 manifest 只能是可选元数据,不能破坏现有模型。

13. 测试、指标与可观测性

13.1 测试层级

  • Rust 单元测试:reducer、输入映射、动画、配置、路径验证和模型语义。
  • fixture:相同输入序列产生规范化状态快照。
  • Cubism fixture:三个预置模型和异常/自定义样本的加载、动作、表情、物理和销毁。
  • GPUI 测试:设置表单、命令、错误状态、键盘导航和窗口重建。
  • 平台集成:窗口、输入、权限、显示器、托盘、启动项和单实例。
  • renderer smoke/golden:非空帧、alpha、遮罩、blend 和资源语义。
  • 性能/soak:启动、首帧、frame time、输入延迟、CPU/RSS/GPU 和 8 小时运行。
    macOS 定时 diagnostic preview 对每次 NativeOverlay::draw 调用记录有界的微秒样本,输出
    nearest-rank p50/p95/p99 与主线程完整循环错过下一 60 FPS deadline 的次数;采样不包括输入、
    runtime handoff 或 sleep,最多保留 4,096 个样本并显式报告溢出数。它是可复现的 renderer
    baseline 输入,不替代 Instruments、Metal System Trace 或跨设备发布验收。

13.2 输入不变量

  • 每个 pressed key 最终由 KeyUp、状态校正或 Reset 释放。
  • 队列压力不能静默丢 key/button edge。
  • 重复 KeyDown 不破坏按压状态或边沿动画。
  • 设备断开、锁屏和睡眠后 pressed set 为空。
  • 鼠标移动合并不能阻塞键盘释放。
  • gilrs adapter 的每 tick event 上限不能替代 backend 与高层 pending queue 上界;fork 的 bounded
    queue/epoch、overflow marker、authoritative reset 和 bounded stop/join acknowledgement 已有代码证据,但 WGI 焦点矩阵和双平台
    物理设备/长期证据仍未完成。backend context 启动后的最终诊断必须真实反映 gilrs shutdown 结果;不得
    因键鼠 callback 和 final Reset 成功而伪造 clean shutdown。

13.3 验收指标

  • 60 FPS 时 p95 frame time <= 16.7 ms。
  • input callback 到 runtime 接收 p95 目标 <= 2 ms。
  • 正常压力测试 key/button edge 丢失计数为 0。
  • 8 小时固定模型无持续内存增长或 GPU 资源泄漏。
  • 所有线程在退出超时内完成 join,不依赖进程强杀。

Windows 使用 ETW/WPA、PresentMon、GPUView;macOS 使用 Instruments、Metal System Trace 和 os_signpost。基准记录硬件、系统、模型、DPI、FPS、样本和构建 commit。

14. Linux 策略

Linux 是后续能力,不是隐藏的首发任务:

  • runtime、配置、模型和 UI 不使用 Windows/macOS 类型。
  • GPUI 保持 X11/Wayland 构建路径,但首期 CI 不发布 Linux 安装包。
  • X11 评估 XInput2;Wayland 全局输入受 compositor/portal 限制,不能承诺功能等价。
  • Linux renderer、托盘、启动项和打包单独立项并建立能力矩阵。
  • 不用轮询兼容层掩盖 Wayland 权限或协议缺失。

15. 风险与 Phase 0 退出条件

风险 控制措施 退出条件
GPUI pre-1.0 精确 pin、UI 封装、升级隔离 双平台 UI/IME/主题 smoke 通过
GPUI 与 overlay 生命周期冲突 最小平台原型 两窗口反复开关并正常退出
Rust Live2D 工作量过大 Core/动作/物理/renderer spike 三个预置模型完成输入到绘制闭环
透明合成不稳定 D3D11/Metal 截图和压力测试 alpha、置顶、穿透双平台通过
输入仍卡键 Raw Input/CGEventTap + reconcile #47 和生命周期矩阵无残留键
gilrs backend 生命周期/队列 固定 fork、窄 adapter、发布门禁 bounded overflow/reset、bounded stop/join、双平台物理矩阵通过
Cubism 授权不明确 二进制/许可证清单 发布方式有书面结论
后续 Linux 不等价 单独能力矩阵 不影响 Windows/macOS 首发

任一核心退出条件失败,先记录 ADR 并调整受影响的实现或发布目标,不能用产品代码、合成测试或编译结果掩盖 spike 失败。按 ADR-0011,未完成的外部证据不阻止无关模块的渐进实现,但持续阻塞对应功能声明和 stable 发布。

16. ADR 摘要

ADR-001:单一 Rust 应用

Rust 同时承担 UI、业务、平台调用和渲染实现,所有模块在同一 workspace 内以强类型接口协作。

ADR-002:GPUI 只用于设置 UI

GPUI 提供设置体验,但不成为 Live2D renderer 或实时状态所有者,以隔离 pre-1.0 风险。

ADR-003:平台原生 Overlay Renderer

Windows 使用 D3D11,macOS 使用 Metal。模型窗口不嵌入 GPUI renderer。

ADR-004:输入状态可校正

输入事件提供低延迟边沿,系统状态查询与生命周期 Reset 保证最终一致。任何 hook 库都不是唯一事实来源。

ADR-005:Cubism 是唯一厂商 FFI 边界

允许调用官方 Cubism Core;BongoCat 业务不得进入 SDK bridge。

ADR-006:首发不支持 Linux,但不封死 Linux

共享模块保持平台无关;Linux 的输入和窗口限制在后续能力矩阵中诚实处理。

ADR-007:单一 Rust 运行环境

生产版本的 UI、运行时、平台服务和渲染器均属于同一 Rust 应用。历史版本只用于行为与资源对照。

ADR-008:应用身份与存储环境隔离

Bundle ID 固定为 com.ayangweb.bongo-cat。Development 与 Production 使用相同数据结构和不同存储根,BongoCat 不读取旧配置。

ADR-011:渐进实现与发布门禁分离

允许正式 Rust workspace 在外部证据补齐期间持续开发;Cubism 授权与分发、实机矩阵、签名和稳定性证据保持 stable 发布门禁。

ADR-012:有序 Motion 音频服务

runtime 非阻塞发布强类型音效命令,独立 Rust worker 使用最小 rodio/FLAC feature 管理唯一 voice、错误恢复和 shutdown;音频失败不影响动作或渲染。

ADR-013:启动项能力与环境隔离(已被 ADR-0043 取代)

Windows 当前用户 Run value 按 Development/Production 分名;macOS 13+ 只允许 Production
.app 使用 SMAppService.mainAppService,macOS 12 与 Development 明确报告不支持且不回退。

ADR-043:启动项后端统一 auto-launch

双平台启动项后端统一为 auto-launch 0.6.0:macOS 12+ 全版本 LaunchAgent plist、Windows
HKCU Run(CurrentUser),环境隔离通过不同 app_name 保持;后端只产生 Enabled/Disabled,
Stale/RequiresApproval/NotFound 保留为契约变体。Windows stale 检测退役。

ADR-051:开发构建不提供登录时启动

登录时启动只属于已发布的产品:开发构建的可执行文件是 Cargo 构建产物,注册项会在 cargo build
或清理 target/ 后指向已过期或不存在的二进制。bongocat-app 因此按构建环境判定,开发构建直接汇报
Unsupported(BuildEnvironment) 且不触碰平台(读取与写入两个方向都答同一状态,写入是 no-op 而非
错误),设置里的启动项整行只在该构建不提供该能力时按统一样式禁用,并保留可见的 unsupported_build 文案说明原因。
取代 ADR-0043 中"Development 构建从此支持启动项"这一条决策,平台后端与环境隔离本身不变。

ADR-021:签名更新 Manifest 信任边界(已被 ADR-0029 取代)

自研 manifest 验签、构建环境绑定与单调 sequence 防降级已退役,更新信任模型改为归档签名。
正文保留在 ADR-0021 中作为历史记录。

ADR-022:更新 Manifest 传输 Envelope(已被 ADR-0029 取代)

携带固定 key ID 与 signature header 的 manifest envelope 随 ADR-0021 一并退役,传输改由更新库
承担。正文保留在 ADR-0022 中作为历史记录。

ADR-029:第三方更新库边界(已被 ADR-0034 取代)

bongocat-update 只保留构建期 ReleaseConfiguration、环境 channel 门禁、签名公钥失败关闭与
匿名诊断契约;下载、校验、安装与重启全部交给第三方更新库。ADR-0021、ADR-0022、ADR-0025 与
ADR-0026 由本 ADR 取代。本 ADR 的库选择(self_update 1.3.0)与签名方案(zipsign 归档内嵌签名)
已由 ADR-0034 取代。正文保留在 ADR-0029 中作为历史记录。

ADR-0036:模型导入的压缩包来源与解压边界(**已撤回**)

该 ADR 已撤回(2026-09-22):压缩包来源、解压边界与 SourceArchiveUnsupported 稳定码连同
实现一并移除,bongocat-model 不再依赖 zip/flate2,ModelPackageLimits 不再有
maximum_archive_bytes。模型来源只有一种:由文件夹选择器或窗口拖放提供的文件夹。ADR 正文保留在
docs/adr/0036-model-archive-import-boundary.md 作为恢复该功能时的设计输入——恢复前先与维护者确认
(压缩包上传需要一组本次不做的新功能)。压缩包与第三方模型内容不进入仓库。

ADR-0035:更新 worker、更新窗口与发布说明

更新管线由独立 worker 线程独占,窗口只消费它发布的状态;UI 拥有协议,app 做穷尽映射。三段可观测
(下载/校验/安装)靠把库的 download_and_install 拆成 download_extended + install 实现。发布说明
随共享 manifest 的 notes 一起发布(有界、无第二次请求)。安装后重启是平台事实:macOS 自动重启,
Windows 由安装器 /R 负责。明确不做下载取消。记录一处与 §8.4 措辞不同的取舍:shutdown 协调放在
安装完成之后、替换进程之前,理由见该 ADR。

ADR-0034:Detached Minisign 更新信任模型

更新库改为 cargo-packager-updater 0.2.3,信任模型改为 detached minisign 签名。签名端与打包端
是同一个 crate(cargo_packager::sign),签名器与验签器不会各自演进。发行清单是一份共享
latest.json(库的 static 形状,含 <os>-<arch> 平台映射),由每个 target 的 fragment 合并
而成;macOS 载荷是根为 BongoCat.app/ 的 .app.tar.gz,Windows 载荷复用已发布的 NSIS 安装器;
两者各附一个 .sig,签名是打包流程的最后一步。本 ADR 取代 ADR-0029 的更新库与签名部分,并记录
换实现新引入的能力损失(归档完整性校验、per-platform 资产匹配、公钥轮换窗)与待验证项。

ADR-0030:先复用现有方案

新功能按现有代码、标准库、平台能力、已安装依赖、成熟第三方方案、最小自有实现的顺序选择。
只有现成方案无法满足已确认的边界,或引入成本明显高于自研时才自行实现;第三方类型和生命周期
仍受现有架构、安全与依赖规则约束。

ADR-0031:托盘第三方库边界

macOS/Windows 托盘使用 tray-icon 0.26.1,菜单与右键弹出使用直接依赖的 muda 0.21.2;平台
adapter 负责加载 PNG、映射强类型 action、调用 hide/show,并从 overlay session 的真实
HWND/NSView 弹出与托盘共用的菜单树。第三方类型、句柄和错误不进入 runtime/UI 公共 API,Windows
固定 GUID 与双平台唯一 owner 由 ADR-0031 约束。

ADR-0068:托盘与模型窗口右键菜单复用

托盘和模型窗口右键由同一个 SystemMenu owner 持有一个 popup 根、同一套菜单项、强类型 action 映射和
事件队列;菜单集中提供设置、模型窗口显隐/穿透/置顶/鼠标移入隐藏、可用的检查更新和退出。源码、版本、
重启和重复的缩放/透明度不进入原生菜单;菜单层级、析构顺序与平台实机验收门禁见 ADR-0068。

ADR-0066:gilrs 手柄后端边界

Windows/macOS 手柄统一使用 ayangweb/gilrs 固定 commit,平台只保留强类型输入与 generation/axis
适配。BongoCat 不再维护 XInput/GameController backend;fork 已提供 bounded queue/epoch、authoritative
reset、macOS/WGI bounded stop/join 和 target-scoped compile guard,Windows WGI 焦点矩阵与双平台物理/
长期证据仍是完成门禁,相关修复不在产品层增加 workaround。

ADR-0070:手柄按键图片词表与解析通道

手柄按钮与键盘键共用按键层通道,KeyPress 改为携带带标签的 KeyIdentity
(Keyboard(hid_usage) | Gamepad(GamepadButton))。每个按钮的图片名等于 GamepadButton 变体名,
16 个按钮互不重名;预置 gamepad 模型已按该词表重命名,导入时由模型 store 把旧 backend 词表名
(LeftTrigger2/LeftThumb/DPad* 等)改写为产品名。手柄词表不提供运行时旧名候选,因为旧词表里
LeftTrigger 已经是肩键的名字;按钮左右手由模型自身的 left-keys/right-keys 目录决定,缺图按钮
保持惰性。

ADR-023:Windows Per-User Installer

Windows 首发采用固定、可审计 NSIS per-user installer,不请求管理员权限或触及环境数据。原设计中的
「独立 Rust update helper 只接收已验证 artifact」已随 ADR-0029 作废:不再有独立 helper,替换由
更新库在进程内完成(Windows 上由库运行下载到的安装器),installer 权限、原子替换与 rollback 仍是
独立发布门禁。

ADR-0066:任务导向的设置信息架构

设置侧边栏固定为七个业务分类(外观与语言、模型库、模型行为、模型窗口、输入与交互、快捷键、应用与系统)
加最后的关于入口;模型库与模型行为各自独立成页,键鼠/手柄、启动/更新/日志形成明确的二级分组。
About 仍是 Settings 中的普通页面,产品/软件信息、项目与反馈入口、日志目录和手动检查更新使用标准设置项;
不展示法律与隐私正文,日志路径不进入 snapshot。页面与分组标题进入搜索关键词,旧页面名作为搜索别名;不增加第三级导航、常用页或高级页。

17. 实施阶段

Phase 0:风险验证和行为冻结

冻结参考行为和模型样本,定义全新配置命名与环境隔离契约;完成 GPUI + 独立 overlay、Cubism、输入可靠性、透明合成和许可证 spike。

Phase 1:Rust 工程骨架

在 Phase 0 外部证据补齐期间渐进建立 Cargo workspace、CI、日志、配置最小实现、runtime 生命周期、GPUI 空设置窗口和双平台空 overlay;只提升已通过对应 contract 的模块。

Phase 2:输入到 Live2D 最小闭环

加载标准模型,键鼠输入驱动参数/动作并绘制;Windows 完成 #47 校正,macOS 完成权限和 tap 恢复。

Phase 3:产品 Runtime 与模型兼容

实现状态、动画、手柄、动作、表情、物理、音效和模型管理,并由 fixture 验证。

Phase 4:GPUI 设置和配置存储

完成 design system、设置页、模型管理、快捷键、权限、诊断、环境隔离与当前 v1 schema。

Phase 5:系统集成

完成托盘/菜单栏、启动项、单实例、更新、日志导出、签名和权限流程。

Phase 6:稳定性与发布

完成性能基线、异常恢复、8 小时 soak、安装/升级/回滚和发布清单。达到门槛后完成发布切换。


Update Signing Guide (`docs/update-signing-guide.md`)

桌面应用更新签名机制入门

文档性质:解释性说明,非规范性事实来源。
本文只用于讲解概念与记录调研结论,不定义目标架构。
规范性事实仍以 docs/technical-design.md 和 docs/adr/ 为准。
如果本文的结论要变成项目约定,必须先写成 ADR(见 §6.3)。


状态更新(2026-09-14):本文的结论已经落地。

§0.3 推荐的方案 B(detached minisign 签名)已被采纳并实现,规范记录见
docs/adr/0034-detached-minisign-update-trust-model.md:更新库改为
cargo-packager-updater 0.2.3,签名端由 crates/bongocat-packaging 调用
cargo_packager::sign,macOS 载荷是 .app.tar.gz、Windows 载荷复用 NSIS 安装器,
各附一个 .sig。检查地址是
https://github.com/ayangweb/BongoCat/releases/latest/download/latest.json:一份共享
manifest
(platforms 映射按 <os>-<arch> 给出每个 target 的载荷),由每个构建 job 写出的
<os>-<arch>.json fragment 在发布前用 just manifest 合并而成。

因此,下文凡是把 "self_update + zipsign 内嵌签名" 描述为"本仓库当前实现"的段落,
指的都是本文写作时(2026-09-14 上午)的 ADR-0029 状态,现已退役
。§2.2、§4.1、
§4.2、§5.2、§5.3 属于该状态的技术记录,保留作为决策依据与历史对照,不再描述现状。
§6.2 的落地步骤已执行完毕;§7 清单中已完成的项在 §6.2 中可查到对应实现。
仍未被验证的事项以 ADR-0034 的"待验证项"为准——本文 §8 的若干条目已由
release_manifest_capability.rs 的 loopback 能力测试覆盖(真实密钥对、真实签名、真实验签)。

状态更新(2026-09-28):macOS 载荷名去掉了 target triple。
下文写作时假定载荷必须含完整 triple,那是 self_update 的 Release::asset_for 按资产名
匹配资产的要求;该库已由 cargo-packager-updater 取代,载荷 URL 来自 manifest,不按名字匹配。
因此载荷现名为 BongoCat-<version>-aarch64.app.tar.gz / BongoCat-<version>-x64.app.tar.gz。
§5.2 那条"资产名必须包含完整 triple"的要求已随旧库退役,保留在正文里只作为历史对照。

写作日期:2026-09-14。所有"已确认"结论均来自本机源码阅读,没有执行过任何一次真实签名
或真实更新
;未验证事项集中列在 §8。


0. 先给结论

0.1 你的产物形状与仓库现状不匹配

你描述的产物是:macOS = .dmg + .app.tar.gz,Windows = 只有一个 .exe,两平台各加一个
.sig。这是 Tauri 的模型(minisign 分离签名)。本文写作时本仓库的实现是
self_update + zipsign 内嵌签名(ADR-0029,现已退役),两者不兼容。三个当时已核实的硬事实:

判断 依据(本机源码)
裸 .exe 和 .dmg 无法被 zipsign 签名,也无法通过验签 self_update 的 detect_archive():只有 .zip 和 .tar.gz 有归档类型,其余任何扩展名(含 .exe/.dmg)落入 ArchiveKind::Plain(None);verify_signature() 对非 zip/tar.gz 直接返回 Error::NoSignatures
zipsign 不产生 .sig 文件,签名写在归档内部 zipsign-api 的 tar 实现把签名块 base64 后作为追加的 gzip 成员注释写入;zip 实现把签名块前置到归档前。不存在独立签名文件
cargo-packager(你已在用的打包工具)自带 Tauri 式 minisign .sig 能力 pub mod sign,公开 generate_key / sign_file / sign_outputs / SigningConfig;依赖里已有 minisign 0.7

0.2 Windows 用 `.exe` 是当前实现的必然失败

即使先不管签名格式:self_update 的安装语义是用下载物替换自己(单文件模式替换
bongocat-app.exe,bundle 模式整包交换 .app)。用 NSIS 安装器去替换 bongocat-app.exe
在语义上就是把应用本体换成一个安装器。上游对此有明确声明(self_update-1.3.0/src/lib.rs:302):

.deb / .msi packages are a different shape entirely — hand the downloaded file to
dpkg -i / msiexec /i yourself; the crate's replace-and-verify semantics do not apply
to a system installer.

所以「Windows 只有一个 .exe」等价于「Windows 必须自己走『下载 → 校验 → 静默运行安装器』」,
这条路 self_update 不提供,必须自研编排。

0.3 推荐(已于 2026-09-14 采纳,见 ADR-0034)

采用 cargo-packager 的 minisign 签名(生产端)+ minisign-verify(消费端),理由是它
同时满足你的产物约束和"不重复实现通用机制"的项目纪律:签名工具已经在依赖图里,.app.tar.gz
和 .sig 由现有工具生成,不需要引入新工具链。代价是要写一个新 ADR 取代 ADR-0029 的签名部分,
并在 Windows 上单独实现"运行安装器"的安装步骤。

落地时发现后一项代价比预期小:消费端不必自研。cargo-packager-updater 与
cargo-packager 同属一个项目
,它自带 UpdateFormat::Nsis(运行下载到的安装器)与
UpdateFormat::App(整包替换 macOS bundle),因此"运行安装器"这一步不需要本项目实现。
最终方案是整条链路换用 cargo-packager-updater,而不是在 self_update 上挂 minisign 钩子。

如果不想动验证模型,则走方案 A(保持 zipsign):macOS 几乎零成本,但 Windows 必须增加一个
载荷归档文件
,与你的"不引入其他文件"约束冲突。详见 §6。


1. 密码学基础

1.1 自动更新要防的是什么

用户装的是你发布的二进制。攻击者有几个现实的入手点:

  1. 网络中间人:篡改下载内容(虽然 HTTPS 已经防了大部分,但 CDN、镜像、企业代理、
    证书被误信任都可能出问题)。
  2. 分发渠道被投毒:GitHub Release 资产被替换、账号被盗后发布恶意版本。
  3. 降级攻击:强迫客户端回退到有已知漏洞的旧版本。
  4. 本地文件被替换:用户机器上已下载的临时文件被改。

签名机制主要针对 1 和 2 的"内容换掉了但看起来一样"这一面。它不能防:你的私钥被偷、
操作系统代码签名被绕过、或用户主动安装了你没有签名的东西。

1.2 哈希(摘要)

哈希函数(如 SHA-256、SHA-512、BLAKE2b)把任意长度的数据压成固定长度的指纹:

  • 单向:从指纹推不回原文。
  • 抗碰撞:极难找到两段不同的数据得到同一个指纹。
  • 雪崩:改一个字节,指纹面目全非。

哈希只解决 完整性(数据有没有被改动),不解决真实性。原因很直接:攻击者改完文件,
顺手把指纹文件一起改成新的就行。所以"我只公布 SHA-256"不足以证明东西是你发的。

1.3 为什么必须用签名而不是哈希

数字签名把"完整性"和"身份"绑在一起:用只有你能持有的私钥产生签名,任何人都能用公开的
公钥
验证。攻击者能改文件,但改不出一个新签名——因为他没有私钥。

这正是哈希做不到的那一半。

1.4 非对称密钥与 Ed25519

  • 一对密钥:私钥(保密,用来签名)+ 公钥(公开,用来验签)。
  • 公钥不是从私钥"反推"出来的,而是数学上成对生成;所以即使公钥满天飞,私钥也不会泄漏。
  • Ed25519 是目前桌面应用签名的默认选择:签名 64 字节、公钥 32 字节、私钥 32 字节种子,
    实现小、速度快、没有 RSA/ECDSA 那类参数选择陷阱(选错曲线、随机数复用导致私钥泄露等)。

Ed25519 的私钥在实践里常存成 64 字节的 keypair(32 字节种子 + 32 字节公钥)。本仓库用到的
zipsign 就是读 64 字节 keypair(SigningKey::from_keypair_bytes),公钥文件则是裸 32 字节。

1.5 预哈希:为什么出现 `Ed25519ph` 和 `BLAKE2b`

Ed25519 原始定义是"对整条消息签名"。文件可能有几百 MB,而且流式读取时无法把整条消息留在内存里。
RFC 8032 因此定义了 Ed25519ph:先对消息做一次哈希,再对哈希值签名(ph = prehash),并用
一个域分离前缀防止与其他用法混淆。

不同项目选取的预哈希算法不同,这是两套签名格式互不兼容的根因:

方案 预哈希 说明
zipsign(本仓库当前) SHA-512 签名字节里写死魔数 \x0c\x04\x01 + ed25519ph + \x00\x00
minisign(Tauri / cargo-packager) BLAKE2b-512 签名结构里带 sig_alg = prehashed 标记

两者都是"Ed25519 + 预哈希",但预哈希不同、封装格式不同,签名文件不能互相识别。

1.6 信任根:公钥必须随二进制发布

这是整个机制里最容易被忽略、却最关键的一点:

验证用的公钥必须内嵌在客户端二进制里(编译期常量),绝不能从网络获取。

如果你从网络拿公钥,攻击者只需把公钥一起换掉,签名验证就变成自证清白。本仓库的公钥就是编译期
常量:crates/bongocat-update/src/runtime.rs 的 RELEASE_SIGNING_KEY。

推论:换钥匙必须先发一个"同时认识新旧两把钥匙"的版本。否则老版本用户会验不过新签名,更新链
从此断掉。这是 §3.3 密钥轮换的约束来源。

1.7 密钥 ID、多密钥与"any-of"语义

真实项目会轮换密钥,所以验签要能同时接受多把公钥。两套方案的差异:

  • zipsign:verifying_keys(&[key1, key2]),语义是 any-of——签名在归档里可以有多条,
    命中任意一条即通过。这天然支持轮换窗。
  • minisign:签名结构里带 8 字节 key ID;验签先比对 key ID,不匹配直接
    UnexpectedKeyId 失败(minisign-verify-0.2.5 源码确认)。所以传错公钥是明确报错,
    不会"悄悄通过"。

key ID 的价值是错误定位:日志里能区分"钥匙不对"和"内容被改".

1.8 上下文绑定:签名可以绑到文件名上

zipsign 的签名计算把文件名作为 Ed25519ph 的 context 一起签(self_update 传的是下载文件的
file_name();zipsign CLI 的 -c/--context 默认也是文件名)。

后果很实际:zipsign 签名过的归档改名之后就验不过了。这条约束直接决定 CI 里"命名"和"签名"
的先后顺序(§5.1)。

minisign 把文件名放在 trusted comment(timestamp:...\tfile:<名字>)里,并且对这段注释有第二
个"全局签名"。但客户端是否比对文件名取决于验证实现:minisign-verify 0.2.5 的
PublicKey::verify() 只校验 key ID + 内容预哈希 + Ed25519 签名,不比对 file: 字段。所以
minisign 改名后技术上仍可验证通过(代价是丢掉了文件名绑定这一层弱保护)。即便如此,仍然应当
"先命名、后签名",避免给未来留坑。

1.9 更新签名 ≠ 操作系统代码签名

这是新手最容易混淆的一点。它们是两套独立机制,各防各的:

更新签名(本文主题) 操作系统代码签名
macOS zipsign / minisign codesign + Gatekeeper 公证(notarization)
Windows zipsign / minisign Authenticode(cargo-packager 通过 sign_command / 证书触发)
防的是 更新内容不是你发的 二进制来源可信、没被篡改、能过 SmartScreen/Gatekeeper
谁验证 你自己的应用 操作系统

本仓库两者都还是缺口:打包只做 ad-hoc 签名(crates/bongocat-packaging/src/main.rs:97 的
ADHOC_SIGNING_IDENTITY = "-"),release.yml 里 Windows Authenticode 目前只打 warning。


2. 两套现成方案

2.1 对比

zipsign(ADR-0029 时的实现,已退役) minisign(Tauri / cargo-packager)
库 zipsign-api 0.2.1(经 self_update 的 signatures feature 引入) 生产端 minisign 0.7.9;消费端 minisign-verify 0.2.5
预哈希 SHA-512 BLAKE2b-512
签名位置 归档内部(内嵌) 独立 .sig 文件(分离)
支持的载体 只有 .zip 与 .tar.gz 任意文件(裸 .exe、.dmg 都行)
是否产生额外文件 否 是,每个被签文件 +1 个 .sig
文件名绑定 强绑定(作为签名 context) 弱绑定(在 trusted comment 里,客户端可不比对)
多密钥语义 any-of key ID 匹配 + 单密钥验证
许可证 MIT OR Apache-2.0 OR Apache-2.0 WITH LLVM-exception minisign: MIT;minisign-verify: MIT

2.2 「哪些产物能被签名」速查

self_update 用扩展名判定归档类型,所以能不能被 zipsign 签名完全由文件名决定:

产物 detect_archive() 结果 能否 zipsign 签名
BongoCat-1.1.0-aarch64.app.tar.gz Tar(Gz) ✅
BongoCat-1.1.0-...app.zip Zip ✅
BongoCat-1.1.0-aarch64.dmg Plain(None) ❌ NoSignatures
BongoCat_1.1.0_x64.exe Plain(None) ❌ NoSignatures

再叠加一条:.dmg 本来也不该作为更新产物。它是人工安装路径(拖进 /Applications),
self_update 没有安装 dmg 的能力。Tauri 同样不使用 dmg 做更新。更新产物必须是"应用本体":
macOS 是 .app 归档,Windows 是安装器或应用本体。

2.3 cargo-packager 已经能做的事(已读源码确认)

cargo-packager 0.11.8 公开导出 pub mod sign,关键 API:

rust
// 生成密钥(返回 base64 编码的公钥与私钥字符串)
cargo_packager::sign::generate_key(Some(String::new()))?;   // 空密码 = 不提示

// 把密钥对落盘:<path> 与 <path>.pub
cargo_packager::sign::save_keypair(&keypair, path, force)?;

// 签名单个文件,产出 <file>.sig
cargo_packager::sign::sign_file(&SigningConfig { private_key, password }, path)?;

// 签名整批产物;目录会先被打成 <dir>.tar.gz 再签名
cargo_packager::sign_outputs(&signing_config, &mut packages)?;

// 打包 + 签名一步完成
cargo_packager::package_and_sign(&config, &signing_config)?;

两个已核实的细节,正好对上你的需求:

  1. 目录会被自动打成 BongoCat.app.tar.gz(with_additional_extension("tar.gz")),且
    归档根目录就是 BongoCat.app/——因为内部用 append_dir_all(filename, src_dir) 而不是
    展开内容。这正是更新库 bundle 模式要求的布局。该布局在落地后由
    crates/bongocat-packaging 的 the_bundle_archive_roots_at_the_bundle_directory 与
    crates/bongocat-update/tests/release_manifest_capability.rs 的
    installing_replaces_the_configured_bundle 共同锁定(原先锁定它的
    archive_layout_capability.rs 已随 self_update 退役删除)。
  2. 签名产出的 .sig 内容是 base64 编码的 minisign 签名盒,sig_alg 固定为
    prehashed,trusted comment 形如 timestamp:\tfile:<文件名>,
    untrusted comment 是 signature from cargo-packager secret key。

3. 需求 1:签名密钥的生成方式与安全存储管理

3.1 生成

zipsign(当前方案) — 需要 cargo install zipsign:

sh
zipsign gen-key priv.key pub.key      # 生成密钥对
zipsign gen-key -e priv.key pub.key   # 只从已有私钥导出公钥

产物形态(已读 zipsign-api 源码确认):

  • priv.key = 64 字节二进制(Ed25519 keypair = seed‖public)
  • pub.key = 32 字节二进制

注意:它是裸二进制,不是 base64 文本。这意味着往 GitHub Secrets 里放之前必须自己 base64
编码,CI 里再解码。多一层手工步骤,也是出错点。

minisign(已采纳) — 已经在依赖的工具里,且不需要全局安装:

sh
# 本项目入口(推荐):用精确 pin 的 cargo-packager 生成,产物已 chmod 600
just keygen ~/.bongocat/release.key

它等价于 crates/bongocat-packaging --generate-signing-key <file>,也等价于直接调
cargo_packager::sign::{generate_key, save_keypair}。口令通过
SIGNING_PRIVATE_KEY_PASSWORD 提供(与签名时解锁私钥用的是同一个变量);不设口令时
会生成未加密的私钥并打印警告。

just keygen 没有 password 参数,这是刻意设计:把口令放上命令行会进入 shell history,
也可能出现在进程列表中。交互式生成带口令的 key pair 时,在当前 zsh 中执行:

sh
read -rs "SIGNING_PRIVATE_KEY_PASSWORD?Signing key password: "
echo
export SIGNING_PRIVATE_KEY_PASSWORD

just keygen ~/.bongocat/release.key

unset SIGNING_PRIVATE_KEY_PASSWORD

read -s 不回显输入;-r 保留反斜杠字面值。若 shell 是 bash,等价写法是:

bash
read -rsp 'Signing key password: ' SIGNING_PRIVATE_KEY_PASSWORD
echo
export SIGNING_PRIVATE_KEY_PASSWORD

just keygen ~/.bongocat/release.key

unset SIGNING_PRIVATE_KEY_PASSWORD

不要写成 SIGNING_PRIVATE_KEY_PASSWORD='...' just keygen ...:虽然功能上可用,但口令会进入
shell history。生成完成后把私钥文件内容放进 SIGNING_PRIVATE_KEY secret,把同一口令放进
SIGNING_PRIVATE_KEY_PASSWORD secret;两者必须同时存在。

产物形态(本机实测,2026-09-14):

  • <file>:私钥,base64 单行文本,348 字节,权限 0600(工具显式收紧;save_keypair
    自身按 umask 落盘,默认账号下会是 644)
  • <file>.pub:公钥,base64 单行文本,152 字节,0644
  • 工具同时把公钥那一行打印出来,可直接粘进 RELEASE_SIGNING_KEY

因为是纯文本,可以直接整段塞进 CI secret,不需要额外编码步骤。

若坚持用上游 CLI:cargo install cargo-packager --version 0.11.8 --locked 后
cargo packager signer generate-key --path <file>。参数为 --path、--password(或环境变量
CARGO_PACKAGER_SIGN_PRIVATE_KEY_PASSWORD)、--force、--ci;不传 --path 时它只把密钥对
打印到日志,在 CI 里会直接把私钥写进日志。本项目不采用这条路,因为它要求全局安装与
lockfile 版本一致的二进制,而 just keygen 用的是同一份 pin。

Tauri 的等价命令是 tauri signer generate -w ~/.tauri/myapp.key,产出同一个格式
(base64 minisign 密钥),并支持给私钥设密码。

3.2 怎么进 CI(这是本需求真正的难点)

私钥绝不能进仓库。 三条铁律:

  1. 不进源码、不进构建产物、不进日志。 检查清单:git log -S 搜不到;构建输出里 grep 不到;
    错误信息不打印私钥内容。
  2. 只通过 CI secret 注入。 GitHub Actions 里用
    secrets.SIGNING_PRIVATE_KEY(口令用 secrets.SIGNING_PRIVATE_KEY_PASSWORD)
    传给打包步骤的环境变量。注意 Tauri 文档特别强调的一点:.env 文件不生效,必须是真的
    环境变量。变量名已在 crates/bongocat-packaging/src/main.rs 中固定为
    SIGNING_PRIVATE_KEY / SIGNING_PRIVATE_KEY_PASSWORD。
  3. 本地开发一律不签名。 签名只在发布流水线里发生;本地 just build 不设私钥时应当
    跳过签名(而不是用一个空密钥签名成功——那是最坏的情况,见 §4.4)。

具体形式(已落地在 .github/workflows/release.yml):

yaml
# .github/workflows/release.yml 里新增
- name: Sign the update artifacts
  env:
    SIGNING_PRIVATE_KEY: ${{ secrets.SIGNING_PRIVATE_KEY }}
  run: just build            # 打包工具读到该变量才签名

对应到 crates/bongocat-packaging/src/main.rs 的现有模式:它已经有一个凭据注入点的先例——
BONGOCAT_MACOS_SIGNING_IDENTITY(第 103 行)。更新签名密钥应该照同样的方式设计:
一个环境变量注入点,不设就跳过,且必须让 CI 能断言"发布构建一定签过名"。

3.3 权限、备份、轮换、丢失

关注点 做法
文件权限 生成后立刻 chmod 600(zipsign gen-key 与 cargo-packager 的 save_keypair 都按默认 umask 落盘,可能是 644)
密码保护 minisign 支持给私钥加密(generate_encrypted_keypair),密码单独存一个 secret;SigningConfig.password。zipsign 的私钥没有口令保护
备份 至少离线备份一份。私钥丢失 = 已安装用户永远收不到更新(因为验证公钥烧在旧二进制里)
轮换 先把新旧两把公钥同时写进客户端并发布,确认用户升上来之后,再用新私钥签名。zipsign 的 any-of 天然支持;minisign 需要客户端支持多 key 列表
泄露响应 换钥匙 + 发一个"吊销公告版本"。没有别的办法——这正是"必须先发双钥匙版本"的代价
一把还是多把 建议一把 release key 覆盖两平台(同一个发布通道,威胁模型相同),减少管理面。分平台只在两平台的发布权限真的分属不同人时才有意义

3.4 本仓库的落点

公钥的位置是 crates/bongocat-update/src/runtime.rs 的 RELEASE_SIGNING_KEY。

已落地的形态(ADR-0034):base64 的 minisign 公钥盒文本,也就是
cargo packager signer generate-key 写出的 .pub 文件内容。仓库现已内嵌发布公钥,
key ID 为 DF5E2C9D255DD85E,因此 Production runtime 的
UpdateRuntime::is_available() 为真,系统菜单可以显示更新入口:

rust
pub const RELEASE_SIGNING_KEY: Option<&str> = Some("<minisign public key>");

公钥不是秘密,可以正常提交到仓库;只有私钥和私钥口令必须留在仓库之外。运行时会拒绝
None、空串和纯空白值,并在构造更新库实例之前返回 update_signature_key_missing;
runtime.rs 的定向测试同时锁定已配置公钥和空白值失败关闭两条路径。真实 GitHub 发布链路尚未
验证,不能据此宣称发布签名已经端到端可用(见 ADR-0034 待验证项 1)。


4. 需求 2:更新校验时签名验证的完整流程

4.1 `self_update` 的完整校验链(已读源码确认顺序)

text
取发布列表 → 选资产 → 下载 → ┌ 1. checksum(显式传入的摘要)
                             ├ 2. release digest(GitHub API 为该资产公布的 sha256)
                             ├ 3. signature(zipsign,用内置公钥集)
                             ├ 4. verify_archive 钩子(你注入的闭包)
                             ├ 解压
                             ├ 5. verify_binary 钩子(跑一下新二进制做冒烟测试)
                             └ 替换安装

第 2 项值得单独说:GitHub 会为每个 Release 资产公布一个 sha256:<hex>,self_update 在有
checksums feature 时默认自动校验。但上游明确写了这是纯完整性检查——攻击者替换资产后
GitHub 会重新计算这个摘要,所以它不能替代签名。

第 4、5 项是两个公开可用的钩子,签名类型是普通闭包:

rust
pub fn verify_archive(&mut self, verify: impl Fn(&std::path::Path) -> crate::Result<()> + Send + Sync + 'static)
pub fn verify_binary(&mut self,  verify: impl Fn(&std::path::Path) -> crate::Result<()> + Send + Sync + 'static)

上游文档把"crate 自身不支持的分离签名校验"明确列为 verify_archive 的用途。这是把
minisign 接进现有流水线的关键入口
——不需要抛弃 self_update 的下载/解压/替换引擎。

4.2 zipsign 的验签步骤(当前实现)

  1. 按扩展名判定归档类型;不是 .zip / .tar.gz → Error::NoSignatures。
  2. 从归档里定位签名块:tar.gz 读追加的 gzip 成员注释;zip 读前置数据。
  3. 校验魔数头 \x0c\x04\x01 + ed25519ph + \x00\x00,读出签名条数(u16 LE)。
  4. 用文件名作为 context,对归档中"签名块之前"的内容算 SHA-512 预哈希。
  5. 对每把公钥 × 每条签名做 verify_prehashed_strict;任意一条通过即通过(any-of)。
  6. 全部不匹配 → Error::Signature → 映射为 update_signature_invalid。

4.3 minisign 的验签步骤(推荐方案)

  1. 读取 <产物>.sig,base64 解码得到 minisign 签名结构。
  2. 比对签名里的 key ID 与内置公钥的 key ID;不匹配 → UnexpectedKeyId。
  3. 对产物内容算 BLAKE2b-512 预哈希。
  4. 用公钥对预哈希值做 Ed25519 验签。
  5. 通过则继续解压/安装;否则拒绝。

(minisign-verify 0.2.5:PublicKey::verify(data, &signature, allow_legacy=false)。
allow_legacy=false 是正确值——它拒绝 minisign 老版本的非预哈希签名,也正好和
cargo-packager 的 prehashed 输出匹配。)

4.4 必须在设计里消掉的陷阱:fail-open

这是本文最重要的一条实操警告。

self_update::verify_signature() 的第一行逻辑是:

rust
if keys.is_empty() {
    return Ok(());      // ← 静默跳过验签
}

公钥集为空 = 验签直接通过 = 会安装未签名产物,且不报任何错。 任何"把公钥列表原样转发下去"
的封装都会踩这个坑。

本仓库已经做了正确补偿:RELEASE_SIGNING_KEY 缺失、为空或纯空白时,runtime 在发出任何网络
请求之前
就返回 update_signature_key_missing(门禁位于 runtime.rs 的 updater(),并由
定向测试锁定)。仓库当前内嵌发布公钥,因此这条拒绝路径只会在未配置的构建或测试输入上命中。

换实现时必须保留这条性质。设计原则一句话:"验签不可能成功"必须等于"拒绝更新",而不是
"跳过验签"。

落地结果(2026-09-14):新库在这一点上已经是 fail-closed——verify_signature 先 base64 解码
公钥再 PublicKey::decode,空字符串解不出公钥,直接得到 Error::Minisign,不会静默通过。
但它发生在下载之后:check() 只解析 manifest,因此空公钥仍会先得到一个更新offer,
失败落在 download()(这正是 release_manifest_capability.rs 的
an_empty_public_key_cannot_verify_a_payload 断言的形状)。

项目仍然保留了自己的门禁,理由不变:它在构造更新库实例之前就返回稳定、无路径的
update_signature_key_missing,而不是一个需要映射的库错误,也不会先发出请求。两者是纵深
防御关系,不是重复实现。

4.5 其余校验原则

  • 先验签,后使用:签名校验必须发生在解压和替换之前(self_update 的顺序已经满足)。
  • 失败一律 fail-closed:任何一步失败都不留下"半个已安装的新版本"。
  • 错误码稳定:crates/bongocat-update/src/diagnostics.rs 的 14 个错误码是诊断导出契约
    (ADR-0016 / ADR-0027),换实现时只能收敛到既有码,不能泄漏第三方错误文本。
  • 降级防护:ADR-0029 记录了 zipsign 模型没有单调 release_sequence,降级攻击不再被
    检测,只按 semver 比较。这是换实现时应当一并考虑补回来的东西。

5. 需求 3:更新分发环节如何接入并校验签名

5.1 顺序:命名 → 签名 → 合并 → 上传

关键约束来自 §1.8:签名与内容(含文件名)绑定。所以流水线上的正确顺序是:

text
编译 → 打包(.app / .dmg / .exe)
     → macOS: codesign + notarize(.app 必须先真正签名并公证)
     → 生成更新归档(macOS: .app.tar.gz;Windows: 复用 .exe)
     → 用正式名字命名
     → 签名(产出 .sig)
     → 写本 target 的 manifest fragment(<os>-<arch>.json)
     → 每个 target 各自上传为构建产物
     → 发布前合并 fragment 成共享 latest.json(just manifest)
     → 上传 Release

三条硬规则:

  1. 签名必须是最后一步。 签名之后任何字节改动(重新压缩、改名、strip)都会让签名失效。
    对 zipsign 而言改名一定失效(文件名是签名 context)。
  2. 归档内的 .app 必须先完成 codesign + notarize。 一旦打成 tar.gz,再想签名里面的 .app
    就晚了——解压、签名、重新打包会直接让归档签名失效。实际上"先打包再公证"在流程上根本走不通。
  3. 命名和签名严格分先后。 先改好名字,再签名。

现状(2026-09-14):合并发生在签名之后,但只作用于 manifest——它把各 target 已经签好的
url/signature/format 组装成一份文档,不触碰任何已签名的载荷字节,因此不破坏上面三条规则。
合并由 crates/bongocat-packaging --merge-manifests 承担(just manifest),CI 只调用工具。

5.2 资产命名要求(决定客户端能不能找到)

现状(2026-09-28):本节描述的是 Tauri self_update 的资产匹配规则,已随 Tauri 退役。
当前运行时用 cargo-packager-updater,载荷从 latest.json 的平台键
(macos-aarch64 / macos-x86_64 / windows-x86_64)取出,不再按资产名匹配,所以
资产名里既不需要也不应该有 target triple。名称规则现在是:每个 target 一个架构标记
(Rust target architecture,即 aarch64 / x64),同一个 target 的磁盘镜像与归档共用它。
唯一定义在 ReleaseTarget::architecture。下面保留 self_update 的原始分析供考古。

self_update 的 Release::asset_for 先用完整 target triple匹配资产名,失败后退化为
arch + os 标记(arch = triple 首段,os ∈ darwin/windows/…)。注意
bin_name 不参与资产名匹配。

所以资产名必须包含完整 target triple,例如:

text
BongoCat-1.1.0-aarch64-apple-darwin.app.tar.gz
BongoCat-1.1.0-x86_64-apple-darwin.app.tar.gz
BongoCat_1.1.0_x64.exe                      ← 现有名字,不含 triple

⚠️ 最后一行是个真问题:现有 Windows 安装器名 BongoCat_<version>_x64.exe 里只有 x64,
退化匹配需要 arch + os 两个标记同时命中才算。这一点在换方案时必须实测确认,不能靠推断。

5.3 归档内布局要求

平台 模式 归档根必须是什么 依据
macOS bundle 模式 BongoCat.app/ 目录 crates/bongocat-packaging 的 write_bundle_archive 回归
Windows 安装器模式 不适用(载荷是裸 .exe,库直接运行它) UpdateFormat::Nsis

多套一层目录会直接报错(不会静默取错文件)。

现状(2026-09-14):Windows 已不再是"单文件替换"模式。落地后 Windows 载荷就是那个 NSIS
安装器,由库的 UpdateFormat::Nsis 路径运行,因此 resources/ 会随安装器一起更新——
原先需要 MoveAll 自行编排的理由(单文件替换不更新 resources/)随 self_update 一并
退役。这一条是结构推论,未在 Windows 实机验证(本机是 macOS),见 ADR-0034 待验证项 2。

5.4 `.dmg` 与 `.sig` 的取舍(直接回答你的约束)

你的约束是"若两平台均需 .sig,则仅额外增加该文件,不引入其他文件"。逐条对照:

方案 A(保持 zipsign)

平台 发布产物 相对现状新增
macOS .dmg(人工安装)、BongoCat-<ver>-.app.tar.gz(更新,签名内嵌) 把现在的 .app.zip 换成 .tar.gz,不加任何文件
Windows .exe 无法签名 → 必须再加一个含 bongocat-app.exe(+resources/)的 .zip/.tar.gz +1 个归档文件,与你的约束冲突

结论:zipsign 满足 macOS 的零新增文件,但满足不了 Windows。

方案 B(改用 minisign,推荐)

平台 发布产物 相对现状新增
macOS .dmg、BongoCat-<ver>-<arch>.app.tar.gz、.app.tar.gz.sig +1(.app.tar.gz 替代原 .app.zip)+ 1 个 .sig
Windows BongoCat_<ver>_x64.exe、.exe.sig 仅 +1 个 .sig

结论:完全符合你的描述。 注意 cargo-packager 的 sign_outputs 会连 .dmg 也一起签名
(产出 .dmg.sig);如果不想发布这个文件,就只对更新产物调 sign_file,或者把
PackageOutput 列表过滤后再交给 sign_outputs。


6. 落地步骤

6.1 方案 A:保持 zipsign(macOS 可用,Windows 阻塞)

  1. 生成并托管密钥
    sh
    cargo install zipsign
    zipsign gen-key release.key release.pub && chmod 600 release.key
    base64 -i release.key | pbcopy        # 存进 GitHub Secret
  2. 把公钥编译进去:release.pub 是 32 字节裸数据 → 填进
    runtime.rs 的 RELEASE_SIGNING_KEY。
  3. 改 CI 的 macOS 归档步骤(.github/workflows/release.yml 的
    Package the bundle for publication):现在用 ditto -c -k 产的是 .zip,改成产
    BongoCat-$version-${{ matrix.arch }}.app.tar.gz(归档根必须是 BongoCat.app/)。
  4. 加签名步骤:在 upload 之前,解码私钥、zipsign sign tar <归档> release.key。
  5. Windows:需要额外决策——要么接受新增一个载荷归档并实现 MoveAll 多文件编排,
    要么放弃 Windows 自更新。

6.2 方案 B:改用 minisign(推荐)

生产端(crates/bongocat-packaging)

  1. 复用已在依赖里的 cargo_packager::sign,不引入新工具。
  2. 在 packaging_config 之后、verify_artifacts 之前插入签名步骤:
    • 从环境变量读私钥(照 BONGOCAT_MACOS_SIGNING_IDENTITY 的先例);
    • 未设置则跳过签名,并让发布流水线能断言"发布构建必须签过名";
    • 把 .app 打成 BongoCat-<ver>-<arch>.app.tar.gz(根目录 BongoCat.app/),
      把 Windows 安装器确认为 BongoCat_<ver>_x64.exe;
    • 对这两个文件调 sign_file,产出 .sig。
  3. 更新 tools/tests/test_update_release_contract.py,把"资产名必须含完整 triple"这条
    编译期不可见的耦合固定下来。

消费端(crates/bongocat-update)

  1. 新增依赖 minisign-verify = "=0.2.5"(MIT,零依赖,本机源码确认支持 prehashed 模式
    与 key ID 校验)。按 §9 的依赖纪律,先用 cargo info minisign-verify 核对最新稳定版。
  2. 把 RELEASE_SIGNING_KEY 换成 minisign 公钥,并把
    builder.verifying_keys([key]) 换成 builder.verify_archive(|path| ...):
    在闭包里读 .sig、调 PublicKey::verify,失败时返回
    self_update::Error::archive_verification_rejected(...)。
  3. 保留 fail-closed 门禁:公钥未配置时,在发请求前就返回 update_signature_key_missing
    (等价于现在的 runtime.rs:202)。
  4. 新增 ADR,取代 ADR-0029 的签名部分,并显式记录丢失的能力(§4.5 的降级防护)。

需要正视的两个未解决问题

  • .sig 从哪来? verify_archive 钩子只拿到下载文件的路径,而 .sig 是另一个 Release
    资产。self_update 只提供 checksum_from_asset(),没有 signature_from_asset()。
    可行做法是在闭包内自行请求一次 Release API 找到同名 .sig 资产并下载(self_update 已
    re-export ureq)。这带来额外一次网络请求和一段自研胶水,必须写进 ADR 的成本栏。
  • Windows 的安装步骤要自研。 .exe 是系统安装器,self_update 明确不支持(§0.2)。
    需要自己实现「下载 → 验签 → 静默运行安装器 → 重启」,并处理安装器与正在运行的进程的冲突。
    自签名发布前还要确认 per-user NSIS 安装器的静默开关(cargo-packager 的 NSIS 模板是否
    支持 /S 需实测)。

6.3 对"我该先做什么"的建议顺序

  1. 先不要动代码。 先写一个 ADR 决定签名方案(zipsign 内嵌 vs minisign 分离),因为这两条
    路对产物、依赖、Windows 安装语义的影响都是不可逆的。
  2. 决定 Windows 的更新语义(自替换 vs 运行安装器)。这是真正的分水岭,比签名算法重要得多。
  3. 再改打包工具产归档 + 签名,最后才是客户端验签。

该顺序已被遵循:决策记录在 ADR-0034,随后才动代码。§6.2 的"消费端"步骤 4–5 在执行时被
简化——self_update 的 verify_archive 钩子方案没有采用,因为整条链路换成了
cargo-packager-updater,它自己取 manifest 里的 .sig,不需要在钩子里再请求一次 Release
API。§6.2 列出的两个"未解决问题"因此都不成立:.sig 由 manifest 携带,Windows 安装步骤由
库提供。

§6.2 的生产端步骤 1–3 与"签名必须是最后一步"的约束按原样执行,落在
crates/bongocat-packaging 的 publish_update_assets。


7. 注意事项清单

下列清单是决策前的检查表。落地后各项的归属见括号内的说明;打勾表示已由代码或契约
强制,而不是靠人记住。

  • 私钥不进仓库、不进构建产物、不进日志;只从 CI secret 注入(.env 不生效)
    (SIGNING_PRIVATE_KEY 环境变量注入,代码里没有任何密钥字面量)
  • 生成后 chmod 600 私钥文件(just keygen 已显式收紧到 0600;实测确认)
  • 公钥编译期内嵌,绝不从网络取(RELEASE_SIGNING_KEY 常量)
  • 公钥未配置时在发请求前就失败(updater() 的门禁,有定向测试锁定)
  • 本地构建默认跳过签名,发布构建断言签过名
    (release workflow 的 "Require the update signing key" 与 "Verify the signed update assets")
  • 签名是所有步骤的最后一步;签名后不改名、不重新压缩、不 strip
    (publish_update_assets 在产物确定后才签名,manifest 的 URL 在签名之后写入)
  • macOS 的 .app 必须在打 tar.gz 之前完成 codesign + notarize(公证仍是独立门禁)
  • 更新载荷名不必再含完整 target triple:载荷位置由共享 manifest 的 platforms 条目
    给出,不再按资产名匹配(这正是 asset_for 退役后的变化)
  • macOS 归档根必须是 BongoCat.app/(write_bundle_archive 回归 + release workflow 断言)
  • .dmg 不作为更新产物(update_payload 只取 bundle 或 .exe)
  • Windows 的 resources/ 随安装器一起更新(结构推论,未在 Windows 实机验证)
  • .sig 只在方案 B 存在;方案 B 已采纳,两平台各一个 .sig
  • 更新签名 ≠ Authenticode / 公证,两件事都要做(Authenticode 仍只打 warning)
  • 一把钥匙泄露 = 换钥匙 = 必须先发"双钥匙版本";务必备份私钥
    (单公钥模型下这条约束更硬,见 ADR-0034 待验证项 4)
  • ADR-0029 已丢失的降级防护(release_sequence)值得在换实现时补回(本次未补)
  • 任何签名/安装路径的改动都要同步更新 ADR、契约测试和诊断错误码

8. 未验证事项(按 `AGENTS.md` §3.4 诚实标注)

下列条目是本文写作时(决策前)的状态。落地后其中一部分已由
crates/bongocat-update/tests/release_manifest_capability.rs 覆盖,逐条标注如下;
仍未验证的事项以 ADR-0034 的"待验证项"为准。

  1. 没有生成过任何密钥 → 已覆盖:能力测试调用 cargo_packager::sign::generate_key 生成
    真实密钥对并落盘;just keygen 也已用于生成并验证发布私钥/公钥。发布私钥的 GitHub secrets
    托管与真实发布链路仍未验证
    。
  2. 没有跑通一次"签名 → 客户端验签" → 部分覆盖:签名与验签都在 loopback 上真实执行过
    (真实密钥、真实 .sig、真实 PublicKey::verify),但真实 GitHub 发布链路仍未跑通。
  3. minisign-verify 0.2.5 与 minisign 0.7.9 的互操作性未实测 → 已覆盖:两者由能力测试
    交叉验证通过,cargo-packager 的 prehashed 输出可被 cargo-packager-updater 验签。
  4. GitHub 上的资产名退化匹配未实测 → 已失效:换库后不再按资产名匹配,载荷位置来自
    manifest。新风险是 manifest 本身没有防降级保护(见 ADR-0034 待验证项 5)。
  5. Windows 静默安装:install_mode = Quiet 映射到 NSIS /S /R 已由源码确认
    (WindowsUpdateInstallMode::nsis_args),但能否在被占用的可执行文件上完成替换仍未验证
    ——本机是 macOS。
  6. .dmg 是否被 sign_outputs 一并签名 → 已失效:本项目不调用 sign_outputs,只对
    更新载荷调 sign_file,因此不会产出 .dmg.sig。
  7. cargo-packager 的 signer generate-key CLI 子命令的确切参数名未核实 → 已核实:
    cargo packager signer generate-key [--path ] [--password ] [--force] [--ci]
    (src/cli/signer/mod.rs 的 Commands::Generate,参数定义在 generate.rs)。
    产出的 .key / .key.pub 是 base64 文本,可直接整段放进 CI secret。

Adr 0001 Single Rust Application (`docs/adr/0001-single-rust-application.md`)

ADR-0001: Single Rust Application

状态:Accepted
日期:2026-08-28

Context

BongoCat 需要在 Windows 和 macOS 上统一实现设置 UI、实时输入、动画、模型管理、窗口、渲染和系统集成,同时保持平台 API 的直接控制能力。

Decision

BongoCat 自有应用代码统一使用 Rust 2024 edition,并在一个 Cargo workspace 中组织。业务模块通过强类型 Rust 接口协作;平台差异进入明确的 platform adapter。

官方 Cubism Core 平台二进制是唯一允许的厂商 FFI 例外。该例外只提供模型计算能力,不承载 BongoCat 业务逻辑。

Consequences

  • 状态、配置、模型和行为只实现一次。
  • Windows/macOS 的窗口、输入和 GPU backend 仍分别实现。
  • 平台 handle、裸指针和 unsafe 不能进入共享业务模块。
  • Linux 可在首发后通过新增 platform/renderer backend 评估。

Verification

Phase 0 必须证明 GPUI、独立 overlay、输入服务和 Cubism wrapper 可以在同一应用生命周期内可靠启动和停止。


Adr 0002 Gpui Settings Ui (`docs/adr/0002-gpui-settings-ui.md`)

ADR-0002: GPUI for Settings UI

状态:Accepted, pending Phase 0 validation
日期:2026-08-28

Context

BongoCat 需要一套跨 Windows/macOS、可主题化、支持复杂表单和模型管理的 Rust 设置界面。设置 UI 不是实时 Live2D 渲染表面。

Decision

使用 GPUI 构建设置、模型管理、快捷键、权限、更新和诊断界面。首个 spike 固定 gpui = "=0.2.2" 并提交 Cargo.lock。

GPUI Entity 只拥有临时视图状态。运行时配置和业务状态通过强类型 command/snapshot 边界访问。

Consequences

  • 项目维护一套设置 UI 和 design system。
  • 不依赖 Zed 应用内部 UI crate 或 GPUI renderer 私有接口。
  • GPUI 升级必须独立验证,不能无约束跟随上游。
  • GPUI 设置窗口关闭后,输入、动画和 overlay 必须继续运行;设置和更新窗口的普通 close 都销毁
    当前 GPUI 窗口,下一次打开重新创建。
  • 设置窗口的侧边栏当前页面由 app coordinator 持有 process-local SettingsNavigationMemory 记忆,
    窗口重建时恢复;该 UI 状态不写入配置,应用重启后回到 Appearance。
  • GPUI 0.2.2 的 Windows WM_DESTROY 回调会同步重入已借用的 AsyncApp;显式 Quit 必须先完成
    全部 BongoCat owner 的 shutdown/join,再由平台适配器跳过有缺陷的最终 GPUI 析构。普通设置/更新
    窗口销毁仍须通过双平台原生 smoke 验证,不得用进程退出代替或提前截断业务 shutdown。

Verification

Phase 0 验证双平台字体、中文输入法、焦点、键盘导航、辅助功能、DPI/Retina、窗口销毁/重建、侧边栏页面记忆、后台生命周期和 shutdown。

历史证据(2026-08-31):Windows CI run 33328391234、job 99302481796 证明保留窗口后的真实
WM_DESTROY 仍以 0xC0000409 fast-fail;同期检查确认 Zed commit
399258feeaf90ad8a3a208c99221ee87b6452f38 的 main close callback 仍执行同步
handle.update。该证据限定上述兼容措施,并不解除未来恢复正常 GPUI 析构的门禁。


Adr 0003 Native Overlay Renderers (`docs/adr/0003-native-overlay-renderers.md`)

ADR-0003: Native Overlay Renderers

状态:Accepted, macOS validated; Windows pending Phase 0 validation
日期:2026-08-28

Context

模型窗口需要透明、置顶、穿透、低延迟、高 DPI/Retina 和可控 GPU 生命周期。GPUI 没有为双平台 Live2D 外部纹理提供稳定的公共合成接口。

Decision

模型窗口使用独立原生 overlay:

  • Windows:Win32 + D3D11 + DXGI + DirectComposition/DWM。
  • macOS:AppKit NSPanel + Metal + CAMetalLayer。

Renderer 只消费不可变 RenderSnapshot,不访问 GPUI、配置或输入服务。

Consequences

  • 设置 UI 与实时渲染拥有独立的窗口和 frame loop。
  • 两个平台分别实现 GPU backend,但共享模型求值和 render contract。
  • 首发不为了后续 Linux 强行统一 GPU API。
  • 窗口、renderer 和 GPU object 必须有明确的 owner 与析构顺序。

Verification

macOS spike 已验证 NSPanel + CAMetalLayer 的透明 clear/present、独立于 GPUI renderer 的窗口生命周期,以及设置窗口与 overlay 同时存在时的显示/隐藏/退出流程。

Windows Win32 + D3D11 尚未在 Windows 实机验证;macOS 结果不能替代 Windows 结论。双平台模型绘制、真实 frame source、device lost/swapchain recovery 和 100 次真实窗口创建/销毁仍是 Phase 0 未完成项。


Adr 0004 Reconcilable Input State (`docs/adr/0004-reconcilable-input-state.md`)

ADR-0004: Reconcilable Input State

状态:Accepted, pending platform validation
日期:2026-08-28

Context

系统快捷键、锁屏、安全桌面、权限变化或输入队列异常可能导致应用收到按下边沿却收不到释放边沿。只依赖事件配对会产生永久 pressed state。

Decision

输入状态由三类信号共同维护:

  1. 低延迟的按下/释放事件。
  2. 对当前 pressed set 的系统状态校正。
  3. 锁屏、睡眠、设备移除和服务重启等生命周期 Reset。

Key/button edge 使用可靠有序队列。鼠标移动和手柄轴使用 latest-value 合并,不得阻塞释放边沿。

Windows 以 Raw Input 为主路径,并用 GetAsyncKeyState 校正。macOS 使用 CGEventTap,并用 CGEventSourceKeyState 校正。

平台 adapter 统一使用单调时钟调度校正:默认间隔为 250 ms,同一个本地 pressed key 必须连续 2 次系统快照缺失才生成释放。单次查询异常只增加该 key 的待确认次数;后续快照确认仍按下时清零待确认次数。正常 KeyUp 和生命周期 Reset 立即清理待确认状态,Reset 不等待确认阈值;时钟回退不得推进校正调度游标。该策略由平台无关状态 contract 固定,平台只负责提供候选 pressed-set。

经过的时间不是第四条路径。早期的 input.keyboard.release_fallback_timeout_ms 按键释放超时已被移除:
用户仍按住的按键与丢失的松开事件在时长上无法区分,用时长区分等于把一帧变慢翻译成一个猫不再
相信的按键。捕获键盘按键因此没有超时配置,runtime 也不维护按键期限或与之相关的匿名计数。

唯一例外是 macOS 的 CapsLock。该键是锁存键,平台实测只投递「锁存翻转」事件而完全不投递物理松开
(见 Technical Design 的 FlagsChanged 条目),它的物理释放在平台层不存在,因此没有「用户仍按着」
这一状态需要区分。平台按产品既定的「短暂触发」语义在固定 100 ms 后合成释放边沿。该时长是按键
行为本身,不是超时配置、不进 schema、不暴露设置项,也不得推广到任何其它键。

Consequences

  • 单个释放事件丢失不会永久卡键。
  • 队列溢出必须可观测,不能静默丢弃边沿。
  • 丢失 KeyUp 的按键只能由状态校正或生命周期 Reset 释放;校正延迟上限由平台查询周期和确认
    次数共同决定,实机应分别测量权限、锁屏和睡眠恢复下的延迟。
  • macOS CapsLock 的释放由 adapter 合成,CGEventSourceKeyState 反映锁存状态、无法替代它;该
    合成边沿走 InputSource::Capture 通道,runtime 侧因此把它计入 captured_up。这是已知且接受的
    精度损失,平台侧计数器不声称该边沿来自系统。
  • Renderer 不直接查询系统键盘状态。

Verification

Windows 强制覆盖 PixPin Ctrl+Alt+A、Win+L、PrintScreen 和 UAC 返回。双平台覆盖锁屏、睡眠、设备变化、服务 restart 和输入压力测试。


Adr 0005 Cubism Core Ffi Boundary (`docs/adr/0005-cubism-core-ffi-boundary.md`)

ADR-0005: Cubism Core FFI Boundary

状态:Accepted, pending SDK and license validation
日期:2026-08-28

Context

BongoCat 需要加载既有 .moc3 模型。官方 Cubism Core 以平台二进制形式提供,应用必须控制其版本、所有权、分发和错误边界。

Decision

通过窄 Rust sys layer 调用官方 Cubism Core,并在其上建立 safe Rust wrapper:

text
Cubism Core binary -> raw bindings -> Moc/Model wrappers -> model evaluation

原始指针不得离开 wrapper。Rust owner 必须保证 Moc、Model 和 backing buffer 的存活与析构顺序。BongoCat 的动作、表达式、物理、资源和状态逻辑保留在 Rust 模块中。

Consequences

  • 构建清单必须记录 SDK/Core 版本、来源、hash、架构和许可证。
  • FFI callback 不执行阻塞工作,不允许 panic 穿越边界。
  • 模型切换采用 prepare/validate/commit,失败保留当前模型。
  • 不通过扩大非 Rust bridge 来绕过兼容性问题。

Verification

Phase 0 使用三个预置模型验证 moc/model 生命周期、drawable 数据、motion、expression、physics/pose 需求和重复销毁,并形成 SDK 分发结论。


Adr 0006 Linux Post Launch Strategy (`docs/adr/0006-linux-post-launch-strategy.md`)

ADR-0006: Linux Is a Post-Launch Evaluation

状态:Accepted
日期:2026-08-28

Context

BongoCat 的首发目标是 Windows 和 macOS。Linux 的透明窗口、窗口层级、全局输入、托盘和桌面集成能力会随 X11、Wayland、portal 与 compositor 改变,不能从 Windows/macOS backend 的结果推断等价支持。

同时,runtime、配置、模型、动画和行为 fixture 本身不需要依赖具体桌面系统。首发实现不应为了未验证的 Linux 能力统一平台 backend,也不应让共享业务类型依赖 Win32、AppKit 或 GPU handle。

Decision

Linux 不进入首发支持矩阵,不阻塞 Windows/macOS 的 Phase 0、实现和发布。

共享 Rust crate 保持平台无关:业务接口使用项目自有类型,平台能力通过 adapter 和 capability reporting 提供。Windows 使用 D3D11,macOS 使用 Metal;首发不为了 Linux 预先引入统一窗口、输入或 GPU abstraction。

Linux 只在首发后按独立 spike 评估。评估必须分别覆盖 X11 与 Wayland,并允许结论是某些桌面环境、compositor 或全局输入能力不受支持。尤其不得承诺 Wayland 下存在与 Windows Raw Input 或 macOS CGEventTap 等价的全局键鼠捕获。

Consequences

  • 首发 CI 可以对平台无关 crate 执行 Linux cargo check,但该结果不代表 Linux 产品支持。
  • 新增共享业务 API 时不得暴露 HWND、Objective-C 对象、X11 handle、Wayland object 或 backend 专用 GPU 类型。
  • Linux backend 可以在首发后新增,不要求修改已冻结的输入、配置和模型语义。
  • 若 Linux 平台能力不足,产品必须显式降级或限制支持范围,不能用静默丢失输入代替能力声明。

Verification

Phase 1 CI 对共享 crate 执行 Linux compile check。首发后按照 TODO 的 Linux backlog 建立 X11/Wayland 能力矩阵,并在全局输入、透明 overlay、渲染和系统集成均达到门槛后,另行提交支持决策 ADR。


Adr 0007 Single Rust Runtime Environment (`docs/adr/0007-single-rust-runtime-environment.md`)

ADR-0007: Single Rust Runtime Environment

状态:Accepted
日期:2026-08-28

Context

BongoCat 的输入、pressed state、动画、当前模型和配置协调需要单一事实来源。如果生产应用同时保留多个业务 runtime,或通过 IPC 在 UI、输入和模型状态之间复制事实,会扩大时序、恢复、版本兼容和 shutdown 风险。

历史实现仍用于行为观察和模型资源兼容证据,但不应进入 BongoCat 的生产执行链路。旧配置只作为已归档考古样本,不属于产品输入。

Decision

生产应用使用一个 Rust 进程和一个业务 runtime owner。GPUI 设置界面、平台输入服务、Live2D 求值、原生 overlay 和系统集成都属于同一 Rust 应用生命周期,并通过强类型 Rust command、event 和 snapshot 协作。

不得引入第二套常驻业务运行环境、跨语言共享业务 core、sidecar 业务进程或用于同步核心状态的 IPC 边界。历史应用只作为行为与资源对照,不随 BongoCat 生产应用启动,也不作为功能 fallback。

官方 Cubism Core 平台二进制仍是 ADR-0005 定义的窄 FFI 例外。它只提供模型计算能力,不成为独立业务 runtime,不拥有应用状态。

安装或更新若因操作系统权限必须使用短生命周期 helper,需要在系统集成阶段单独威胁建模和 ADR;helper 必须使用 Rust、不得持有持续业务状态,也不得改变本 ADR 的单一应用 runtime 所有权。

Consequences

  • pressed state、动画、当前模型和配置协调只存在一个可变 owner。
  • GPUI 关闭或重建不会创建第二份业务状态;UI 只发送 command 并读取带 revision 的 snapshot。
  • 输入 callback、renderer 和平台服务不能绕过 runtime 直接维护产品状态。
  • 发布切换前保留历史源码、tag 和样本;生产包不包含历史 runtime、Web 资产或旧配置读取器。

Verification

Phase 0 验证 GPUI、输入服务、runtime、Cubism wrapper 和独立 overlay 可以在同一进程中按既定顺序启动、停止和重建。Phase 1 对 release dependency tree 与产物内容执行检查,确认不存在第二套业务 runtime、WebView 或 JavaScript runtime。


Adr 0008 Application Identity And Storage Environments (`docs/adr/0008-application-identity-and-storage-environments.md`)

ADR-0008: Application Identity and Isolated Storage Environments

状态:Accepted
日期:2026-08-28

Context

BongoCat 需要稳定的应用身份,同时开发构建不能读取、覆盖或锁住用户的生产数据。旧版配置字段和存储布局不再是新应用的兼容目标,因此新配置可以直接采用清晰、一致的 Rust 领域命名。

仅依赖 debug_assertions 或可变命令行参数选择数据目录会让错误构建访问生产数据。只隔离 config.json 也不充分:模型、备份、日志、锁和其他可变文件仍可能互相污染。

Decision

BongoCat 的 Bundle ID 固定为:

text
com.ayangweb.bongo-cat

构建产物携带不可变的 BuildEnvironment:

text
Development
Production

工作区内直接执行的 Cargo 命令默认选择 Development;Production build/package 必须显式启用
bongocat-app/production Cargo feature。环境选择不依赖进程环境变量;应用启动后不能通过 CLI、
环境变量或设置项切换环境。packaging 的 --environment 只作为产品 CLI 校验并映射为上述 feature。
正式启动 API 只从当前平台与该编译期环境解析数据根,不接受调用方传入 StorageLayout 或路径。
需要隔离临时根目录的进程级测试必须显式启用 storage-test-injection,该能力不进入默认产品
CLI/API,并且 Production 构建与该 feature 的组合在编译期失败。

持久数据根目录为:

平台 Development Production
Windows %APPDATA%\com.ayangweb.bongo-cat\development\ %APPDATA%\com.ayangweb.bongo-cat\production\
macOS ~/Library/Application Support/com.ayangweb.bongo-cat/development/ ~/Library/Application Support/com.ayangweb.bongo-cat/production/

两个环境使用相同的目录结构和 schema:

text
<data-root>/
  config.json
  window-state.json
  models/
  backups/
  logs/
  updates/
  locks/

锁、单实例命名、诊断和更新 channel 也必须包含环境身份。任何环境都不得探测、读取或回退到另一个环境的目录。

BongoCat 不读取或导入旧 Tauri/Pinia 配置。配置 JSON 键统一使用 snake_case,名称从当前产品领域语义定义,不保留旧字段 alias。

初始版本是全新的,当前完整 config.json 与 window-state.json 都以 schema_version: 1
开始。在首次正式发布前,新增字段直接修改当前 v1,不保留开发中间结构,也不实现迁移、
旧数据转换或历史版本兼容判断。解析边界保留版本字段并拒绝非 v1 数据,避免静默改写未知格式。
首次正式发布后,后续版本才以实际发布的 v1 为基线单独设计顺序、幂等迁移。

window-state.json 的领域名称明确限定其只承载 settings 与 overlay 的可恢复窗口状态,不能作为
通用 application state 容器。开发期旧路径不属于首版兼容输入;产品不读取、迁移或 fallback 到它。

Alternatives Considered

  • Cargo profiles and debug_assertions were rejected: both describe the Cargo profile, not the immutable product environment. A release Development smoke and a release Production package must remain distinguishable.
  • Build-metadata crates such as vergen, shadow-rs, and built were rejected: they expose Git, Cargo, Rust, and system metadata, but they do not own or enforce BongoCat's mutually exclusive Development/Production data-root semantics. Adopting one would retain the current custom mapping and validation while adding a dependency and lockfile surface.
  • cfg_aliases was rejected because it only aliases conditions; the project can express the two states directly with one Cargo feature and cfg.
  • A build script that reads an environment variable was rejected in favor of Cargo's native feature resolution. Cargo now owns the selection path and feature propagation; the remaining explicit compile_error! represents the required product invariant that Production cannot include storage test injection.

Consequences

  • 首次启动 BongoCat 时生成当前环境的全新配置。
  • 开发构建可安全使用合成数据和自定义模型,不影响生产安装。
  • 旧配置目录和字段只保留为历史行为参考,不进入生产 dependency graph 或发布产物。
  • 用户模型仍可通过受验证的显式导入流程加入;不会根据旧配置路径自动发现或搬运。
  • 工作区直接 Cargo 命令默认 Development;Production build/package 必须显式启用
    bongocat-app/production。
  • Production 构建不能携带存储根注入能力;需要隔离临时数据根的进程级测试必须使用独立 Development 测试产物并显式启用 storage-test-injection。
  • 开发过程中产生的旧 schema 或中间数据需要删除并重新生成,不属于产品兼容输入。

Verification

  • 对 Windows/macOS path resolver 分别测试 Development 和 Production,断言根目录不同且内部相对结构一致。
  • 工作区直接 cargo check 成功并编译为 Development;启用 production feature 后编译为
    Production,且 Production 与 storage-test-injection 的组合必须失败。
  • 默认产品参数拒绝存储测试入口;CI 证明 Production + storage-test-injection 构建失败。
  • 在两个环境写入不同 sentinel,重启后只读取各自数据。
  • 验证锁、日志、备份、模型目录和更新 channel 均无跨环境访问。
  • 发布产物验证 Bundle ID 精确等于 com.ayangweb.bongo-cat。
  • 扫描发布依赖和运行日志,确认没有旧 Tauri/Pinia 配置探测。
  • 扫描产品代码,确认不存在 schema migration、字段 alias 或旧结构转换路径。

Adr 0009 Gpui Accessibility Gate (`docs/adr/0009-gpui-accessibility-gate.md`)

ADR-0009: GPUI Accessibility Gate

状态:Proposed;历史 release/UI-completion gate,项目自有 AccessKit 部分由 ADR-0054 取代
日期:2026-08-28

后续修订(2026-09-23):ADR-0054 将设置 UI 收口为 visual-first。项目自有 AccessKit tree/native bridge/action 不再是 UI 完成或发布门槛;gpui-kit 传递语义可保留,但不是 BongoCat 语义契约。

Context

设置 UI 必须向 macOS 辅助功能 API 暴露可识别的 label、value、role、错误和进度。spikes/gpui-settings/ 在 macOS 26.5.2 上的人工 smoke 能识别应用、标准窗口、标题栏按钮和菜单,但不能识别 GPUI 绘制的 Appearance、Theme、模型名称输入框或 Refresh 控件。可见截图不能替代辅助功能树证据。

GPUI 0.2.2 的公开源码和 feature 列表没有为普通 element 提供 role/label/value 的通用 accessibility API。直接依赖私有 Zed UI crate、修改 GPUI 私有 renderer 或以隐藏原生控件伪造整棵表单,都会破坏已冻结的依赖和 UI 边界。

Decision

GPUI 继续作为当前首选设置 UI,但在辅助功能 gate 通过前保持 provisional:

  • 允许建立正式 UI crate 和最小产品窗口,但在门禁通过前不实现或宣称完整、可发布的设置界面;
  • 不引入 GPUI 私有 API、未维护 fork 或隐藏控件 workaround;
  • 使用 GPUI 公开的 raw window handle 安装项目自有 AccessKit semantic tree;macOS/Windows adapter 只负责系统协议,语义 snapshot 由 UI crate 维护;
  • 辅助技术 action 必须通过有界强类型 channel 回到 GPUI 主线程,不允许平台 callback 直接修改 Entity 或 runtime;
  • 可见控件和语义节点使用同一份 UI 状态,必须覆盖 role、label/title、value、focus、selected、loading/error 和支持的 action;
  • 若 GPUI 仍无法提供基础语义,才启动 Iced 替代 spike。Iced 只作为此门槛的 fallback,不与 GPUI 并行进入产品。

截至本 ADR 日期,Iced 0.14.0 的公开 crate feature/source 中也未发现可直接满足 role/label/value 的通用 accessibility API,因此它是待验证候选,不是当前 go 结论。不能因为换 UI 框架名称就视为问题已解决。

Gate

以下条件是 ADR-0054 前的历史验收定义。满足后才能将本 ADR 标记 Accepted;“项目自有 AccessKit tree”的当前完成与发布门槛已由 ADR-0054 退役。当时要求:

  1. 选定版本的公开 API 能为设置表单提供稳定 role、label、value、错误和进度节点;
  2. macOS AX tree 能读取主题选择、模型名称输入框、Refresh、错误和 loading 状态;
  3. Windows UI Automation 能读取同等语义;
  4. 键盘导航、真实 IME、剪贴板、缩放和窗口重建回归仍通过;
  5. 方案不依赖私有 crate、未审阅 patch 或平台特定的业务状态副本。

若以上历史条件在完整设置 UI 实现前仍不满足,形成明确的 NO-GO 或 GO WITH CONDITIONS 决策,并为 Iced 或其他后续方案指定独立 owner、截止阶段和回退条件。ADR-0011 允许其他正式产品模块继续开发。ADR-0054 后,项目自有 AX tree 的部分不再阻塞当前 visual-first UI。

Evidence

  • GPUI 版本固定为 crates.io 0.2.2,见 spikes/gpui-settings/Cargo.toml;
  • macOS 本机通过 AppKit AX API 读取 8 个项目语义节点,并通过 accessibilityPerformPress 将 Dark radio action 送回 GPUI;
  • commit fd9ad85 的 push run 33255204781、job 99107586036 使用进程外 UI Automation client 读取同等节点,并通过 SelectionItem.Select 验证 Dark radio 的 selected/action 闭环;
  • 2026-08-30 的 macOS .app smoke 读取 Reset 的 help text,并验证打开确认框后只暴露 modal AlertDialog 子树、初始 Cancel 焦点、键盘关闭与语义树恢复;commit 45b8dba 的 push run 33273470907、job 99156013603 通过同一实现的 Windows UI Automation dialog 门禁;
  • macOS .app 的延迟 runtime probe 已通过 AX value/invalid 状态观察到 Runtime Ready · revision 1 -> runtime probe failed -> Runtime Ready · revision 2;commit 21ee8aa 的 push run 33291750411、job 99204478369 与 pull request run 33291751558、job 99204481348 已从 Windows UI Automation 依次读取 loading、注入错误、retry 和 revision 2 恢复。runner 托管 UIA client 不提供 AriaPropertiesProperty 标识,因此仍无法据此验证 AccessKit 的 busy=true 投影;VoiceOver/Narrator 操作和宣读仍未验证;
  • 双平台设置 spike 通过原生合成 mouse-move 进入 GPUI 平台事件链,验证 Reset hitbox、500ms tooltip delay/build 和 hover exit;物理 pointer 与 VoiceOver/Narrator tooltip 朗读仍未验证;
  • Iced 候选版本与 feature 清单通过 cargo info [email protected] --verbose 获取,源码检索未发现通用 accessibility surface。

Adr 0010 Windows Cpu Targets (`docs/adr/0010-windows-cpu-targets.md`)

ADR-0010: Windows CPU Targets

状态:已被 ADR-0033 取代(2026-09-14,Windows ARM64 不再是产品目标,只剩 x64)

Context

BongoCat 需要明确 Windows 发布架构,避免继续为不再支持的 target
维护输入、renderer、安装和更新分支。当前固定的 Cubism 5 SDK for Native R5
提供 desktop Windows x86/x64 Core,但没有 desktop Windows ARM64 Core;其
experimental UWP ARM64 DLL 不能用于 Win32 desktop 应用。

Decision

Windows 产品目标只包括:

  • x86_64-pc-windows-msvc。

i686-pc-windows-msvc 和 aarch64-pc-windows-msvc 都不属于 BongoCat
的构建、CI、安装包、更新或测试目标。历史 x86 安装包只作为行为与发布考古证据
保留,不形成兼容承诺。

Windows ARM64 曾作为产品目标保留;ADR-0033 已取消该目标。原因不是"R5 缺少
Core"这一次要阻碍,而是它使产品矩阵多出一个无法端到端验证的原生构建:
没有官方可授权的 desktop ARM64 Core,就没有真实的 ABI、模型和 renderer 证据链。
Windows 自身提供 x64 仿真,Windows on ARM 设备运行 x64 构建在正常情况下可用,
因此维持一个不可验证的原生 ARM64 目标只增加成本、CI 面积和发布风险。

Consequences

  • 新的 target matrix、binding fixture 和发布配置不得加入 i686-pc-windows-msvc
    或 aarch64-pc-windows-msvc。
  • deny.toml 的 [graph].targets、bongocat-update::UpdateTargetTriple 与
    发布流水线必须只包含 x86_64-pc-windows-msvc;契约测试强制三者一致。
  • Windows 平台依赖与 renderer 只在 x64 上验证。ARM64 上的实际体验通过 Windows
    的 x64 仿真覆盖,不再有独立的原生 ARM64 证据要求。

Verification

  • tools/inspect-cubism-sdk.py 把 Windows ARM64 报告为 unsupported_by_r5,
    把 i686 报告为 excluded_by_product。
  • tools/cubism-bindgen 只为当前 R5 可用且仍在产品矩阵内的 Windows x64 和
    macOS targets 生成合成 bindings;它拒绝 Windows ARM64 和 i686。
  • Phase 0 CI 不安装或编译 i686,也不再安装或编译 Windows ARM64。
  • tools/tests/test_packaging_contract.py 断言打包工具的 target 集合、
    deny.toml 与 UpdateTargetTriple 三者一致且不含 ARM64。

Adr 0011 Progressive Implementation Release Gates (`docs/adr/0011-progressive-implementation-release-gates.md`)

ADR-0011: Progressive Implementation With Release Gates

状态:Accepted
日期:2026-08-30

后续修订(2026-09-23):ADR-0033 已退役 Windows 原生 ARM64,Windows on ARM 改由 x64 仿真覆盖;因此本文的 Windows ARM64 desktop Core 发布门槛只作历史记录。

Context

Phase 0 已通过独立 spike 验证 runtime、配置隔离、GPUI 设置窗口、原生 overlay
和可校正输入的核心所有权模型。剩余证据主要依赖厂商授权、标准 Cubism SDK、
物理设备、辅助技术、目标系统和签名环境。把这些外部证据全部作为创建产品
workspace 的前置条件,会阻止已经具备自动化契约的业务模块进入正式实现。

维护者已提供仓库外的 CubismSdkForNative-5-r.5.zip,并明确授权本地技术
验证和正式产品代码开发;公开发布授权和实机验收延后到产品完成后处理。

Decision

项目采用渐进式实现,并把开发门禁与发布门禁分离:

  • 允许建立正式 Rust workspace,并逐步实现 runtime、配置、模型、UI、平台和
    renderer;每项仍必须满足自己的自动化测试和代码边界。
  • Phase 0 未完成项继续保持未勾选。缺失的 Cubism 行为、物理设备、辅助功能、
    GPU、签名和目标系统证据不能由编译或合成测试替代。
  • Cubism 发布授权、最终 SDK/二进制清单、Windows ARM64 desktop Core、实机矩阵、
    签名、notarization、更新回滚和 soak 结果全部是 stable 发布门禁,不再阻止
    不公开分发的本地开发。
  • 维护者批准把功能开发所需的最小 Core、header、生成 bindings 和预置模型固定到
    vendor/ 与 resources/,使本地开发和目标 ABI 检查可复现。完整
    SDK ZIP 与 Framework 源码不进入仓库;公开安装包仍在发布阶段核对 attribution、
    再分发范围和最终合规清单。
  • 产品构建默认不联网,也不从未固定来源下载 SDK。缺少本地 SDK 时,与 Cubism
    无关的 workspace、测试和 CI 必须保持可用。
  • 完整设置 UI 仍受 ADR-0009 的辅助功能条件约束;允许建立 UI crate 和最小产品
    窗口,但不得把未通过的交互或辅助功能宣称为可发布功能。

Supplied SDK Disposition

当前本地基线是 Cubism SDK for Native 5-r.5,archive SHA-256 为
7ff3a4bbc19c0a8728965aa522ab77eb11b252916453e68a8a78d3b71188bb12,Core
版本为 06.00.0001。它包含 Windows x64 和 macOS arm64/x64 Core,不包含
desktop Windows ARM64 Core。

此前提供的 CubismSdkMotionSyncPluginForNative-5-r.2.1.zip 是可选 MotionSync
插件,SHA-256 为
26baaf30b0ebb26bc6253884aa61b010666e2ecc2491e3a7fff6d43eb64d6548。它不是
标准 Native SDK,不能加载 .moc3,当前不进入产品范围。

Consequences

正式实现可以从已验证的 runtime/config 契约开始,不再等待外部测试人员或合规
流程。发布状态必须单独判断;“功能已实现”不表示“可以公开分发”。如果后续发布
核对、SDK ABI 或实机验证失败,受影响的发布目标保持阻塞,并通过后续 ADR 调整
能力或目标矩阵,不能用错误架构的 artifact 或模拟结果绕过。


Adr 0012 Json Localization (`docs/adr/0012-json-localization.md`)

ADR-0012: JSON Localization

状态:已接受(2026-09-10;2026-10-06 修订)

决策

BongoCat 使用 rust-i18n = 4.2.4 加载编译期嵌入的 JSON 语言资源。应用层资源由独立的 bongocat-i18n crate 管理,默认语言为 en-US,已落地的语言为 zh-CN、zh-TW、ar-SA、vi-VN、pt-BR、ko-KR 与 fr-FR。

语言命名采用带地区子标签的提名式,与 en-US 一致:一份语言只发一份 catalog,地区子标签提名这份文案所依据的主要变体,并不声称 catalog 未携带的地区特化。en-US 本身也是这种名字——它同样服务 en-GB。

解析走 RFC 4647 的 language-subtag fallback(浏览器 Intl、CLDR 与操作系统自身的做法):
按 primary subtag 匹配已发布 catalog,ar/ar-EG/ar_MA/ar-SA 全部落到 ar-SA,vi 与
vi-VN 全部落到 vi-VN,pt/pt-PT/pt-BR 全部落到 pt-BR,ko 与 ko-KR 全部落到 ko-KR,
fr/fr-CA/fr-BE/fr-CH 全部落到 fr-FR,en/en-GB 全部落到 en-US。
因此机器实际上报的 tag 几乎总不是 catalog 自身的名字,而 primary subtag 匹配让它们收敛到同一份
文案,而不是各自回退英文。pt-BR 尤其说明这件事的必要性:葡萄牙语没有单一写法,catalog 只发
一种变体,报 pt-PT 的机器读到的也是它。

语言枚举的变体名按语言本身命名,不带地区:Arabic、Vietnamese、Portuguese、Korean、
French 各自服务整个语言,地区子标签只是提名,PortugueseBrazil 这类名字会声称 catalog 并不携带的地区特化。
语言下拉里的名称按 endonym 规则写成本地人自称语言的名字,同样不附地区后缀——Português 而不是
Português (Brasil),العربية 而不是 العربية (السعودية),한국어 而不是 한국어(대한민국),
Français 而不是 Français (France),对应的 i18n key 也只写 portuguese、arabic、korean、
french。ChineseSimplified 是唯一保留限定词的名字,
因为它命中的确实是脚本分叉:zh 下的简繁是两种书写体系。

zh 是唯一不能只看 primary subtag 的情形:简繁是不同书写体系而非地区变体,因此两种写法各发
一份 catalog(zh-CN 简体、zh-TW 繁体),并在 zh 分支内按书写体系子标签直接分流,不进入
下面的 subtag 循环。hant 与 TW/HK/MO 判为繁体,hans 与其余 zh 标签判为简体——两种
拼写都要覆盖,因为平台只报地区子标签时 TW/HK/MO 是唯一可用的线索。
bongocat-config 的 Language::from_system_locale 与 bongocat-i18n::locale_code 各做一次
同样的判定:两个 crate 之间不存在依赖方向,不能共用一个 helper,行为必须由测试对齐。

语言文件放在 crates/bongocat-i18n/locales/,每种语言一个 JSON 文件,使用 _version: 1
和真正嵌套的领域结构。rust-i18n 在编译期将嵌套路径解析为查找 key;JSON 源文件本身不得使用
点号分隔的扁平 key。Rust UI 在文案实际使用处直接引用稳定的领域路径,不内嵌翻译文本或维护
enum 到 key 的集中映射。

新增一种语言是一次数据加注册的改动,不改变本 ADR 的任何结构决定:写一份与 en-US 同
key、同占位符的 JSON,然后在 build.rs 的 CATALOGS、Language/SettingsLanguage 两个
枚举及其 code/catalog_locale/from_system_locale/resolve、settings_language_display_name
和 tools/validate-locales.py 的 EXPECTED_LOCALES 中各加一项。语言下拉里每种语言用自身
文字书写(endonym),而不是当前窗口语言的名字。

约束

  • 翻译 key 使用小写 snake_case,按 navigation、settings、models、shortcuts、
    diagnostics、about、actions、status 和 errors 等领域分层;字段名必须表达具体上下文。
  • 插值使用 rust-i18n 的 %{name} 语法;所有语言必须保持相同的占位符集合。
  • 找不到语言或 key 时回退到 en-US;system 在配置/平台层先解析为受支持语言。
  • GPUI 语言切换通过已有带 revision 的设置 snapshot 触发重绘;本地化查询不进入 overlay frame loop。
  • 新文案必须先加入 JSON,再由 Rust 使用 key;禁止在 .rs 中新增自然语言翻译文本。
  • bongocat-i18n 是唯一调用 rust_i18n::i18n! 的 catalog owner。UI 使用其 text(locale, key)
    facade,并在使用点写出 key;不得为 UI crate 再初始化同一份 catalog 或依赖全局 locale。
  • 测试/CI 必须检查 JSON 可解析、语言 key 集合相同、值为非空字符串且占位符集合一致。
    语言清单只在 crates/bongocat-i18n/src/tests/mod.rs 的 LOCALES 一处枚举,各条比较型测试
    都遍历它,因此新增语言不会让某条比较悄悄少覆盖一种语言。
  • UI 文案的书写约定见 docs/localization-copy-conventions.md。当前已落地的一条:省略号一律写
    单个 …(U+2026,视觉上是三个点)
    ,禁止中文排版习惯的 ……(六个点)与拉丁写法的 ASCII
    ...——同一个 key 由所有语言共用,写法必须与语言无关。该规则由 tools/validate-locales.py
    机械强制,不依赖 review。

取舍

gpui-kit 的底层 gpui-component 也使用 rust-i18n,因此依赖生态一致。rust-i18n 与传递的 rust-i18n-macro/rust-i18n-support 必须停在同一个补丁序列:4.2.4 的宏展开调用 rust_i18n::replace_patterns_cow,只存在于 4.2.4 的运行时 crate,跨版本混用会让 gpui-component 与 bongocat-i18n 同时编译失败。JSON 比 YAML/TOML 更适合现有前端资源、翻译工具和跨语言校验;本方案不引入 Fluent 的复数/选择语法。若后续产品需要复杂 ICU/Fluent 消息,应另行提交 ADR,不在业务代码中混用第二套格式。


Adr 0012 Ordered Motion Audio Service (`docs/adr/0012-ordered-motion-audio-service.md`)

ADR-0012: Ordered Motion Audio Service

状态:Accepted
日期:2026-08-31

后续修订(2026-10-12):rodio 由 =0.22.2 升级到 crates.io 精确 pin =0.23.0。
该 release 把 cpal 由 0.17.3 升到 0.18.2,并把此前分散的 cpal 错误类型
(StreamError、BuildStreamError、PlayStreamError、DevicesError、
DefaultStreamConfigError、SupportedStreamConfigsError)统一为 cpal::Error,以
CpalError/CpalErrorKind 重导出,rodio::DevicesError 被移除。项目只使用 Decoder、
SamplesBuffer、DeviceSinkBuilder、Player 与 rodio::cpal::BufferSize,不匹配这些
错误类型,源码零改动。仍然只启用 playback 与 flac,不改变本 ADR 的替换边界。

Context

预置 model3 的 motion 通过相对路径引用 FLAC。音效是动作被接受后的产品副作用,
不能进入 RenderSnapshot、阻塞 runtime/input,也不能因文件、解码或输出设备故障让
动作或画面失败。旧产品同一时刻只保留一个 motion voice;新动作、显式停止、禁用、
成功切模和退出都需要明确停止边界。

Decision

  • 使用 crates.io 当日最新稳定版 rodio 0.23.0,精确 pin,只启用 playback 与 flac。
    不启用录音、MP3、MP4、Vorbis、WAV、dither 或其它未使用 feature。
  • bongocat-audio 是独立 Rust owner。它持有固定容量 16 的有序 command queue、专用
    worker、decoder、output stream 和唯一 voice;runtime 只非阻塞发布强类型
    Play/Stop,不执行文件、解码或设备工作。
  • motion request 只有在 runtime priority 与 Live2D resource 校验均接受后才触发音效。
    新 motion 替换当前 voice;无声音的新 motion 也停止旧 voice。显式 motion stop、关闭
    配置、成功 model commit 和 shutdown 立即停止 voice,不等待 motion fade 结束。
  • 模型 activation 在 renderer prepare 成功后,把该模型的去重 sound paths 作为 Prepare 发送给
    audio worker。worker 在这里解码 FLAC 为 immutable PCM cache;renderer prepare 与 PCM prepare 都完成后
    才提交该模型。预热失败只形成稳定 audio 诊断,仍允许模型提交;随后 play 会按既有降级语义失败,不能阻塞
    动作或渲染。成功 commit 后 ActivatePrepared 只保留新活动模型的 cache。
  • 已活动模型的 motion 在同一 runtime command 中先向有序队列发布缓存 Play,再以同一单调 tick
    启动 motion。动作路径不执行文件 I/O、解码、设备创建、get_pos() 等待或 1 ms 轮询。audio owner
    优先以 512-frame buffer 打开默认设备,设备拒绝时回退 rodio 默认 sink。
  • 音量使用经过校验的 [0, 1] 强类型值,当前产品触发值为 1.0。同一时刻最多一个
    motion voice,不混音、不排队延后播放。
  • 队列满载不阻塞 runtime:返回原 command、增加匿名 overflow 计数、丢弃无法证明
    顺序的 backlog 并强制停止 voice。文件 I/O、解码和输出故障进入稳定 error code 与
    聚合诊断,后续 command 可以恢复服务;诊断不包含用户路径或音频内容。
  • 应用先停止输入与 runtime,再停止并 join 音频 worker;worker 在返回 Stopped 前
    停止 voice、丢弃 pending command 并释放 rodio/CPAL output owner。

Dependency Boundary

rodio 0.23.0 声明 MIT OR Apache-2.0,Rust 1.87+,仓库在审计日仍活跃;底层 CPAL
支持 Windows/macOS 系统输出。rodio 0.23.0 把 cpal 升到 0.18.2,lockfile 因此解析为
0.18.2;项目不直接依赖或 fork CPAL 来伪造版本升级。

rodio/CPAL 类型不离开 bongocat-audio 私有 backend。替换其它音频库时只需保持
MotionAudioClient、强类型 command、诊断和 shutdown contract,不修改 runtime、模型
或 UI 公共协议。Linux 首发后评估时再启用对应 output target;当前 Linux contract build
不链接 ALSA,也不把 Linux 音频变成 Windows/macOS 首发阻塞。

Consequences

音频解码成本转移到模型 activation;设备打开继续由 audio owner 惰性执行,但 runtime 不等待它。
rodio::Player::get_pos() 只反映 mixer 位置,不能作为扬声器起播确认,因此不再用作动画时钟。当前实现支持现有预置模型实际使用的 FLAC;
新增格式必须先形成模型兼容需求,再单独启用 decoder feature 和测试。默认设备热切换、运行中 stream
error callback、端到端音频/画面时序测量、100 次 model/audio 资源测量与 8 小时 soak 仍属于后续
稳定性验收,不由本 ADR 的单元测试替代。


Adr 0013 Startup Item Capability (`docs/adr/0013-startup-item-capability.md`)

ADR-0013: Startup Item Capability and Environment Isolation

状态:Superseded by ADR-0043
日期:2026-08-31

Context

BongoCat 支持 macOS 12+,但 SMAppService.mainAppService 只在 macOS 13+
可用。Development 与 Production 又固定使用相同 Bundle ID;若开发构建直接注册 main
app login item,会覆盖同一用户的生产启动项系统状态。Windows 的当前用户启动项没有同样
的 Bundle ID 限制,但仍必须按构建环境隔离名称与目标命令。

启动项是可选平台能力,失败不能阻止 runtime、overlay 或设置窗口启动。UI 需要区分未启用、
已启用、路径过期、等待系统批准和当前环境不支持,而不能把所有情况压缩成一个布尔值。

Decision

  • 共享平台 API 使用稳定的 StartupItemEnvironment、StartupItemState 和
    StartupItemError,不暴露 registry handle、Objective-C object 或第三方错误类型。
  • Windows 使用当前用户 HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run,
    Development 与 Production 使用不同 value name。启用时写入当前 executable 的带引号绝对
    路径和 --run-seconds 0;状态检测必须区分完全匹配与 stale value;禁用只删除当前环境的
    value,不读取或修改另一环境。
  • macOS 13+ Production .app 使用 SMAppService.mainAppService。状态映射保留
    not-registered、enabled、requires-approval 和 not-found;not-found 是可显式尝试注册的
    预注册状态,不在 adapter 或 UI 提前判为无效。注册/取消注册错误只向上返回稳定匿名 code。
  • macOS 12 返回明确的 unsupported capability,应用其余功能继续运行。不得回退到已废弃的
    SMLoginItemSetEnabled、自行写 LaunchAgent 或修改用户登录项数据库。
  • macOS Development 构建只报告 unsupported build environment,不注册或取消注册
    mainAppService,从而不改变 Production 的系统登录项状态。真实变更 smoke 使用临时签名的
    Production .app 和隔离测试用户/CI runner,并从 /Applications 下的临时唯一安装目录
    启动,使 LaunchServices 与 SMAppService 验证的是安装态 main application。
  • 启用、禁用只能由显式用户 command 触发;正常启动只读取状态。平台失败不改变配置,也不
    阻塞 input/runtime/renderer。

Consequences

  • macOS 12 用户可使用产品核心功能,但设置页会明确显示登录启动不可用。
  • 开发构建不能直接演练 macOS 注册变更;平台 contract test 与 Production bundle smoke 负责
    覆盖,公开发布前仍需签名构建和 System Settings 批准路径实测。
  • Windows 安装位置变化会显示 stale,而不是误报 enabled;用户再次启用可原子覆盖当前环境
    value。
  • 启动项状态属于平台 snapshot,不进入配置 schema,也不由 runtime 动画状态持有。

Verification

  • 纯 contract test 覆盖所有 state/error code、环境命名和 Windows command 编码。
  • Windows 临时当前用户 value smoke 覆盖 disabled -> enabled -> stale -> disabled,并保证只
    触及当前环境 value。
  • macOS 12 availability probe 不引用缺失 class;macOS 13+ Production bundle smoke 从
    /Applications 的临时安装目录覆盖 status、register/unregister 和 requires-approval 映射,
    结束时恢复原状态并注销/删除临时 bundle。首次 not-found 注册后取消注册允许规范化为
    not-registered,因为两者都不留下启用的登录项。
  • 双平台 smoke 后运行完整 format、Clippy、workspace test 和 release check。

Adr 0014 Formal Settings Accessibility Bridge (`docs/adr/0014-formal-settings-accessibility-bridge.md`)

ADR-0014: Formal Settings Accessibility Bridge

状态:Accepted(2026-08-31)

后续修订(2026-09-23):ADR-0054 已退役项目自有设置语义桥。本文保留作为当时的 AccessKit/action 实现证据;当前设置 UI 只验收可见控件、焦点和键盘导航。

Context

The settings window needs project-owned accessibility semantics before the startup-item UI can
close its accessibility gate. GPUI 0.2.2 does not provide a stable public element-level semantic
API, while platform accessibility adapters must not become a second business-state owner.

Decision

  • bongocat-ui builds an AccessibilityTree from its existing page, snapshot, pending and focus
    state. Node IDs, roles, values, toggles and supported actions are project-owned types.
  • bongocat-platform owns the AccessKit macOS/Windows adapter and receives only a copied raw
    window handle plus the project tree. It does not read settings, runtime or config state.
  • AccessKit Click/Focus actions cross a bounded capacity-32 channel and are applied by the GPUI
    entity. Clicks reuse the existing typed settings commands; focus is applied through existing
    FocusHandles on the next render.
  • Disabled/loading/unsupported controls do not expose mutation actions. Adapter diagnostics count
    forwarded and rejected actions without recording labels, key identities or user content.
  • Product smoke may inspect the native AX/UIA role, value, enabled/focusable state and advertised
    action pattern. It must not toggle the startup item; platform lifecycle smoke remains responsible
    for mutation and restoration.
  • The bridge is attached after the GPUI native handle exists but while WindowOptions::show is
    still false, then the window is activated. It is retained by SettingsView and dropped before
    the corresponding GPUI window is destroyed; attachment failure leaves the window hidden and is
    returned to the product coordinator.

Consequences

The startup switch now has a formal switch role, value, toggle state and action mapping on both
platform adapters. This does not claim VoiceOver/Narrator or physical assistive-technology
verification; those remain release evidence gates. If GPUI later exposes an equivalent stable API,
the platform adapter can be removed without changing runtime or settings command contracts.


Adr 0015 Settings Revision Boundaries (`docs/adr/0015-settings-revision-boundaries.md`)

ADR-0015: Separate UI Snapshot and Configuration Revisions

状态:已接受

背景

设置 snapshot 需要在 runtime、平台状态、输入诊断和模型目录变化时刷新。配置写入同时需要
compare-and-swap,防止窗口中的旧编辑覆盖后台配置。若两者共用一个 revision,纯诊断变化会
错误拒绝仍然有效的配置编辑。

决策

  • SettingsSnapshot.revision 是 UI 可观察内容的单调版本,只用于丢弃过期异步结果和刷新视图。
  • SettingsSnapshot.config_revision 是当前环境持久化配置的版本;没有可编辑配置 snapshot 时为 None。ADR-0054 后,损坏配置按备份/默认值 fallback,恢复路径仍进入普通设置窗口,不再有 recovery-only 版本。
  • 所有已接入的配置/模型选择 command(overlay settings、motion audio、model selection)携带
    expected_config_revision,settings worker 在调用 Application 写入前比较它。Overlay visibility
    是 runtime-only command,不写配置;它仍携带当前 config revision 作为 stale-view guard。
  • 比较失败返回匿名 SnapshotOutdated,不改变 runtime、配置文件或任何 revision;UI 读取最新
    snapshot 并保留可操作错误。

后续边界

模型选择、启动项和其他配置域接入 optimistic concurrency 时继续使用 config_revision;纯
平台/诊断状态不得改变该 token。revision 仍可因这些状态变化推进。


Adr 0016 Diagnostics Export Contract (`docs/adr/0016-diagnostics-export-contract.md`)

ADR-0016: Anonymous Diagnostics Export Contract

状态:已接受(2026-09-01)

背景

Diagnostics 页面已经聚合 runtime、输入、配置和模型目录状态,但用户和维护者需要一个可预览
的文件来复现问题。直接打包原始日志、模型目录或输入事件会泄漏用户路径、模型身份和按键内容,
也会让导出格式依赖 Rust Debug 文本而无法稳定解析。

决策

  • 通过 settings service 的 ExportDiagnostics 强类型 command 生成 format version 1 JSON。
  • 导出目标固定为当前不可变构建环境的 logs/diagnostics.json,使用同目录原子写入;UI executor
    不执行文件 I/O。
  • 文档只包含 runtime/input/configuration 的稳定 code、匿名计数、模型来源计数和
    settings/config revision。模型 ID、路径、原始按键或事件流、原始配置、时间戳和动态 I/O 文本
    永远不进入导出。
  • 导出成功状态只回传格式版本和字节数,作为 UI snapshot 的非配置 revision;失败返回固定的
    diagnostics_export_failed,不暴露操作系统文本。
  • Diagnostics 页面提供键盘和 AccessKit 可访问的导出操作;“无报告”与成功字节数属于显示状态,
    不影响配置 revision。
  • 更新诊断以可选的 update 分栏加入导出,仅允许匿名稳定错误码和阶段计数
    (check/download/install 的 started/succeeded/failed);未接入更新 worker 时该分栏为 null。
    provider 的错误码必须来自 bongocat-update 统一 catalog;未知值在 Application 边界被丢弃,
    计数仍保留。不导出 endpoint、URL、HTTP 状态正文、artifact 路径、版本或签名材料。
  • 导出额外包含 app-owned 和 Cubism Core 两个日志 owner 各自的匿名聚合统计(written、dropped、
    rotated、pruned、bytes 和 retained_files),但不读取或复制任何日志正文、路径或 Core 消息。
  • format version 1 是当前完整的匿名结构;消费者必须拒绝非 v1,不能猜测或忽略不兼容的
    导出格式。在首版发布前字段直接修改当前 v1,并同步此 ADR、测试与文档,不保留中间格式的兼容分支。
    首版发布后才为不兼容演进建立新的版本、schema、测试与 ADR。

后续边界

应用级日志 writer 的实现与有界清理已记录在 ADR-0017;跨域历史日志合并、预览器和更新系统的
诊断 manifest 仍需单独设计。
实现不得为了导出而放宽日志隐私、环境隔离或原子写入约束。


Adr 0017 Bounded Application Logging (`docs/adr/0017-bounded-application-logging.md`)

ADR-0017: Bounded Application Logging

状态:已接受(2026-09-01);2026-09-24 由 ADR-0064 修订文本格式、过滤与统一 retention

背景

BongoCat 需要可诊断的应用生命周期记录,但不能把按键、路径、剪贴板、配置/模型正文、URL、密钥/凭据或用户文件
内容写入日志。Cubism Core callback 只覆盖厂商边界,不能替代应用级日志;无界文件增长也会破坏环境
隔离和稳定性验收。早期实现使用 JSONL 与两个独立 writer,ADR-0064 已把输出改为普通文本,并让
application/Core 共用一套 writer policy。

决策

  • bongocat-app 持有 app-owned ApplicationLogHandle,业务调用只能提交闭合 event catalog 中的
    module、stable code、固定 message 和有界安全上下文。路径、原始按键、剪贴板、配置/模型正文、
    动态 OS 错误文本和密钥不进入日志协议。
  • application 与 Cubism Core 使用 bongocat-log 的同一套 UTF-8 单行文本 writer、级别过滤、按 UTC
    日切换、单文件大小保护、目录总量/文件数预算与 retention;两类 stream 仍分离,便于支持人员区分
    产品事件和厂商 callback message。
  • 文件名固定为 application-YYYY-MM-DD.log / cubism-core-YYYY-MM-DD.log;轮转段使用
    .<generation>.log。单个文件达到 1 MiB 后继续切分,两类日志合计最多 8 MiB/32 个文件,当前
    UTC 日活动文件始终保留,过期或超预算的最旧已轮转文件优先删除。
  • 当前 v1 logging.level 接受 error、warn、info、debug、trace,默认 info;
    logging.retention_days 接受 1..=30,默认 7。设置先原子持久化,再立即更新共享 controller
    并收敛允许删除的旧文件;不提供 rotation mode、文件大小或目录容量配置。
  • Cubism Core callback 只复制至多 512 bytes 到容量 128 的 non-blocking queue;专用 Rust worker
    才调用共享 writer。callback 不执行格式化或文件 I/O;callback-slot contention、queue full 和 stop
    后迟到记录只增加匿名 dropped 计数。shutdown 先注销 callback,再拒绝新记录、排空队列并 join
    worker。Core callback 没有可信 severity,原始 message 固定按 debug 过滤;实际 Core/renderer
    失败由 app owner 以稳定 error/warn event 记录。
  • Application startup creates application-running.marker inside the environment's log directory.
    The v1 marker contains only a schema version and one fixed phase: running, shutting_down, or
    panicked; it is flushed before services start and is removed only after runtime/audio shutdown
    and the shutdown_completed event. The next startup classifies a leftover marker anonymously as
    forced/unknown, shutdown interrupted, or panic, then immediately overwrites it with a new
    running marker. This preserves diagnosis without recording payloads or paths and prevents a
    stale marker from causing a repeated recovery loop.
  • Diagnostics export includes aggregate counts for the fixed application event codes and a saturated
    application-plus-Core retained-byte/file summary, alongside separate anonymous source counters, as
    part of its current format version 1. Consumers must reject non-v1 data rather than guessing. The
    export never embeds raw log records, panic payloads, paths, key values, or user file content.
  • Diagnostics preview 另由 ADR-0027/0064 约束:只从严格命名且可解析的 application .log 来源重写
    闭合 code,不复制原日志行;Core 原始 message 永远不进入 preview。

验证

bongocat-log 单元测试覆盖级别顺序、共享 controller、UTC 日切换、1 MiB 轮转、配置化保留、目录级
8 MiB/32 文件预算、未知文件/符号链接隔离、严格文本 parser 与 owner-only 权限。bongocat-app 覆盖
固定 event catalog、panic 脱敏、运行标记分类、共享过滤/retention 和脱敏 preview;bongocat-live2d
覆盖 Core callback contention、queue saturation、shutdown drain、文本写入和跨 writer prune 统计。
macOS Development release smoke 已验证 diagnostics-export 固定 entries、panic=abort 恢复和
settings-window lifecycle;独立 bounded normal run 在本机最新尝试中超时并留下 unclean marker,仍需复核。
Windows release smoke 与完整双平台证据仍待发布门禁补齐。

后续边界

远程上传、Core message 导出和第三方稳定日志 schema 不在本 ADR 范围。.log 面向用户与支持人员,
需要机器处理时使用匿名 diagnostics.json 或严格重写后的 preview entry。


Adr 0018 Model Interaction Settings (`docs/adr/0018-model-interaction-settings.md`)

ADR-0018: Typed Model Interaction Settings

状态:已接受(2026-09-01;2026-09-25 增加键盘/手柄模型输入门禁;2026-09-27 指针跟随按轴拆分)

背景

BongoCat 的 配置已经包含水平镜像、镜像指针跟随和忽略指针字段,但此前
这些字段没有穿过 runtime 边界。直接让 renderer 读取配置会违反 renderer 只消费不可变
RenderSnapshot 的所有权规则,也会让模型求值与显示变换在不同线程拥有不一致的设置。

决策

  • bongocat-runtime::ModelSettings 是六项模型交互设置的唯一 runtime 类型,并通过
    RuntimeCommand::SetModelSettings 写入、RuntimeSnapshot::model_settings 读回。
  • mirror 由 runtime 写入 RenderSnapshot::mirror_horizontal;Windows D3D11 和 macOS
    Metal 只依据该不可变字段对模型中心执行水平变换,不读取配置或 UI 状态。
  • 指针跟随按轴拆成 mirror_pointer_tracking_horizontal 和
    mirror_pointer_tracking_vertical,两项互相独立、默认均为 false:水平项在产品参数覆盖阶段
    反转指针的 X/Z 值(Z 与 X 是同一水平扫视的两个方向),垂直项只反转 Y 值。拆成两个布尔值而不是
    一个方向枚举,是因为「左右相反」和「上下相反」可以只出现其一、也可以同时出现,枚举无法在不增加
    第三种取值的前提下表达这四种组合;ignore_pointer 仍然跳过所有指针参数覆盖,使 Core 默认值或
    前序动画值保持有效。
  • ignore_keyboard 和 ignore_gamepad 是模型输入投影门禁:分别屏蔽键盘的键图/手部贡献,
    以及手柄的按钮/手部/摇杆/扳机贡献。它们不停止平台采集、pressed-state 校正与复位、
    匿名诊断或独立的快捷键注册;原始 pressed state 始终保留,解除门禁后仍能正确处理释放。
    三个模型输入忽略开关各自有一个可选的 application shortcut target,触发后仍经 settings
    service 的 typed command 和配置 revision 原子持久化。
  • 应用启动在配置验证后发送一次 typed settings command。动态设置页编辑、revision-checked
    持久化 command 和完整多显示器/实机输入证据由后续 TODO 继续跟踪。

验证

runtime 测试验证 settings command 的 revision、snapshot 和 shutdown 保留,分别验证键盘/手柄
输入门禁只改变模型投影而不丢失另一来源或原始 pressed state,并验证两个指针翻转开关各自只影响
所属轴:单开水平项时 X/Z 反号而 Y 不变,单开垂直项时只有 Y 反号,两项同开等于两次单开结果的组合。
app service 测试验证三个输入门禁快捷键会切换并持久化对应设置,并验证两个翻转字段经 typed command
写入配置文件后重启仍被恢复。macOS Cubism 测试验证指针镜像/忽略规则及 render snapshot 镜像
字段;overlay 变换测试验证普通与水平镜像在中心点保持相同且 X scale 符号相反。Windows 使用
相同纯 Rust 变换逻辑,其真实 GPU/DPI 矩阵仍属于平台门禁。

后续边界

本 ADR 不改变 physics/pose 的授权 fixture 和行为求值门禁,不声称完整 Live2D 兼容;
也不定义快捷键冲突、用户编辑或平台全局 hotkey 注册协议。


Adr 0019 Gpui Component Design System (`docs/adr/0019-gpui-component-design-system.md`)

ADR-0019: gpui-component Design System

状态:已被 ADR-0020 取代(2026-09-04)

后续修订(2026-09-23):除 ADR-0020 的 facade 取代外,ADR-0054 进一步退役了项目 AccessKit bridge;本文的组件与语义细节均为历史证据。

背景

设置窗口需要与固定的 gpui 0.2.2 兼容的主题、输入和桌面控件。维护者决定将现有
guise-ui 1.5.3 替换为 gpui-component,并指定其官方组件文档作为 API 事实来源。

决策

  • workspace 使用 GPUI Kit 官方开发版 gpui-component 0.5.2 revision
    c0946e6acdc9e2f984f317ef7f998ee2c79f1a87,关闭未使用的可选 feature;其许可证为
    Apache-2.0,依赖 GPUI 0.2.2。GPUI Kit 的 manifest 使用官方 Zed git source,提交的
    Cargo.lock 固定当前解析到的 GPUI revision 55c0cc36d18cf06c6c54f640a87ce83d09413ae4,
    避免组件与应用加载两份 GPUI 类型;gpui_platform 使用同一 Zed source。
  • 设置窗口在首次创建组件前调用 gpui_component::init,以官方 Root 作为窗口根视图,并在
    系统外观变化时同步 Theme。
  • 该 GPUI revision 默认会为窗口安装自己的 AccessKit adapter,而现有项目桥接仍承载已验证的
    macOS AX/Windows UIA 语义和 action contract。过渡期所有 GPUI 应用入口使用
    Application::new_inaccessible(gpui_platform::current_platform(false)),仅禁用重复的 GPUI
    adapter,避免两套 accesskit_macos 向同一 NSView 注册同名 Objective-C 类并触发 abort;
    项目桥接继续提供辅助功能。迁移到 GPUI 原生 element-level 语义后必须同时删除项目桥接、
    直接 AccessKit 依赖和此兼容构造,并重跑双平台辅助功能门禁。
  • 图标资源使用官方 gpui-component-assets = 0.5.1 的 Assets(Lucide SVG),通过
    Application::with_assets 注册给 GPUI;应用不自维护重复的 icons/*.svg 文件。
  • UI 使用组件库的 Settings、SettingPage、SettingGroup、SettingItem、SettingField,以及
    Button、Switch、Tag、Input 和 NumberInput。偏好设置页面由官方 Settings
    sidebar 管理,页面内按职责使用 SettingGroup;Settings 自带的搜索会按 SettingItem 标题、描述和显式关键词过滤,页面/分组名称由 ADR-0066 作为关键词提供,业务关键词不再塞进可见描述。
    InputState 只持有编辑状态;InputEvent/NumberInputEvent 转换为现有 typed command,
    runtime snapshot 仍是持久状态事实来源。
  • gpui-component 0.5.2 没有普通 Card primitive;HoverCard 是悬浮预览组件而非内容容器。
    Settings 分组使用官方 GroupBoxVariant::Outline,模型管理和诊断等复杂内容通过官方
    SettingField::element 嵌入,不另建设置框架。
  • 第三方类型不进入 runtime、config 或 model 公共 API。停止维护或 GPUI 版本不兼容时,替换
    边界保持在 bongocat-ui 和窗口初始化代码内。

影响

gpui-component 提供的控件范围更广,但传递依赖和编译成本高于 Guise。锁文件必须提交,后续
升级继续按仓库依赖规则审计版本、许可证、平台构建和完整输入/窗口 smoke。双平台辅助功能、缩放
及真实交互证据仍是 UI 完成门禁,本 ADR 不将这些 TODO 标记完成。


Adr 0020 Gpui Kit Facade (`docs/adr/0020-gpui-kit-facade.md`)

ADR-0020: GPUI Kit 统一依赖入口

状态:已接受(2026-09-04)

后续修订(2026-09-24):ADR-0054 退役项目自有设置语义桥。上游已合并 SettingGroup::variant(),但包含该能力与 Popover::arrow() 的 crates.io release 尚未发布;当前直接依赖上游固定 revision 500852f449c05dc01920ec82f3ae2656a61d0387,不再使用 [patch.crates-io] 或维护者 fork。对应 release 发布后恢复纯 registry 精确 pin。

后续修订(2026-09-28):上游已发布 gpui-kit v0.7.0,该 release 同时包含
SettingGroup::variant() 与 Popover::arrow()。按上一条与 ADR-0056 的既定条件,直接依赖
切回 crates.io 精确 pin =0.7.0,longbridge/gpui-kit git source 与 deny.toml 的对应
放行项一并删除。GPUI 依赖随之上移到 gpui-pre 0.3.7。

后续修订(2026-10-06):升级到 crates.io 精确 pin =0.7.1,GPUI 依赖随之上移到
gpui-pre 0.3.8。仍然只依赖同一个 registry 版本,不改变本 ADR 的替换边界。

后续修订(2026-10-12):升级到 crates.io 精确 pin =0.7.3,GPUI 依赖随之上移到
gpui-pre 0.3.9(Zed 快照 93d745668805029476e6d7fbb19652116fed51de)。仍然只依赖同一个
registry 版本,不改变本 ADR 的替换边界。

背景

ADR-0019 直接组合 Zed git source 的 gpui、gpui_platform 与 GPUI Component 开发版及
assets。应用必须手工保证四个 source 的类型一致,manifest、import 和升级边界分散。GPUI Kit
随后在 crates.io 发布 0.6.0,提供 GPUI、platform、base、component 和 assets 的统一入口。

决策

  • workspace 只直接依赖 gpui-kit,当前为 crates.io 精确 pin =0.7.3,并提交完整
    cargo update 后的 Cargo.lock。不再直接声明 gpui、gpui_platform、gpui-component
    或独立 assets crate,也不使用其它 git source 混装 GPUI。
  • 代码从 gpui_kit 根使用 GPUI 类型,从 gpui_kit::platform、gpui_kit::component 和
    gpui_kit::assets 使用对应层;组件初始化统一调用 gpui_kit::init。
  • 当前固定的 gpui-kit 使用 Apache-2.0 许可证。它的 GPUI 依赖
    通过 crates.io gpui-pre 同步包交付;当前 lockfile 解析到 0.3.9(v0.7.3 起
    gpui-kit 以 =0.3.9 精确 pin 该家族,gpui-pre-reqwest 仍为 0.12.15),包元数据声明
    Zed gpui 0.2.2 revision 93d745668805029476e6d7fbb19652116fed51de。因此项目不再直接
    覆写 GPUI,但也不把该传递包误记为 crates.io 包名 gpui = 0.2.2。
  • 2026-09-19:gpui-kit 由 =0.6.1 升级到 =0.6.4(v0.6.2 功能版与 v0.6.4 补丁的
    最新稳定版;无 breaking API 变化),升级范围仍在 bongocat-ui 与 bongocat-app,
    不改变本 ADR 的替换边界。
  • 2026-09-22:gpui-kit 由 =0.6.4 升级到 =0.6.6(crates.io 最新非 yanked 稳定版;
    v0.6.5/v0.6.6 为补丁:masked label 跳过高亮,以及把 gpui-pre 改为 =0.3.6 精确
    pin,无 breaking API 变化)。lockfile 中 gpui-kit 本体之外,gpui-base/
    gpui-component/gpui-kit-assets 0.6.6 与 gpui-pre 0.3.6(Zed 快照
    bcf6582ce3500df93a8a39366640173e6786cea6,zed-version 仍为 0.2.2)在本次
    cargo update 前已由此前一次完整 cargo update 解析到位,本次无 diff;v0.6.6 的
    =0.3.6 约束恰好与既有解析一致。升级范围仍在 bongocat-ui 与 bongocat-app,
    不改变本 ADR 的替换边界。
  • 2026-09-24:cargo search gpui-kit --limit 1 与 cargo info gpui-kit 仍显示 crates.io
    最新稳定版为 0.6.6,但该 release 不含上游后来合并的 SettingGroup::variant() 与
    Popover::arrow()。上游 merge commit 500852f449c05dc01920ec82f3ae2656a61d0387
    的 package 元数据仍为 0.6.5,因此本仓库直接固定该完整 commit;lockfile 中
    gpui-kit、gpui-component、gpui-base、gpui-component-macros 与
    gpui-kit-assets 统一为同一 git source,gpui-pre 仍为 crates.io 0.3.6。
  • 同一 revision 由 Root 自动挂载 dialog、sheet 与 notification layer;业务根视图删除旧的
    Root::render_*_layer 调用。PopConfirm 转发 Popover::arrow(bool),模型删除确认启用
    anchor-aligned arrow。对应上游 release 发布后,本条临时 git source 决策自动失效并恢复
    crates.io 精确 pin。
  • 2026-09-28:gpui-kit v0.7.0 发布,包含上述两项 API 与 Root 的自动挂载行为,因此
    切回 crates.io 精确 pin =0.7.0,删除 longbridge/gpui-kit git source 与 deny.toml
    的对应放行项。lockfile 中五个 suite package 统一解析为 0.7.0,GPUI 同步包升到
    gpui-pre 0.3.7(v0.7.0 以 =0.3.7 精确 pin 该家族)。项目未使用 release notes 中
    breaking change 涉及的 Chart/Plot、DatePicker/TimeField、Table、TextView heading 与
    表单/表格/弹层 primitive 迁移面,源码零改动;Root 与 WindowExt 仍由业务窗口入口
    直接使用。
  • 2026-10-06:gpui-kit 由 =0.7.0 升级到 =0.7.1(crates.io 最新非 yanked 稳定版)。
    suite 五个 package 统一为 0.7.1,GPUI 同步包升到 gpui-pre 0.3.8(v0.7.1 以 =0.3.8
    精确 pin 该家族;同版本内含 continuation-indent API 适配,保持原有换行行为)。release notes
    里的两处行为变化——chart 首次绘制动画默认开启、Questionnaire 单选即确认——只涉及项目未使用的
    组件;新增的 speech/ColorSelect 与 RenderedText::source 也是增量 API。IME 候选框定位、输入框
    单行裁剪、disabled Radio、菜单键盘高亮、CJK 标点换行等修复落在设置界面实际使用的组件上,
    源码零改动。workspace 范围的 cargo update 同时把 rust-i18n-macro/rust-i18n-support 带到
    4.2.4,与 gpui-component 展开 t! 所需的 rust-i18n 运行时不再匹配,因此直接 pin 由 =4.2.2
    上调到 =4.2.4(ADR-0012)。
  • 2026-10-12:gpui-kit 由 =0.7.1 升级到 =0.7.3(crates.io 最新稳定版;上游发布说明
    声明 v0.7.3 取代 v0.7.2 并包含其全部改动)。suite 五个 package 统一为 0.7.3,GPUI 同步包
    家族统一为 gpui-pre 0.3.9(v0.7.3 以 =0.3.9 精确 pin 该家族)。lockfile 随之移除
    gpui-pre 不再需要的传递依赖——tracing-subscriber/sval/value-bag 系列、
    bindgen/clang-sys/cexpr/prettyplease、bzip2 与 libbz2-rs-sys、cgl、
    core-graphics2、core-video、io-surface、objc2-core-media、
    objc2-screen-capture-kit、async-compression/compression-*——并新增 v0.7.3 引入的
    可选后端 gpui-fast 0.1.7 家族与 Diff 组件所需的 similar。libbz2-rs-sys 已不在依赖图
    中,deny.toml 为它保留的 bzip2-1.0.6 例外一并删除(cargo deny 会把它报成
    unmatched license exception)。
  • v0.7.3 的两处行为变化落在项目实际使用的组件上:TextView 默认排版改为按继承字号计算标题
    字号(0.7.1 是相对硬编码 px(14.) 的绝对 rem 阶梯,更新窗口以 .text_xs() 渲染更新日志时
    二级标题会到 21px),段落间距由 1rem 收到 0.75rem;设置列表的横向内边距移进列表行,
    焦点环不再被列表裁剪。两者都是外观变化,不涉及项目代码的 API 迁移面——Dialog 入场动画、
    TooltipDefaults、DataTable header_row_height、Editor 补全、InputState::add_selection
    等均为项目未使用或纯增量能力,源码零改动。
  • gpui-fast 是 v0.7.3 新增的可选 feature,启用后整套 GPUI 实现由 gpui-pre 换成 wgpu 后端的
    gpui-fast。本 ADR 不启用它:后端替换需要单独的 ADR、双平台实机证据与设置窗口、IME、缩放、
    窗口重建回归,不能作为依赖升级的附带结果。
  • GPUI Kit 默认 component/assets feature 正好覆盖当前设置窗口。tree-sitter、decimal、
    inspector 和 test-support 等可选 feature 不启用。其 native facade 还会引入配套 HTTP client
    与 TLS 传递依赖;这些依赖不得进入 BongoCat 的业务 API。
  • 当时保留项目 AccessKit bridge、typed command/snapshot 和独立 overlay 边界;Application::new_inaccessible 通过 gpui_kit::platform 构造。ADR-0054 后项目桥接已退役,new_inaccessible 只作为禁用 GPUI adapter 的兼容选择保留,不再形成 BongoCat 的辅助功能 contract。
  • 替换边界限定在 bongocat-ui 与 bongocat-app 的窗口入口。上游停止维护、许可证变化或
    GPUI 版本不兼容时,只替换这一 UI 边界,不向 runtime、config、model 或 renderer 扩散
    GPUI Kit 类型。

影响

manifest 和 Rust import 只有一个版本入口,避免应用与组件解析到两套 GPUI 类型。来源在
2026-09-28 回到 crates.io 精确 pin,lockfile 记录 registry checksum,deny.toml 不再放行
GPUI Kit 的 git 仓库。代价是 GPUI Kit
统一管理整套传递版本,升级必须作为单独变更重跑双平台构建、设置窗口、IME、辅助功能、缩放、
窗口重建和 shutdown smoke;ADR-0054 后“辅助功能 smoke”不再是项目自有 UI 完成条件。本 ADR 不把仍缺少实机证据的 UI TODO 标记完成。


Adr 0021 Signed Update Manifest (`docs/adr/0021-signed-update-manifest.md`)

ADR-0021: Signed Update Manifest Trust Boundary

状态:已被 ADR-0029 取代(2026-09-13)

后续修订(2026-09-23):本文已由 ADR-0029、再由 ADR-0034 取代。当前更新目标为 Windows x64、macOS aarch64 与 macOS x64;Windows 原生 ARM64 已由 ADR-0033 退役。

背景

BongoCat 将自动/手动更新列为首发行为,但旧版 updater 使用 HTTP endpoint、前端固定凭据和
旧 payload,不能进入新架构。下载和安装前必须先建立独立、可离线验证的信任边界;否则网络 client、
设置 UI 和高权限 installer 会各自解释版本、target、hash 或签名,形成不一致的安全判断。

Development 与 Production 构建也不能共享可变 channel。Windows 只支持 x64/ARM64,macOS 支持
Intel/Apple Silicon;Windows ARM64 在官方 desktop Cubism Core 可用前仍是发布阻塞,而不是由更新
manifest 绕过。

决策

  • 新增平台无关且禁止 unsafe 的 bongocat-update crate。它只执行 manifest、签名、版本、target
    和 artifact 完整性验证,不联网、不写文件、不启动 installer,也不持有 runtime 或 UI 状态。
  • manifest v1 使用严格 snake_case JSON 和 detached Ed25519 签名。客户端先对收到的原始 manifest
    bytes 执行严格验签,成功后才反序列化;未知字段、非 v1 schema、超过 1 MiB 或无效 JSON 均拒绝。
  • manifest 固定包含 Development/Production channel、SemVer release、最低可升级版本、单调
    release_sequence、Unix 发布时间和 1 至 8 个 artifact。artifact 明确携带 target triple、arch、
    HTTPS URL、字节数和小写 SHA-256;只接受既定的四个 Windows/macOS target/arch 组合。
  • verifier 由不可变构建环境、当前 target、当前版本、已安装 release sequence 和编译期信任公钥集
    构造。公钥以稳定 key ID、环境 channel 和 sequence 有效窗绑定;未知、跨环境、重复或超出轮换窗
    的 key 均拒绝。私钥不进入源码、构建产物或运行时配置。
  • manifest sequence 低于已安装值,或新版本没有更高 sequence,视为降级攻击。当前版本低于
    manifest 的最低可升级版本时不尝试就地更新;相同或更旧版本只返回 up-to-date,不产生安装候选。
  • 每个环境的最高已验证 sequence 由 bongocat-update::UpdateSequenceStore 从不可变
    StorageLayout 单独保存为 updates/update-sequence.json v1,不进入用户 config 或窗口 state 事务。
    状态固定包含 channel
    和最高 sequence;同目录锁串行化 writer,写入经同目录原子替换并回读验证。未知字段、非 v1、
    跨环境、符号链接、损坏或较低 sequence 一律失败关闭,绝不重置或覆盖已有状态。Unix 在创建或
    重开 updates/ 时强制 0700;Windows 继续以用户 profile ACL 作为同等私有边界。
  • 验证成功只返回字段私有、只提供只读 accessor 的项目自有不可变 VerifiedUpdate/VerifiedArtifact。
    第三方 URL、SemVer、签名或 digest 类型不进入公共 API。下载层必须把流交回 VerifiedArtifact
    同时验证精确长度和 SHA-256,通过前不得进入安装阶段。
  • UpdateVerificationSession 将 verifier 与当前环境的 sequence store 成对创建:打开时读取最高
    sequence,验签和严格 manifest 验证成功后才记录新 sequence;Available 与 UpToDate 都属于
    成功验证,并立即更新 session 的 rollback 下限。验证失败不得创建或推进 state;该 session
    不联网、不下载或启动 installer。
  • 公共 schema 与 accept/reject fixtures 位于 shared/update/。签名传输 envelope、endpoint、下载
    恢复、OS 包签名、installer 权限与回滚属于后续独立 contract。

依赖与替换边界

  • ed25519-dalek = 3.0.0(BSD-3-Clause)提供纯 Rust Ed25519 严格验签;关闭默认 zeroize feature,
    只启用 verifier 所需 fast 表。维护仓库为 dalek-cryptography,Rust 1.85+。
  • sha2 = 0.11.0、semver = 1.0.28 和 url = 2.5.8 均为当前稳定版,许可证为
    MIT OR Apache-2.0,支持项目 Rust 1.97。它们分别只位于 digest、版本和 HTTPS 解析边界。
  • bongocat-config 是同一 workspace 的平台无关依赖,只提供不可变的 StorageLayout 与
    BuildEnvironment,用于把更新 sequence 固定在当前环境的 updates/ 根;它不向 update
    verifier 暴露配置内容、平台 handle 或 UI 类型。
  • 这些依赖均由 bongocat-update 封装;若停止维护,可替换实现而不改变 app/runtime/UI 公共协议。
    manifest 算法或字段变化仍需新 schema/ADR,不能在 v1 中静默替换。

验证

  • 单元测试使用固定测试私钥覆盖有效签名、篡改、环境错配、公钥轮换窗、sequence 降级、最低版本、
    target/arch 错配、重复 target、未知字段、HTTP URL、artifact 长度/hash 和读取失败。
  • Draft 2020-12 门禁验证共享 accept/reject fixtures;Rust 测试对同一 valid fixture 签名并解析,防止
    schema 与实现漂移。
  • 完整 workspace 在 macOS、Windows 和 Ubuntu 执行 format、严格 Clippy、测试与 release
    check;dependency policy 继续覆盖许可证和 registry source。

后续边界

下一步建立环境绑定 endpoint 与 24 小时自动/手动检查调度,再实现有界下载、取消、临时文件清理、
平台 installer、操作系统签名验证和失败回滚。没有这些证据前不得声称更新功能或 stable 发布完成。


Adr 0022 Update Manifest Transport Envelope (`docs/adr/0022-update-manifest-transport-envelope.md`)

ADR-0022: Update Manifest Transport Envelope

状态:已被 ADR-0029 取代(2026-09-13)

背景

ADR-0021 已固定签名 manifest 的离线验证边界,但尚未定义网络响应如何无损携带 key ID、detached
signature 和原始 body。让 HTTP client、设置服务或发布脚本各自选择 JSON wrapper、base64 或 redirect
策略会使待验签 bytes 产生歧义,也会把传输库类型带入信任判断。

决策

  • manifest endpoint 只能经 HTTPS GET 获取,客户端不跟随 redirect,只接受 200 响应。endpoint
    自身继续由不可变环境发布配置提供,不能来自用户 config、CLI 或运行时输入。
  • 响应 body 是不经 JSON 解析、字符转换、解压或重编码的 manifest 原始 bytes,最大为 1 MiB。
  • bongocat-update-key-id header 携带最多 64 字节的 ASCII key ID,只允许字母、数字、-、_ 和 .。
    bongocat-update-signature-ed25519 header 携带恰好 128 个小写十六进制字符,代表 64-byte detached
    Ed25519 signature。缺失、过长或语法无效的 header 以稳定 update error code 失败关闭。
  • transport adapter 只把有限 header 和精确 body 转为 UpdateManifestEnvelope。它不验证 manifest、写入
    sequence state、下载 artifact 或调用 installer;envelope 只可交给 ADR-0021 的 verifier/session。
  • HTTP、TLS、代理、状态文本和 response 类型不离开 transport adapter。自动/手动调度、取消、下载与
    安装另行实现。

验证

bongocat-update 单元测试覆盖 envelope 的 body 上限、key ID/signature 编码拒绝、exact-byte 保留以及
通过 envelope 的验签路径。后续网络 adapter 必须补充真实 HTTPS、redirect、代理、非-200、截断和超限
body 测试。

后续边界

本 ADR 不选择 HTTP crate、Production endpoint 或公钥注入方式,也不授权下载、包签名、installer 权限或
回滚行为。


Adr 0023 Windows Per User Installer (`docs/adr/0023-windows-per-user-installer.md`)

ADR-0023: Windows Per-User Installer

状态:已接受(2026-09-05);NSIS toolchain 与 wrapper 边界已由 ADR-0033 取代(2026-09-14)

注(2026-09-14):本 ADR 关于 per-user 安装、权限面、卸载语义与数据隔离的决策仍然有效。
以下内容已随 ADR-0033 作废,不要再据此实现:

  • 由本项目维护 NSIS 脚本、固定官方 nsis-3.11-setup.exe 的 MD5、以及通过
    BONGOCAT_NSIS_SETUP_PATH / BONGOCAT_MAKENSIS_PATH 注入本机 NSIS 路径;
    安装器改由 cargo-packager 的 NSIS 模板与 install-mode 配置生成。
  • "x86 不构建或发布,ARM64 在 Cubism desktop Core 门禁通过前不生成可发布 artifact":
    Windows 现在只有 x64(ADR-0010 已更新)。
  • "Windows packaging 仅接受已 Authenticode 签名的 payload":新流水线在未配置签名
    身份时会生成未签名安装器并输出警告,签名验证降级为 release workflow 的显式门禁。
  • "未来 Rust update helper 只接收 ADR-0021 验证完成的本 target/arch artifact"一句已随
    ADR-0029 作废:不再有独立 Rust update helper,替换由 self_update 1.3.0 在进程内完成,
    其权限边界、原子性与失败恢复未经本项目威胁建模。ADR-0021 已标记「已被 ADR-0029 取代」。

背景

BongoCat 已使用当前用户 HKCU Run 启动项、环境隔离的数据根和独立的签名更新 trust boundary。
Windows 首发仍需要固定安装格式,以便明确 installer 权限、卸载语义和未来 update helper 的交接。

决策

  • Windows 首发采用 per-user NSIS installer。每个受支持 target/arch 生成独立安装 artifact;
    当前只有 x86_64-pc-windows-msvc。
  • 安装器以当前用户身份安装,不请求管理员权限,不安装 service、driver 或机器级注册项;
    卸载元数据写在 HKCU。
  • installer 升级只在固定 product root 内替换旧的 product files,避免遗留 binary。
  • 安装、升级和卸载不能读取、导入、迁移或删除 Development/Production 的 config、window state、models、
    backups、logs 或 updates 数据。默认卸载只删除 product files;删除用户数据必须是独立且明确的
    用户操作。
  • installer 不联网、不解析 update manifest,也不自行选择 artifact。NSIS 只作为安装 packaging
    tool,不承载 BongoCat 业务逻辑。

备选方案

  • MSIX 会把启动、activation 与更新交由 package model,不能作为当前 HKCU Run 与进程内
    self_update 路径的无附加重构替代。
  • WiX / MSI 主要服务机器级部署,通常引入管理员权限与企业安装策略;首发不需要该权限面。
    cargo-packager 的 wix format 因此不进入发布矩阵。

验证

后续 installer job 必须在干净 Windows 10 1903+ 与 Windows 11 用户 profile 验证安装、升级、卸载、
签名、环境数据保留、x64 artifact 选择和失败 rollback。这些证据尚未取得,不能宣称 Windows
package 可发布。


Adr 0024 Macos Tcc Capability Boundary (`docs/adr/0024-macos-tcc-capability-boundary.md`)

ADR-0024: macOS TCC Capability Boundary

状态:已接受(2026-09-05)

后续修订(2026-09-23):ADR-0054 退役了项目自有 AccessKit/AppKit 设置桥;Input Monitoring 与 Accessibility TCC 的边界仍然有效。

背景

BongoCat 需要在应用失焦时监听键盘和鼠标边沿,以驱动 BongoCat overlay;同时设置窗口必须向
VoiceOver 等辅助技术公开自身的可访问语义。两种能力不能因为都被 macOS 归入隐私或辅助技术领域而
混为同一项 TCC 请求。

决策

  • 全局键盘、鼠标和修饰键监听只使用 Input Monitoring。bongocat-platform 仅以
    CGPreflightListenEventAccess 查询状态,并只在由用户发起的明确设置操作中允许调用
    CGRequestListenEventAccess;启动、轮询和服务恢复不得弹出请求或重复请求。
  • listen-only CGEventTap、CGEventSourceKeyState 和 CGEventSourceButtonState 都属于上述
    Input Monitoring 用途。权限拒绝或撤销必须让输入服务进入匿名 PermissionDenied 状态并可靠 Reset;
    overlay、设置窗口和本地配置仍继续运行。
  • 设置窗口的 AccessKit/AppKit adapter 只公开 BongoCat 自己窗口的可访问树和 action channel。它不
    读取、控制或自动化其它应用,不调用 Accessibility trust/prompt API,因此不得为此请求 Accessibility
    TCC 权限。
  • NSOpenPanel、NSWorkspace URL 打开和 pasteboard wrapper 保持各自最小能力边界;它们不得借由
    Input Monitoring 或 Accessibility 状态扩大访问范围。
  • Settings 必须分别显示 Input Monitoring 的当前状态和输入服务状态。状态变化只更新 snapshot/诊断,
    不把“已授权”表述为输入服务已经重启;重新启动输入服务仍必须经过显式、受控的 owner 生命周期。

验证

  • 静态检查确认平台输入路径只使用 listen-event preflight/request API,AccessKit bridge 不链接或调用
    Accessibility trust API。
  • macOS 实机矩阵覆盖未授权、拒绝、授权和撤销 Input Monitoring;确认不出现 Accessibility 提示,且
    拒绝时 overlay/settings 保持可用。
  • 后续 TCC UI 刷新实现必须分别测试 permission snapshot 与 service status,确保授权变化不会被误报为
    已运行的 event tap。

引导流程与逐次重置(2026-09-30,ADR-0078)

ADR-0078 把 macOS 的引导动作换成 permission-flow 的浮动面板,并在每次进入授权流程前执行
tccutil reset ListenEvent com.ayangweb.bongo-cat。本 ADR 的 Input Monitoring 边界因此有两处修订:

  • 「只在由用户发起的明确设置操作中允许调用 CGRequestListenEventAccess」仍然成立,且引导流程
    整体位于用户点击之后,没有引入启动或轮询路径上的 TCC 请求。
  • 但「不重复请求」的语义改变了:逐次重置会主动把已有授权清回未授权,因此用户每次进入引导都要重新
    授权。这是产品明确要求的行为,不是实现偏差。
  • 「权限拒绝或撤销必须让输入服务进入匿名 PermissionDenied 状态并可靠 Reset」中,进入
    PermissionDenied 这一半已有覆盖
    (tap 被禁用 → 重启 → 进程内 CGPreflightListenEventAccess
    复查 → PermissionDenied),但重新授权之后没有自动重启输入服务的触发点:重置使运行中的
    tap 失效,用户重新授权后输入要等应用重启才恢复。这一缺口记录在 ADR-0078 的「未完成项」。
  • 能力面保持最小:permission-flow 虽然暴露 8 类权限,但产品只使用 INPUT_MONITORING 一项,
    且其类型不离开 bongocat-platform 的私有 adapter。

Adr 0025 Signed Manifest Https Transport (`docs/adr/0025-signed-manifest-https-transport.md`)

ADR-0025: Signed Manifest HTTPS Transport

状态:已被 ADR-0029 取代(2026-09-13)

背景

ADR-0021 和 ADR-0022 已定义离线验签及 HTTP envelope 字节协议,但没有实际 HTTPS client 时,
endpoint、redirect、压缩和 response-body 限制仍可能在后续 app 接线时被各自实现。尤其是 transparent
compression 会改变 detached Ed25519 signature 必须覆盖的原始 bytes。

决策

  • bongocat-update 使用精确固定的 ureq 3.4.0 作为唯一 signed-manifest HTTPS transport。它只启用
    纯 Rust rustls feature,显式关闭默认 features,因而不启用 gzip、charset、cookie 或 proxy feature。
  • UreqUpdateManifestSource 只接受 UpdateManifestEndpoint。endpoint 最多 2 KiB,必须是无 credentials、
    无 fragment 且具有 host 的 HTTPS URL;它来自后续不可变的构建发布配置,绝不来自用户 config、CLI 或
    运行时输入。
  • 每次 fetch 只发送一次 GET,限制为 15 秒 global deadline,不跟随 redirect,且只接受 HTTP 200。
    response body 以 Read::take(1 MiB + 1) 有界读取,不进行 JSON/字符集/压缩处理;超过上限和空 body
    均通过既有 manifest_too_large verifier code 失败关闭。
  • transport 只构造 UpdateManifestEnvelope,由其继续校验 key-ID/signature header。endpoint、status、
    transport 和 body-read 错误只公开固定匿名 transport code;底层 URL、HTTP status text、TLS 或 client
    类型不离开 update crate。调用方必须在专用 update worker 运行 blocking fetch,禁止在 GPUI executor
    执行。
  • UpdateCheckCoordinator 只能以 UpdateManifestSource 取得 envelope,再调用既有
    UpdateVerificationSession。其顺序固定为 fetch -> signature/schema/target verification -> sequence
    store commit;任何 fetch 或 verification failure 都不能改变当前环境的 anti-rollback state。
  • 同一受限 agent 还提供 artifact source,但它只可打开 VerifiedArtifact 已验证的 URL,固定 HTTPS、
    无 redirect、200,并只返回 raw reader 或既有 HttpStatus/Transport retry 分类。下载 coordinator
    继续独占重试、取消与 staging,artifact verifier 继续独占长度和 hash 校验。

依赖与替换边界

ureq 3.4.0 为 MIT OR Apache-2.0、Rust 1.85+ 的公开维护 crate。它是纯 Rust blocking client;只在
UreqUpdateManifestSource 私有字段出现。若维护或安全边界不再满足要求,可替换该 source,不改变
UpdateManifestEndpoint、UpdateManifestEnvelope、verifier、runtime 或 UI contract。

验证

  • 单元测试锁定 HTTPS/credential/fragment/长度 endpoint 边界、HTTPS-only、no-redirect agent config 和
    transport code 唯一性。
  • 注入式 source 回归验证通过时仅写入验证后的 release sequence,而 source failure 后 sequence 保持不变。
  • workspace 的 format、strict Clippy、test、release check 与 dependency policy 覆盖新 crate graph。
  • 后续 app-owned worker 必须在真实 HTTPS endpoint 验证断网、代理、redirect、non-200、截断、超限、
    签名错误和降级攻击,且不影响本地 overlay。

后续

本 ADR 不选择 Production endpoint 或公钥,不实现 UI command、自动/手动 dispatch、installer 或 rollback。
它们仍需要各自的发布基础设施与安全验收。


Adr 0026 Update Helper Replacement Boundary (`docs/adr/0026-update-helper-replacement-boundary.md`)

ADR-0026: Update Helper Replacement Boundary

状态:已被 ADR-0029 取代(2026-09-13)

背景

ADR-0021 至 ADR-0025 已将 manifest、artifact URL、下载和 staging 限制为不可变、已验证的
项目类型。它们不授权网络 worker、GPUI 或 installer 写入正在运行的 product files。若将这一
边界隐含在后续 helper 中,path substitution、部分替换、权限扩大和 rollback 降级都会绕过已有
的签名与 anti-rollback 结论。

威胁模型

  • 不可信输入包括 update endpoint、manifest transport、artifact bytes、staging directory contents、
    product directory contents、环境变量、当前工作目录和用户可修改的 registry/launch arguments。
  • 可信输入只包括编译进当前 app 的环境、target/arch、trust key、当前 installation root,和由
    VerifiedArtifact 只读 accessor 传递的 release identity、精确长度、digest 与 staging file。
    私钥、endpoint 配置和任意用户配置均不进入 helper。
  • helper 与 app 同为当前用户权限;它不得请求 elevation、安装 service/driver、写入 HKLM、修改其他
    用户 profile,或以 Development/Production 用户数据目录作为 product replacement source/target。
  • 被攻击者替换、删除或锁定的 app/helper/staging file 必须导致失败关闭和保留已知可运行 product,
    而不是重试到另一路径或启动未知 binary。

决策

  • update helper 是独立、平台限定的 Rust owner,不属于 bongocat-update、runtime 或 GPUI。它只在
    app 完成 shutdown coordinator 的停止 frame tick、输入、runtime、config flush、audio/renderer/overlay
    release 后启动;app 必须等待 helper acknowledgement 或将本次更新报告为未安装。helper 不接受裸
    URL、manifest、target、版本、安装目录或 shell command-line replacement request。
  • 调用方传入的 installation root 必须等于平台固定的 current-user product root。Windows 为
    $LOCALAPPDATA\Programs\BongoCat,macOS 为当前已签名 .app bundle;helper 在写入前重新解析
    root、拒绝 reparse point/symlink/非目录和任何与当前 target/arch 不一致的 payload。它绝不从
    StorageLayout 的 config、state、models、backups、logs 或 updates root 导出安装路径。
  • helper 只接受同一环境 UpdateDownloadCoordinator 完成的唯一 staging file,重新检查 regular-file
    identity、所有权、无 link、精确 byte length 与 SHA-256,并执行对应 OS package signature/integrity
    verification。解包后的每个 product file 必须保持在 private same-volume work directory,拒绝 link、
    absolute path、path traversal 和已有 uninstaller;不允许把 archive entry 直接写入 product root。
  • 替换采用 prepare -> validate -> commit:先在 product root 同卷创建全新的 private candidate directory,
    验证 candidate 的 target/arch、required resources、OS signature 与 executable launchability;再将
    current root 原子重命名为唯一 rollback directory,将 candidate 原子重命名为 current root。任何
    prepare/validate/commit failure 都不得删除 current root。commit 后 launch failure、health timeout 或
    explicit failure acknowledgement 必须在同一 helper invocation 内将 rollback directory 原子恢复;成功
    acknowledgement 后才删除 rollback directory。
  • 同一 installation root 一次只允许一个 helper。lock/pid ownership 失效、父进程未退出、超时、unexpected
    child process 或 ambiguous recovery state 都停止替换并保留可诊断的 anonymous failure code。下一次
    launch 仅可在验证 current/rollback 两个固定 sibling 的签名与 target 后恢复其中一个;不得猜测、
    merge 或扫描其他目录。
  • helper 不联网、不解析 JSON、不检查更新、不记录 URL、路径、按键、artifact 内容或签名材料。日志只记录
    stable replacement phase/error code、environment、target 和 release sequence;其保留策略服从应用
    diagnostics contract。Windows NSIS installer 继续只用于初始/显式 per-user packaging,不成为
    background update executor。

验证

  • 平台 contract tests 覆盖拒绝裸输入、root/staging link substitution、path traversal、cross-environment、
    cross-target、invalid signature、concurrent helper、parent alive、prepare failure、atomic commit failure、
    launch/health failure rollback 与成功后 cleanup。
  • Windows 10 1903+ 和 Windows 11 以非管理员 profile 验证真实 Authenticode、lock、upgrade、rollback、
    uninstall 后无残留和 Development/Production 数据保留;macOS 12+ 验证 signed/notarized bundle、
    quarantine、rollback 和 data preservation。
  • 断电/kill fault injection 在每个 prepare/rename/acknowledgement 点重启,断言只会启动完整已验证的
    current 或 rollback product,且 never deletes user data。

后续边界

本 ADR 不实现 helper、OS signature verification、archive format、health protocol、endpoint、公钥或
release hosting。每个实现需要独立平台代码、自动化与实机证据;在上述验证通过前,更新安装和 stable
发布仍不可宣称完成。


Adr 0027 Diagnostics Preview Bundle Boundary (`docs/adr/0027-diagnostics-preview-bundle-boundary.md`)

ADR-0027: Diagnostics Preview Bundle Boundary

状态:已接受(2026-09-06);2026-09-24 由 ADR-0064 修订 application event 输入与输出格式

后续修订(2026-09-23):ADR-0054 后设置 UI 不再以 AccessKit 语义作为当前契约;本文的 “AccessKit 语义”验收应理解为当时的历史证据,当前改以可见文案和 typed result 为准。

背景

ADR-0016 定义的 diagnostics.json 是当前环境内原子写入的匿名摘要。它足以显示
runtime、输入、配置和日志 owner 的聚合状态,但不能让维护者查看有界的应用生命周期 code
历史。直接复制 logs/ 的所有内容不可接受:Cubism Core 的 message 不能被当作稳定、无用户内容
的记录,文件名、路径和未来日志 owner 也不能隐式进入导出。

决策

  • 预览包是当前不可变环境 logs/ 中的固定 diagnostics-preview.zip。它是 format version 1 的
    ZIP container,内容路径固定为 manifest.json、diagnostics.json 和
    application-events.log;archive entry 不携带宿主绝对路径、环境根、时间戳、模型 ID 或任意
    user-provided name。
  • package writer 只枚举严格的 application-YYYY-MM-DD.log 与
    application-YYYY-MM-DD.<generation>.log 普通文件,并使用 bongocat-log 的共享严格文本 parser
    验证固定宽度 UTC timestamp、level、module、stable code、message 与有界 context 语法。历史 JSONL
    与 cubism-core-*.log 不是 application event 来源,也不会触发兼容 reader。
  • 每个 code 必须来自 app-owned 闭合 catalog,module 必须与 code 的固定 component 相等,level
    必须与 code 的固定 severity 相等。任一记录未知、损坏、UTF-8/大小/普通文件检查失败时,整份来源
    文件跳过并增加匿名 skipped_source_files;不从部分文件复制记录。目录本身无法枚举则整个导出
    失败关闭,不伪装成空 preview。
  • 重写后的每条 event 固定为 <LEVEL padded> [<module>] <code>\n。输出不包含源 timestamp、固定或
    动态 message、context、原始行、来源路径或模型身份;因此 application-events.log 既保留旧版
    component/level/code 的可读性,又不会把日志正文或用户邻近数据复制进 support bundle。
  • Cubism Core 原始 .log 文件和 Core message 永远不进入预览包。Core 的 retained-file/byte 与
    written/dropped/rotated/pruned 聚合值继续只由 ADR-0016 的 diagnostics.json 表示。未来若要导出
    Core 历史内容,必须先定义可证明的固定结构和新的 ADR,不能复用路径关键词清洗作为授权。
  • ZIP 在当前环境 logs/ 下以私有同目录 staging file 创建,完成所有 entry 的大小、数量和结构验证
    后 flush/sync_all 并原子替换目标。取消、解析、写入、同步或替换失败必须删除本次 staging,
    保留上一份有效 preview;Unix 目录/文件分别恢复 0700/0600,Windows 使用当前 profile ACL。
  • package format 继续是首版 v1;在首版发布前字段可直接同步调整 manifest、writer、
    fixture 和测试,不添加任何兼容 reader 或 migration。首版发布后才为不兼容演进建立新的 versioned
    format 与 ADR。
  • UI executor 只调用 typed export command。UI 显示 stable result code、format version、entry count
    和 bytes,不直接枚举日志或解压 archive;失败不得显示 OS text、路径、archive entry 或日志正文。

验证

  • unit tests 覆盖 active/rotated 固定文件名、固定 entry 名称、严格 text record reserialization、
    全部 catalog code、unknown/mismatched module/level/code、malformed timestamp/delimiter、整文件跳过、
    旧 JSONL/Core 排除,以及路径/context/message/时间戳不进入输出。
  • transaction tests 覆盖成功原子替换、parse/write/sync/replace failure 清理、已有 preview 保留、
    symlink/non-regular source 拒绝、目录枚举失败和 Development/Production 目录隔离。
  • settings/UI contract tests 覆盖 typed result、pending/error/retry,不显示动态 path 或 archive 内容。
    Windows/macOS release smoke 验证 owner-only storage、真实 application event 重写和预览包可由系统
    工具打开,但不需要把原始日志上传或读回产品 runtime。

后续边界

本 ADR 不实现 remote upload、Core history export 或 support endpoint。具体 archive writer lifetime
必须在实现提交中记录;在完整验证前,Phase 7 日志导出门禁保持未勾选。

首个 writer 使用 zip = 9.0.2(MIT,zip-rs/zip2,Rust 1.88+),精确 pin 且关闭默认 feature,
只写标准 Stored entries;它不提供 encryption、compression 或 archive extraction。替换边界是同样
能在双平台写出并验证此固定 v1 ZIP entry 集合的维护中 Rust crate,不能改变本 ADR 的隐私/原子性约束。


Adr 0028 Rust I18n Catalog Structure (`docs/adr/0028-rust-i18n-catalog-structure.md`)

ADR-0028: Rust i18n catalog structure

状态:已接受(2026-09-10)

背景

BongoCat 最初把所有文案放在 ui.* 扁平 key 下。随着设置、模型、快捷键和诊断页面
增加,这种命名无法表达页面上下文,也让相同词语在不同语义下被误复用。rust-i18n 会在
编译期把嵌套 JSON 展平为查找路径,因此可以同时获得可读的源文件层级和稳定的运行时 key。

调研结论

  • Zed 的桌面 UI 资源按产品域和页面区域组织,状态与
    操作不是一个无意义的 common 大对象。
  • Joplin 的 locale 资源强调稳定语义标识和参数契约,
    复数/参数消息与页面结构分离,便于翻译工具和校验脚本处理。
  • Nextcloud 的大型桌面/服务界面
    以功能上下文维护消息,避免把视觉组件名称作为 key;同一文案只有在语义确实相同才复用。
  • KeePassXC 的桌面对话框
    和菜单动作按交互上下文组织,说明按钮、菜单项和错误提示需要稳定的领域归属,而不是泛化
    的 label/text。

这些项目没有被直接照搬;BongoCat 根据自己的页面和 runtime 状态建立独立目录。

决策

locale 文件保留根级 _version: 1,所有叶子 key 使用 snake_case,并按以下顶层领域组织:

领域 内容
navigation 设置窗口导航项及页面简介
settings 外观、overlay、模型交互、输入、应用和运行时设置
models catalog、身份、导入流程、行为和校验
shortcuts 快捷键范围、录入、动作和冲突
diagnostics 预留 runtime、输入指标、配置恢复和导出(当前无叶子)
about 产品信息和用户支持动作
actions 跨页面且语义稳定的确认、取消、刷新等动作
status 跨页面生命周期状态
errors 按 settings/models/runtime 归属的错误消息

页面区域使用 title、description 等有限且有上下文的字段;实体或状态继续使用具体名称,
例如 models.identity.source.built_in 和 settings.overlay.maximum_fps.description,不使用
无上下文的 common.name。需要参数的消息保留 %{name} 占位符,所有 locale 的 key、层级和
占位符集合必须完全一致。

Rust 在文案实际使用处直接通过 bongocat-i18n 查询稳定路径,例如
"models.catalog.load_failed"。bongocat-i18n 保持唯一的 rust_i18n::i18n! catalog owner:
UI 不重复嵌入 JSON,也不使用会在并发窗口间共享状态的全局 locale。动态摘要、错误、快捷键冲突
和诊断指标在负责其展示语义的函数中通过同一 facade 查询,不按语言分支写自然语言。

平台覆盖(platform override)

少数文案在两个首版平台(macOS / Windows)上需要不同的表达(例如「菜单栏」与「系统
托盘」是同一概念在不同操作系统的命名)。这种差异交给 catalog 而不是调用点处理:

  • 在 catalog 里,把 base key 拆成嵌套对象,例如
    settings.application.status_icon.label.macos 与
    settings.application.status_icon.label.windows,两个 locale 同时拥有。
  • UI 不写 cfg(target_os = ...),统一调用 bongocat_i18n::platform_text(locale, base_key)。
  • platform_text 的查找顺序是:.<current_platform_id> →
    ``。current_platform_id() 在编译期取 target_os,
    返回 "macos" / "windows" / "unsupported"。

扩展方式:未来再有需要分平台的项,只在两个 locale 文件里新增 .macos
和 .windows,调用点保持不动。仍不需要分平台的项继续用 bongocat_i18n::text。
未知平台(Linux、CI 主机)会回退到 base key,避免生产 UI 暴露 key 字符串。

验证

bongocat-i18n 测试递归展开 JSON 后比较语言 key 集合和占位符集合;rust-i18n 的 fallback
继续使用 en-US。新增语言必须先复制完整结构,再提交翻译,不得增删字段。

平台覆盖的覆盖测试随 bongocat-i18n 单元测试一起跑:
platform_text 在每个支持的 current_platform_id 上都返回对应平台的副本,并在没有
override 时回退到 base key。


Adr 0029 Third Party Update Library Boundary (`docs/adr/0029-third-party-update-library-boundary.md`)

ADR-0029: Third-Party Update Library Boundary

状态:已被 ADR-0034 取代(2026-09-14)
取代:ADR-0021、ADR-0022、ADR-0025、ADR-0026

后续修订(2026-09-23):ADR-0033/ADR-0034 已把历史“四个 target”收口为 Windows x64、macOS aarch64 与 macOS x64;本文的 self_update、zipsign、四目标和相应验证记录只作历史。

本文已失效,正文保留为历史记录。

2026-09-14 的换实现表明,本文记录的 self_update 1.3.0 + zipsign 归档内嵌签名模型有两条
硬性阻塞:zipsign 只能签 .zip 与 .tar.gz,裸 .exe / .dmg 必然验签失败;且
self_update 的 replace-and-verify 语义不适用于 NSIS 这类系统安装器(上游 lib.rs:302)。
更新库已改为 cargo-packager-updater,信任模型已改为 detached minisign 签名,见
ADR-0034。本文中的库选择、签名方案、依赖清单与验证证据均不再有效。

仍然有效的部分——构建期 channel 隔离、签名密钥缺失时失败关闭、稳定错误码收敛、
第三方类型不外泄为项目公共 API——已在 ADR-0034 中重述。

文末引用的 archive_layout_capability.rs、local_install_rehearsal.rs 与
multi_file_install_capability.rs 已随该库退役删除,替代品是
release_manifest_capability.rs。

背景

ADR-0021、ADR-0022、ADR-0025 与 ADR-0026 共同建立了一套自研更新栈:严格 manifest v1 schema、
detached Ed25519 清单签名、Development/Production channel 绑定、单调 release_sequence 防降级、
精确固定的 ureq 3.4.0 传输,以及一个独立承担全部校验职责的 update helper。

维护者决定:更新属于通用机制,不重复实现。第三方库提供完整实现时直接采用,包括其验证模型,
不再并行保留自研验证层。本 ADR 记录该决定并取代上述四个 ADR;被取代的 ADR 保留正文作为历史记录。

调研结论

已核实(2026-09-13,cargo info / cargo tree / 源码阅读):

  • self_update 1.3.0(MIT,Rust 1.88+)提供下载、tar/zip 解压、zipsign 归档签名校验、
    单文件自身替换、macOS .app 整包 rename 交换(含 stash 与 best-effort 回滚)以及进程重启。
    项目 toolchain 为 1.97.1,满足要求;deny.toml 允许 MIT 且只允许 crates.io registry。
  • UpdateConfig 与 ReleaseUpdate 均为 sealed,外部无法自行实现,只能通过后端 builder
    构造。接入自有发行源需实现其 ReleaseSource trait 并使用 backends::custom,或使用内置
    github / gitlab / gitea / s3 / manifest 后端。
  • self_update 1.3.0 公开导出本地下载、解压与文件搬运原语:Download、Extract、Move、
    MoveAll 都在 crate 根导出(src/lib.rs:2216 / 1390 / 1922 / 2045)。其中 MoveAll 是
    事务式多文件安装器:逐个把被替换的目标 stash 走再 rename 新文件就位,任一移动失败则逆序
    回滚全部已应用的移动。github::Update::update() 自身只走「单文件」与「macOS bundle」两条安装
    路径,但这些原语可用于自行编排多文件更新。

    更正(2026-09-13):本 ADR 初稿曾写「self_update 1.3.0 不提供公开的本地归档解压或文件搬运
    工具(无 Extract、无 MoveAll),这些能力只在它自己的 update() 编排内部使用」。该结论
    错误,且是我此前一次 BSD grep 误用(\| 交替在 BSD grep 的 BRE 下不生效)造成的假阴性。
    上述可见性与行为均已重新按源码确认,并由
    crates/bongocat-update/tests/multi_file_install_capability.rs 在本机实测锁定。

  • 未启用任何 HTTP client feature 时 self_update 无法编译,上游使用无条件 compile_error!。
    因此依赖它就必然引入其网络层。
  • 依赖 ureq feature 时,self_update 声明
    ureq = { version = "3.0.6", default-features = false, features = ["gzip", "json", "socks-proxy", "charset"] }。
    Cargo feature 在整个构建图中取并集,调用方的 default-features = false 无法阻止这些 feature 被启用。
    该冲突在 ADR-0025 下不可接受,在本 ADR 下随 ADR-0025 一并作废。
  • fail-open 行为:self_update::verify_signature 在公钥集为空时直接返回 Ok(()),
    即静默跳过验签。任何转发空公钥列表的封装都会安装未签名产物而不自知。
  • macOS 上 bundle_install_path 会从运行中的可执行文件自动推导其所属 .app,
    并对 Gatekeeper translocation(隔离属性导致运行于只读临时挂载)返回专门错误,
    而不是在中途 swap 时报出只读文件系统错误。

决策

  • 删除 bongocat-update 的全部现有实现(9 个模块、4615 行),改为基于 self_update 1.3.0
    的薄封装。保留 crate 名称与诊断契约,其余全部替换。
  • 采用 self_update 的完整信任模型:zipsign ed25519 归档签名。不再有 detached 清单签名、
    不再有先验签后解析、不再有 manifest v1 schema 与 1 MiB 上限。
  • 采用内置 github 后端,仓库 ayangweb/BongoCat。发行资产须按目标 triple 命名以供匹配。
  • 传输使用 self_update 的 ureq feature(而非 reqwest)。理由:项目本就固定 ureq 3.4.0,
    该选择避免引入 reqwest / hyper / h2 / tower-http / cookie_store /
    rustls-platform-verifier / aws-lc-rs / cmake 整套依赖。实测真实编译图增量为
    +17 个包(462 → 479),reqwest 方案为 +31。
  • 启用 features:ureq、rustls、github、archive-tar、archive-zip、
    compression-tar-gz、compression-zip-deflate、checksums、signatures。
    不启用 progress-bar(GPUI 应用无需终端进度条)。
  • channel 隔离保留:AGENTS.md §10 要求更新 channel 按环境隔离,这是本 ADR 之外的约束。
    Development 构建的 ReleaseChannel 不允许联网或安装,check() / install() 在发出任何请求前
    即失败关闭。
  • 签名密钥缺失时失败关闭:由于上游空公钥集是 fail-open,RELEASE_SIGNING_KEY 为 None 时
    runtime 拒绝安装并返回 update_signature_key_missing。这是本项目对该 fail-open 的补偿。
  • 稳定错误码保留:原 32 个错误码收敛为 13 个,覆盖新实现的实际失败面。其中
    update_download_transport_failed 沿用原名,因为它是既有诊断导出契约的一部分
    (ADR-0016 / ADR-0027)且被测试固定。
  • 第三方错误、配置与平台类型不得扩散为项目公共 API。self_update::Error 在本 crate 边界内
    被映射为自有稳定码;UpdateDiagnostics 不含任何库类型。
  • 依赖按 AGENTS.md §9 精确 pin(=1.3.0)并提交 Cargo.lock。
  • 随本 ADR 一并移除因验证层退役而失效的 workspace 依赖:ed25519-dalek、ureq、sha2、semver。

被取代的 ADR 与其损失

以下能力随 ADR-0021 / ADR-0022 / ADR-0025 / ADR-0026 一并退役,self_update 没有对应实现。
记录在此以免日后被误认为仍然有效:

退役能力 原依据 现状
detached Ed25519 清单签名、先验签后解析 ADR-0021 由 zipsign 归档签名替代,信任模型不同
单调 release_sequence 防降级 ADR-0021 无替代。现仅按 semver 比较,降级攻击不再被检测
manifest 层 Development/Production channel 绑定 ADR-0021 改为构建期 ReleaseChannel 门禁,弱于原方案
manifest v1 严格 schema、未知字段拒绝、1 MiB 上限 ADR-0021 无替代
公钥轮换窗(key ID + channel + sequence 有效期) ADR-0021 无替代。zipsign 为 any-of 多密钥语义
4 个 target/arch 白名单在清单层强制 ADR-0021 改为资产名匹配 + 构建期 target 常量
32 个稳定错误码作为公共契约 ADR-0021 收敛为 13 个,语义不同
https_only、禁止 redirect、15s 截止、仅接受 200、禁止透明压缩 ADR-0025 无替代。改由库的传输策略决定
独立且承担全部校验的 update helper ADR-0026 由库内部编排替代
updates/ 目录 0700 与原子写入 ADR-0021 随 sequence store 一并退役

AGENTS.md §10「更新只允许 HTTPS,校验版本、target、arch、hash 和签名,并提供失败回滚」
仍然成立:库分别以 HTTPS-only 传输、资产 target 匹配、构建期 target 常量、checksums
feature 与 signatures feature、install 阶段的 stash 回滚覆盖这些点。

验证

已完成(2026-09-13):

  • 依赖解析与编译:self_update 1.3.0 在 Rust 1.97.1 下与项目精确 pin 无冲突,
    cargo check -p bongocat-update 通过。

  • 真实编译图增量实测为 +17 个包,且 reqwest / aws-lc-rs / cmake / tower-http /
    cookie_store / rustls-platform-verifier 均不在图中。

  • cargo fmt --all --check 通过。

  • cargo clippy --locked --workspace --all-targets --all-features -- -D warnings 零警告。

  • cargo test --locked --workspace 全部通过:497 passed / 0 failed / 5 ignored。新增 13 个单元
    测试覆盖 channel 门禁、签名密钥失败关闭、错误码稳定性与诊断计数;既有 bongocat-app 诊断
    测试保持通过。

  • cargo check --locked --workspace --release 通过。

  • ./tools/check-dependencies.sh 通过:四个首发 target 的 release 依赖树均无
    tauri / wry / webview2 / nodejs-sys / neon / deno_core / quickjs /
    javascriptcore;14 个 manifest 全部 licenses ok, sources ok。新增的 self_update 及其
    传递依赖未触发任何许可证或来源违规,deny.toml 无需为它新增例外。

  • python3 tools/validate-json-schema.py 通过(9 input / 9 expected / 10 config / 6 state),
    已不含 update 校验入口。

  • 三平台 CI(run 34737269808)首次运行暴露一个本机不可见的问题:Linux runner 不属于四个首发
    target,ReleaseConfiguration::for_current_build 在那里返回 None,导致两个直接用它构造
    runtime 的测试断言了错误的期望值(left: None, right: Some("development") 与
    left: NotConfigured, right: SignatureKeyMissing),Test workspace (ubuntu-latest)
    因此失败 2 项。已把这两个测试改为从显式 ReleaseConfiguration 构造 runtime,使其在任意宿主上
    都真正执行 channel 与签名密钥门禁,而不是被静默跳过;并新增
    a_host_outside_the_shipped_targets_has_no_release_configuration 覆盖 None 配置路径。
    本地复检 14 项测试通过、workspace 498 passed / 0 failed。

  • 修复后三平台 CI(run 34738189816,commit b2b1f51)全绿:23/23 作业成功,包括
    Test workspace (ubuntu-latest) / (macos-latest) / (windows-latest)、
    Check dependency policy 与全部平台 smoke。

  • bongocat-config::StorageLayout 的 update_staging 字段(<env>/updates/staging/)已移除:
    staging 模块删除后该字段无任何写入方,create_directories、环境形状断言与 0700 权限断言
    已同步更新。

  • RELEASE_BINARY_NAME 原先声明为 BongoCat,与实际发布的可执行文件 bongocat-app 不一致。
    由于 self_update 用它派生 Windows 归档内的提取路径,这个偏差会让第一次真实更新在解压阶段
    失败。已改为 bongocat-app,并新增 tools/tests/test_update_release_contract.py 把这层
    编译期不可见的耦合固定下来:二进制名 ↔ bongocat-app 包名 ↔ build-windows.ps1 的 exe 名、
    bundle 名 ↔ package-macos.sh 的 .app 名、仓库 owner/name ↔ workspace manifest 的
    repository、四个 target ↔ deny.toml 的 [graph] targets。

  • 上述最后两项(update_staging 移除、RELEASE_BINARY_NAME 对齐与契约测试)由 run 34740872994
    (commit fe5a5de)覆盖,同为 23/23 作业全绿;其中 Validate shared fixtures 作业运行
    python3 -m unittest discover -s tools/tests,即新增的契约测试已在 CI 上执行并通过。

  • MoveAll 的事务语义已在本机实测,不依赖文档:crates/bongocat-update/tests/multi_file_install_capability.rs
    验证「可执行文件 + 资源文件」两条移动全部生效,以及第二条源缺失时第一条已应用的移动被回滚、
    未被触及的目标保持原内容。该测试不覆盖本项目尚未实现的多文件编排路径,只锁定库原语语义。

  • 两种发行资产布局已在本机实测:crates/bongocat-update/tests/archive_layout_capability.rs
    用库自身的 Extract 验证 macOS 归档整包解压后 BongoCat.app/ 位于归档根并携带
    Contents/Resources/、Windows 归档根级的 bongocat-app.exe 可被取出、以及多包一层目录时
    失败且目标目录保持为空。这三项均不依赖网络或签名密钥。

  • 完整安装链路已在本机端到端演练(crates/bongocat-update/tests/local_install_rehearsal.rs):
    用 backends::custom(未门控,可接任意 ReleaseSource)配合一个只回放固定字节的 loopback
    HTTP 服务,跑通资产按 target triple 选择 → 下载 → 按扩展名识别归档 → 解压配置路径 → 安装
    全链路,且使用的就是生产常量 RELEASE_BINARY_NAME / RELEASE_BUNDLE_NAME。两项覆盖:
    单文件替换落到配置的安装路径;bundle 模式整棵 .app 被替换、Contents/Resources/ 随包到达、
    旧包遗留文件被清除。不含签名校验、GitHub 后端与进程重启。

待验证项(不得当作已确认)

  1. 替换机制只在本机以「假安装目标」验证过,未在真实发行物上验证。已实测(见上一条):
    单文件替换、.app 整包交换与旧包清理。未实测:Windows 上运行中的 exe 被改名替换、
    安装阶段失败后的回滚、替换后的进程重启、以及从真实 GitHub 发行下载。真实 .app 还需
    签名与公证,当前打包脚本只做 ad-hoc 签名。
  2. 未配置真实 endpoint 与签名密钥。RELEASE_SIGNING_KEY 为 None,因此当前构建
    update_check_available() 恒为 false,更新入口在 UI 中不显示。
  3. 发行流程尚未产出 self_update 可消费的归档。 读 self_update 1.3.0 源码确认的硬性要求:
    • 资产名匹配:Release::asset_for 先用完整 target triple 匹配资产名,失败后退化为
      arch + os 标记(arch 取 triple 首段,os ∈ linux/darwin/windows/…)。
      bin_name 不参与资产名匹配。因此资产名必须包含 target triple,例如
      BongoCat-<version>-aarch64-apple-darwin.tar.gz。
    • 归档类型按扩展名判定(detect_archive):.zip / .tar / .tar.gz / .tar.xz / .gz /
      .xz 走对应解压器,其余任何扩展名(含 .exe)落入 ArchiveKind::Plain(None),即当作裸
      单文件直接使用
      。因此 Windows 资产不必是压缩包,一个裸 BongoCat.exe 就能被消费;macOS
      bundle 模式则必须用归档。
    • 归档内布局:bundle 模式走 install_bundle,它用 Extract::extract_into 解压整个归档
      再从解压根取 bundle_path_in_archive,因此归档根必须是 BongoCat.app/ 目录
      (注意 Extract::extract_file 只能取单个文件,取目录会失败——这就是 bundle 模式用
      extract_into 的原因)。单文件模式下 bin_path_in_archive 由 bin_name 派生为
      bongocat-app.exe,裸文件或归档内的该路径都必须是根级,多套一层目录会直接报错而非
      静默取错文件。
    • 现状:scripts/package-macos.sh 只产出 target/package/BongoCat.app,不产归档;
      scripts/build-windows.ps1 只产出 NSIS 安装器 BongoCat-$version-x64-setup.exe。
      两者都还需要新增发行步骤,本次未实现。

      注(2026-09-14):上面两个脚本已随 ADR-0033 删除。当前打包入口只产出 .app、.dmg 与
      Windows NSIS 安装器,仍然不产可更新归档,结论与上面一致:更新资产是一项未完成的
      发布门禁。ADR-0033 记录了新的打包链路与这条缺口。

    • 上述布局要求已在本机实测(不依赖网络):
      crates/bongocat-update/tests/archive_layout_capability.rs 用库自身的 Extract 验证
      macOS 归档整包解压后 BongoCat.app/ 位于根部且携带 Resources/、Windows 归档根级的
      bongocat-app.exe 可被取出、以及被多包一层目录时会失败且不留残留。
    • Windows 多文件更新需要自行编排:github::Update::update() 的单文件模式只替换可执行文件,
      不会更新 resources/,预置模型或 resources/ 内容的变更无法经由此路径下发。库为此提供了
      公开原语:用 Download + Extract 取得文件,再用 MoveAll 完成「二进制 + 附属资源」的事务式
      替换(全成或全回滚)。该语义已在本机实测确认,见
      crates/bongocat-update/tests/multi_file_install_capability.rs。代价是这条路径不走
      update() 编排,需自行保证暂存目录与目标同文件系统、zipsign 校验仍然执行、以及失败后的
      启动恢复。本项目尚未实现这条路径。
    • macOS 归档需携带已签名并公证的 .app;package-macos.sh 目前只做 ad-hoc 签名,
      分发签名与公证仍是独立发布门禁。
  4. 断电/强杀残留的清理策略仍未决定,但残留形态已查实(读 self_update 1.3.0、
    self-replace 1.5.0 与 tempfile 源码):
    • 下载与解压暂存使用 tempfile::TempDir(默认前缀 .tmp):单文件模式建在系统临时目录,
      macOS bundle 模式建在安装目标的父目录(即 .app 旁边)。正常返回或 panic 时 Drop 会删除,
      只有硬断电或 SIGKILL 才会留下 .tmp* 目录。
    • Windows 的单文件替换由 self-replace 完成:它先把当前 exe 改名挪开,再复制自身为
      *.__selfdelete__.exe、以 FILE_FLAG_DELETE_ON_CLOSE 打开并 spawn 该副本,等父进程退出后
      删除被挪开的旧 exe。硬断电可能同时留下被挪开的旧 exe 与 .__selfdelete__.exe 副本。
    • 本项目不提供启动时清理。任何清理都必须 (a) 不在更新路径内运行、(b) 绝不删除可能属于
      进行中替换的 .__selfdelete__.exe、(c) 不与并发更新争用同一暂存目录。
    • 附带约束:自有可执行文件不得以 .__selfdelete__.exe 结尾——self-replace 的删除胶水按该
      后缀判定,误命名会触发非预期行为。
  5. CI 覆盖面:ci.yml 的 dependency-policy 作业会运行
    ./tools/check-dependencies.sh,workspace 作业在 Windows/macOS/Ubuntu 三平台
    运行 fmt / Clippy / test / release check / production 环境构建。因此本 ADR 的门禁已在 CI 覆盖,
    但仍未在真实发行流程中验证:发行资产命名、签名与安装包产物未与本 ADR 的 target 匹配规则
    对齐(见待验证项 3)。
  6. StorageLayout::updates(<env>/updates/)当前无写入方:self_update 把解压暂存目录建在
    可执行文件旁边,不落在环境根下。该目录被保留为环境私有的保留命名空间,仍由环境形状断言与
    0700 权限断言覆盖。若确认不需要,可另行移除;本次未动,以免改变已文档化的环境目录形状。

后续边界

本 ADR 不实现 endpoint、公钥注入、update worker、更新 UI、OS 包签名验证或启动恢复。
在待验证项 1 与 2 完成前,不得声称更新功能或 stable 发布完成。


Adr 0030 Prefer Existing Solutions (`docs/adr/0030-prefer-existing-solutions.md`)

ADR-0030: Prefer Existing Solutions Before Implementing

状态:已接受(2026-09-13)

背景

项目已有依赖版本、许可证和平台边界规则,但缺少一条明确的新功能实现方式顺序。部分文档使用
“自行实现”“独立实现”描述平台能力,容易让贡献者把它理解成默认自研。Rust 生态、操作系统 API
和现有 workspace 已经提供许多经过生产验证的能力,重复实现会放大维护、安全和兼容成本。

本决策参考 Ponytail 的核心思想:先理解问题并追溯
真实流程,再按复用阶梯选择最小可行实现;代码、抽象和依赖都不应为未经证明的未来需求提前建设。

决策

实现新功能时按以下顺序停在第一个可行方案:

  1. 确认需求确实需要实现,避免 YAGNI 之外的工作。
  2. 复用现有仓库内的 helper、contract、类型或模式。
  3. 使用 Rust 标准库。
  4. 使用操作系统原生能力。
  5. 使用 workspace 已安装且满足边界的依赖。
  6. 调研并直接采用成熟、可靠且维护良好的第三方库或开源方案。
  7. 只有现有方案确实无法满足需求,或其引入成本明显高于自行实现时,才编写满足当前需求的最小
    自有实现,并保留一项可运行的验证。

第三方方案必须继续通过仓库的精确版本、许可证、target、维护状态、unsafe 面积和替换边界评审。
复用不得形成第二套业务 runtime,不得绕过强类型 command/event/snapshot 边界,也不得让第三方
错误、配置或平台类型进入公共 API。若成熟方案的引入成本明显更高,必须说明具体成本,而不是仅以
“减少依赖”为理由拒绝。

影响

  • 通用能力默认采用成熟方案;自研只保留产品特有语义、可靠性和平台所有权边界所需的适配代码。
  • 新依赖仍是显式决策,不因“优先复用”免除版本、许可证、安全性和可维护性检查。
  • 现有已经落地且满足契约的实现无需仅为遵循本 ADR 进行重写;后续新增或替换功能按本顺序评估。
  • ADR-0029 把更新实现从自研栈切换到 self_update,与本 ADR 的方向一致。

Verification

  • 代码评审检查新增功能是否说明复用/自研选择,尤其是已经存在等价 crate 或平台能力的场景。
  • 新依赖提交必须同时提供版本、许可证、source、target 和替换边界证据。
  • 自研通用机制时,以任务记录或 ADR 说明现有方案无法满足的具体约束或明显更高的引入成本。

Adr 0031 Tray Icon Boundary (`docs/adr/0031-tray-icon-boundary.md`)

ADR-0031: Tray Icon and Menu Library Boundary

状态:已接受(2026-09-13);2026-09-14 补充 Windows set_tooltip 上游缺陷与 tooltip 创建期不变量;2026-09-25 接受托盘与 overlay 右键菜单复用(ADR-0068)

后续修订(2026-09-23):ADR-0033 已退役 Windows 原生 ARM64;本文提到的 Windows x64/ARM64 cross-check 只是历史证据,当前 Windows 发布 target 只有 x64。

后续修订(2026-10-12):tray-icon 由 =0.25.0 升级到 crates.io 精确 pin =0.26.1,
muda 由 =0.20.0 升级到 =0.21.2。两者仍关闭默认 features、仍只在 macOS/Windows target
声明,并仍解析到同一份 muda package;本 ADR 的替换边界与安全不变量不变。

  • v0.26 弃用了 TrayIconBuilder::with_icon_as_template(bool)(#[deprecated(since = "0.26.0")]),
    改为 macOS 专属的 with_icon_templated(icon),它在一次调用里同时设置图标并标记 template。
    adapter 相应改为在 macOS 上调用 with_icon_templated、其它平台调用 with_icon;template
    语义与原先「先 with_icon 再 with_icon_as_template(true)」一致。
  • 源码复核确认「Windows set_tooltip 对以固定 GUID 注册的图标必然失败」的上游缺陷在 0.26.1
    仍未修复:set_tooltip 仍只设 NIF_TIP 后直接 NIM_MODIFY,而 set_icon、
    set_tray_visible、register_tray_icon 与 remove_tray_icon 都经 apply_guid 补
    NIF_GUID。因此本文「SystemMenuPresentation::tooltip 是创建期输入」的不变量继续成立,
    实现未改动,Windows 实机验收仍是发布门禁。
  • v0.26 把 macOS 图标高度从硬编码的 18pt 改成 MAX_ICON_HEIGHT = 22.0 上限,且
    RgbaIcon::to_nsimage 只缩小不放大;TrayIconBuilder 与 Icon 都没有尺寸 API,像素数
    直接当点数用,因此任何大于 22px 的画布都会被画成 22pt。项目原来的 256×256 蒙版占满画布,
    菜单栏图标随之从 18pt 变成 22pt。为保持更新前的视觉尺寸,tray-macos.png 重绘为
    「图形按 18/22 缩到 210×210 并居中、四周透明留白」的 256×256 画布,渲染回 18.05pt。
    代价是状态栏占位仍为 22pt 宽,图形左右各多约 2pt 空隙;PNG 容器契约(256×256、
    8-bit RGBA、非隔行、deflate)不变。
  • 本文「调研结论」与「安全与生命周期不变量」中仍写有 0.25.0/0.20.0 的段落均为
    2026-09-13/2026-09-14 的升级前复核记录,保留原样;除上述 set_tooltip 缺陷外,本次未在
    Windows 实机复核 set_visible 等 shell 行为,这些不变量在 0.26.1 上仍待实机确认。

背景

Windows 的 Shell_NotifyIcon/HMENU 与 macOS 的 NSStatusItem/NSMenu 原本各自实现,约 690 行
平台代码需要分别维护隐藏窗口、菜单句柄、回调、主线程与生命周期。两边已经共享
SystemMenuAction、SystemMenuError 和 SystemMenuPresentation,但 native owner 的行为与
清理顺序仍容易漂移。项目需要在不改变 runtime/UI 强类型边界的前提下,减少重复平台代码并保留
Windows GUID、菜单顺序、左键打开设置和右键菜单等产品行为。overlay 右键最初复用托盘隐藏窗口,
这会让 Windows 菜单 owner 与实际窗口不一致,并绕过 macOS 的跨平台菜单 API;因此托盘与菜单的
第三方边界在本 ADR 中分别固定。

调研结论

已核实(2026-09-13,cargo search、cargo info、cargo tree 与源码阅读):

  • tray-icon 0.25.0(MIT OR Apache-2.0,Rust 1.90+)是 crates.io 当日最新稳定版,由 Tauri
    项目维护,同时支持 macOS NSStatusItem 与 Windows Shell_NotifyIcon,并提供图标、tooltip、
    菜单、固定 GUID 和显隐 API。当前 toolchain 为 Rust 1.97.1,满足其 MSRV。
  • muda 0.20.0(Apache-2.0 OR MIT,Rust 1.90+)是 crates.io 当日最新稳定版,由 Tauri 项目维护;
    Menu/MenuItem/CheckMenuItem 提供菜单构建与动态文本、状态更新,ContextMenu 提供 Windows
    HWND 与 macOS NSView 的右键弹出 API。它没有可设置的菜单标题,因此既有
    SystemMenuPresentation::title 不再有对应可变标题 API。tray-icon 0.25.0 也依赖同一
    muda 0.20.0 package 来挂载托盘菜单;项目直接声明 muda,确保共用 popup 根使用同一份第三方
    类型和同一个项目 owner,而不是依赖第三方 crate 的重导出。
  • tray-icon 的 TrayIcon、TrayIconEvent::receiver 与 muda::MenuEvent::receiver 均以进程级
    状态和 Rc<RefCell<_>>/静态 channel 实现。一个进程只能有一个长期存活的 SystemMenu 业务
    owner;隐藏图标只切换平台表示,不能销毁并重建 owner,否则会丢失菜单事件消费者。
  • Windows 固定 GUID 仍使用
    123f3c6f-7d2a-4ca3-b8cb-9b1d1eaf2f10。库以 GUID 作为托盘身份并在 Explorer
    TaskbarCreated 广播后恢复注册;应用不自行重试或创建平行图标。
  • 两个依赖都使用 default-features = false:tray-icon 关闭 libappindicator,muda 关闭
    默认的 gtk3 与 libxdo。它们只在 macOS/Windows target 下声明,因此 Linux 不引入
    GTK/libappindicator 系统依赖。Linux 托盘仍按 ADR-0006 单独评估,不属于本决策。
  • Windows PNG 资源来自维护者指定的
    https://raw.githubusercontent.com/ayangweb/BongoCat/refs/heads/master/src-tauri/assets/tray.png,
    已逐字节核验为 256×256、8-bit RGBA、非隔行 PNG,SHA-256 为
    65f3a1e71f60417916c0511945131c7341edbd46d037fa9e703014e6155434dd。macOS 现有
    tray-macos.png 也固定为 256×256、8-bit RGBA、非隔行,SHA-256 为
    9342c5f8138b03e492434133041264147b759e2309f1ed3b7fab2e0e51b5a99d(2026-10-12 重绘,
    见文首「后续修订」)。

决策

  • macOS 与 Windows 状态图标统一由 bongocat-platform 私有 system_menu_native adapter 管理:
    tray-icon 0.26.1 是托盘/菜单栏图标的 native owner,直接依赖的 muda 0.21.2 是共用 popup 根
    和菜单事件的 owner;旧 system_menu_macos.rs、system_menu_windows.rs 与 tray-windows.ico 退役。
  • adapter 是 SystemMenu 的唯一 owner,长期持有 TrayIcon、一个托盘/模型窗口共用菜单根、模型窗口
    子菜单、全部可变菜单项和 muda 菜单事件 receiver。set_visible(false) 只改变平台表示(macOS 移除
    NSStatusItem,Windows 保留注册并设置隐藏),菜单 owner 与事件通道不销毁;重新显示不创建第二套
    业务状态。
  • Windows 使用固定 GUID 与 tray-windows.png;macOS 使用 tray-macos.png 并标记为 template
    image。两平台在菜单构建时共享相同 action id 到项目自有 SystemMenuAction 的映射。
  • Windows 左键抬起映射为 OpenSettings,右键由 tray-icon 弹出共用菜单。macOS 由 tray-icon
    在左右键时弹出同一菜单;模型窗口右键也调用同一个菜单根。菜单按应用任务组织为「设置 → 模型窗口
    子菜单(隐藏模型窗口、穿透、置顶、鼠标移入隐藏)→ 可用的检查更新 → 退出」;模型窗口显隐的勾选
    表示当前已隐藏,不再提供单独的“显示模型窗口”项;其它模型窗口属性直接复用偏好设置已有文案。
    不可用的更新入口在构造期不创建,两个入口都使用同一组 action id 和同一个事件队列。
  • overlay 右键由 adapter 的 show_context_menu_for_window 从 overlay session 获取真实 Windows
    HWND 或 macOS content NSView,再调用对应的 muda::ContextMenu;不得继续借用托盘隐藏 HWND,
    也不得由 overlay 自己创建第二个 owner。
  • 第三方 tray_icon/muda 类型、platform handle、错误和回调不得进入 bongocat-runtime、
    bongocat-ui 或公共协议;adapter 只暴露项目自有的 SystemMenu、SystemMenuAction、
    SystemMenuError 和 SystemMenuPresentation。
  • 新依赖精确固定为 tray-icon = "=0.26.1" 与 muda = "=0.21.2",两者都关闭默认 features,
    只在 macOS/Windows target 声明。直接 muda 必须与 tray-icon 实际依赖的版本解析为同一
    package,避免菜单类型来自两个版本;升级时以 cargo tree 复核。
  • PNG 格式契约由 bongocat-app build script 与 product_icon_contract 测试固定:PNG 签名、
    IHDR、尺寸、通道位深、RGBA、压缩/过滤方法和隔行字段,不把 image 类型暴露为产品 API。ADR
    记录字节 hash 作为来源与变更审计证据,不把 hash 写成运行时业务校验。
  • SystemMenuPresentation::title 保留为兼容输入,但迁移后不映射到 native 菜单标题;菜单中的
    可见本地化文本、状态和可用性仍逐项同步。若未来产品需要可变菜单标题,必须先确认 muda 的
    上游 API,不得绕过安全边界直接修改库内部 NSMenu。

安全与生命周期不变量

  • macOS 创建、更新、弹出菜单和销毁均要求 MainThreadMarker;set_presentation 也执行主线程
    检查,避免在错误线程触碰 AppKit。
  • Windows show_context_menu_for_hwnd 需要在 unsafe block 中传入有效 HWND。应用窗口右键传入
    由 overlay session 持有、覆盖整个同步 TrackPopupMenu 生命周期的真实 overlay HWND;不得使用
    托盘隐藏窗口作为替代 owner。菜单 tracking 是同步调用,调用方不得在回调内销毁 overlay 或 adapter。
  • macOS show_context_menu_for_nsview 要求 main thread 和有效 NSView。应用窗口右键传入
    overlay panel 的 content view;panel 保留该 view,调用期间也由 overlay session 保持存活,
    同步弹出结束后才返回。
  • SystemMenu 的字段顺序保证 tray_icon 先于菜单项与根菜单析构;显式 shutdown 先隐藏图标,
    再按既定产品顺序停止输入、runtime、配置、GPU 和 overlay。禁止在多个线程或模块创建第二个
    SystemMenu。
  • Windows adapter 捕获 TrayIconEvent 时按自有的稳定 TrayIconId 过滤,不消费其他 owner 的
    事件;菜单事件 receiver 也只在同一个 app owner 线程轮询并转换为强类型队列。
  • tray-icon 0.25.0 的 Windows set_visible 会发送 WM_USER_SHOW_TRAYICON 并在库内部忽略
    Shell_NotifyIconW(NIM_MODIFY) 的失败结果,可能对已失效的 shell registration 返回 Ok;
    同样,初次 NIM_ADD 失败会等待 TaskbarCreated 恢复。adapter 的 Ok 不能替代真实托盘
    可见性验收,发布前必须在 Windows 10 1903+ 实机验证隐藏、恢复、Explorer 重启和右键菜单。
  • show_taskbar_icon 同时出现在三个位置:runtime 发布的 settings snapshot、产品记录的已应用
    值,以及 shell 对两个产品窗口 taskbar button 的实时读数。设置写入按"先原生面、再配置、
    最后重新发布 snapshot"三步到达,因此写入与携带它的 snapshot 之间三者本应不一致;shell
    对 WS_EX_TOOLWINDOW 的可见性读数也会滞后于产品已施加的窗口样式。任何断言这三者一致的
    检查都必须是有界轮询等待收敛,而不是采样一次——采样一次正是 system menu smoke 的启动断言
    在 Windows runner 上偶发失败的原因(startup taskbar visibility diverged)。轮询预算只界定
    等待时长,不豁免结果:预算内不收敛仍然是失败,且必须报告具体是哪一处未收敛。判定逻辑放在
    平台无关的 taskbar_settle 里,以便在任意平台单测。
  • tray-icon 0.25.0 的 Windows set_tooltip 对以固定 GUID 注册的图标必然失败:它发出的
    NIM_MODIFY 未带 NIF_GUID,而 shell 对以 guidItem 标识的图标忽略 uID,并要求后续每次
    Shell_NotifyIcon 调用都携带同一 GUID
    (https://learn.microsoft.com/windows/win32/api/shellapi/ns-shellapi-notifyicondataw#troubleshooting)。
    库内 set_icon 与内部 set_tray_visible 都调用了 apply_guid,只有 set_tooltip 漏掉,属上游
    缺陷;上游 dev 分支同样如此,0.25.0 已是 crates.io 最新稳定版,没有可升级的修复版本。
    因此 SystemMenuPresentation::tooltip 是创建期输入:由 start_with_presentation 经
    with_tooltip 一次性写入(该路径的 NIM_ADD 携带 NIF_GUID,可正常工作),
    set_presentation 不得再调用 set_tooltip。当前产品文案 system_menu.title 在中英目录中
    恒为 BongoCat,运行期不发生变化;若将来需要运行期更换 tooltip 文案,必须连同 GUID 一起
    重建托盘 owner,或先确认上游已修复该调用。

验证

  • product_icon_contract 覆盖两个 PNG 的容器/尺寸/RGBA 格式,build script 在编译前验证资源,
    Windows RC 只嵌入产品 ICO,不再把托盘 PNG/ICO 作为 executable icon group。
  • macOS/Windows target 的 cargo check、release build、完整 workspace fmt/clippy/test/check 和
    native dependency policy 必须通过。
  • macOS release system-menu smoke 需验证 tray-icon 托盘 owner、托盘与 overlay 右键共用的 muda popup
    根、强类型 action、显隐恢复与 shutdown;现有 smoke 注入 action 而不弹出真实菜单,因此不能替代真实菜单弹出验证。
  • 发布前必须在 Windows 10 1903+ 与受支持 macOS 实机验证托盘与 overlay 右键展示同一菜单层级、子菜单展开、
    cursor 定位、缩放/DPI、窗口层级、点击外部关闭、action 派发和 shutdown;重点确认两个入口的菜单项集合、
    check 状态和 action 行为一致。Windows x64 cross-check 只能证明编译与边界,不能证明 tray registration、菜单层级、
    左键打开设置、Explorer 重启恢复或资源管理器交互。
  • workspace 的 Smoke Windows D3D11 product overlay 在真实 Windows runner 上运行
    --settings-window-open-smoke 与 --settings-window-smoke --models-page-smoke,是上述
    set_tooltip 缺陷的唯一回归来源:2026-09-14 的 job 日志显示 set_presentation 每 50ms 重试一次,
    在 30 秒内累计 380 条完全相同的 StatusItemUpdateFailed。该 job 和其中每个 smoke step 都必须
    保持启用,不得以跳过、忽略错误或放宽断言的方式让 CI 通过。
  • 当前环境未运行 Windows 实机 smoke;Windows 托盘与右键菜单行为仍是发布门禁,不得以
    cross-compile 结果宣称完成。

替换边界

替换点只有 bongocat-platform 私有 system_menu_native adapter 和 overlay 的
HasWindowHandle 实现。升级或替换 tray-icon/muda 时必须复验:两者仍解析到同一 muda
package、托盘与 overlay 共用 popup 根的 action 投影与层级、菜单项/根的析构顺序、Windows 固定 GUID、
GUID 注册下的 set_tooltip 是否已修复、左键/右键行为、macOS template image 与主线程约束、overlay
HWND/NSView 生命周期、cursor 定位、隐藏后的恢复、shutdown 清理、依赖 target/features、Windows shell
failure 语义和 PNG 资源格式。adapter 之外不得依赖第三方 tray/menu 类型。


Adr 0032 Startup Permission Prompt Boundary (`docs/adr/0032-startup-permission-prompt-boundary.md`)

ADR-0032: Startup Permission Prompt Boundary

状态:已接受(2026-09-14);同日实机验收发现 macOS 侧必须使用不触碰 AppKit 的 rfd 路径,见「macOS 弹框实现修正」;2026-09-15 修正检查执行方式为专用 worker 线程上的非阻塞检查,见「非阻塞执行修正」;同日启用 Windows 自定义按钮文案,见「Windows 按钮文案修正」;2026-09-18 为修复主题跟随把 macOS 提示改为主线程 NSAlert,见「主题外观修正」;2026-09-24 收口 Windows 按钮文案,使其准确描述当时的按钮文案;2026-09-26 Windows 提示标题改为「建议以管理员权限运行」,次要按钮改为「稍后设置」并与 macOS 共用同一个 key startup_permission.later;同日主按钮改为「退出并前往设置」

背景

全局输入需要一项平台能力,而缺省状态下两平台都不会主动告知用户:macOS 需要 Input Monitoring
TCC 授权,Windows 需要提权令牌才能在完整性级别更高的前台窗口存在时继续收到 Raw Input。旧版
Tauri 实现把这两件事做成产品自有弹窗,并记录用户是否已经处理过;BongoCat 此前只在
Settings 和 Diagnostics 里投影状态,用户必须自己发现问题。

项目需要一套启动时的检查与引导:使用平台原生弹框,复用既有平台权限检测,不给用户新增需要
维护的提示状态。同时 ADR-0024 已经限定 macOS 的 TCC 请求 API 只能在用户发起的设置操作中调用,
本决策必须与之相容。

调研结论

已核实(2026-09-14,源码阅读 rfd 0.17.2 与 windows 0.62.2):

  • rfd 0.17.2(MIT,双平台 target-only,已按 ADR-0030 精确 pin 且 default-features = false)
    的消息对话框在没有父窗口时的实现是平台原生的,并与有父窗口时不同:macOS 的同步路径走
    sync_pop_dialog → CFUserNotificationDisplayAlert
    (src/backend/macos/utils/user_alert.rs),Windows 走 MessageBoxW
    (src/backend/win_cid/message_dialog.rs,not(feature = "common-controls-v6") 分支)。
    两条路径都不需要产品自建窗口,也不进入 GPUI renderer。
  • macOS 同步路径虽然避开 NSAlert::runModal(因此不重入 GPUI 事件循环),但它为了设置激活策略
    与焦点会先构造 PolicyManager/FocusManager,两者都调用 NSApplication::sharedApplication
    (src/backend/macos/utils/{policy_manager,focus_manager}.rs)。这会创建共享应用实例,而该实例
    在启动阶段属于谁是有约束的,见下方修正章节;无父窗口的异步路径(async_pop_dialog →
    UserAlert::new(opt, None))不创建这两个 manager,因此完全不触碰 AppKit,只构建
    CFUserNotification。
  • macOS 自定义按钮文案会被保留(OkCancelCustom 映射为前两个按钮标题);Windows 不会保留,
    因为自定义标题来自 TaskDialogIndirect,而它只由 ComCtl32 v6 导出。rfd 自身文档要求同时
    启用 common-controls-v6 feature 并在应用 manifest 中声明 ComCtl32 v6 依赖。原始调研
    认为本产品两者都不具备(crates/bongocat-app/windows/bongocat-app.rc 不含该 dependency,
    依赖声明也没有该 feature),所以当时 Windows 只显示系统标准 OK/Cancel 两个按钮;2026-09-15
    复查发现 manifest 前提实际由 gpui-pre 静态库满足,只补了 feature,见「Windows 按钮文案
    修正」。
  • Windows 提权状态以 TokenElevation 为准:OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY)
    • GetTokenInformation(_, TokenElevation, ..)。windows 0.62.2 的 HANDLE 未实现 Drop
      (只实现 windows_core::Free),必须显式 CloseHandle。用户按引导勾选「以管理员身份运行此
      程序」后新进程的令牌就是 elevated,与提示承诺的状态一致,同时也能正确识别在提权 shell 中启动
      的情况。
  • 现有可复用能力:bongocat-platform::input_monitoring_permission
    (CGPreflightListenEventAccess,只读)、request_input_monitoring_permission
    (CGRequestListenEventAccess)、rfd(目录选择已使用的既有依赖)、
    opener 0.9.0 的 reveal(既有依赖且已启用 feature,用于在资源管理器中定位可执行文件)。
    不需要任何新依赖。

决策

  • 检查与提示全部放在 bongocat-platform 的私有 startup_permission adapter:
    只暴露项目自有的 StartupPermissionPrompt、StartupPermissionStatus、
    STARTUP_PERMISSION_CAPABILITY、startup_permission_available() 和
    check_startup_permission()。rfd、windows token API 和 AppKit 类型都不离开该 adapter。

  • 文案由 bongocat-app 从翻译目录构造后传入 adapter,沿用 SystemMenuPresentation 的既有模式;
    adapter 不读取配置、不持有产品文案。

  • 两平台都先做只读查询,再决定是否提示:

    平台 只读查询 提示实现 「授权/去设置」动作
    macOS CGPreflightListenEventAccess 主线程 NSAlert(经 dispatch2::run_on_main 投递,2026-09-18 起,见「主题外观修正」) CGRequestListenEventAccess + NSWorkspace 打开 x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent
    Windows TokenElevation rfd::MessageDialog(TaskDialogIndirect,调用线程,见「Windows 按钮文案修正」) opener::reveal 定位当前可执行文件,正文给出「属性 → 兼容性 → 勾选以管理员身份运行」路径
  • macOS 曾要求使用 rfd 的无父窗口异步实现(见「macOS 弹框实现修正」),2026-09-18 起改为
    主线程 NSAlert;同 crate 的同步实现仍被 contract 测试禁止,原因不变(见「主题外观修正」)。

  • macOS 先调用 TCC 请求 API 再打开面板:请求是产品出现在「输入监控」列表里的唯一途径,否则用户
    还要手动添加应用。该调用仍严格位于用户点击之后,满足 ADR-0024 「启动、轮询和服务恢复不得弹出
    请求」的约束;启动路径只调用只读 preflight。

  • Windows 不自动提权:不调用 ShellExecuteEx + runas,不写入 HKCU/HKLM,不注册 service 或
    scheduled task。产品继续按 ADR-0023 以 per-user、无提权方式安装和运行,提示只解释兼容性开关
    这一系统标准路径。

  • 提示不做任何持久化:不新增配置字段、不写入 window-state.json、不新增应用日志事件、不缓存「用户选过
    稍后」。每次启动都重新读取平台当前状态;已授权则完全不提示,未授权则本次启动继续提示。判定依据
    只有平台状态。

  • 检查点(2026-09-15 修正):检查不再阻塞启动流程。产品在 GPUI run loop 内完成 overlay、设置
    服务、系统菜单和 update worker 的启动之后,由主线程 spawn 一个专用 worker 线程执行检查与提示;
    提示未应答、被关闭或检查失败都不影响任何产品窗口的显示与使用。ADR-0054 前由 --configuration-recovery-mode 进入的恢复-only 路径不提示;当前配置 fallback 直接走普通设置窗口,自动化 harness 运行仍不提示。原始决策「检查点在 GPUI run loop 之前、提示
    先于猫窗口出现」已被本修正取代,见「非阻塞执行修正」。

  • --startup-permission-smoke 是只读诊断开关:只输出当前能力名与
    available/missing,用于双平台可重复验收,不弹框、不写状态。

  • 自动化 harness 运行不提示:除 --run-seconds 与帮助外的任何参数都表示这是一次 smoke/诊断运行
    (脚本与 CI 上无人应答原生弹框)。产品路径(--run-seconds 0,含登录启动项)与 Development
    cargo run 始终检查,与打包后的 Production 行为一致,不因构建环境而不同。

  • 与旧 Tauri 行为的已知差异:旧版在 Windows 非管理员时提示后不继续运行,本决策改为用户选择次要按钮
    仍可进入应用;2026-09-15 起收窄为「用户选择设置路径且流程成功时退出」,见「Windows 按钮文案
    修正」。macOS 的「请求 + 再次引导」意图保留,但请求时机收紧到用户点击之后(ADR-0024)。旧版
    行为对照仍见 docs/migration/legacy-behavior-inventory.md,不在本 ADR 重复维护。

macOS 弹框实现修正(2026-09-14 实机验收)

首次实机验收在真实打包产物上复现了「启动即 abort」的崩溃,根因是下面这条 GPUI 与 AppKit 的
交互约束。原先「无父窗口同步弹框在 GPUI run loop 之前弹出是安全的」这一条结论是错的:它只检查了
NSAlert::runModal,漏看了 PolicyManager/FocusManager 对共享 NSApplication 的创建。

现象与证据

  • 真实场景:双击 /Applications/BongoCat.app(Production 打包产物)→ 权限提示出现 → 用户应答后
    进程 EXC_CRASH (SIGABRT)、abort() called;应用日志只有 started 紧跟 panicked
    (release profile 的 panic = "abort" 把 panic 变成 abort)。
  • 崩溃报告的帧偏移以「同源码重新链接的二进制」反查:本地用同一源码做
    cargo rustc --release -p bongocat-app --bin bongocat-app -- -C strip=none(__text 大小
    0xd901e4 与已安装产物逐字节一致)后用 atos 得到:
    bongocat_app::main → gpui::Application::run → gpui_macos::platform::MacPlatform::run →
    objc::runtime::Object::get_mut_ivar → panic → abort。
  • 用同一份代码重建并触发同一条件,panic 消息为:
    panicked at objc-0.2.7/src/runtime.rs:503:25: Ivar platform not found on class NSApplication。

约束

  • gpui_macos 在 #[ctor] 中注册 GPUIApplication(NSApplication 子类)并在其上声明 platform
    ivar;MacPlatform::run 先 [GPUIApplication sharedApplication],再对该实例
    set_ivar("platform", ..),而 objc 0.2 在 ivar 不存在时 panic。
  • 进程共享的 NSApplication 实例由第一个 sharedApplication 调用决定。若它先在基类
    NSApplication 上被调用(rfd 的同步 macOS 消息框正是如此),之后
    [GPUIApplication sharedApplication] 返回的就是基类实例,GPUI 的 set_ivar 随即 panic。
  • 因此在产品建立 GPUI 平台之前运行的任何代码都不得创建共享 NSApplication。
    NSWorkspace、CGPreflightListenEventAccess 和 CGRequestListenEventAccess 已实机验证不创建它。

决策修正

  • macOS 启动提示改用 rfd::AsyncMessageDialog(无父窗口 → async_pop_dialog →
    UserAlert::new(opt, None) → 不构造 PolicyManager/FocusManager → 不触碰 AppKit),主线程用
    async_io::block_on 等待结果。这与 directory_picker 已在用的 rfd + async_io 用法一致,
    不引入新依赖、不改变提示出现时机(仍在任何产品 UI 之前)。
  • Windows 保持同步 MessageDialog:MessageBoxW 没有该约束,且在调用线程上语义最直观;改用
    TaskDialogIndirect 后该路径也保持同步并在 worker 线程上运行,见「Windows 按钮文案修正」。
  • 该路径用无点击方式验证过:请求弹框但不等待答复时,产品正常启动并干净退出(exit 0),同时采样确认
    对话框线程真实阻塞在 CFUserNotificationReceiveResponse(即弹框确实已提交显示);同一位置若换回
    同步实现,则必然复现上述 panic。

非阻塞执行修正(2026-09-15)

启动实机使用中发现原始检查点(GPUI run loop 之前同步执行)让权限提示成为启动的第一个交互:
用户不应答,模型窗口、菜单栏/托盘和设置 UI 都不会创建。产品要求权限提示只作为辅助提示存在,
于是把「检查的执行方式」从启动路径中拆出来,检查与提示逻辑本身不变。

变更

  • 移除 main 在 gpui_application.run 之前的同步调用;语言在主线程解析一次,检查改由 GPUI
    run loop 内(overlay、设置服务、系统菜单、update worker 均已启动、ProductCoordinator
    已注册之后)spawn 的专用 worker 线程执行,线程名 bongocat-startup-permission。
  • 只读平台查询(CGPreflightListenEventAccess / TokenElevation)与提示文案构造随 worker
    一起移入后台;CGRequestListenEventAccess 仍严格位于用户点击引导按钮之后,满足 ADR-0024。
  • Worker 线程刻意 detached:原生对话框由 OS 持有、没有可用的远程取消通道,若在退出时 join,
    会把「未应答的弹框阻塞退出」这一原始问题原样搬到 shutdown 路径。该线程只拥有语言与文案
    字符串,不与产品共享任何锁或句柄,用户应答后自行终止;进程退出时 OS 回收线程并随之关闭
    对话框,不构成任务泄漏。
  • spawn 失败仅记入启动失败收集,应用照常启动(与「提示失败不阻止启动」同语义)。

线程安全证据(2026-09-15 核实)

  • macOS 的异步 rfd 路径只构建 CFUserNotification、不触碰 NSApplication,在 worker 线程
    上与 GPUI run loop 并行是安全的;同步路径会在调用线程上运行 NSAlert 模态机制,禁止使用,
    原 contract 测试继续固定这一约束。
  • objc2-app-kit 0.3.2 绑定中 NSWorkspace::sharedWorkspace() 与 openURL 均未标记
    main-thread-only(无 MainThreadMarker 参数),可在 worker 线程执行。
  • Windows 的 MessageBoxW 是调用线程模态,不阻塞其他线程的消息循环;OpenProcessToken 与
    opener::reveal 均无线程亲和性要求。
  • worker 与产品无共享可变状态,不存在与窗口创建、输入服务或 shutdown 的锁竞争。

安全与生命周期不变量

  • 提示运行在专用 worker 线程上(2026-09-15 修正,原始的「主线程阻塞等待用户应答」已被取代):
    主线程继续 GPUI run loop,overlay、输入服务和 runtime 的启动不等待用户应答;worker 只拥有
    语言与文案字符串,不与渲染或输入生命周期共享任何锁。macOS 的对话框在 2026-09-18 起经
    dispatch2::run_on_main 在主线程上运行,worker 阻塞等待其结果(见「主题外观修正」);
    rfd 的同步 macOS 路径在该线程上仍被禁止(见上方修正与 contract 测试)。
  • 平台对话框的 owner 是 rfd,产品不持有任何 dialog handle;不注册回调,不在回调中做阻塞工作。
  • macOS 侧只使用 CGPreflightListenEventAccess(只读)与用户点击后的
    CGRequestListenEventAccess;不调用任何 Accessibility trust/prompt API,不扩大 TCC 面
    (ADR-0024)。
  • Windows 侧只读取自身进程令牌,不打开其他进程、不改写系统设置、不请求 UAC。令牌句柄在读取后
    立即关闭;失败路径返回「未提权」而不是 panic。
  • 提示内容只包含产品名、权限用途和系统设置路径,不含用户路径、按键、剪贴板或任何诊断载荷。
  • 提示失败(对话框后端不可用、opener 失败)不阻止应用启动,也不改变产品行为;此时仅相当于用户
    选择了「稍后」。

验证

  • 单元测试覆盖 rfd 结果到选择的映射:自定义标签、标准 OK/Cancel 对以及关闭窗口/未知标签都不能
    被误判为「已授权」,并固定能力名的稳定取值。
  • bongocat-i18n 目录测试保证中英两份目录键集与占位符一致,bongocat-app 侧测试保证本平台用到
    的四个键在两种语言下都有非空、非键名回退的文案。
  • RunOptions 测试固定:--startup-permission-smoke 不打开设置窗口且标记为自动化运行,
    --run-seconds 0 保持交互式,其余被接受的参数都判定为 harness 运行。
  • macOS contract 测试禁止 startup_permission 的 macOS 模块出现 rfd::MessageDialog::new;
    该测试做过反向自检(注入该字符串后确实失败),避免变成空断言。
  • 崩溃回归的验证方式(自动测试无法覆盖「用户点击原生弹框」):用同一源码重新链接出带符号的
    release 二进制并用 atos 反查崩溃报告帧偏移;以及「请求弹框但不等待答复」的运行确认产品可正常
    启动并干净退出。
  • 可重复验收命令:cargo run -p bongocat-app -- --startup-permission-smoke,在授权前应输出
    missing,授权后应输出 available;该命令不弹框、不写入任何产品状态。
  • 发布前必须在真实设备补足下列人工验收(当前环境无法代替):
    • macOS 12+:未授权启动出现原生提示;「打开系统设置」落点为「隐私与安全性 → 输入监控」且产品
      已在列表中;「稍后设置」后猫窗口正常出现且输入服务进入匿名 PermissionDenied;授权并重启后
      不再提示。
    • Windows 10 1903+:普通权限启动出现原生提示且按钮为「退出并前往设置」/「稍后设置」两个
      自定义文案(Task Dialog);「退出并前往设置」能定位可执行文件且产品随后走常规 shutdown
      退出;勾选兼容性开关并重启后不再提示;提权启动时完全不提示。
  • Windows 实机矩阵与 macOS TCC 矩阵仍是发布门禁,cross-check 只能证明编译与边界。

Windows 按钮文案修正(2026-09-15)

本节保留 2026-09-15 的实现背景;按钮名称已按最新 catalog 审计同步为当前文案。

产品要求 Windows 提示的两个按钮显示自有文案(「退出并前往设置」/「稍后设置」),而原始决策下
Windows 走 MessageBoxW、只显示系统标准 OK/Cancel。「替换边界」曾推断 manifest 前提不满足,实查
后发现只差一半:

  • manifest 前提本已满足:gpui-pre 的静态库(gpui.lib)内嵌了一份完整应用 manifest
    (asInvoker、Windows 10 supportedOS、PerMonitorV2 DPI、SegmentHeap、ComCtl32 v6 的
    Common-Controls 依赖),链接期已进入 executable。原始调研只检查了产品自有
    bongocat-app.rc,漏看了这条来源。
  • feature 是唯一缺口:为 rfd 启用 common-controls-v6(仅为 windows-sys 增加
    Win32_UI_Controls gateway,不新增 crate)。

曾尝试在 bongocat-app.rc 追加自有 manifest(1 24),与 gpui 的 manifest 资源
(同 ID 1)冲突,链接报 CVT1100 duplicate resource: MANIFEST, name 1;已回退。不得再为
manifest 另加来源。

行为与证据

  • 启用后 Windows 提示走 TaskDialogIndirect,OkCancelCustom 的两个自定义文案成为真实按钮,
    结果以 MessageDialogResult::Custom(label) 返回,既有映射逻辑无需修改。
  • 官方文档的 Remarks 未要求调用线程初始化 COM;rfd 自身的异步 Windows 实现也是在裸
    std::thread 上调用 TaskDialogIndirect,与本项目专用 worker 线程同构,该路径经 Tauri
    生态长期使用。
  • 失败语义保持保守:TaskDialogIndirect 绑定失败(激活上下文不可用)时 rfd 返回 Cancel,
    映射逻辑把它视为选择次要按钮「稍后设置」,绝不会被误判为同意;文案正文(已精简)不引用具体按钮名,
    两种按钮形态下都成立。
  • 按钮顺序与默认焦点由 Task Dialog 决定:主按钮「退出并前往设置」在前且为默认按钮(与原
    MessageBoxW 默认「确定」一致),Esc/关闭窗口等价于「稍后设置」。
  • Windows 的主按钮现在名副其实:权限流程成功(reveal 已定位可执行文件)后,worker 通过托盘
    退出使用的同一个 shutdown_requested 标志请求退出,产品走常规 shutdown coordinator
    (flush 配置、停 runtime、join 服务)后进程退出,不绕过任何清理步骤;兼容性开关需要重启
    才生效,退出与按钮承诺一致。reveal 失败时保持运行(同「提示失败仅相当于选择稍后」的
    既有语义),用户可以再次尝试。macOS 主按钮只打开系统设置,没有退出承诺,行为不变。
  • 验证:workspace 构建链接通过;链接产物 bongocat_app.exe 内含且仅含一份 RT_MANIFEST,
    内容即 gpui 的 ComCtl32 v6 manifest。

主题外观修正(2026-09-18)

ADR-0048 把用户选择的浅色/深色接到原生表面后,实机发现启动权限提示在应用深色下仍是浅色。根因:
macOS 的 rfd 无父窗口消息框(同步与异步都是)最终调用 CFUserNotificationDisplayAlert
(rfd 0.17.2 src/backend/macos/utils/user_alert.rs),这条路径完全不经过 AppKit,不继承
NSApplication.appearance,只会跟随系统外观。ADR-0048 修复的正是 NSApp.appearance 继承链,
而本提示当时根本不在链上。

变更

  • macOS 提示改用 objc2-app-kit 的 NSAlert(NSAlertStyle::Warning,两个自定义按钮文案不变,
    返回值经既有 requested_permission_flow 映射,Windows 侧零改动)。NSAlert 的窗口继承应用
    appearance,从第一帧起跟随启动时设置的进程级主题(ADR-0048 的 apply_process_theme)。
  • 呈现通过 dispatch2::run_on_main 投递主线程并同步等待结果(dispatch2 =0.3.1 因此从
    dev-dependencies 转为正式依赖):worker 仍负责只读查询与阻塞等待,AppKit 窗口只允许在主线程
    构建。worker 在 GPUI run loop 内 spawn(2026-09-15 修正),GPUIApplication 已存在,因此
    09-14「不得触碰共享 NSApplication」约束的场景(GPUI 平台建立之前)不适用于呈现阶段;
    rfd 同步消息框仍被 contract 测试禁止,原因不变(它会在调用线程上构造共享 NSApplication
    的 plumbing)。
  • runModal 从主队列 block 执行,位于 GPUI 事件之间而非某个事件处理器栈内。模型选择器注释记录的
    RefCell already borrowed 重入崩溃发生在「GPUI 事件处理器内同步 runModal」,与此处不同;
    rfd 自身的文件对话框同样经 run_on_main 在主线程上运行模态,是该模式的既有先例。
  • 提示出现时调用 activateIgnoringOtherApps(true) 把产品带到前台(-activate 为 macOS 14+,
    产品支持 12+,故用旧选择器并 #[allow(deprecated)])。这是相对 CFUserNotification 的行为
    变化:提示现在是模态的,应答前主线程处于 modal panel run loop(GPUI 的渲染走 display link,
    不受影响),与文件对话框先例一致。
  • 响应常量不用 objc2-app-kit 的 NSModalResponseOK/NSModalResponseCancel:该绑定把弃用的
    NSOKButton(1)/NSCancelButton(0) 值挂在了这两个名字上,而 -runModal 对自定义按钮返回
    NSAlertFirstButtonReturn(1000)/NSAlertSecondButtonReturn(1001)。已对照 macOS SDK 的
    NSAlert.h 核实(2026-09-18),crate 内以自有常量 ALERT_FIRST_BUTTON_RESPONSE 固定。

证据与验证

  • 根因证据:rfd 0.17.2 源码调用链 + 实机截图(应用深色、提示浅色)与该路径「只跟随系统外观」
    的行为一致;SDK 头文件确认 NSAlert 返回值与绑定常量的出入。
  • cargo check / cargo clippy --all-targets -p bongocat-platform 通过;platform 全部单测通过,
    含更新后的 contract 测试:仍禁止 rfd::MessageDialog::new、要求 macOS 模块经 run_on_main
    呈现、禁止回退 AsyncMessageDialog。
  • 深浅两色下弹框外观的实机肉眼验收未完成,归入 ADR-0048 既有的「双平台实机主题验收」门禁。

设置契约与软件信息报告的能力模型(2026-09-28)

本 ADR 一直把能力表达为「平台自己的能力名 + available/missing」(见决策第 6 条的
--startup-permission-smoke 输出),但 SettingsInputDiagnostics 早期用的是 macOS 语汇的
SettingsInputMonitoringPermission { Unsupported, Denied, Granted },Windows 侧因此固定投影成
unsupported。该值只在 snapshot、变更日志和用户可复制的软件信息报告里出现,没有 UI 渲染它,
所以它长期无人发现;实机在 Windows 上复制报告时才暴露问题:报告里
input_monitoring_permission: "unsupported" 读起来像「该平台没有权限概念」,而事实是
「Windows 的门在提权令牌上,且当前进程未提权」——恰好是决定高完整性前台窗口存在时 Raw Input
是否继续送达的那一个状态(背景第 2 条与本 ADR 决策表)。

因此契约改为 SettingsInputCapability { name, available },name 取自
bongocat_platform::STARTUP_PERMISSION_CAPABILITY(macOS input_monitoring、Windows
administrator),available 取自 startup_permission_available()(macOS TCC preflight、
Windows TokenElevation)。带来的约束:

  • 平台差异只留在 adapter 内。bongocat-app 的投影函数因此不再有 cfg 分支,报告方
    (About 页复制的 JSON)也只读 protocol 类型,两个平台的措辞不会各自漂移。
  • 应用日志的 input/permission_unavailable 与 input/status_changed 记录用
    source=<capability> 说明是哪一个能力,code 承担「具备/缺失」;不再拼出
    input_monitoring_* 这类只对 macOS 成立的字符串。
  • 未被观察到的能力(只出现在测试 fixture 与尚未填充的 snapshot)用 unobserved() 显式
    构造,name = "unknown" 且 available = false,不得借用真实平台的能力名。
  • 隐私边界不变:能力名是平台属性,不含机器或用户信息。

替换边界

替换点只有 bongocat-platform 的私有 startup_permission adapter 和 bongocat-app 的文案
构造。升级 rfd 时只需复验 Windows 侧:无父窗口消息框仍为原生实现、在
common-controls-v6 + ComCtl32 v6 manifest 齐备时仍通过 TaskDialogIndirect 显示自定义按钮、
manifest 缺失时仍回退到 MB_OKCANCEL 且结果被当作「稍后设置」、以及无父窗口路径仍不需要应用
已运行(macOS 提示已不再使用 rfd,见「主题外观修正」)。common-controls-v6 feature 与
executable 内嵌的 ComCtl32 v6 manifest 必须同时存在,缺一会让
Windows 对话框静默失败或按钮回退为系统标准文案。manifest 的唯一来源是 gpui-pre 静态库内嵌
的 gpui.lib manifest:升级/更换 gpui-pre 时必须重新核实该 manifest 仍声明 Common-Controls
v6(可用字节扫描 Common-Controls 复核),且不得在产品自有 .rc 里再嵌入 manifest
(RT_MANIFEST ID 1 冲突,CVT1100)。升级 opener 时必须复验 reveal feature 与失败语义。
adapter 之外不得依赖 rfd 类型。升级 objc2-app-kit 时必须复验两件事:NSAlert 绑定的方法面
(本提示依赖 runModal/addButtonWithTitle/setMessageText/setInformativeText/
setAlertStyle)与 NSModalResponseOK/NSModalResponseCancel 常量值是否仍与
-runModal 实际返回值不符(当前不符,crate 内用 NSAlert.h 的 1000/1001 自有常量)。
若升级 gpui-kit/gpui-pre-macos,必须复验 GPUIApplication 子类与 platform ivar 的约束是否
仍然成立:一旦 GPUI 不再依赖该 ivar,本 ADR 的 macOS 同步/异步选择可以重新评估。

复核记录(2026-10-06,gpui-kit 0.7.1 / gpui-pre 0.3.8):resources/windows/gpui.rc 与
gpui.manifest.xml 相对 0.3.7 逐字节相同,仍是单条 RT_MANIFEST 24 内嵌
Microsoft.Windows.Common-Controls 6.0.0.0,产品自有 .rc 无需也不再新增嵌入。macOS 侧的
Ivar platform not found 约束由 startup_permission.rs 的源码 pin 测试固定;同日的 macOS
just dev-smoke 通过,设置窗口关闭、销毁并重新打开,未触发该 abort。Windows 侧仍需实机安装、
升级与卸载证据。

复核记录(2026-10-12,gpui-kit 0.7.3 / gpui-pre 0.3.9):gpui-pre 的
resources/windows/gpui.rc 与 gpui.manifest.xml 相对 0.3.8 逐字节相同
(diff -r gpui-pre-0.3.8/resources gpui-pre-0.3.9/resources 无输出),仍是单条
RT_MANIFEST 24 内嵌 Microsoft.Windows.Common-Controls 6.0.0.0,产品自有 .rc 无需也不再
新增嵌入。macOS 侧的 Ivar platform not found 约束由 startup_permission.rs 的源码 pin 测试
固定——该测试断言的是本仓库自己的模块源码,不读上游,因此 GPUI 快照换代不改变它的含义;
同日的 macOS just dev-smoke 通过。Windows 侧仍需实机安装、升级与卸载证据。

macOS 引导流程换成 permission-flow(2026-09-30,ADR-0078)

提示壳不变:仍是主线程 NSAlert,文案仍由 bongocat-app 从翻译目录构造,worker 生命周期、
「不做任何持久化」「已授权则不提示」的判定依据都不变。变化只有三处:

  • macOS 的「授权/去设置」动作由「CGRequestListenEventAccess + NSWorkspace 打开输入监控面板」
    换成 permission-flow 的 INPUT_MONITORING 引导面板(带拖拽引导)。CGRequestListenEventAccess
    不再由本路径调用:注册到输入监控列表这一步改由面板引导用户完成。
  • 该动作前增加一次 tccutil reset ListenEvent com.ayangweb.bongo-cat。这使「已授权则完全不提示」
    在流程入口侧被反向使用:每次进入流程都会把授权清回未授权,因此每次都要重新授权,即使此前已授权。
    同时因为 Bundle ID 全环境共用(ADR-0008),它也会清掉已安装 Production 构建的授权。
  • StartupPermissionPrompt 增加 locale 字段,把产品语言交给 adapter,供 macOS 引导面板对齐语言
    (ADR-0078 决策第 5 条)。Windows 侧接受并忽略该参数。

已知后果:重置会让运行中的事件 tap 失效并进入 PermissionDenied,而用户重新授权后没有自动重启
输入服务的触发点,输入要等应用重启才恢复;见 ADR-0078 的「未完成项」。本 ADR 原「决策」表中
macOS 一行的「授权/去设置」动作以本节为准。


Adr 0033 Build Packaging And Release Toolchain (`docs/adr/0033-build-packaging-and-release-toolchain.md`)

ADR-0033: Build, Packaging and Release Toolchain

状态:已接受(2026-09-14)
取代:ADR-0010 的 Windows target 集合、ADR-0023 的 NSIS toolchain 与 wrapper 边界

背景

BongoCat 之前的构建体系是自己维护的:

组件 之前的状态
justfile 通过 os() 分支调用两个平台脚本
scripts/ build-macos.sh、package-macos.sh、build-windows.ps1、package-windows.ps1
windows/installer/BongoCat.nsi 自维护的 56 行 NSIS 脚本
GitHub Actions 只有 ci.yml(PR 触发);没有任何 release workflow
版本号 只有 [workspace.package].version 一个来源,但 macOS / Windows 各自重新解析一次

这套体系的问题不是"实现得不好",而是它同时承担了三件事:编译、打包、发布。
平台判断、架构判断、目录布局、Info.plist 注入、图标校验、provenance、NSIS 调用
全部由项目自己实现,任何一个平台细节变化都要改项目代码。Windows 侧还要求发布机器
预置 nsis-3.11-setup.exe 与 makensis.exe 并通过 BONGOCAT_NSIS_SETUP_PATH /
BONGOCAT_MAKENSIS_PATH 注入——这是"依赖个人机器状态"的构建,不是项目级构建。

本次重构的目标由用户明确给出:三个发布目标、项目级配置、成熟开源工具、薄 just
入口、删除 scripts/、本地与 CI 同一套体系、版本号唯一来源。用户同时明确:现有 ADR
是参考资料而不是约束,与最终目标冲突时以目标为准。

发布目标

  • x86_64-pc-windows-msvc → NSIS 安装器(.exe)
  • x86_64-apple-darwin → .app + .dmg
  • aarch64-apple-darwin → .app + .dmg

Windows ARM64 不再是产品目标。Cubism Native R5 没有 desktop ARM64 Core,而
Windows 自身的 x64 仿真可以运行 x64 应用,因此维护一个无法端到端验证的原生
ARM64 目标只增加成本和发布风险。ADR-0010 的 ARM64 结论作废。

决策

1. `cargo-packager` 作为唯一打包工具,并以库形式集成

采用 cargo-packager =0.11.8(default-features = false,仅启用 rustls-tls,
Apache-2.0 OR MIT),由 workspace 成员 crates/bongocat-packaging 以库方式调用
cargo_packager::package()。

  • 打包配置是仓库内的 Rust 源码,编译期检查,不存在弱类型配置文件。
  • 版本由 Cargo.lock 精确固定,不要求任何开发者 cargo install 全局工具。
  • 本地与 CI 执行同一个 crate,接口天然一致。
  • 打包工作流完全归 cargo-packager 所有:bundle 目录布局、Info.plist 生成、
    资源复制、图标转换(.icns / .ico)、NSIS payload 组装与 installer 生成。

cargo-packager 的关键能力与 BongoCat 的对应关系:

需求 cargo-packager 能力
Windows x64 安装器 nsis,install_mode = currentUser 是默认值
macOS .app app,自动生成 Info.plist 并合并仓库 overlay
macOS .dmg dmg(本项目按下方第 4 条处理)
桌面 GUI 应用 本身就是 Tauri/Wry/Dioxus/egui 等桌面框架使用的打包器
资源与图标 resources(支持 src/target 映射)、icons
签名 macOS signing_identity、公证凭据;Windows signtool / 自定义签名命令
Cargo workspace 直接消费 cargo target 目录与二进制名

2. 删除自研构建与打包脚本

  • 删除 scripts/(4 个文件,约 14 KB shell / PowerShell)。
  • 删除 windows/installer/BongoCat.nsi(由 cargo-packager 的模板与
    install-mode 配置取代)。
  • justfile 只保留统一入口,不含平台判断、复制、打包或版本处理逻辑:
    just build [--target ...] [--environment ...] [--formats ...]。
  • BONGOCAT_NSIS_SETUP_PATH / BONGOCAT_MAKENSIS_PATH 与 NSIS 3.11 的 MD5 固定
    要求一并作废:NSIS toolchain 由 cargo-packager 自动获取并缓存。

3. 版本号唯一来源

[workspace.package].version 是唯一来源。bongocat-packaging 继承该版本,
因此 Cargo 在打包进程启动前就解析完成;--print-version 把同一个值暴露给发布流水线,
release workflow 用它校验 tag 与版本一致,不再出现"脚本一个版本、CI 一个版本"的局面。

4. `.dmg` 由操作系统磁盘映像工具产出(有明确退出条件的偏离)

2026-09-29 补充:Finder 窗口布局与卷图标由 ADR-0075 决定,本条只负责"镜像由
操作系统磁盘映像工具产出"这件事本身。

cargo-packager 0.11.8 的 DMG 实现依赖 create-dmg 在 2022 年的固定提交
28867ba,该脚本在当前 macOS 上无法完成 DMG 构建。它用

text
hdiutil attach ... | grep -E '^/dev/' | sed 1q | awk '{print $1}'

解析挂载结果,sed 1q 在读到第一行后关闭管道,hdiutil 收到 SIGPIPE 后挂载事务
没有完成,脚本随后的 hdiutil detach <device> 报
detach failed - No such file or directory。

本机实测(macOS 26.5,使用与 cargo-packager 完全相同的参数调用该脚本):

  • 原样调用 3/3 失败;
  • 把 hdiutil attach 的完整输出读完再解析 3/3 成功。

create-dmg 的 URL 与 revision 是 cargo-packager 的编译期常量,配置无法覆盖;
其 main 分支仍然固定同一个 revision,因此没有"换一个受支持的版本"这条路。

最终做法:.app 仍然由 cargo-packager 产出(bundle 布局、Info.plist、资源、
图标、签名全部不变),随后由 bongocat-packaging 用 macOS 自带工具把它封装成
DMG:ditto 暂存 bundle、加入 /Applications 拖放入口、hdiutil create -format ULMO
压缩、codesign 签名。这恰好是 create-dmg 自己封装的东西,只少了它的脚本层。

DMG 压缩格式选 ULMO(LZFSE)而不是默认的 UDZO(zlib)。UDZO/UDBZ 按固定
64 KB 分块压缩,看不到 bundle 里重复的预置模型资源;ULMO 对整条流做压缩。同一个
2.0.0 arm64 bundle 实测,每种格式都实际挂载并整读校验(可执行文件与三个预置模型
齐全):UDZO 13,795,779 B、UDBZ 13,462,431 B、ULFO 13,082,584 B、
ULMO 10,380,216 B,比 UDZO 小 24.8%。挂载并整读镜像平均 0.68 s,UDZO 为
0.92 s,更小的镜像同时装得更快;UDBZ 读得更快(0.45 s)但大 23%。
ULMO 是 Apple 的只读压缩格式,支持版本远早于产品声明的 macOS 12 下限。

退出条件:cargo-packager 换用可在当前 macOS 工作的 create-dmg revision 后,
改回 PackageFormat::Dmg 并删除这段代码(窗口布局与卷图标的部分由 ADR-0075 补充,同样
在这条退出条件下一并删除)。

5. Release workflow 由项目自己描述,原生 runner 构建

新增 .github/workflows/release.yml,在 tag 推送时于原生 runner 上运行同一套
just build:

  • windows-latest → just build(NSIS 安装器)
  • macos-latest → just build --target aarch64-apple-darwin(.app + .dmg)
  • macos-26-intel → just build --target x86_64-apple-darwin(.app + .dmg)

两个架构都用 --target 显式指定,因此产物集合不依赖 runner 恰好是哪一种架构。runner 则
按目标架构分开,修订见文末「macOS 两条 leg 分 runner 构建(2026-09-30,ADR-0078)」。

备选方案

  • cargo-dist 0.32.0:官方 GitHub Actions 生成、archives 与包管理器集成都很强,
    但它的产物模型里没有 .app 或 .dmg——macOS 只产出 tar.xz 归档,installer
    是 shell/PowerShell/Homebrew/NPM。BongoCat 明确要求 .app 与 .dmg,这是硬性阻塞,
    不是偏好差异。它的设计中心是"CI 发布归档",也不适合"开发者本地一次命令拿到 .dmg"。
  • 直接使用 cargo-packager CLI:需要 cargo install cargo-packager,版本无法随仓库
    固定,且把配置放到 Packager.toml 会削弱编译期检查。库形式严格更好。
  • 保留自研脚本:正是本次要删除的东西;它已导致 Windows 打包依赖发布机器预置的
    NSIS 路径。
  • 其他 Rust DMG 构建方案:crates.io 上不存在成熟的 DMG 安装器构建 crate
    (apple-dmg / udif 是读写库,不是安装器构建器)。
  • tauri-bundler:cargo-packager 的前身,桌面框架耦合更深,没有额外收益。

影响

  • 打包行为的一部分由第三方工具决定,项目不再逐行控制 Info.plist 与安装器脚本。
    对应地,仓库保留 macos/Info.plist 作为overlay,只声明 cargo-packager
    不生成的键(LSMultipleInstancesProhibited、NSPrincipalClass);其余键由工具生成,
    避免两处定义同一个值。
  • Windows 安装目录由 $LOCALAPPDATA\Programs\BongoCat 变为 $LOCALAPPDATA\BongoCat
    (cargo-packager 的 currentUser 布局),注册表仍写在 HKCU。首个正式版本发布前,
    不存在需要迁移的已安装实例。
  • cargo-packager 硬编码 Windows 安装器文件名为
    {主二进制名}_{version}_{arch}-setup.exe,且没有可配置项。发布资产名是产品决策,
    因此 bongocat-packaging 在打包完成后把已产出的安装器重命名为
    BongoCat_<version>_x64.exe(<version> 取自 CARGO_PKG_VERSION,架构取自
    ReleaseTarget::architecture,两者都随构建参数自动解析)。重命名只作用于文件本身,
    安装器内容、Info.plist 与 bundle 布局仍完全由 cargo-packager 拥有。
  • 不再有 NSIS 许可证页面与 DMG EULA 页面(未配置 license-file)。
  • 本 ADR 接受时没有产出可更新资产:当时的 self_update 方案需要按 target triple 命名、
    macOS 根为 BongoCat.app/、Windows 根为 bongocat-app.exe 的归档,且
    RELEASE_SIGNING_KEY 仍为 None(fail-closed)。这是本 ADR 的历史状态,不是当前实现。
    更新方案随后由 ADR-0034 取代为 cargo-packager-updater + detached minisign,并由
    crates/bongocat-packaging 产出、签名更新载荷;真实发布链路仍是未完成的发布门禁。

已接受的残余风险

  1. cargo-packager 的 NSIS plugin 获取:nsis-3.09.zip 与 nsis_tauri_utils.dll
    有 SHA-1 校验,但 NSIS-ApplicationID.zip 只下载、不校验,且来源是
    tauri-apps/binary-releases 而不是官方 SourceForge。ADR-0023 原先要求的"官方
    acquisition artifact + MD5 + makensis /VERSION"边界被替换成对
    cargo-packager 自身供应链的信任。这是明确接受的降级,不能声称等价。
  2. DMG 外观布局:cargo-packager 的 create-dmg 路径会在挂载后调用 Finder
    AppleScript 排列图标,需要一次"自动化控制 Finder"授权;CI=true 时它会传
    --skip-jenkins 跳过。本项目已不经过该路径,因此不受影响;如将来恢复该路径,
    必须重新评估无人值守环境。本项目自己的窗口布局同样不经过 Finder(ADR-0075
    决策第 1 条:构建期直接写 .DS_Store),所以这条风险对当前实现不适用。
  3. x86_64-apple-darwin 产物在 CI 中不被原生执行:CI 交叉编译并校验两种架构的
    bundle 结构,但只在宿主架构上运行应用。原生 Intel 机器上的运行验证仍是独立门禁。
  4. Windows 端到端未在本机验证:Windows 安装器只能在 Windows runner 上验证,
    本次没有在本机执行过 Windows 构建。

验证

  • just build 在本机(macOS 26.5 / aarch64)产出 BongoCat.app 与
    BongoCat-<version>-aarch64.dmg;plutil 断言 bundle id、LSMinimumSystemVersion、
    LSMultipleInstancesProhibited、版本号;codesign --verify --deep --strict 通过;
    .app 内含 Contents/Resources/models/{standard,keyboard,gamepad} 与
    Contents/Resources/build-provenance.json。
  • just build --target x86_64-apple-darwin 产出 x86_64 的 .app 与 .dmg。
  • tools/tests/ 下的契约测试改为断言新的打包配置:target 集合、产物集合、
    Windows 安装器发布名、bundle id、最低系统版本、版本号来源、与 bongocat-update 的
    target/命名一致性。
  • release workflow 用 just version 校验 tag。

后续

  1. 为更新流程产出按 target triple 命名的归档并接入 zipsign/RELEASE_SIGNING_KEY。
    已由 ADR-0034 取代:更新载荷改为共享 manifest 指向的
    BongoCat-<version>-<arch>.app.tar.gz(aarch64 / x64)与 NSIS 安装器,并用 detached
    minisign 签名;RELEASE_SIGNING_KEY 已内嵌公钥。真实 GitHub 发布与安装链路仍需端到端验证。
  2. cargo-packager 修复 DMG 后删除本项目第 4 条的偏离。
  3. 原生 Intel 机器与干净 Windows 10 1903+ / Windows 11 profile 上的安装、升级、
    卸载与回滚验证。
  4. 上报 create-dmg / cargo-packager 的 hdiutil 解析缺陷。

macOS 两条 leg 分 runner 构建(2026-09-30,ADR-0078)

本 ADR 决策第 5 条原本让两种 macOS 架构在同一个 macos-latest 上构建,理由是 SDK、工具链与
配置完全一致。ADR-0078 接入 macOS 输入监控引导面板后这个前提不再成立,而那一段本身要按本节
修订:

  • 引导面板链接一个 Swift 静态库,产出它的 build script(swift-rs)取的是宿主架构而不是
    Rust 目标架构,并且把 macOS 永远当成非交叉编译。因此在 arm64 runner 上构建
    x86_64-apple-darwin,链接进二进制的是 arm64 目标文件,链接失败在
    _permission_flow_* 未定义符号上;把 runner 换成 x64 则同一问题出现在
    aarch64-apple-darwin 那条 leg 上。
  • 因此两条 leg 各用一台与目标同架构的 runner:Apple Silicon 用 macos-latest
    (macos-26-arm64),Intel 用 macos-26-intel(同为 macOS 26 的 x64 镜像)。两者
    macOS 版本相同,Xcode 与 Swift 工具链仍对齐,「配置完全一致」的原意得以保留。
  • rustup target add ${{ matrix.triple }} 保留:它针对的是 rust-toolchain.toml 固定的
    工具链,而不是交叉编译——两条 leg 现在构建的都是各自 runner 的宿主 target。
  • 代价:本机在 Apple Silicon 上 just build --target x86_64-apple-darwin 仍然打不出来,
    Intel 产物只能由 release workflow 或一台 Intel 机器产出。要恢复「单 runner 出两个包」,
    需要 swift-rs 让 --arch 跟随 Rust 目标(上游仓库 Brendonovich/swift-rs),见
    ADR-0078 的「未完成项」。
  • 本 ADR「验证与已知限制」第 3 条(x86_64-apple-darwin 产物在 CI 中不被原生执行)仍然成立:
    Intel 那条 leg 换成 x64 runner 后,CI 里的构建与结构校验都发生在目标架构上,但应用本身
    仍然只在本机(Apple Silicon)运行过。

补充(2026-10-03,ADR-0078):上一节「两者 macOS 版本相同,Xcode 与 Swift 工具链仍对齐」的结论
现需修订。v2.1.0 实测显示,用 macOS 26 SDK 构建的 arm64 包在 macOS 27 上点权限引导的「打开系统
设置」会 SIGTRAP(SwiftUI AG 断言),同源码用 macOS 27 SDK 构建则正常。release workflow 因此改为:
Apple Silicon leg 用 xcode-27(macOS 27 / Xcode 27.0 / SDK 27.0),Intel leg 保持
macos-26-intel(macOS 27 是 Apple silicon 专用,Intel 机最高只到 macOS 26,SDK 26.x 与之匹配)。
两个 leg 的 Xcode 大版本不再人为一致;「配置完全一致」收窄为「同为与目标架构一致的原生 runner」。


Adr 0034 Detached Minisign Update Trust Model (`docs/adr/0034-detached-minisign-update-trust-model.md`)

ADR-0034: Detached Minisign Update Trust Model

状态:已接受(2026-09-14;§3 的 manifest 形状于同日修订,见该节修订说明)
取代:ADR-0029 的更新库与签名部分(正文保留为历史记录)

背景

ADR-0029 把更新栈换成 self_update 1.3.0,信任模型为 zipsign ed25519 归档内嵌签名。
换实现之后暴露了两个问题,两个都是"能力缺失"而不是"实现缺陷",且都由源码阅读确认:

  1. zipsign 只能签 .zip 与 .tar.gz。 self_update 的 detect_archive() 按扩展名判定
    归档类型,裸 .exe 与 .dmg 落入 ArchiveKind::Plain(None),verify_signature() 直接返回
    Error::NoSignatures。而产品在 Windows 上发布的更新产物就是那个 NSIS 安装器 .exe。
    一旦 verifying_keys 非空,Windows 必然验签失败;不配置密钥则等于不验签。
  2. self_update 的替换语义不适用于系统安装器。 上游 src/lib.rs:302 明确:
    .deb / .msi 这类系统安装器不在 replace-and-verify 语义内,需要调用方自己交给
    dpkg -i / msiexec /i。NSIS .exe 同理——用安装器去替换 bongocat-app.exe 在语义上
    就是把应用本体换成一个安装器。走 zipsign 就意味着 Windows 必须自研
    「下载 → 验签 → 静默运行安装器 → 重启」这条编排。

同时维护者给定了产物形状约束:macOS 为 .dmg + .app.tar.gz,Windows 只有 .exe,
两平台各只额外增加一个 .sig,不引入其他文件。这正是 Tauri 的 minisign 分离签名模型。

调研过程与结论的完整记录见 docs/update-signing-guide.md(解释性文档,非规范性)。

调研结论

已核实(2026-09-14,读 cargo-packager 0.11.8、cargo-packager-updater 0.2.3 与
minisign 0.7.9 源码,未执行任何真实签名或真实更新):

  • 签名端已经在依赖图里。 cargo-packager 0.11.8 公开 pub mod sign:
    generate_key、save_keypair、sign_file、sign_file_with_secret_key、SigningConfig;
    其 minisign 0.7.9 已由 cargo-packager 引入。签名工具与产物出自同一个 crate,
    不存在"两个工具各自演进"的漂移面。
  • cargo-packager-updater 0.2.3 是同一项目的消费端,覆盖 manifest 获取 → 版本比较 →
    下载 → 验签 → 安装的完整链路,且自带 Windows 安装步骤:UpdateFormat::Nsis 会用
    PowerShell Start-Process 运行下载到的安装器,随后 std::process::exit(0);
    UpdateFormat::App 则整包替换 macOS bundle。这正是 ADR-0029 缺失的那一半。
  • 验签发生在安装之前。 Update::download() 读完 payload 后立即调
    verify_signature(...),install() 只接受已验签的字节。失败即返回,不进入安装。
  • manifest 有两种形状(RemoteReleaseData,#[serde(untagged)]):dynamic(顶层
    url/signature/format,一份 manifest 描述一个 target)与 static(platforms 映射,
    键为 <os>-<arch>)。dynamic 形状没有 per-platform 条目,因此它不可能报 target miss。
  • WindowsConfig { install_mode: Quiet } → NSIS 参数 ["/S", "/R"];installer_args: None
    不追加任何参数。/S 静默,/R 要求安装器结束后重启应用——Windows 路径依赖它,因为库
    自己 exit(0)。
  • SigningConfig.private_key 是 base64 文本,不是路径(decode_private_key 先 base64
    解码再解析 minisign SecretKeyBox),所以 CI secret 可以直接整段传入,不需要先落盘。
  • minisign 与 zipsign 密码学上不互通:minisign 用 BLAKE2b-512 预哈希并比对 8 字节 key ID,
    zipsign 用 SHA-512。换模型等于换信任模型,不是换封装。
  • Config.pubkey 是单个 String:验签只支持一把公钥,没有多密钥 any-of 语义。

决策

1. 以 `cargo-packager-updater` 取代 `self_update`,保留 `bongocat-update` 的对外契约

采用 cargo-packager-updater =0.2.3(default-features = false,仅 rustls-tls)。
bongocat-update 的公开面不变:ReleaseConfiguration、ReleaseChannel、
UpdateTargetTriple、UpdateRuntime::{check, install, restart}、UpdateError、
UpdateOutcome、14 个稳定错误码与 10 项匿名计数。(错误码目录于 2026-09-15 增至 14 个:ReleaseFetchFailed 原本兼任「取不到发布信息」与「取到了但读不懂」,ADR-0035 把这两件事拆成两个码。新增码向后兼容,见该 ADR 的「真实端点首次运行的结果」。)

库的 Error(#[non_exhaustive])、Config、semver::Version 与 Url 全部映射为项目
自有类型,不出现在 diagnostics.rs 或 app/UI 协议中;未识别的库变体降级为
update_internal_failed,不把库文本带进诊断导出。

2. 信任模型:detached minisign 签名,密钥缺失时失败关闭

RELEASE_SIGNING_KEY: Option<&str> 存放 base64 的 minisign 公钥盒文本(即
cargo packager signer generate-key 写出的 .pub 文件内容)。仓库现已内嵌一把发布公钥
(key ID DF5E2C9D255DD85E),Production 构建的 UpdateRuntime::is_available() 因而可以为真。
公钥为 None、空串或纯空白时,runtime 在发出任何请求之前返回
update_signature_key_missing;系统菜单不会在没有有效公钥时显示「检查更新」入口。

这条门禁是刻意的第二道防线。库对空 pubkey 的行为是解码失败(Error::Base64 /
Minisign),即 fail-closed 而非 ADR-0029 记录的 self_update fail-open;但项目仍保留
自己的门禁,因为它产出的是稳定、无路径的错误码,而不是一个库错误。

3. 一份共享 manifest,static 形状

runtime 请求 releases/latest/download/latest.json,manifest 是库的 static 形状:顶层
version 加一个 platforms 映射,键为 <os>-<arch>,每个条目含 url、signature、format。

crates/bongocat-packaging 仍然每个 target 写一份 fragment(<os>-<arch>.json),因为每个构建
job 只能声明自己产出的载荷;发布前由同一个工具用 --merge-manifests 把 fragment 合并成
latest.json。合并放在打包工具而不是 CI 里,理由与打包本身一致:manifest 的形状与资产名必须由
一处拥有,工作流只负责调用工具。

平台键拼写必须与库的 get_updater_target() / get_updater_arch() 一致——是 macos 而非
darwin,是 aarch64 而非 arm64。fragment 的文件名就是该键,合并会校验它属于已发布 target,
拼错的文件名会让发布失败而不是产出一个没有 host 能命中的条目。

修订(2026-09-14,同日): 本节原决定是"每个 target 一份 manifest,dynamic 形状",理由是
避免跨 job 合并。产品要求把检查地址固定为 .../releases/latest/download/latest.json,而单一
文件名下三个 job 会互相覆盖、最终所有平台拿到同一个载荷,因此必须改为共享 manifest + 合并。
平台门禁随之回到 manifest 内:UpdateErrorCode::NoMatchingAsset 重新变为可达(发布漏掉某个
平台键时会命中它)。fragment 形状不变,只是从"被 runtime 读取的 manifest"降级为"合并的输入"。

4. 更新载荷:macOS 用 bundle 归档,Windows 复用已发布的安装器

target 更新载荷 签名
aarch64-apple-darwin / x86_64-apple-darwin BongoCat-<version>-<arch>.app.tar.gz(aarch64 / x64),归档根为 BongoCat.app/ .app.tar.gz.sig
x86_64-pc-windows-msvc 已发布的 NSIS 安装器 BongoCat_<version>_x64.exe .exe.sig

macOS 的归档由 crates/bongocat-packaging 用 tar + flate2 从已完成的 .app 生成,
follow_symlinks(false) 保留 bundle 内部链接。库在 UpdateFormat::App 下丢弃归档的根条目
再把其余内容装到 bundle 路径,所以归档根必须是 BongoCat.app/。

  • 补充(2026-09-28):载荷名里的 target triple 去掉了。macOS 载荷曾经叫
    BongoCat-<version>-aarch64-apple-darwin.app.tar.gz,现在叫
    BongoCat-<version>-aarch64.app.tar.gz(Intel 侧是 x64)。去掉平台后缀不削弱本 ADR 的
    任何一条结论:载荷由 manifest 的 URL 指定,库不按资产名匹配,所以命名只需稳定且可读;
    而该文件只可能出现在它所属的平台上,重复平台名对读者没有信息量,扩展名也已经把它和 Windows
    安装器区分开。Homebrew tap 的 cask 从同一行 url 读名字,因此不需要单独同步。

.dmg 不是更新载荷:它是人工安装路径(拖入 /Applications),库没有安装它的能力。

5. 签名在 `crates/bongocat-packaging` 内完成,且是最后一步

签名密钥通过 SIGNING_PRIVATE_KEY / SIGNING_PRIVATE_KEY_PASSWORD
注入,与既有的 BONGOCAT_MACOS_SIGNING_IDENTITY 同一形状——凭据注入点,不是隐藏构建步骤。
未设置即跳过签名,本地构建因此只产出 bundle 与安装器,不产出更新资产。

密钥对由同一个入口一次性生成:just keygen <file>(crates/bongocat-packaging 的
--generate-signing-key)。它用同一份精确 pin 的 cargo-packager 调 generate_key /
save_keypair,因此不需要任何全局 cargo install;生成后显式把私钥收紧到 0600,并打印公钥
那一行供填入 RELEASE_SIGNING_KEY。生成是离线的运维动作,不在 CI 中执行。

release workflow 反过来断言发布构建一定签过名:未配置 secret 时 job 直接失败,
并在打包后逐项校验载荷、.sig 与 manifest 存在且形状正确。这消除了"发布一批无人能更新的
产物"这一类静默失败。

顺序上签名必须最后发生:minisign 签名覆盖发布时的确切字节,签名之后再改名、重压缩或
strip 都会让签名失效。manifest 的 URL 因此在签名之后才写入(载荷文件名已经确定)。

6. 退役 `self_update` 专属的能力测试,改为钉住真实的签名/验签对

archive_layout_capability.rs、local_install_rehearsal.rs 与
multi_file_install_capability.rs 锁定的是 self_update 的 Extract / MoveAll /
backends::custom 原语,随该库一并退役。

替代品 release_manifest_capability.rs 用 loopback HTTP 服务把
**cargo_packager::sign::sign_file(生产端用的签名器)与 cargo-packager-updater(应用真正
运行的验签器)**放在一起跑,覆盖:共享 manifest 与它的 <os>-<arch> 平台键查找、被篡改的载荷、
由未知密钥签名的载荷、空公钥、以及(macOS)整包替换。

manifest 形状的跨 crate 一致性不靠正则匹配源码,而是由 crates/bongocat-packaging 的合并测试
把自己写出的 latest.json 喂给更新库自己的读取类型(cargo-packager-updater 作为 dev 依赖),
因此形状不匹配会在这里失败而不是在用户机器上。tools/tests/test_update_release_contract.py
覆盖另一半——资产名与平台键必须与 runtime 声明的一致。

7. 更新源的 GitHub 代理回退(2026-09-15 增补)

国内直连 GitHub 不稳定,一次更新运行在请求共享 manifest 时按固定顺序逐个尝试代理源
(/https://github.com/.../releases/latest/download/latest.json):

  1. https://cdn.gh-proxy.org
  2. https://v6.gh-proxy.org
  3. https://axisnow.gh-proxy.org
  4. https://v4.gh-proxy.org
  5. https://gh-proxy.org
  6. GitHub 官方源(兜底,不加前缀)

一个源可用的判定与库对 endpoint 的判定一致:请求返回成功状态且 body 能被更新库
作为本发布管线的 manifest 反序列化(§3 的 static 形状)。超时、网络错误、HTTP 错误或
内容不可解析都跳到下一个源;全部失败时报最后一个错误,错误码目录不变。
NoMatchingAsset(manifest 可读但不含本平台条目)不触发回退——所有源服务的是同一份
release 资产,换源不会改变答案,该诊断必须原样到达用户。

成功的代理贯穿本次更新:cargo-packager-updater 0.2.3 的 Update.download_url 是公开
字段,runtime 在下载前把 manifest 里的官方 GitHub URL 统一转换为
/<official-url>;转换只作用于 scheme 为 HTTPS 且 host 为 github.com 的 URL,
因此已代理的 URL(host 是代理本身)不会被二次加前缀,非 GitHub URL 不被改动。官方源
成功的运行不做任何转换。release_page_url(人工查看 changelog 的页面)不属于传输路径,
不转换。

单次 manifest 请求有独立的 30 秒上界(UPDATE_MANIFEST_REQUEST_TIMEOUT),低于载荷
传输的 1800 秒上界(UPDATE_REQUEST_TIMEOUT):源是串行尝试的,一个黑洞连接若占用
传输级超时会把后续每个源都堵死 30 分钟。成功的源确定后,超时在 Update.timeout 上恢复
为传输级上界再下载。

信任模型不受影响(§2):代理只是 URL 前缀转发,能迟滞一次运行、返回过期或伪造的
manifest、把下载指去别处,但无法伪造 minisign 签名,任何被篡改的内容到不了安装步骤;
"旧但签名有效"的降级风险即 §待验证项 5 的既有风险,代理化不改变它。该回退由
runtime.rs 的单元测试钉住(代理列表的顺序与字面量、代理 endpoint 的精确形态、
逐源尝试/首个可用即停/官方兜底/全失败报最后错误、NoMatchingAsset 短路、下载 URL
转换的幂等性与范围、超时上界),Windows 与 macOS 走同一段平台无关代码。

被取代的能力与损失

ADR-0021 / ADR-0022 / ADR-0025 / ADR-0026 的退役能力沿用 ADR-0029 的记录(detached 清单
签名、manifest v1 严格 schema 与 1 MiB 上限、https_only / 禁止 redirect / 15s 截止 /
仅接受 200 / 禁止透明压缩、独立 update helper、updates/ 目录 0700 与原子写入)。
相对 ADR-0029,本次换实现又新增了以下损失,记录在此以免日后被误认为仍然有效:

能力 ADR-0029 时的状态 现状
归档完整性校验(checksums feature,用 GitHub 公布的 per-asset sha256) 存在,但上游明确它只是完整性检查、不能替代签名 无替代。UpdateErrorCode::ChecksumMismatch 已无产出路径(diagnostics.rs 已注明)
per-platform 资产匹配(asset_for 按 triple 匹配资产名) 存在 无替代。平台选择改为 manifest 内的 <os>-<arch> 键,而不是按资产名匹配;UpdateErrorCode::NoMatchingAsset 因此仍然可达(发布漏掉某个平台键时命中)
公钥轮换窗 无替代(zipsign 至少是 any-of 多密钥语义) 更弱。Config.pubkey 是单把公钥,无 key ID 白名单、无有效期概念
单调 release_sequence 防降级 无替代,仅按 semver 比较 同左,未改变
归档内嵌签名(签名随产物走,不需要额外资产) 存在 改为分离 .sig:每个被签产物 +1 个文件(这正是维护者要求的形状)

另外两条由库决定、不是项目选择的行为,一并记录:

  • 库调用 minisign-verify 的 verify(data, signature, allow_legacy = true),因此同时接受
    minisign 的 legacy(非预哈希)签名,而不只是 cargo-packager 产出的 prehashed 签名。
    这是比 allow_legacy = false 更宽的接受面。
  • 验签前把整个 payload read_to_end 进内存。当前载荷规模(约百 MB 量级)可接受,但它是
    一条随产物增长而增长的常驻内存路径。

已接受的残余风险

  • Windows 安装路径未在本机验证。 本机是 macOS。install_mode = Quiet 对应的
    /S 静默开关、/R 重启语义、以及"安装器替换被占用文件"的可行性均来自源码阅读,
    未在真实 Windows 上跑通。per-user NSIS 安装不需要提权,这一点与 Quiet 的前提一致。
  • 发布私钥的 CI secret 与真实发布链路尚未验证。 客户端已内嵌公钥
    (DF5E2C9D255DD85E),但仓库无法得知 GitHub 上 SIGNING_PRIVATE_KEY /
    SIGNING_PRIVATE_KEY_PASSWORD 是否已设置。未设置时 release workflow 会在
    「Require the update signing key」一步主动失败;这是刻意的:在没有可用密钥的情况下发布,
    比发布失败更糟。真实签名、上传、下载与安装仍需一次完整 release 才能确认。
  • minisign 私钥默认无口令保护。 generate_key(Some(String::new())) 产出的私钥是明文
    base64;SigningConfig.password 只在生成时设了口令才有意义。密钥的离线备份、权限
    (0600)与轮换流程仍是运维职责,不是代码保证。
  • 签名/验签从未在真实发布上执行过。 全部证据来自 loopback fixture 与源码阅读。
  • manifest 合并是本项目新增的自有步骤。 更新库只消费一份 manifest,不提供合并;共享
    latest.json 由 crates/bongocat-packaging --merge-manifests 生成。它是本项目代码,因此它的
    正确性(版本一致性、平台键合法性、重复键拒绝)由本项目测试保证,不由上游保证。

验证

已完成(2026-09-14,本机 macOS 26.5 / aarch64):

  • cargo fmt --all --check 通过。
  • cargo clippy --locked --workspace --all-targets --all-features -- -D warnings 零警告。
  • cargo test --locked -p bongocat-update:17 个单元测试 + 8 个 release_manifest_capability
    测试通过,含共享 manifest 的名称、平台键查找与 target 无关的 endpoint 断言。
  • cargo test --locked -p bongocat-packaging:12 个单元测试通过,含合并产物被更新库自身读取
    类型反序列化成功、版本不一致/非法平台键/空输入被拒绝。
  • python3 -m unittest discover -s tools/tests 全部通过,含 manifest 资产名一致性契约与
    依赖策略目标一致性契约。
  • 端到端冒烟:用三份 fragment 跑 just manifest,产出的 latest.json 含 version 与三个
    platforms 键,Windows 条目为 nsis、macOS 条目为 app。
  • 依赖图:self_update、zipsign-api、ureq、sha2、semver 已从 workspace 依赖与
    Cargo.lock 中移除;ed25519-dalek 的失效声明一并删除。cargo update 无待解析变更。
  • 签名器与验签器的互操作由 release_manifest_capability.rs 实测锁定(生成真实密钥对、
    真实签名、真实验签
    ,只是走 loopback 而非 GitHub)。

待验证项(不得当作已确认)

  1. 真实发布链路:从真实 GitHub Release 下载、验签并安装,未执行。
    • 补充(2026-09-15):manifest 获取这一步已在真实端点上执行。结论是端点被旧产物占用——
      releases/latest/download/latest.json 返回 200,但内容是旧 Tauri 版本的 updater manifest
      (平台键 darwin-*、条目缺本库必需的 format、签名为 Tauri 私钥),因此客户端在平台查找前
      反序列化失败。下载、验签与安装仍未执行,线上尚无可用的新流程 release。排查细节、由此修正的
      诊断粒度与新增的能力测试见 ADR-0035 的"真实端点首次运行的结果"。
  2. Windows:安装器静默安装、/R 重启、被占用可执行文件的替换、安装失败回滚,均未实测。
  3. .app 的 codesign + notarize:当前打包只做 ad-hoc 或注入身份的 codesign,
    公证仍是独立发布门禁。归档必须在公证之后生成,否则签名对象不是最终分发的 bundle。
  4. 密钥轮换:单公钥模型下,换钥匙必须先发布一个"认识新钥匙"的版本,否则已安装用户
    的更新链直接断裂。轮换流程尚未设计。
  5. 降级攻击:仍只按 semver 比较。manifest 由 HTTPS 保护,但能够替换 manifest 的攻击者
    可以把客户端指向一个旧但签名有效的载荷。
  6. 内存占用:库把整个载荷读进内存后验签,大产物下的峰值内存未测量。

后续边界

本 ADR 不实现 endpoint 配置界面、公钥注入的自动化、update worker、更新 UI、操作系统包签名
验证或失败启动恢复。在待验证项 1 与 2 完成前,不得声称更新功能或 stable 发布完成。


Adr 0035 Update Worker And Window (`docs/adr/0035-update-worker-and-window.md`)

ADR-0035: 更新 worker、更新窗口与发布说明

状态:已接受(2026-09-15;2026-09-25 修订自动检查默认值与间隔)
依赖:ADR-0034(detached minisign 信任模型)、ADR-0029(第三方库边界)、ADR-0016(匿名诊断导出)、ADR-0059(UI protocol 边界)

背景

ADR-0034 交付了更新链路与信任模型,并在「后续边界」中明确排除了四件事:endpoint 配置界面、
公钥注入自动化、update worker、更新 UI,以及操作系统包签名验证与失败启动恢复。

在此之前,产品里与更新相关的可见能力只有两处,且都不完整:

  • 设置页有一个「自动检查更新」开关,它只把 updates.check_automatically 写进配置,
    没有任何代码读取它——打开它不会有任何行为。
  • 系统菜单有一个「检查更新」入口,由 bongocat_app::update_check_available() 决定是否显示,
    但它的处理分支是 SystemMenuAction::CheckForUpdates => Ok(true),即点下去什么都不发生。

也就是说:更新管线是完整且被测试覆盖的,但没有任何调用方。本 ADR 记录把这条链路接成
一个完整产品功能所采用的决策。

决策

1. 更新 worker 与设置 worker 分开,各自一条线程

新增 bongocat-app::ApplicationUpdateService:一条名为 bongocat-update-service 的线程独占
UpdateRuntime,是唯一触碰网络、下载和安装的组件。

不复用 ApplicationSettingsService 的命令循环。设置循环是串行阻塞的:把 check/download/install
放进去,会让一次百 MB 级传输期间的所有设置读写排队。两者生命周期也不同——设置服务在退出序列里
先于 overlay 结束,更新服务可以在安装完成后仍在运行。

2. worker 只发布状态,不推送事件

worker 把当前阶段写入 UpdateStateHandle(Arc<Mutex> + revision),窗口按
UPDATE_STATE_POLL_INTERVAL(250 ms)读取。理由:

  • 窗口关闭、销毁或渲染慢,都不会阻塞或反压 worker;下载进度是覆盖写,不是队列。
  • revision 只在阶段真正变化时推进,同一个阶段重复发布不推进 revision,轮询因此不会无意义重绘。
  • 命令通道(UpdateClient → UpdateCommand)保持有界且非阻塞(try_send),窗口不会因为
    worker 忙碌而卡住。

关闭窗口不取消任何操作,因为操作不属于窗口。这是刻意的:库没有取消钩子(见 §9),一个
"取消"按钮只能让 UI 停止显示,而传输仍在继续——那是在撒谎。窗口因此始终可以关闭。

3. UI protocol 拥有跨层 contract,app 负责适配

bongocat-ui-protocol 定义 UpdateSnapshot / UpdatePhase / UpdateClient 等全部更新面
类型,bongocat-ui 只负责 GPUI view 和展示策略。bongocat-ui-protocol 与 bongocat-ui 都不
依赖 bongocat-update。bongocat-app 做映射,且映射是穷尽 match:

  • UpdateStage → UpdateFailureStage
  • 14 个 UpdateErrorCode → UI 的同名目录

新增一个 stage 或 code 会让 bongocat-app 编译失败,直到它被赋予用户可见的含义。这沿用了
SettingsRuntimeErrorCode 等既有做法,也让 cargo-packager-updater 的类型不越过应用边界。
代价是两份字符串目录,由 bongocat-app 的测试逐项比对锁定。

4. 三个阶段可观测,靠拆分库调用实现

窗口需要区分「下载中 / 校验中 / 安装中」,但库的
download_and_install() 把三者合成一次调用。因此 runtime 改用
Update::download_extended() + Update::install() 两次调用:

text
download_extended(on_chunk, on_finish)   读完全部字节 → on_finish → 验签 → 返回已验签的字节
install(bytes)                           写入安装位置

on_finish 在验签之前触发,返回之后表示验签已通过。于是三个可观测点正好落在
UpdateEvent::{Progress, DownloadFinished, Verified} 上,窗口的 Downloading / Verifying /
Installing 分别对应它们,而不是靠猜。

失败阶段不再靠调用方推断:UpdateError 现在带一个 stage 字段,由 runtime 在失败点设置。
库把下载与验签合在一条错误里,所以下载阶段的错误按 code 再分一次
(SignatureInvalid → Verify,其余 → Download,未识别的按 Download 处理——不宣称载荷已通过认证)。

5. 重启要求是平台事实,不是运行时判断

restart_required_after_install() 返回 cfg!(target_os = "macos"):

  • macOS:库整包替换 .app(remove_dir_all + rename),运行中的进程此后执行的是已被删除的
    文件。preset_root() 虽然只在启动时读取,但 PresetModelCatalog::list/load 是惰性读盘的,
    所以继续运行会让预设模型切换直接失败。因此 macOS 在安装成功后自动重启:先按 §5.3 的顺序
    完成产品 shutdown,再以新进程启动新的可执行文件。

    重启必须是新进程,不能用 exec 替换进程镜像。exec 不结束进程,PID 不变,而 shutdown 序列在
    重启前已经移除了托盘 NSStatusItem;替换后的镜像随后创建的 NSStatusItem 创建成功、也自报
    visible,但窗口从不被布局,菜单栏里什么都没有——只有用户手动退出再启动才恢复。改为 spawn
    之后新进程拿到干净的注册,图标正常出现。restart_current_process 的注释记录了完整原因,
    the_restart_starts_the_updated_build_as_a_new_process 守住这条不变量。

  • Windows:库把载荷交给 NSIS 安装器后立即 process::exit(0),Installed 在本平台不可观测;
    安装器的 /R 负责重启。窗口因此没有重启按钮。

自动重启由应用侧看门狗触发,而不是由窗口触发。 窗口可以随时关闭(关闭不取消操作),如果重启
挂在窗口上,用户在传输途中关掉窗口就会留下一个执行着已删除文件的进程。因此应用每 25 ms 观察一次
发布出来的阶段:看到 Installed { restart_required: true } 后等 UPDATE_RESTART_DELAY(1200 ms,
只为让窗口把结果显示出来)再重启;窗口上的「立即重启」按钮只是把这个等待缩短,两条路径共用一个
update_restart_started 标志,因此只重启一次。

6. 自动检查由 GPUI 侧驱动,并在发现更新时打开窗口

开关与间隔值在用户配置里,只有设置服务读得到,所以调度放在 main.rs 的 GPUI 侧,而不是 worker 里:
启动后等 10 秒(不与启动争网络和窗口),之后按配置中的整小时数等待下一次检查。自动检查开关
默认 false,因此新配置不会主动发起检查;用户显式打开后才启动该调度。间隔默认 24 小时,
接受 1..=8760;调度器以最近一次实际派发为期限锚点,并通过只读取自动更新设置的轻量轮询重新
安排下一次检查,因此修改间隔会重排期限。关闭自动检查不会清空该值,手动检查也不受它影响。该补充取代本 ADR 最初
记录的“固定 24 小时且间隔未持久化”状态。

发现可用更新且窗口未打开时打开更新窗口。替代方案(什么都不做)会让这个开关变成一个
不可观测的设置;这与 §8.4 里"开关存在但无行为"的旧状态没有本质区别。窗口是单例,重复检查
不会叠窗口。

7. 发布说明随 manifest 一起走

crates/bongocat-packaging 的 --merge-manifests 增加 --release-notes <file>,把说明写进共享
latest.json 的顶层 notes;runtime 把它透传为 UpdateRelease.notes,窗口在「更新内容」区域
渲染,并另给一个指向 releases/tag/v<version> 的链接。

  • 不新增第二次网络请求:说明和版本、载荷地址来自同一份 manifest,检查更新时一次拿到。
  • 不新增依赖:说明由发布工作流用 gh api .../releases/generate-notes 生成(工作流本来就用
    --generate-notes),只是改为先生成再同时喂给 manifest 和 gh release create --notes-file,
    因此发布页和客户端看到的说明不会分叉。
    • 补充(2026-09-16):这一条已被取代。generate-notes 给的是两次 tag 之间的提交摘要(PR 与
      commit 列表),而本项目对外发布的是手写的双语 changelog;两者内容不同,让发布页显示提交摘要
      就等于放弃了 CHANGELOG.md / CHANGELOG.zh-CN.md 作为事实来源。现在由
      bongocat-packaging --extract-release-notes <file>(just release-notes)按产品版本号从两份
      changelog 中各取同一条目,按「英文正文 → --- → 中文正文」合成一份文件,同一份文件仍同时喂给
      manifest 与 gh release create --notes-file。"一次请求拿到"与"发布页和客户端不分叉"两条结论不变,
      变的只是说明从哪来:不再是 GitHub 的提交摘要,而是仓库里那份 changelog。该步骤因此也不再需要
      GH_TOKEN,并且在下载发布产物之前执行。版本在 changelog 里没有条目时合成失败,发布中断——这是
      有意的门禁,理由见下一条。
  • 有界:manifest 每次检查都会被解析,说明因此上限 32 KiB,超长在字符边界截断并追加可见标记,
    而不是让发布失败。空文件按"没有说明"处理,不写空字符串。
    • 补充(2026-09-16):双语合成后上限仍按 32 KiB 判定,截断发生在字符边界、不区分语言:说明
      长到需要截断时,被切掉的会是中文那一段。当前 changelog 的条目约 6 KiB,离上限很远,因此这是
      一个已记录而未处理的边界,而不是一个已知会发生的行为。另需注意:更新窗口渲染的是整份说明,
      不做语言过滤,所以中文用户会先看到英文段、再看到中文段——这与本项目发布页一贯的排版一致,
      是刻意保留的,不是渲染缺陷。
    • 补充(2026-09-28):每个语言块由工具补一个 ## Changelog / ## 更新日志 标题包住 changelog
      条目,块末尾追加一节生成的下载链接、模型库和赞助。补标题的理由是层次:条目自己只写
      ###,生成块三节都是 ##,不补的话条目会顶着文档开头,读者分不清哪一半是人写的。
      "一次请求拿到"
      这条结论因此多了一个必须一起成立的约束:生成块只能引用 bongocat-update 不需要的资源。
      所以只链接人工安装用的产物(Windows 安装包、两个 macOS 磁盘镜像),不链接签名更新载荷
      *.app.tar.gz——它由 updater 从 manifest 取,对读者是同一份程序的第二份下载。链接目标一律
      https,因为更新窗口只把 https 渲染成可点击控件;生成块不含图片和内联 HTML,
      update_markdown 本来就会拒绝解释两者。这些内容不进 changelog,理由见
      docs/changelog-conventions.md §1.3。
  • pub_date 仍然不写:库把它当可选,新鲜度由版本决定,而打包工具没有日期格式化能力。

8. 传输有界

UpdaterBuilder::timeout(UPDATE_REQUEST_TIMEOUT) = 30 分钟。transport 自身没有超时,不设界的话
一条卡死的连接会让 worker 永久阻塞。取值刻意宽松(覆盖整条载荷传输,而不是单次读取),因为它
要逃离死连接,不是约束慢链路;窗口不阻塞在传输上,可以随时关闭。

9. 明确不做的三件事

  • 取消下载:库在 download_extended 内部读完全部载荷并验签,没有 abort 钩子。要做到真取消
    只能自研下载与验签——而自研验证层正是 ADR-0029/0034 删掉的东西。因此不提供取消按钮,并在
    runtime 的文档里写明原因。
  • 安装前 quiescence:见下节。
  • 操作系统包签名验证(codesign / Authenticode 的客户端校验):仍是独立发布门禁,本 ADR 不碰。

需要维护者复核的一处偏差

AGENTS.md §10 与 TODO §8.4 的条目是「安装前协调 runtime/renderer shutdown,失败可回滚」。
本实现把协调放在载荷安装完成之后、替换进程之前:

text
check → download → verify → install → 按 §5.3 顺序 shutdown → 以新进程启动新构建

理由是三条同时成立的观察:

  1. macOS 的整包替换对运行中进程是 inode 安全的:rename 后旧 inode 仍被进程持有,替换本身
    不需要进程先退出。
  2. Windows 的安装路径本来就会终止进程:库在运行安装器后 exit(0),不存在"安装时进程还在写
    文件"的窗口。
  3. 先 quiescence 会让安装失败变成不可恢复:Application::shutdown(self) 消耗 Application,
    没有对应的 restart。若在安装前就把 runtime/overlay 停掉,而安装随后失败(磁盘满、权限、
    库在 macOS 上 remove_dir_all 后 rename 失败),应用会停在"overlay 已销毁、runtime 已停止、
    只能重启"的状态。当前顺序下,安装失败时应用完好无损,可以直接重试。

因此当前顺序在失败路径上严格更安全,而在成功路径上同样满足"新构建启动前完成 shutdown"。
这是一处与既有文档措辞不一致的取舍,需要维护者确认;若不接受,应改为
"先 shutdown 再 install",并同时为 Application 增加可恢复的 pause/resume 能力——那是另一项
独立工作,不在本 ADR 范围。

真实端点首次运行的结果(2026-09-15)

第一次在真实安装产物上运行检查更新时,窗口报 update_release_fetch_failed("无法读取发布信息")。
排查结论记录如下,因为它同时说明了两件事。

https://github.com/ayangweb/BongoCat/releases/latest/download/latest.json 存在且返回 200,但内容
是旧 Tauri 版本的 updater manifest,不是本项目打包流程产出的那份:

观察 线上文档 本项目产出
平台键 darwin-aarch64、windows-x86_64-nsis、linux-* macos-aarch64、windows-x86_64
每个条目 只有 url + signature 另有必需的 format
pub_date 有 刻意不写
签名 trusted comment: signature from tauri secret key 本项目 minisign 私钥

库里 ReleaseManifestPlatform::format 是非可选字段,所以 18 个条目全部缺 format 时,整份文档在
平台查找之前就反序列化失败 → Error::Serialization → 当时被映射到 ReleaseFetchFailed。

也就是说:更新功能本身没有缺陷,是线上还没有由新流程发布的 release。 这也是 ADR-0034 待验证项 1
第一次被真正执行到——它暴露的是"端点被旧产物占用",而不是"客户端有问题"。

排查过程还暴露了一个诊断粒度问题并已修正:

  • ReleaseFetchFailed 原本同时表示"取不到发布信息"和"取到了但读不懂"。这两件事需要不同的应对
    (前者通常可重试或属运维遗漏,后者说明发布的 manifest 不是本产品的),合并成一个码会让排查必须
    读源码。
  • 现在拆成两个稳定码:update_release_fetch_failed(Error::ReleaseNotFound:没有 release、没有
    manifest 资产、或非成功响应)与 update_release_manifest_invalid(Error::Serialization,以及
    Error::Semver——本构建自己的版本在任何请求之前就已解析,所以库返回的 semver 错误只可能来自
    manifest 的 version 字段)。按 ADR-0034 的说明,新增码是向后兼容的。
  • 两条新增能力测试在 loopback 上固定了这个故障形态:
    a_release_without_a_manifest_is_a_fetch_failure 与
    a_manifest_from_another_pipeline_is_rejected_as_unreadable。后者不只断言失败,还做对照实验:
    给同一份文档的每个条目只补上 format,失败就从 Serialization 变成 TargetNotFound——这证明
    缺 format 是解析失败的真正原因,而平台键拼写是第二个独立问题。

运维含义(不属于代码范围,但必须写下来):线上 v1.1.0 仍是旧 Tauri 版本,而工作区版本号也是 1.1.0。
即使把 manifest 换成正确形状,release.version > current_version 对已安装的旧 1.1.0 也不成立,
旧用户会被判为"已是最新"而永远不迁移。新流程的首次发布需要选一个高于旧线的版本号。

残余风险与待验证项(不得当作已确认)

  1. 真实发布链路未跑通:manifest 获取这一步已在真实端点上执行并完成定位(见上一节),但没有
    任何一次真实 GitHub Release 被下载、验签、安装过——线上没有由新流程发布的 release。窗口的
    其余状态由离线脚本化 engine 测试覆盖,真实链路仍见 ADR-0034 待验证项 1。
  2. Windows 安装路径未在本机验证:本机是 macOS。Installed 在 Windows 不可观测这一点来自源码
    阅读,未实测;窗口在该平台的 installed_relaunching 文案因此也未被真实观察到。
  3. macOS 自动重启未随真实安装端到端验证:重启已从 exec 改为 spawn(见 §5,附托盘图标消失的
    原因与不变量测试),但「安装成功 → 自动重启 → 托盘图标仍在」这条完整链路仍需要一次真实安装
    才能确认。启动新构建失败时进程以退出码 1 结束并写 stderr,用户需要手动启动。
  4. 发布说明注入未在真实发布中跑过:gh api .../generate-notes 的形状、--notes-file 与
    manifest notes 的一致性只有单元测试与工作流静态检查,没有真实 tag 发布证据。说明来源已于
    2026-09-16 改为 changelog 提取(见 §7 补充),本项仍未消除:just release-notes →
    just release-manifest 已在本机端到端跑通,但没有真实 tag 发布证据,也没有在 GitHub 发布页上
    验证过合成后的 Markdown 渲染。
  5. manifest 仍无防降级保护:说明字段不改变这一点,能替换 manifest 的攻击者仍可把客户端指向
    旧但签名有效的载荷。
  6. 库的内存行为未变:验签前把整个载荷读进内存,大产物下的峰值内存仍未测量。

验证

已完成(2026-09-15,本机 macOS 26.5 / aarch64):

  • cargo fmt --all --check 通过。
  • cargo clippy --locked --workspace --all-targets --all-features --exclude bongocat-app -- -D warnings
    零警告;bongocat-app 的 storage-test-injection 与 production 两种 feature 组合各自
    --all-targets -D warnings 通过。
  • cargo test --locked --workspace 全部通过。
  • bongocat-update:27 个单元测试 + 10 个 release_manifest_capability 测试通过,新增 stage 归属、
    UpdateEvent、unavailability()、传输上限、发布页 URL,以及"取不到 manifest"与"manifest 读不懂"
    分成两个码的定向测试;能力测试另在 loopback 上固定了两种失败形态(无 manifest →
    ReleaseNotFound;旧 Tauri 文档 → Serialization,并用只补 format 的对照实验证明原因)。
  • bongocat-app::update:26 个测试通过,其中 11 个用脚本化 engine 在真实 worker 线程上驱动
    状态机,覆盖 check 的三种结果、失败的 stage/code 归属、安装的四步顺序、无 release 时 install
    不改状态、不可用构建不进入管线、重启请求只被消费一次,以及 drop 会停止线程。
  • bongocat-app(bin):新增 restart_delay_elapsed 的边界测试(未到延时、刚好到、以及单调时钟
    回退时不得提前重启)。
  • bongocat-ui 的无头渲染测试(update_window::render_tests,7 个):这是本项目第一批真正
    渲染窗口的测试。bongocat-ui 的 dev-dependency 里给 gpui-kit 打开 test-support,GPUI 因此
    使用测试平台,可以在没有显示器的情况下开窗、布局、绘制,并对注册过的元素做查询与点击。
    覆盖:18 个阶段逐一绘制且 footer 只提供该阶段允许的动作(render 是否真的遵守 offers_*
    以及每个变体能否活着画完,只有这里能看出来)、检查按钮文案随阶段区分、更新内容区跟随
    manifest 的 notes、以及完整链路(共享状态 → 窗口的 250 ms 轮询 → view 快照 → 渲染)。
    为可查询,产品代码里给 footer 动作与更新内容区加了 .test_support();test-support 关闭时
    它是恒等函数,正常构建不受影响。
  • 临时模拟面板(update_simulation.rs,为人工逐一切换查看每种状态而临时加入)已于 2026-09-15
    整体删除:全部 SIMULATION-HOOK 标记清除,默认窗口高度恢复 460,面板自身 6 个测试与 3 个
    依赖面板的渲染测试一并移除,render_tests 只保留不依赖面板的永久用例。
  • bongocat-ui::update / update_window:13 个测试覆盖错误码目录唯一性、stage 往返、阶段可用动作、
    revision 语义、客户端与共享状态的一致性,以及"每个错误码都能解析出文案而不是解析出它自己的
    key"。后者用删除 key 的方式双向反证过:两个语言都删则它失败,只删 zh-CN 则它通过而
    bongocat-i18n 的跨语言一致性测试失败——rust_i18n 的 en-US fallback 意味着"某个语言缺
    文案"由那条既有测试守卫,本测试只负责"所有语言都缺"这一种。
  • bongocat-packaging:16 个测试通过,新增发布说明写入、空说明不写入、超长按字符边界截断。
  • python3 -m unittest discover -s tools/tests 全部通过(57 项)。
  • 端到端冒烟:用三份 fragment 跑 just release-manifest,产出的 latest.json 含 version、
    notes 与三个 platforms 键,Windows 条目为 nsis、macOS 条目为 app。
  • 产品启动冒烟:target/release/bongocat-app --run-seconds 12 退出码 0、无 stderr 输出。12 秒跨越
    了 10 秒的自动检查起点,因此同时验证了更新服务能启动、自动检查在 Development 构建上被
    unavailability() 拦下而不发请求、以及退出序列能 join 更新 worker 而不记录失败。
  • 真实端点首次运行(2026-09-15):在真实安装产物上点击检查更新,窗口正确显示"当前版本"、真的
    发起了请求,并诚实报出失败;随后用 curl 取回线上 manifest 完成字段级定位(见上一节)。
    这是真实链路第一次被走到网络层,结果是"端点被旧产物占用"而非客户端缺陷。
  • 未验证:更新窗口的视觉(间距、字号、是否重叠)没有实机截图证据,键盘导航也没有实机
    走查;无头渲染测试证明的是"每个阶段都画得出来、该有的动作都在、文案正确",不是"看起来好看"。
    按 §11 的 800x600 / 125-200% 缩放条件核对仍待人工完成。下载与安装仍未在真实 release 上跑通
    (线上尚无可用的新流程 release)。
  • 依赖面变化:本次给 bongocat-ui 增加了一个 dev-dependency(gpui-kit 的
    test-support feature),只影响测试构建。它把 proptest 及其传递依赖(bit-set、bit-vec、
    rusty-fork、wait-timeout、quick-error、convert_case、proptest-macro)带入 lock;
    proptest 本就在 workspace 依赖里,因此没有引入新的依赖家族。按 §9 跑了完整 cargo update,
    只升了 camino 1.2.5 → 1.2.6 与 rustls 0.23.44 → 0.23.45 两个补丁版本。
  • 正式依赖变化:更新内容渲染 Markdown 新增了一个正式依赖 pulldown-cmark =0.13.4
    (当时 crates.io 最新稳定版,MIT,default-features = false 去掉 getopts CLI 解析与 html
    渲染器——说明渲染为 GPUI 元素,不产出 markup)。纯解析器、无 I/O、无 unsafe,停止维护时的
    替换边界是自写一个受限于标题/列表/强调/链接/代码块的极简解析器;GitHub 风格表格刻意不支持。

已完成(2026-09-16,说明来源改为 changelog 提取,本机 macOS / aarch64):

  • bongocat-packaging:23 个测试通过(原 16 个 + 新增 7 个)。新增覆盖:
    条目按二级标题匹配(正文提及的版本、### 子标题、围栏代码块里的标题都不参与)、两种标题写法
    与"版本必须是完整 token"(<version>-rc.1、<version>.1、11.1.0、##1.1.0 都不匹配)、
    CRLF 归一、缺失条目的错误信息含该文件实际记录的版本、--extract-release-notes 与构建/合并选项
    互斥、仓库两份 changelog 记录的版本列表一致且非空。
  • 端到端:just release-notes → just release-manifest 用三份 fragment 跑通,产出的 latest.json
    的 notes 为 6168 字节,结构为「英文条目 → --- → 中文条目」,不含版本标题,远低于 32 KiB 上限。
    另单独验证了门禁:版本在 changelog 里没有条目时命令以退出码 1 失败,stderr 为
    CHANGELOG.md has no release notes for <version>; it documents <列表>。
  • tools/tests/test_release_changelog_contract.py 新增 6 个用例;python3 -m unittest discover -s tools/tests 共 63 项。
  • 既有失败(非本次引入):test_product_version_contract.py 的
    test_runtime_and_ui_use_the_compiled_product_version 失败——该测试要求当前工作区版本
    字面量不出现在指定源文件里,而 crates/bongocat-update/src/runtime.rs 的测试夹具 URL 里有三处
    v1.1.0(1144、1149、1172 行,HEAD 版本即如此,本次未改动该文件)。夹具用哪个版本号不影响它
    要固定的形状,但修与不修属于该测试自己的取舍,未在本次改动范围内。本次新增的测试数据因此一律
    使用合成版本号(9.9.9 一类),避免新写死一个"当前版本"。
  • 未验证:合成后的说明在 GitHub 发布页上的 Markdown 渲染没有实机证据;真实 tag 发布仍未跑过
    (见残余风险 4)。

当前修订验证(2026-09-25):

  • bongocat-config、bongocat-ui-protocol、bongocat-ui 的定向/全量单元测试,独立
    config-store contract(含两个恢复测试),Draft 2020-12 schema、locale 校验、定向 Clippy 和
    cargo check --workspace --release 通过。
  • 调度器纯函数测试覆盖 1 小时、默认 24 小时、自定义 48 小时、最大 8760 小时、从实际派发
    锚点重排期限和轻量轮询上限;配置服务测试覆盖 48 小时持久化、关闭开关保留值、stale revision、
    0/8761 拒绝以及重启恢复。
  • 当前 Windows 环境的全量 workspace 测试仍有一个未触及的模型封面路径规范化失败;工具测试另有
    symlink 权限和路径分隔符环境失败,均不改变本项配置/调度证据。

补充:更新窗口改为按内容定高(2026-09-26)

为什么改

窗口原本是固定 560×460,十个状态共用。逐状态实测(无头渲染,二分查出「内容刚好放得下」的最小高度)
显示,其中四个状态的内容只有一行文字:

状态 内容所需高度 原窗口 空出来的部分
Idle / UpToDate / Available(无说明) 159px 460px 301px
Checking 179px 460px 281px
Failed(无说明) 187px 460px 273px

四个状态里没有一个用过窗口的一半,而「检查更新」这条最常见的路径正好全程停留在这几个状态里——
用户看到的是一个 560×460 的大框,里面一行字。这是 Sparkle 那类更新对话框采用「按内容定高 + 设上限

  • 超出滚动」的直接原因,也是本次改动的唯一动机。

改了什么

高度在布局后测量、下一帧应用,夹在下限 180 与上限 460 之间。460 是改动前就用的高度,因此窗口不会比
原来更高。实测:单行状态收敛到 187px;带 40 行说明的 Available 收敛到 411px,说明区取满
NOTES_MAX_HEIGHT 后滚动。

四处是让测量能收敛的必要条件,不是风格选择:

  • 说明区按内容取高、以常量封顶。如果让它跟着窗口长,窗口变高 → 说明区变高 → 窗口再变高,两者
    互相喂给,永不收敛。the_height_does_not_depend_on_which_height_the_window_opened_at 从 180 和
    460 两个起点验证同一个结果。
  • 内容列不参与收缩(flex_shrink_0)。否则窗口偏小时说明区被静默压缩,帧报告的是「放得下」,
    窗口就永远不会长回去。
  • 间距与内边距取自该帧自身的 bounds,不写死常量。改 p_4 或 gap 不会让窗口高度与内容脱钩。唯一
    取不到的是下内边距(窗口太小时它被压掉而不是把动作推出屏幕),因此上内边距被同时用作两侧,
    the_window_pads_its_content_symmetrically 守住这个假设。
  • 窗口从上限打开再向下收敛。内容定高只能在开窗后的下一帧才知道,从下限打开会让每个高于下限的
    状态都有一帧动作被裁掉;向上收敛比向下收敛更显眼。

用户拖动窗口后由用户的高度接管(observe_window_bounds 比对上次请求的高度),说明区让出空间并滚动。
没有这条,下一次状态变化会悄悄撤销用户的拖动——那才是让「按内容定高」显得有缺陷而不是有用的那种行为。

本节以上两条(从上限打开、用户拖动后接管)已被同文件末尾「消除开窗与状态切换时的高度闪烁」一节
取代:窗口改为隐藏状态下量好画好再显示,也不再允许拖拽。保留原文是为了记下当时为什么那么选。

验证(2026-09-26,本机 macOS 26.5 / aarch64)

  • cargo fmt --all -- --check、cargo clippy -p bongocat-ui --all-targets --all-features -D warnings、
    cargo test --workspace、cargo check --workspace --release、python3 -m unittest discover -s tools/tests 全部通过。
  • bongocat-ui::update_window 的无头渲染测试从 7 个增加到 13 个。新增的 6 个分别固定:窗口高度落在
    自身区间内、单行状态塌缩而长说明撑开、同一内容从上下两个起点收敛到同一高度、每个状态都放得下动作、
    超预算的说明滚动而不是撑高窗口、用户拖动后不再被内容改动。另加 2 个纯函数测试(帧高度算术与夹取
    规则)和 1 个内边距对称性测试。
  • 实机截图(macOS 26.5):Unavailable 状态的窗口按内容收紧,标题、版本、状态与关闭按钮的位置和
    间距正常,无裁剪。
  • 仍未验证:Windows 上的窗口缩放没有实机证据(window.resize 走 GPUI 的跨平台路径,但按
    AGENTS.md §4 不得由 macOS 推断);800x600 与 125/150/200% 缩放下的高度仍待人工核对。带说明的
    状态在 2026-09-26 之前没有实机截图,原因见下一节。

补充:更新窗口状态浏览器(2026-09-26 已删除)

为什么当时需要它

按内容定高之后,「这个状态看起来对不对」只能靠看,而大部分状态没法看到:Development 构建的更新
服务在发出任何请求前就被 unavailability() 拦下,所以 Available / Downloading / Verifying /
Installing / Installed 与各阶段 Failed 在构建它的机器上一条都到不了。此前唯一能看到的状态是
Unavailable——恰好是内容最少、最不需要看的那个。

合成点击也不可靠:一次 System Events 的合成点击会落到别的窗口上,而拿不到按钮的人不该被要求去按
真实的按钮。

做了什么,以及为什么现在删掉

bongocat-ui::update_state_browser 开一个开发专用窗口,每个按钮往共享更新状态里发布一个状态,
并请求应用把更新窗口叫起来。20 个按钮按「紧凑 / 更新内容 / 已安装 / 失败」分组——分组是重点:哪些
状态高,从状态名上看不出来,而分组把「过一遍」变成一次检查。驱动的是真实窗口而不是副本:副本就
不是要发布的那份代码了。

它是定高这项工作的脚手架,不是产品的一部分。定高与闪烁都改完、无头渲染测试已经逐个状态覆盖之后,
它唯一剩下的用途是人工过一遍十个状态——这件事无头测试在做,而且做得更多(每个状态都断言动作、
高度、内容)。留着一个 700 行的开发窗口、CLI 开关和 9 个只为它存在的测试,等于让发布构建多带一个
没人维护的界面。所以删除,bongocat-ui 与 bongocat-app 随之回到没有它的样子。

当时的验证(2026-09-26,本机 macOS 26.5 / aarch64)

  • 9 个测试:点击按钮会发布该按钮自己的状态并请求显示窗口(而不是只把自己标成选中);Back to a real check 真的退回真实状态;窗口能渲染的每种状态都有按钮;有两个分支的状态(Unavailable /
    Installed)与四个失败阶段各有一个按钮;分组名称连续且顺序稳定;面板不会开到屏幕外(含屏幕太窄的
    情况);脚本入口发布的内容与按钮一致;示例更新内容确实长到窗口放不下。
  • 实机截图:紧凑状态约 187px;Installing + 完整双语更新内容为 431px,更新内容取满预算后滚动,
    「在 GitHub 上查看」与「关闭」两个动作都在窗口内。这批截图是下面那节定高结论的实机依据。

删除时一并失去的东西

  • 未验证:Windows 上浏览器与更新窗口的并排位置;面板的键盘导航。这两项随浏览器一起消失,
    没有留下结论。
  • 定高本身在 Windows 上的行为不受影响:它由无头渲染测试与下面的 trace 覆盖,与浏览器无关。

补充:消除开窗与状态切换时的高度闪烁(2026-09-26)

现象

用户报告:更新窗口在高度变化过程中会先瞬间变成上限高度,再迅速回退到内容实际需要的高度。

原因不是「算错」,是「算得太晚」

原来的实现把窗口开在上限高度(460),等第一帧测出内容高度再收下来。内容定高只能在布局之后知道,
所以从上限高度开始是当时能想到的唯一安全猜测——上限装得下任何内容,不会裁掉动作。代价就是那一下
可见的收缩。

真正多余的是中间那一步。旧代码在窗口比内容矮时返回 TooSmall,目标高度直接取上限:意思是
「先长到上限,再收敛回来」。而这个中间高度并非必需——内容列不收缩、空隙塌成 0,所以即使窗口装不下
内容,这一帧报出来的仍然是它需要的高度。Fit::TooSmall 分支因此整条删掉,测量一步到位。

显示时机:隐藏状态下量好、画好,再显示

目标高度一步到位之后,还剩「窗口出现时是哪个高度」。这一条比看上去难,有两个平台事实挡路:

  1. macOS 的 resize 是异步的——PlatformWindow::resize 把 setContentSize_ 排进 foreground
    executor,当轮拿不到新尺寸。
  2. 窗口没显示时拿不到帧。macOS 只为「遮挡状态判定为在屏幕上」的窗口跑 display link,也就是只发帧
    给它。于是「等一帧画对了再显示」会死等——先做的那版正是这样,窗口永远不出现。

做法是三步,且都不能交给 frame loop:隐藏状态下手动 draw 一次量出目标高度,resize,然后再排
一个 foreground executor 任务
,把新高度同步回来、重画、activate_window。应用 resize 本身也是同
一个 executor 上的任务,所以后排的任务一定排在它后面——保证来自顺序,不是轮询也不是超时。

用 cx.update_window(untyped handle)而不是 WindowHandle::update:后者在闭包期间 lease 住 root
view,而 draw 会再 lease 一次,直接 double-lease panic。frame loop 用的是 untyped handle,同理。

顺带:窗口不允许拖拽改变尺寸

is_resizable: false,一行跨平台:macOS 去掉 NSWindowStyleMaskResizable,Windows 去掉
WS_THICKFRAME/WS_MAXIMIZEBOX。理由与「按内容定高」是同一条——允许拖拽等于让用户选一个下一次
状态变化就会撤销的高度。

于是「用户拖过就接管高度」那条分支(observe_window_bounds 比对上次请求的高度、pinned 状态、
start_height_pinning)连同它的测试一起删除:窗口没有 resize 手柄,那条分支是死代码。

平台取整

trace 里平台把请求的 410.5 变成 411,下一帧量到 410.5、又问一次。平台不给尺寸所以也不会回调,
循环自己停了,但每帧问一次平台给不出的高度是没道理的事,所以「已经在目标高度」允许半像素误差
(HEIGHT_MATCH_TOLERANCE)。

验证(2026-09-26,本机 macOS 26.5 / aarch64)

  • 实机 trace(临时插桩,已移除):开窗 180 -> 187 且 在 187 才显示;状态切到完整双语更新内容
    187 -> 410.5 一步到位;切到下载中 187 -> 257.5 一步到位;切到失败 187 -> 207 一步到位。
    上限 460 从未成为目标高度。
  • 实机辅助功能读数:更新窗口 560x219(含标题栏,即内容 187),AXZoomButton 为 missing value
    (对照组:同一次运行里的其它窗口有 zoom 按钮)——不可缩放在 macOS 上确实生效。
  • bongocat-ui::update_window 的无头渲染测试 13 -> 20 个。删掉 1 个(用户拖动接管),新增 3 个纯
    函数断言(过矮时测量仍准确、目标高度夹取、半像素容差),新增 1 个渲染测试固定「窗口被显示时那一帧
    是按它最终落定的高度画出来的」(从上限高度和从下限高度两个起点各验一次)。该测试在去掉
    bounds_changed 时确实失败,不是空转。
  • 15 秒长跑:trace 行数不增长,无 resize 循环。
  • cargo fmt --all -- --check、cargo clippy(workspace 去 bongocat-app,另加 bongocat-app 的
    storage-test-injection 与 production 两种 feature)、cargo test --workspace(44 个 target)、
    cargo check --workspace --release、11 个 tools/tests 脚本与 tools/validate-locales.py 全通过。

边界

  • 未验证:Windows 上的开窗时序。resize 在 Windows 上是否同样异步、隐藏窗口是否同样拿不到帧,
    本机无法验证;上面两处都写了 macOS 的具体原因,若 Windows 行为不同则需要按平台分支。
  • 未验证:800x600 与 125/150/200% 缩放下的高度。

补充:开窗即显示正在检查(2026-09-26)

现象

用户报告:打开更新窗口时先显示上次检查的结果(“已是最新”、上一次的更新内容或失败原因),
过一会儿才开始显示“正在检查”和进度条。

原因不是“慢”,是“请求发得太晚”

系统菜单与 About 页的「检查更新」都走 open_update_window_and_check,而它当时的顺序是
先开窗并显示,再请求检查。两件事叠起来才是这一次闪烁:

  1. 窗口在 open_window 闭包里构造时读的是 client.snapshot(),此刻 worker 发布的仍然是上一次
    检查的阶段。窗口的第一帧——也就是用户看到的那一帧——因此画的是上一次的答案。
  2. view.check() 随后才把 Check 命令发出去。命令是发给 worker 线程的消息,worker 取到它之后
    才发布 Checking;而窗口按 UPDATE_STATE_POLL_INTERVAL(250 ms)轮询共享状态。所以从点击到
    进度条出现之间,屏幕上一直画着旧阶段。

只把请求提前并不能消除它:即使开窗前就把命令发出去,worker 仍是另一个线程,窗口构造那一刻
Checking 未必已经发布。第一帧要显示这次检查,就得由窗口自己渲染它。

改了什么

  • 窗口带着“开窗要做什么”打开:UpdateWindowStart::{Current, Check}。Check 在 view 构造期间
    (即第一帧被绘制之前)就发出请求,Current 保持原语义,供自动检查“只展示结果”那条路径使用。
  • 请求之后由窗口渲染这次检查:view 记下请求发出时的 revision(PendingCheck)并把阶段渲染成
    Checking;poll_state 在 revision 还没有前进时不采用共享状态里那个仍属于上一次检查的阶段。
    revision 一旦前进就说明 worker 回答了,此时以 worker 发布的阶段为准。命令发不出去(通道满或已关闭)
    时不渲染进度条——没有 worker 在跑的检查不能画成正在跑的检查。
  • 这个 revision 是发请求那一刻从共享状态读的,不是复用窗口上次轮询到的那一个。worker 按自己的
    节奏发布,一个结果完全可能落在两次轮询之间;若用旧 revision 作锚,下一次轮询会因为「revision 已经
    前进」而把那个还没被窗口看到的结果当成对本次请求的回答——同一次闪烁,只是晚了一轮询。
  • asks_for_a_new_check 收口三个条件:没有未完成的请求、当前阶段不忙、当前构建不是
    Unavailable。最后一条不是防御性代码而是必需条件:这类构建的 worker 只会重新发布它已有的阶段,
    revision 不前进,照旧渲染的话进度条会永远转下去。同一条件也让「开窗即检查」对已经打开的窗口是
    空操作,因此一个 view 不会有两次在途检查。
  • 调用点不需要区分新旧窗口:open_update_window_and_check 末尾那次 view.check 仍用于已经打开
    的窗口(用户在窗口空闲时再次点击「检查更新」),对刚创建的窗口是空操作。

与既有决定的关系

§2「worker 只发布状态,不推送事件」不变:worker 仍是共享状态的唯一写者,窗口仍然按 250 ms 轮询。
变化的是窗口对自己刚发出的请求的本地渲染,它有一个明确且可验证的终止条件(revision 前进),
而不是一个超时或猜测。窗口关闭不取消任何操作也不变:这次渲染的检查在窗口关掉后照常由 worker 完成。

验证(2026-09-26,本机 Windows)

  • bongocat-ui::update_window 新增 5 个测试:2 个纯函数(a_requested_check_renders_instead_of_the_previous_result
    固定 revision 语义、a_check_is_only_asked_for_when_one_would_start 固定三个准入条件),3 个无头
    渲染(a_window_opened_for_a_check_paints_that_check_from_its_first_frame 固定第一帧与轮询都不把
    上一次结果放回来,a_check_asked_over_an_unpolled_result_is_not_overwritten_by_it 固定锚点取的是
    请求那一刻的 revision 而非上次轮询的,a_window_opened_onto_the_published_state_offers_to_check
    固定 Current 语义未被这次改动带偏)。渲染测试总数 20 -> 23,bongocat-ui 170 项通过。
  • 反向验证:新渲染测试在去掉「构造期发请求」时失败(报 a window opened for a check opened onto the result of the last one),在去掉「轮询不采用未前进的 revision」时失败(报 the result of the last check came back over the check being asked for),在把锚点改回窗口上次轮询的 revision 时也失败(报
    a result this window had not polled replaced the check it just asked for)——三块改动各自承重,
    不是空转。
  • cargo fmt --all -- --check、cargo clippy(workspace 去 bongocat-app,另加 bongocat-app 的
    storage-test-injection 与 production 两种 feature)、cargo test --workspace(44 个 target)、
    cargo check --workspace --release 通过。tools/tests 中 symlink 权限与路径分隔符两项为既有
    Windows 环境失败,与本改动无关。

边界

  • 未验证:实机上第一帧的观感。逻辑由无头渲染测试固定,但「打开时立刻看到进度条」这一眼仍然
    需要人看;本次没有启动产品做人工核对。

Adr 0036 Model Archive Import Boundary (`docs/adr/0036-model-archive-import-boundary.md`)

ADR-0036: 模型导入的压缩包来源与解压边界

状态:已接受(2026-09-16)→ 已撤回(2026-09-22)
依赖:ADR-0030(先复用既有方案)、ADR-0027(诊断包已使用 zip 写入器)、ADR-0011(渐进实现与发布门禁)

本文是已撤回的历史设计记录;当前 ModelStore、Mver 转换和用户侧封面 writer 位于
bongocat-model-store,只读包解析与 PreparedModel 仍位于 bongocat-model。

撤回(2026-09-22)

维护者决定先删掉压缩包来源的实现,恢复时另行确认。以下内容随之移除:

  • crates/bongocat-model/src/archive.rs 整个模块(来源识别、plan_archive、extract_archive、
    条目名规范化、包装目录剥离、文件管理器元数据过滤);
  • ModelStore 的归档分支(import_with_observer、inspect_source、import_mver_with_observer)、
    MverSource::Archive 变体;
  • ModelStoreDiagnostic::SourceArchiveUnsupported(ALL 由 13 项回到 12 项),以及它在
    bongocat-app 两个映射函数与两处穷尽性测试里的分支;
  • ModelPackageLimits::maximum_archive_bytes;
  • bongocat-model 对 zip(deflate-flate2)与 flate2 的依赖;Cargo.lock 里 zip 8.6.0
    因此也不再携带 flate2;
  • 相关测试:bongocat-model 的 11 个归档用例与 ArchiveBuilder/write_archive_tree/
    write_package_archive 测试辅助、mver.rs 的归档转换用例、bongocat-app 的
    application_imports_folder_and_archive_sources_of_the_same_model 与 archive_fixture 辅助;
  • examples/model_conversion_smoke.rs 的 --source <folder|archive> 收窄为 <folder>。

本文的全部决策保留,作为恢复该功能时的设计输入——尤其是决策 1(按内容而非扩展名识别)、
决策 2(压缩包不是第二个解析器,共用 PreparedModel 校验)、以及解压前按中央目录校验的安全边界。
恢复前先与维护者确认:压缩包上传需要一组本次未做的新功能,不是把代码加回来那么简单。

bongocat-ui-protocol::model_source_display_name 的「去掉归档扩展名」规则保留:settings service 的
兜底标题仍与它共用,且删掉它会让恢复时的那条契约失去单一实现;调用方负责提供来源是否为目录。

同日更早的两次 UI 修订也一并成为历史记录,保留如下:决策 10 的「一个来源一个按钮」先被单一上传卡片
取代(依据是 rfd 0.17.2 新增的 pick_file_or_folder——NSOpenPanel 可以在同一次调用里既接受
文件又接受目录,决策 10 当时「原生面板没有统一形态」的前提在 macOS 上不再成立;
pick_model_directory/pick_model_archive 收敛为 pick_model_source,
validate_selected_directory/validate_selected_archive 收敛为 validate_selected_source),
随后入口再收窄为只接受文件夹的 pick_model_folder + validate_selected_folder。这两步的产物
(pick_model_source、validate_selected_source)现已随本次撤回一并移除,只留下
pick_model_folder。

原始决策(已撤回,保留作设计输入)

背景

到本次改动为止,用户只能通过「选择文件夹」导入模型:ModelStore::import_with_observer 接收一个
目录,PreparedModel::prepare 就地校验它,copy_package 复制进 store 自己的 staging 目录,最后
rename 原子提交。

但模型站与社区导出普遍给的是 .zip。两个真实样本已经暴露了这件事:

归档文件 内部根目录 入口
经典小键盘 · 标准模式.zip 经典小键盘 · 标准模式/ cat.model3.json
送葬人 · 标准模式.zip 图弟 · 标准模式/ demomodel.model3.json

两点值得注意:两个归档都把包放在一层包装目录下面,而第二个的包装目录名与归档文件名
并不一致。用户要在导入前手动解压,且解压后仍然要自己去猜该选哪一层目录。

同时,Technical Design 早已写明「模型导入防止路径穿越、符号链接逃逸、压缩炸弹和覆盖现有用户
数据」,但在此之前没有任何实现对应「压缩炸弹」——因为不存在解压路径。本 ADR 记录把压缩包接成
一等来源所采用的决策,以及解压这一步的安全边界。

决策

1. 来源类型由内容识别,不由扩展名或调用方标志决定

detect_source_kind 只看文件系统类型与文件头:目录 → 目录来源;常规文件且以 PK\x03\x04 /
PK\x05\x06 / PK\x07\x08 开头 → 压缩包来源;其余 → model_store_source_archive_unsupported。

因此:归档被改名(model.package)仍能导入;叫 looks-like.zip 的目录仍按目录处理;.rar
或损坏下载得到的是一个稳定诊断,而不是"先解压失败、再报包无效"。用户不需要告诉产品他选的是
什么,产品也不需要靠字符串猜。

2. 压缩包不是第二个解析器

压缩包来源与目录来源共用同一套校验:解压写满 staging 之后,仍然调用同一个
PreparedModel::prepare。压缩包只是"把同样的字节放进 staging 的另一种方式",因此不可能绕过
路径规范化、符号链接拒绝、JSON/PNG/FLAC 侧车校验、纹理尺寸上限或包级上限。

这是本 ADR 最重要的一条:任何"为压缩包单独写一遍检查"的做法都会在两侧漂移。

3. 解压写进 store 自己的 staging 目录

解压目标就是 create_staging_directory 建好的 .importing---<seq>,不引入第二个临时
目录、不复用系统 temp。由此得到的性质都是既有的,不需要新机制:

  • 提交仍是一次 rename;失败时 StagingCleanup 删掉整个 staging,store 根目录不留痕迹。
  • 权限仍是 store 的 owner-only(目录 0700、文件 0600),解压出来的字节不会经过一个"更宽松的
    中间位置"。
  • 配置里没有指向临时路径的引用,取消与崩溃恢复沿用既有 recover_abandoned_operations。

4. 先校验、后解压:plan → extract 两遍

plan_archive 只读中央目录(by_index_raw,不解压、不解密),在这一遍里完成:条目名规范化、
条目类型、压缩方法、加密标志、条目数、深度、单文件与整包声明字节。extract_archive 才真正
解压。

这样做的直接收益是压缩炸弹在还只是一个头部数字的时候就被拒绝:恶意归档不需要被解压一个
字节就会失败,store 也不会先建好 staging 再报错。

代价是文件被读两遍,以及两遍之间归档可能被替换。后者由第二遍的逐条目复核处理:索引位置的
条目名、声明大小、is_file 必须与 plan 一致,实际写出的字节数必须等于声明值,否则
model_store_source_changed。zip 默认校验 CRC32,损坏的载荷因此也不会被当作成功。

5. 包装目录剥离

归档一个文件夹必然给每个条目加一层前缀,所以"压缩这个目录"永远是 X/...。保留这一层会让入口
发现失败(model_entry_missing),因此 strip_wrapper_directories 把它去掉,并且反复去掉
(a/b/... 这种嵌套压缩同样常见),直到剩下的条目不再共享同一个首段。

判定规则是被刻意收窄的:

  • 只有当所有条目都以同一个首段开头时才剥离;
  • 任何一个文件直接位于归档根就取消剥离(根目录本身就是包);
  • 一个目录条目位于根且等于该首段时不取消剥离——那正是包装目录在声明自己。

最后一条是必要的:X/ 这个目录条目在每个"压缩文件夹"产物里都存在,如果它取消了剥离,功能
在真实归档上完全不工作。这个缺陷在开发中真实出现过,由
wrapper_prefix_requires_one_directory_shared_by_every_entry 固定。

6. 归档工具元数据被丢弃

__MACOSX/** 与 ._* / .DS_Store / .localized / Thumbs.db / desktop.ini 不参与包:
它们既不参与包装目录判定,也不被解压。理由与 ModelStore::list 忽略同样文件的理由一致——它们
属于文件管理器,不属于模型。副作用是 __MACOSX/ 与真正的包目录并列时,包装目录仍能被正确
识别(否则这一常见形态会直接导入失败)。

如果一个模型真的引用了被丢弃的 ._* 文件,它会得到"资源缺失",这是诚实的结果:包确实不完整。

7. 上限:给"容器"一个自己的界

ModelPackageLimits 新增 maximum_archive_bytes(默认 1 GiB,与 maximum_package_bytes 同值)。

不能复用 maximum_package_bytes:那条上限约束的是归档产出的包,在归档读取器解析中央目录
之前就要用它挡住输入;而"用 Stored 存一个 8 字节包上限的归档"本身可能远大于 8 字节。
把两者合并会让包字节上限对归档来源永不可达。也不能复用 maximum_file_bytes,否则单文件上限
同样变得不可达。

此外有一个独立的结构上限:条目数超过 maximum_file_count × 4 直接拒绝。乘数给目录条目与
被丢弃的元数据条目留出余量,同时保证"声明海量条目"的输入不会进入逐条目循环。

8. 诊断码:新增一个,复用其余

新增 ModelStoreDiagnostic::SourceArchiveUnsupported(model_store_source_archive_unsupported),
只在"这个文件根本不是可用归档"时使用:无法作为 zip 打开、加密、不支持的压缩方法、条目类型
既非文件也非目录、容器超出 maximum_archive_bytes、无条目、只有目录没有文件。

其余情形刻意复用既有码,因为它们描述的是同一件事:

情形 码
条目路径绝对 / 穿越 / 平台前缀 / 重复 / 文件与目录冲突 SourceEntryUnsupported
条目是符号链接 SourceSymlinkUnsupported
条目类型是设备/FIFO 等 SourceEntryUnsupported
深度、条目数、单文件或整包字节超限 SourceChanged
实际字节数与声明不符、归档在两次读取之间变化 SourceChanged

SourceChanged 用于上限与声明不符,是因为目录来源在同一处也用它("源不再匹配已校验的形态"),
并且对归档而言,声明值就是归档自己的元数据,不一致就是源自相矛盾。这保持了"用户可见结果是
一组稳定码"的收敛性,而不是为同一结果造第二套词汇。

settings 层把新码映射到既有 SettingsErrorCode::ModelImportSourceUnsupported
("模型来源中包含不支持的文件"),因此没有新增用户可见错误码。

9. 依赖:`zip` 的 `deflate-flate2`,并显式依赖 `flate2`

zip =8.6.0 已在 workspace 依赖里(诊断包用它写 .zip),但当时是 default-features = false
且只走 Stored,没有解压能力。真实模型归档用 deflate(两个样本的压缩方法都是 8),因此打开
features = ["deflate-flate2"]。

zip 对 flate2 声明的是 default-features = false,自己不选后端;而 flate2 一个后端都没
选中时以 compile_error! 编译失败。因此 bongocat-model 直接依赖 workspace 的
flate2 =1.1.10,让 rust_backend(miniz_oxide,纯 Rust)成为本 crate 在任何构建图里的确定
事实,而不是依赖"某个兄弟 crate 恰好打开了默认 feature"——那只在测试构建里成立。

其余 feature 一律不开(无 AES、无 bzip2/zstd/lzma/ppmd/deflate64):模型归档不需要它们,而未
开启的压缩方法会以稳定诊断被拒绝(这也是"加密归档"的拒绝路径)。按 ADR-0030 与 AGENTS.md §9,
两个 crate 的版本都在当次用 cargo info 核对了 crates.io 最新非 yanked 稳定版:zip 8.6.0
(9.0.0-pre3 是预发布,不采用)、flate2 1.1.10。

10. UI:一个来源一个按钮(已撤回,见文首)

原生面板没有"选文件夹或文件"的统一形态(macOS 是 pick_folder 与 pick_file 两个不同面板,
Windows 的 common item dialog 同样区分),所以 Models 页面提供两个按钮:选择文件夹与
选择压缩包,各自的 tab index、焦点句柄与 AccessKit 节点齐备。

不做的替代方案:一个按钮加一个模式开关(用户要猜当前是什么模式)、或一个按钮先弹选择再弹面板
(多一步且同样要说明)。由按钮决定来源种类,由字节决定它究竟是什么。

压缩包按钮的对话框只过滤 .zip 作为便利,不作为判据:validate_selected_archive 只要求
"绝对路径 + 真实常规文件",因此被改名的归档仍可通过,而"不是归档"由 store 报稳定码——对话框
层拒绝一个能用的归档,会把可导入的文件变成对话框级失败。

状态文案按所选来源区分(models.import.archive.*),不再把"压缩包"说成"文件夹"。挑选过程本身
与来源无关的三条文案(取消、取消后保留上次选择、选择器必须在 UI 线程)上移到
models.import.picker.*,因为它们描述的是选择器而不是文件夹。

11. picker 模块与稳定码改名

bongocat-platform 的 directory_picker 模块改名为 model_source_picker,
DirectoryPickerOutcome / DirectoryPickerError 改为 ModelSourcePickerOutcome /
ModelSourcePickerError,稳定码前缀由 directory_picker_* 改为 model_source_picker_*,
示例改名为 examples/model_source_picker_smoke.rs 并新增 --kind directory|archive。

理由是这次改动引入了一处不准确:让"压缩包选择器"返回 DirectoryPickerError 是错的。与之
相邻的既有模块名一并修正,代价是 4 个文件里的机械替换与一次示例改名。

12. `zip` 归档内容与第三方模型不进入仓库

两个真实样本是第三方模型(含 Live2D 模型数据),不提交到仓库。真实归档的验证改为一个由环境
变量驱动、未设置即跳过的测试:
imports_the_archive_samples_named_by_the_environment(BONGOCAT_MODEL_ARCHIVE_SAMPLES 指向一个
放 .zip 的目录)。仓库内的永久测试用测试期生成的归档(真 deflate 流)覆盖结构与拒绝面。

明确不做

  • .rar / .7z / .tar.gz:只有 zip 被真实需求驱动。新增格式需要各自的边界分析(tar 的
    符号链接与硬链接、gzip 的单流特性、7z/rar 的许可证),不在本 ADR 范围。
  • 加密归档:没有密码输入面,也没有需求。加密条目被拒绝。
  • 新增"解压中"进度阶段:typed 操作契约的 stage 集合(prepare/copy/validate/commit)不变,
    解压计入 Copying ——它描述的是"把来源字节物化进 staging",对归档而言就是解压。新增阶段会
    让 model/ui/app 三处的单调性契约与测试同时改,收益只是一个更细的动词,不值得。
  • 把归档解压到已安装目录:本产品不允许任何就地更新语义;导入永远是"新 id + 新目录"。
  • 压缩包内的多模型选择:一个归档 = 一个模型,仍是入口发现规则(0 个 → 缺失,≥2 个 → 歧义)。

残余风险与待验证项(不得当作已确认)

  1. 中央目录的分配没有在解析前被界住。maximum_archive_bytes 限制输入文件大小,但
    ZipArchive::new 会在返回之后我们才拿到 len();在此期间它已按中央目录声明分配。条目数
    上限(× 4)是在那之后生效的。最坏内存未测量,也没有针对"1 GiB 输入 + 海量声明条目"的测试。
  2. 真实样本只有两个,且都是单层包装、deflate、无加密、条目数 < 70。zip64、data descriptor、
    非 UTF-8 条目名、大小写混合扩展名只有合成测试覆盖。
  3. Windows 未实机验证:本机是 macOS。归档解压路径本身是平台无关的(std::fs + zip),
    但 SourceArchiveUnsupported 在 Windows 上的真实触发路径(例如从资源管理器拖出的 zip)没有
    实测;just check 的 Windows 部分按仓库约定在 Windows 实机/CI 上运行。
  4. UI 实机交互未执行:两个按钮、压缩包面板、SelectionInvalid 文案都只有无头测试覆盖。真实
    NSOpenPanel 选 zip 的路径需要在 macOS 上运行
    cargo run -p bongocat-platform --example model_source_picker_smoke -- --expect-selected-any --kind archive
    才算验证过。
  5. maximum_archive_bytes 的取值(1 GiB)没有基于实测压力测试,它与包字节上限同值只是出于
    "容器通常不大于内容"的判断。
  6. 本次 cargo update 顺带了 4 个无关传递依赖的补丁升级(synstructure 0.13.2 → 0.14.0,
    连带切到 syn 3;yoke-derive、zerofrom-derive、zlib-rs)。这是 AGENTS.md §9 要求的
    完整 cargo update 的结果,不是本功能需要,但会让 diff 变大。

验证

以下证据属于已撤回的实现,其中的测试与辅助已随实现删除;保留它只为说明这些决策当年确实被
验证过,恢复时可以直接复用同一批用例。

已完成(2026-09-16,本机 macOS 26.5 / aarch64):

  • bongocat-model 60 个测试通过,其中 15 个新增(store.rs 10 个 + archive.rs 5 个),新增的
    store.rs 用例直接针对压缩包来源:
    同一包以目录与归档两种来源导入后 ModelPackageIndex 逐字段相等(
    zip_and_directory_sources_of_the_same_package_import_identically,并用"包装目录名与归档名
    不一致"的形态构造);按内容而非名字识别(无扩展名、Stored 归档、名为 .zip 的目录);
    嵌套包装剥离与深度上限;__MACOSX / .DS_Store 被丢弃且不干扰包装识别;非归档 / 截断
    / 空 / 只有目录的归档;路径穿越(../、绝对、平台前缀)与重复条目(猫//model.moc3 与
    猫/model.moc3 归一化后相撞)、文件与目录冲突;符号链接条目;四类上限(文件数、整包字节、
    单文件字节、结构条目数)在解压前拒绝;进度单调 + 取消后无残留 staging 且不影响已装模型。
    另有 archive.rs 内 5 个单元测试覆盖包装判定、剥离、祖先冲突、条目名规范化、元数据识别。
  • 真实归档验证:BONGOCAT_MODEL_ARCHIVE_SAMPLES=/tmp/bongocat-samples cargo test -p bongocat-model imports_the_archive_samples 通过(样本 = 两个真实 zip)。逐字段结果:经典小键盘 · 标准模式.zip
    → 入口 cat.model3.json、moc demomodel.moc3、3 张 1024x512 纹理、cdi3、3 个表情、
    2 组动作(各 2 条,1 条带 FLAC 音轨)、31 文件 / 1 218 791 字节;送葬人 · 标准模式.zip
    → 入口 demomodel.model3.json、1 张 1024x512 纹理、cdi3、61 文件 / 791 595 字节。两个归档的包装
    目录都被剥离(安装根目录下直接是 .model3.json),源归档未被修改。
  • bongocat-app 120(lib)+ 21(bin)个测试通过,新增
    application_imports_folder_and_archive_sources_of_the_same_model:
    同一模型分别以文件夹与 .zip 导入,两个 UUID id 各自独立、索引一致、目录侧标题为 非 ASCII 模型、
    归档侧标题为归档名去掉 .zip,两者都出现在 installed 目录里。
  • bongocat-ui 115 个测试通过,新增 model_source_display_name 的 UTF-8 边界与大小写回归、
    目录名为 something.zip 时保留原名、归档草稿的状态文案为"已选择压缩包/所选压缩包不可用"、
    两个按钮的 AccessKit 节点与禁用传播。
  • bongocat-platform 新增 pick_model_archive 的验证测试(归档与"不是归档的文件"都算合法选择,
    目录/文件互斥拒绝,相对与不存在路径拒绝,稳定码唯一性),示例改为支持 --kind。
  • cargo fmt --all --check、三组 clippy(workspace --all-targets --all-features、
    bongocat-app 的 storage-test-injection 与 production)、cargo test --locked --workspace、
    cargo check --locked --workspace --release 全部通过。

顺带修复的既有缺陷(与本功能无关,需要单独复核):bongocat-platform 的示例
model_source_picker_smoke 本来无法编译(已用 git stash 在 HEAD 上复现):
它引用 objc2_app_kit::NSBackingStoreType,而该类型属于 NSGraphics feature,清单里没有打开。
因为本次需要改这个示例,所以补上了 "NSGraphics";这也是 cargo check --all-targets 能通过的前提。
若不接受这次修复,应单独回滚该清单行,并另行处理该示例。


Adr 0037 In App Bongo Cat Mver Conversion (`docs/adr/0037-in-app-bongo-cat-mver-conversion.md`)

ADR-0037: 在应用内导入 BongoCatMver 模型

状态:已接受(2026-09-17)
依赖:ADR-0030(先复用既有方案)、ADR-0036(模型来源按内容识别、压缩包与目录共用校验;
该 ADR 已于 2026-09-22 撤回)、ADR-0011(渐进实现与发布门禁)、ADR-0060(model/store 边界)、
ADR-0065(模型模式元数据与卡片 Badge)

修订(2026-09-22):ADR-0036 撤回后,本文**决策 4「归档来源不解压」**连同它描述的
ArchivePlan / MverSource::Archive 一并失去实现——Mver 源现在只从用户选中的文件夹就地读取,
MverSource 退化为一个持有已 canonicalize 根路径的 struct。其余决策(转换边界、多模型产出、
键位图合成、重编码、诊断码)全部不变。归档侧的读取上限逻辑(用声明大小在解压前判定)随实现
删除,但 LEGACY_RESOURCE_MAXIMUM_BYTES 仍然约束目录侧的单次读入。

背景

Bongo-Cat-Mver 是 BongoCat 的上游原版实现(docs/migration/bongo-cat-mver-reference.md
固定了参考 commit)。它的模型格式与 BongoCat 不同,而且在两处不一致:

BongoCatMver BongoCat
Live2D 包位置 <源>/img/<模式>/cat_model/ 包根(入口发现只看根目录)
键位图 每个键分成 hand/*.png 与 keyboard/*.png 两层,另有一张与模式一一对应的背景和封面 每个键一张合成图,按 HID usage 解析出的名字放在 resources/{left-keys,right-keys}
键位表 根 config.json 的 standard/keyboard/gamepad 三个 section 无需键位表,模型只声明 Live2D 资源
一个"模型" 每种输入模式各自一个 Live2D 包 + 一套键位图 一个目录 = 一个模型

历史上这件事由外部工具 BongoCat-Converter(TypeScript/浏览器)完成:用户选中整个 Mver 应用
文件夹,工具按模式产出 BongoCat - <模式> 目录,再在 BongoCat 里逐个导入。这要求用户离开产品、
理解两个格式的差异、并在两次产物之间手动搬运文件。用户要求把这一步内置到产品里:导入时直接
识别 BongoCatMver 模型并触发转换,转换产出的模型进入既有模型存储与管理流程。

三个只有看真实数据才会发现的事实决定了本 ADR 的形态:

  1. 三种模式的键位表不在同一个编码空间里。 standard/keyboard 用 Windows 虚拟键码
    (49 = 1,65 = A),gamepad 用 XInput 按键序号(10 = 十字键左,0 = A)。同一
    个 13 在前者是 Return、在后者是十字键下。
  2. 一个源最多对应三个模型,因为三种模式带的是三份不同的 .moc3(参考样本里分别是
    demomodel.moc3/demomodel2.moc3/demomodel3.moc3),无法合并成一个包。
  3. gamepad.lefthand 与 righthand 共用同一份 keyboard 图集,右手的图集下标从左手的
    长度继续(lefthand.length + index),而不是从 0 重新开始。

决策

1. 检测仍按内容识别,且要求两条独立证据

ModelStore::inspect_source 复用 ADR-0036 的 detect_source_kind(目录 / zip 签名),然后问该
来源是否带 Mver 键位表。认定条件有两条,缺一不可:

  • 根 config.json 存在且能解析成 legacy 的 section 形状;
  • 这些 section 里至少有一个模式真的在 <资源根>/<模式>/cat_model/ 下恰好有一个
    .model3.json。

第二条让误判几乎不可能:转换后的 BongoCat 包把入口放在包根,不在 cat_model/ 下,两者从外部
无法混淆。config.json 读不到、或解析失败、或找不到任何带模型的模式,一律回退到普通包导入,
由那条路径给出它自己的诊断——检测是试探性的,不负责报错。

资源根优先取 img/(Mver 应用的实际布局),不存在时取源根本身,以容纳把模式文件夹直接摊在根上
的模型包。

修订(2026-09-25):每个 Mver 转换条目现在在 imported_models[].input_mode 中持久化
实际选中的 standard/keyboard/gamepad source section;该字段是模式事实,标题后缀只保留
既有显示兼容语义。普通模型包则由 ModelStore 在 staging 提交前按 left-keys/right-keys 资源
判定模式,判定失败直接拒绝导入。详情见 ADR-0065。

修订(2026-10-02,issue #1080):「能解析成 legacy 的 section 形状」现在按该应用自己的读取端
判定,不再按严格 JSON 判定。固定 commit 的 CatUILauncher/data.cpp 用 JsonCpp
CharReaderBuilder 读 config.json,而其 setDefaults() 明确设置 allowComments = true
(jsoncpp.cpp 的 CharReaderBuilderDefaults),因此 // 与 /* */ 注释是这个格式接受的内容。
带注释的键位表此前被严格读取拒绝,而检测是试探性的、拒绝不报错——文件夹被当成普通包,
再以「模型包无效」结束,用户拿到的解释与模型本身无关。读取前先剔除字符串字面量之外的注释:
字符串里的 / 是数据,\" 不结束字面量,块注释保留换行以免错误位置漂移,多字节字符不被
拆分。放宽只到注释为止——不是 config 的文件仍然不是 config。

同一次修订:转换逐字节复制模式的 Live2D 包这一约定,增加一个唯一例外。issue #1080 的真实模型
在 keyboard 模式的 model3.json 里声明了 Sound: live2d_motion1.flac,而整个包里没有任何
flac 文件——该模式是从 Cubism 官方 demomodel2 示例包改装的,留下了示例的音频名,Mver 实际
从自己的每模式 sounds/ 列表播放音效。保留这个名字会让整个模式过不了包校验(ADR-0060 的
引用必须解析),丢掉它不损失任何东西。因此沿用决策 5 的既有判断("少一张键位图不等于这个模型
不可用"),转换时以源实际携带的文件集合判定悬空并丢弃该 Sound 键,其余内容保持不变。只
丢弃缺失的引用
:无法规范化的引用(越出包根、绝对路径)保持原样,交由包校验报告,不因为
转换而抹掉一条路径安全发现;没有悬空引用时 entry 逐字节不变。这条修订只在 Mver 转换路径
生效,普通包导入的引用校验不变严格。

2. 一个源产出多个模型,每个模型独立安装

每个模式转换成一个 BongoCat 包,各自分配一个随机 UUID v4 存储键,各自写一条
imported_models 元数据记录和对应的 input_mode,因此三种模式成为模型列表里三个可以独立
启用、改名、删除的普通条目。模式值来自本次导入选择的 legacy section,不从转换后目录形状
或标题反推。

逐模式提交,不做整体回滚:某个模式的键位图损坏时,已经转换成功的模式保留下来。理由是这些
模型彼此独立,把已经转换成功的模式一起丢弃比留下一个用户可重试的部分结果更糟。

标题是「用户标题 · 本地化模式名」(我的猫 · 标准模式),即社区导出模型已经在用的命名。模式名
在截断之后再拼接:模式名是三个模型之间唯一的区别,长来源名不能把它挤掉,所以先按标题上限
减去后缀长度裁剪来源名。

3. 转换写进 store 自己的 staging,与包来源共用提交尾部

ModelStore 的导入尾部(PreparedModel::prepare 校验 + 单次 rename 提交 + 失败清理 staging)
被抽成 commit_installed_staging,目录复制、归档解压与 legacy 转换三条路径共用它。因此转换
结果不可能绕过任何包校验,也不存在第二个临时位置:失败的转换不留痕,成功的转换仍是一次原子
rename。

4. 归档来源不解压(**已随 ADR-0036 撤回,见文首**)

目录来源就地读取;归档来源只按需读取被命名的条目(ArchivePlan::read_file,仍逐条目复核
名字/声明大小/实际字节数)。导入一个 Mver .zip 不会把整个应用——exe、DLL 和用不到的模式——
解压到磁盘上,只把转换真正需要的那几十个文件读进内存。

读取有独立上限 LEGACY_RESOURCE_MAXIMUM_BYTES(64 MiB):它约束的是转换期临时读入的量,与
约束"可被安装的包"的 ModelPackageLimits::maximum_file_bytes 是两件事。归档侧在解压前用声明
大小判定,因此超限只花一次头部读取。

5. 键位图合成 = 最小画布上的 Porter-Duff over

合成画布取两层中较小的宽高,两层都锚在原点绘制,因此比画布大的一层被裁切而不是缩放——这与
canvas drawImage 的行为一致。合成用整数形式的 over 算子:不透明来源整体替换、全透明来源
不改变,只有抗锯齿边缘像素会四舍五入一次(浮点形式在此处最多每通道差 1 档)。

模式里根本没有 keyboard/ 图集时,不合成,直接按字节安装 paw 图(复古模型可能已经把两层画在
一起了)。某个绑定的 paw 或配套键帽缺失时该绑定被跳过而不是让整次转换失败——与参考实现一致,
且"少一张键位图"不等于"这个模型不可用"。

6. 输出图使用 lossless 重编码,`oxipng` 是唯一选择

image 的 PNG 编码器本身不做滤波与熵编码优化,直接用它的输出会比源文件更大。生成的合成图
交给 oxipng 10.2.1 做无损重编码:位深/颜色类型/调色板/灰度缩减都保持解码后像素不变,
optimize_alpha 只改写全透明像素的颜色通道(肉眼不可见),交错被关闭。

测得的取舍(样本 15 张 612×354 合成图,release 构建):

后端 总字节 用时
仅 image 编码 160 641 —
oxipng + libdeflate 91 470(−43%) 0.68 s
oxipng + Zopfli 86 605(−46%) 10.25 s

Zopfli 多 5% 换 15 倍时间,对一次交互式导入不划算,因此只开 oxipng 的库入口(zopfli、
parallel、binary 三个 feature 全关)。作为对照,仓库内既有预置模型的键位图是 6.7–9.5 KB,
本次转换产出的同类文件是 4.1–8.4 KB,量级一致。

明确排除 imagequant(pngquant 的 Rust 绑定,TinyPNG 式有损量化):它的许可证是
GPL-3.0-or-later(pngquant.org 的商业授权另售),与本项目 Apache-2.0 的 deny.toml 白名单冲突。
用户要的"保持尺寸与视觉质量基本不变"由无损重编码完全满足,不必引入许可证风险。

重编码失败时写回普通编码结果而不是让转换失败:文件在两种情况下都是合法 PNG,只有体积有差别。

7. 键位名必须落在产品自己的词汇表里

转换输出的文件名是 bongocat-live2d-render 从 HID usage 解析出的名字(KeyA、Return、
BackQuote、ControlLeft/Control……),不是第二套命名。一处刻意偏离参考实现的写法:

  • 0x08:参考表写作 BackSpace,产品运行时与预置模型都写作 Backspace,采用产品写法。

gamepad 使用 XInput 按钮序号映射到产品自己的十六个按钮名(GamepadButton::key_image_name),
不是映射到任何 backend 的控制名。XInput 顺序是 0–3 面部键、4/5 肩键、6/7 模拟扳机、8/9 菜单键、
10/11 摇杆键、12–15 方向键(上/下/左/右)。

修订(ADR-0070):本节原先把 gamepad 的输出名定义为"预置 gamepad 模型已经装载的名字",
那套名字是旧 Tauri 输入层从第三方 backend 派生的(DPad*、LeftTrigger = 肩键、
LeftTrigger2 = 模拟扳机、LeftThumb/RightThumb = 摇杆键),并且当时的序号表有六个条目按错
位置。预置模型已按产品词表重命名,序号表已按真实按钮顺序重写,转换结果与
resources/models/gamepad/{left-keys,right-keys} 的文件集合逐个同名这一条仍然成立。

无法命名的控制码(鼠标按键、产品没有 overlay 的键)不产出图片,也不报错:它本来就没有可显示的
键位图,产出 undefined.png 之类的东西比不产出更糟。

8. 诊断码:新增一个 store 码,复用既有用户可见码

新增 ModelStoreDiagnostic::SourceConversionFailed
(model_store_source_conversion_failed,ALL 由 12 增至 13),只用于"已经认出这是
BongoCatMver 源,但无法把它变成 BongoCat 包":键位图不是可读 PNG、合成图无法编码、绑定引用的
资源超出读取上限、请求的模式不在此源中。

它刻意与 InvalidPackage 区分:后者是"按包读入并且包校验失败",前者从未成为包。settings 层
把它映射到既有 SettingsErrorCode::ModelImportSourceUnsupported(沿用 ADR-0036 §8 的做法),
因此没有新增用户可见错误码;具体原因留在 detail/resource 里,进入日志与诊断包。

9. 进度跨模型保持单调

settings 的 report_progress 会丢弃回退的更新,而 store 每个模型都从 Preparing 与 0 重新
开始。Application 因此在跨模型处折叠进度:累计已完成模型的文件数与字节数、并对 stage 取
最大值后再上报。于是用户看到的是一个不倒退、且最终等于三个模型总和的一条序列,而不是卡在
第一个模型的终值上。

10. 模式选择与卡片投影

Mver 导入仍由服务层执行阻塞文件与模型解析;用户在检查来源后通过现有模式选择对话框选择要转换
的 section。选择的每个 section 生成一个独立模型,并将该 section 写入 installed metadata 的
input_mode。Models 页面只消费 SettingsModelEntry.input_mode,不在 UI 线程重新检查资源或从
标题反解析模式。Application::import_models* 仍是唯一的导入入口。

11. 按键图按字节安装,legacy 窗口坐标由渲染器重框(issue #1129)

决策 5 把按键图合成成"画布上的一层",但合成只是把两层叠成一张图,它并不改变这张图的坐标系。
BongoCatMver 的 mode98_live2d_standard.cpp 用
projection.Scale(l2d_correct, l2d_correct * window_size[0] / window_size[1]) 画模型,因此画布居中
落在 window_size 窗口里;而 drawhand/drawkeyboard 对 hand/*.png 只调用 window.draw(sprite),
没有 setPosition 也没有 setScale,按键图是窗口原点锚定、一图素对一窗口像素的。按键图的尺寸
恰好等于画布在窗口里的尺寸(参考模型是 986×1000 对 1000×1000 窗口,官方样例是 612×354 对 612×352
默认窗口),所以两者之间差的正是窗口左右各留的那条边距
(window_size[0] − 按键图宽) / 2。

产品把按键图铺在 ModelBounds::from_canvas 得到的画布四边形上,于是按键图从窗口坐标系被当成
画布坐标系使用,每张按键图都整体偏右这条边距。实测(issue #1129 的白露模型,模板匹配模型自己的
键帽贴片):G/R/E/W/Q 五个键的落点一致偏右 6.2–7.2 图素,与 (1000 − 986) / 2 = 7 吻合。

修法放在渲染器而不是转换器:bongocat-render::legacy_key_overlay_bounds 由按键图自身尺寸算出这条边距
(窗口宽取该图较大的一边——按键图就是画布在窗口里的尺寸,窗口不会比它窄,作者不按画布尺寸设窗口时
用的是包含它的正方形),双平台渲染器对每张按键图各建一个四边形缓冲。这样选而不是在转换时平移像素,
有三个理由:转换产出的字节除合成外保持"源的像素",与决策 5/6 的契约一致;已经装在 store 里的模型
不需要重写资源即可正确显示(旧包没有记录 window_size,转换器事后无法还原这条边距);出厂预置模型
与所有宽按键图模型算出 0 偏移,行为逐字节不变。

明确不做

  • 普通包导入后的资源重判:模式只在 staging 提交前判定一次并写入 metadata;卡片和 runtime
    不在每次渲染时重新扫描资源。
  • 让模式 Badge 隐式切换 runtime 输入绑定:input_mode 在 ADR-0065 中首先是导入/构建来源
    元数据;若要让 gamepad metadata 驱动 installed Mver 的实际输入,需要独立 runtime/overlay 任务。
  • 鼠标按键 overlay:standard.mouse_left/right/side 与 mouse*.png 不转换。产品当前没有
    鼠标按键的 overlay 通道(InputControl::Mouse 不产生 KeyPress),参考实现同样忽略它们。
  • face/、sounds/、arm*.png、tablet*.png:参考实现不处理,产品格式里也没有对应位置;
    模式自带音效已经通过 cat_model/ 里的 Sound 引用随包复制。修订(2026-10-02):Sound
    引用指向源中不存在的文件时按决策 5 的判断丢弃该引用(issue #1080),sounds/ 目录本身仍不处理。
  • 有损量化 / 降分辨率 / 减帧:用户明确要求不牺牲画质,且 GPL 有损库被许可证排除。
  • .rar/.7z:只有 zip 被真实需求驱动,与 ADR-0036 的范围一致。
  • 把 Mver 源就地"升级"成 BongoCat 模型:本产品不做就地更新,导入永远是"新 id + 新目录"。
  • 修改运行时让 F1–F12 逐键解析:转换输出按 F1…F12 命名以保留模型自己的区分度,但
    bongocat-live2d::key_name_candidates 目前把功能键统一回退到 Fn 别名,因此逐键 F 图暂时
    不可达
    。这是运行时的既有缺口,不由本次改动引入;未改运行时是因为仓库内没有任何数据能验证
    这个改动(预置模型只有 Fn.png,真实样本的键位表也不含功能键),先记录为后续项。

残余风险与待验证项(不得当作已确认)

  1. 真实样本有两份(用户提供的 bongo_cat_mver_0.1.6_64,三模式齐全、deflate 归档、
    /<模式> 布局;issue #1080 的菲比模型,/<模式> 布局 + 带注释的键位表 +
    一个悬空 Sound 引用)。把模式文件夹摊在根上、缺少 keyboard/ 图集、非 UTF-8 文件名、
    非 ASCII 路径只有合成测试覆盖。
  2. Windows 已在 issue #1080 的修订中实机验证(本机 Windows 11 / x86_64-pc-windows-msvc,
    oxipng 的 C 工具链在该平台构建通过并跑通了真实模型)。此项在本修订之前记录为未验证。
  3. gamepad 的键位图目前不可达:bongocat-runtime 只对键盘按键产生 KeyPress,手柄按键
    仅置 left_hand_down/right_hand_down/stick_*_down。转换输出与预置 gamepad 模型同名,
    但两者在当前运行时下都只是随包携带的资源。这是既有缺口,不由本 ADR 引入,也不在本 ADR 修复。
  4. 64 MiB 的读取上限没有基于实测压力测试,取值依据是"真实模型的单文件是几 MB 量级"。
  5. Zopfli 之外的压缩收益未再评估:只测了 libdeflate 与 Zopfli 两条后端,没有试
    oxipng 的高阶 preset 或 filter 集合调参。
  6. cargo update 顺带升了 3 个无关传递依赖(granit-parser 1.2.1 → 1.3.0、
    serde-saphyr 1.2.0 → 1.3.0、redox_users 0.5.2 → 0.5.3)。这是 AGENTS.md §9 要求的完整
    cargo update 的结果,不是本功能需要。

验证

已完成(2026-09-17,本机 macOS / aarch64):

  • bongocat-model-store 83 测试(mver.rs 新增 18 个单元测试 + store.rs 新增 5 个):检测的两条
    证据与"配置了模式却没有模型"的跳过、模式文件夹摊在根上的布局、左右手共用键盘图集的
    lefthand.length + index 下标、没有 keyboard/ 图集时按字节复制、合成后包根布局与
    [128, 0, 127, 255] 的合成像素、over 算子边界(不透明 / 全透明 / 半透明 / 空画布)、
    lossless 重编码在缩小文件的同时保持每一个 alpha > 0 的像素逐通道不变、鼠标键与非法码被
    跳过、缺 layer 时跳过绑定、重复绑定只写一次、符号链接被拒绝、归档来源不解压即转换、
    超限资源被拒绝、两个编码空间的键名。
  • 真实模型验证:cargo run -p bongocat-model-store --example model_conversion_smoke -- --source /Users/ayang/Downloads/bongo_cat_mver_0.1.6_64(也不带 BONGOCAT_MVER_SAMPLE 时由
    converts_the_legacy_sample_named_by_the_environment 覆盖)。逐模式结果:
    standard → 3 张纹理、15 张键位图(Num1..Num7/KeyQ/KeyE/KeyR/Space/KeyA/KeyD/
    KeyS/KeyW,与 config.json 的 15 条绑定逐个对应)、31 文件 / 1 081 672 字节 / 0.87 s;
    keyboard → left-keys{Control,KeyR,Shift} + right-keys{四方向键}、23 文件 /
    1 015 987 字节 / 0.46 s;gamepad → left-keys{DPadDown,DPadLeft,DPadRight,DPadUp,
    LeftTrigger,LeftTrigger2} + right-keys{East,North,RightTrigger,RightTrigger2,
    South,West}、28 文件 / 1 082 753 字节 / 0.74 s。gamepad 的两个集合与仓库内
    resources/models/gamepad/{left-keys,right-keys} 的文件名逐字符一致;源目录未被修改。
  • bongocat-app 现在还验证:一个 Mver 源导入出 3 个模型、3 个不同 UUID、三条 metadata 的
    input_mode 分别为 standard/keyboard/gamepad,改名只改变 title,模式在重启后仍从
    config 读回;普通包按 left-keys/right-keys 资源判定模式,缺少可用键图时导入失败。
    bongocat-ui 验证三种模式的可见标签和 Badge 几何不改变卡片编辑面。
  • bongocat-i18n 的 models.mver.mode.* 继续用于标题后缀;models.mode.* 用于卡片 Badge 的
    简短可见标签。两种 locale 键与占位符完全一致。
  • cargo fmt --all --check、三组 clippy(workspace --all-targets --all-features 与
    bongocat-app 的 storage-test-injection/production)、cargo test --locked --workspace
    (全部二进制全绿)、cargo check --locked --workspace --release 全部通过。

未运行:Windows 编译与实机导入、NSOpenPanel/Windows 对话框的实机交互(本次没有改动选择器)、
任意 UI 实机点击(本次只在服务层与文案层改动,Models 页面的控件集合未变)。


已完成(2026-10-02,本机 Windows 11 / x86_64-pc-windows-msvc,issue #1080):

  • 真实模型验证:cargo run --locked -p bongocat-model-store --example model_conversion_smoke -- --source <用户提供的菲比模型目录>。该源此前被报成「模型包无效」;修复后被判为三模式的
    BongoCatMver 源,逐模式结果:standard → 126 文件 / 23 940 725 字节 / 0.17 s、
    left-keys 91 张;keyboard → 18 文件 / 516 461 字节 / 1.50 s、left-keys 3 张
    (Tab/Num1/Num2——该模式的 lefthand/ 只带了 0–2 三张,其余绑定按决策 5 跳过);
    gamepad → 47 文件 / 16 574 934 字节 / 0.08 s、left-keys/right-keys 各 6 张,
    与仓库内预置 gamepad 模型的按钮词表一致。standard 与 gamepad 的 entry 与源逐字节相同,
    只有含悬空 Sound 的 keyboard entry 被改写;源目录未被修改。
  • bongocat-model-store 新增测试:带注释的键位表仍被识别为 legacy 源且三种模式的绑定逐个
    对应;注释移除只作用于字符串之外(URL、\" 转义、多字节中文注释、块注释保留换行、文件末尾
    无换行的注释);非 JSON 与非 UTF-8 仍然不是 config;悬空 Sound 被丢弃而其余声明保留、
    能解析的 Sound 保持 entry 逐字节不变、越出包根的 Sound 留给包校验;含悬空音频的 legacy
    模式经完整导入事务安装成功且源文件不被改写。
  • bongocat-app 新增测试:带注释的 legacy 源经 inspect_model_source 报出三种模式(即进入模式
    选择对话框而非回退成包)并导入出 3 个模型;store 失败写入日志时携带 store 自己的码。

Adr 0038 Alt Key Image Naming (`docs/adr/0038-alt-key-image-naming.md`)

ADR-0038: 左右 Alt 的键位图命名与旧名兼容

状态:已接受(2026-09-17)
依赖:ADR-0037(应用内导入 BongoCatMver 模型)、ADR-0036(模型导入来源识别与 staging 边界;
该 ADR 已于 2026-09-22 撤回)、ADR-0004(可校正输入状态)

修订(2026-09-22):ADR-0036 撤回后模型来源只剩目录,本文「两种来源得到同一结果」「用户选中的
源目录/归档始终只读」里的归档一半不再适用;调用点也从「目录复制与归档解压之后」变成「目录复制
之后」,commit_installed_staging 之前的位置不变。命名决策本身未变。

背景

旧版 BongoCat 用 rdev 做输入。rdev 把两个 Alt 键分别命名为 Alt(左)和 AltGr(右),
因此按旧命名导出的模型把左右 Alt 画成了 resources/left-keys/Alt.png 与
resources/left-keys/AltGr.png,仓库内三个预置模型也是这个名字(git log 可见 141 张键位图
的既有布局)。

BongoCat 的输入层从 HID usage 出发,左右修饰键是可区分的 canonical 名
(shared/behavior/input-semantics.md:AltLeft/AltRight)。bongocat-live2d 因此按
AltLeft、AltRight 去查资源,而预置模型里既没有 AltLeft.png,AltGr.png 也不在任何候选
列表里,于是:

按键 修复前的候选 实际命中 结果
左 Alt(HID 0xe2) AltLeft、Alt Alt.png 正确
右 Alt(HID 0xe6) AltRight、Alt Alt.png 画成左 Alt 的图,模型自己的 AltGr.png 不可达

两个真实事实决定了本 ADR 的形态:

  1. Alt.png 与 AltGr.png 是两张不同的图(预置模型中两者 SHA-256 不同,尺寸同为
    612×354),所以右 Alt 命中 Alt.png 是可见错误,不是"反正一样"。
  2. BongoCatMver 的键位表不区分左右 Alt。 上游固定 commit 的教程原图
    (BongoCatMverUI/Resources/tutorial/img_tutorial_8.jpg)把左右两个 Alt 都编号为 18,而
    C++ 侧用 GetKeyState(pkeynotecurrent->key) 查询该码——VK_MENU 对任一 Alt 都为真。旧格式
    本身没有"哪一侧"这一位信息。

决策

1. 预置模型按 canonical 名重命名

resources/models/{standard,keyboard}/resources/left-keys/ 下:

text
Alt.png   → AltLeft.png
AltGr.png → AltRight.png

改名用 git mv,字节不变;shared/fixtures/model-fixtures/preset-model3-index.json 是
spikes/model-package 解析器产出的冻结快照,同步更新对应 id 与 file(重跑该解析器逐字段
比对通过)。文件名顺序在 ASCII 下不变(. < L),因此 left_keys 数组次序无需调整。

不新增通用的 Alt.png:重命名后两侧各有专属图,家族图对预置模型已无意义。

2. 运行时优先精确名,`AltGr` 是唯一的旧名遗留

bongocat-live2d::key_name_candidates 的候选顺序:

text
0xe2 (左 Alt) → AltLeft, Alt
0xe6 (右 Alt) → AltRight, AltGr, Alt

AltGr 只出现在右 Alt 的候选里,且排在 AltRight 之后、家族名 Alt 之前,所以:

  • 已按新名导出的模型命中自己的图;
  • 未经归一化的旧模型右 Alt 命中 AltGr.png(正确的右侧图),左 Alt 仍走 Alt.png;
  • 只有一张共用 Alt.png 的模型两侧都还能画出来。

保留 AltGr 而不是让未归一化模型"降级为共用图",是为了让已经安装过的模型不出现回退:
导入归一化只作用于新导入,不会回头改写用户数据根里已存在的模型目录,而这些目录
没有任何迁移路径可用(AGENTS.md §4.1)。

Alt/Control/Shift/Meta 家族回退保持原样;本 ADR 不改变 ControlLeft/ShiftLeft 等
既有行为。

3. 包导入时归一化,且在 store 自己的 staging 上做

bongocat-model-store 持有 key_names 模块:把 staging 树里 resources/{left-,right-}keys/ 下的
Alt.png、AltGr.png 改名为 canonical 名。调用点在目录复制之后、共用的
commit_installed_staging 之前,因此:

  • 用户选中的源目录始终只读,与 ADR-0036 的边界一致(该 ADR 的归档一半已于 2026-09-22 撤回);
  • 改名不改变文件数与字节数,ModelImportProgress 的计数仍然真实。

冲突不猜:canonical 文件已存在时保留它,旧名文件原样留下——运行时优先 canonical,模型照常
可用。只处理 resources/*-keys 下的一层常规文件;目录同名或其它位置出现 Alt.png 交给既有的
包校验按它自己的规则拒绝。

Mver 转换路径不做这层归一化:转换输出已经是产品词汇表(ADR-0037 §7),它写出的每个名字都是
从 HID usage 反推出来的,没有"旧名"需要翻译。

4. Mver 的 Alt 映射:有侧信息就用侧,没有就两侧都写

mver::legacy_key_names 取代原来的单值 legacy_key_name:

控制码 输出 依据
0x12 VK_MENU(教程图的 18) AltLeft + AltRight 旧表把两侧都记为该码,转换把同一张合成图同时装到两侧,既不丢行为也不再用共用的 Alt 名
0xA4 VK_LMENU AltLeft 手写键位表可以点名一侧;与该码在产品 Windows 输入层(bongocat-platform)的含义一致
0xA5 VK_RMENU AltRight 同上

一个绑定因此可以产出多于一个目标名;重复目标仍按"先到者胜"去重,且同一次绑定的合成图只做一次
编码(MverSlotImage 复用)。

0x10(Shift)、0x11(Control)与 0x12 属于同一类歧义,但本次不改:它们保持
Shift/Control 家族名,运行时会为两侧解析该家族图,且真实样本(bongo_cat_mver_0.1.6_64)
的转换结果 left-keys{Control, KeyR, Shift} 已被 ADR-0037 记录为验证证据,改动它会同时作废那份
证据。若将来要统一,应作为一次单独改动连同该证据一起更新。

明确不做

  • 不新增 Alt 家族图的兼容 alias 以外的键名:不引入 LeftAlt/RightAlt 之类第三套拼写。
  • 不就地升级已安装模型:本产品不做迁移;既有安装靠运行时的 AltGr alias 保持正确。
  • 不改 Control/Shift/Meta 的候选顺序与家族回退(理由见决策 4)。
  • 不把归一化做成模型加载期的动态映射:磁盘上的包应当是产品自己的词汇表,否则每个消费方都要
    再实现一遍旧名识别。

残余风险与待验证项(不得当作已确认)

  1. 没有真实"绑定 Alt 的 Mver 模型"样本:上游教程图标明两侧同为 18,但仓库内唯一真实样本
    (bongo_cat_mver_0.1.6_64)的键位表只有 17/16(Control/Shift)与字母、方向键,不含 Alt,
    所以 0xA4/0xA5 与 0x12 的展开都只有合成测试覆盖。
  2. 只提供一张 Alt.png 的模型:归一化后它变成 AltLeft.png,右 Alt 再无专属图,只能靠家族
    回退画同一张。这与旧版 rdev 行为不完全相同——旧版右 Alt 报 AltGr,模型没有 AltGr.png
    时什么都不画。选择"两侧都画同一张"而不是"右侧留空",是因为后者在用户侧看起来是"某个键
    没反应"的新缺口,而前者只影响本来就无法区分左右的老模型。真实样本里存在这类模型
    (Bongo Cat v0.16/BongoCat - 标准模式 只有 Alt.png)。
  3. 0x12 展开成两张图在极端情况下会掩盖旧数据的分歧:若某个 Mver 模型真的为左右 Alt 各配了
    一个绑定(两条 18),转换结果只保留先出现的那张图覆盖两侧——旧实现同样只保留一张,但那时
    是共用名,现在两侧共用同一份字节。
  4. Windows 未实机验证:本次改动全部在 macOS/aarch64 上验证;0xe2/0xe6 的实际事件来源
    (Raw Input 的 E0 扩展码路径)不在本次改动范围内,也未重新实机复核。
  5. AltGr alias 只在单元测试里验证:没有在真实用户数据根里的旧模型上跑过整条链路。

验证

已完成(2026-09-17,本机 macOS / aarch64):

  • bongocat-live2d 45 测试(新增 2)。alt_keys_resolve_their_own_image_and_keep_the_legacy_alias
    固定三组候选与命中结果(新名、旧名、共用图);
    shipped_keyboard_models_draw_both_alt_keys_with_their_own_artwork 用真实预置模型断言左 Alt 命中
    resources/left-keys/AltLeft.png、右 Alt 命中 .../AltRight.png、两者字节不同,且预置模型里
    不再存在 Alt.png/AltGr.png。

  • bongocat-model-store 92 测试(新增 6):key_names 3 个(两侧目录改名、冲突保留 canonical、缺目录/
    非 PNG 名不动)、store 3 个(目录来源不修改源、归档来源同样生效、两侧同存时 canonical 优先)、
    mver 2 个(0x12/0xA4/0xA5 的展开;一次绑定产出两侧同名同图且都落盘)。

  • 真实社区模型验证(新增 env 门禁用例 imports_the_bongo_cat_sample_named_by_the_environment,
    由 BONGOCAT_PACKAGE_SAMPLE 指定,未设置时直接返回):逐个跑通本机 ~/Downloads 与
    ~/Documents/BongoCat 下的真实模型,目录与 .zip 两种来源各跑一遍:

    样本 left-keys 旧名 结果
    送葬人 · 标准模式(目录 / zip) 56 2 Alt.png→AltLeft.png、AltGr.png→AltRight.png,字节相同
    经典小键盘 · 标准模式(目录 / zip) 15 0 无改名,15 张图逐字节相同
    Bongo Cat v0.16/BongoCat - 标准模式 50 1 Alt.png→AltLeft.png

    用例同时断言:导入后 resources/{left-,right}-keys 的完整文件集合与逐文件字节等于"源集合
    按规则改名后的期望",且重新读取源得到的快照与导入前完全一致(目录不被改写、归档条目不变)。

  • 真实 Mver 样本回归:cargo run -p bongocat-model-store --example model_conversion_smoke -- --source /Users/ayang/Downloads/bongo_cat_mver_0.1.6_64 逐模式输出与 ADR-0037 记录的完全一致
    (standard 15 张键位图 / 31 文件 / 1 081 672 字节;keyboard 23 文件 / 1 015 987 字节且
    left-keys{Control, KeyR, Shift};gamepad 28 文件 / 1 082 753 字节),说明展开改动没有改变
    真实样本的产物。

  • 冻结快照:用 spikes/model-package 的解析器对仓库预置模型重新生成索引,与更新后的
    preset-model3-index.json 逐字段相等(工作树里未跟踪的 resources/models/standard/resources/.DS_Store
    需排除,否则会多出一个 package_file_count 与一个 unreferenced 文件)。

  • cargo fmt --all --check、三组 clippy(workspace --all-targets --all-features --exclude bongocat-app、bongocat-app 的 storage-test-injection 与 production,均 -D warnings)、
    cargo test --locked --workspace(全部测试二进制全绿:bongocat-app 126、bongocat-ui 115、
    bongocat-model-store 92、bongocat-runtime 73+2、bongocat-platform 50、bongocat-live2d 45、
    bongocat-update 36+10、bongocat-render 15、bongocat-overlay 28、bongocat-packaging 23、
    bongocat-config 51、bongocat-audio 10、bongocat-i18n 4、bongocat-log 3)、
    cargo check --locked --workspace --release 均以退出码 0 通过(仅剩与本次无关的
    block v0.1.6 future-incompatibility 警告)。

未运行:Windows 构建与实机按键、真实旧版安装数据根上已安装模型的 AltGr 兼容路径、任何 UI
实机点击。


Adr 0039 Enter Key Image Naming (`docs/adr/0039-enter-key-image-naming.md`)

ADR-0039: 主 Enter 与小键盘 Enter 的键位图命名与旧名兼容

状态:已接受(2026-09-17)
依赖:ADR-0038(左右 Alt 的键位图命名与旧名兼容)、ADR-0037(应用内导入 BongoCatMver 模型)、ADR-0036(模型导入来源识别与 staging 边界;该 ADR 已于 2026-09-22 撤回)、ADR-0004(可校正输入状态)

修订(2026-09-22):ADR-0036 撤回后模型来源只剩目录,本文决策 4 里「目录与 .zip 两种来源」的
归档一半不再适用。命名决策本身未变。

背景

ADR-0038 处理了左右 Alt 的 rdev 旧名,但同一套 rdev 词汇表还有第二个问题:主 Enter 键
(HID usage 0x28)的键位图叫 Return。仓库预置模型
(resources/models/{standard,keyboard}/resources/left-keys/Return.png)与真实社区模型
(送葬人 · 标准模式、Bongo Cat v0.16/BongoCat - 标准模式)都带 Return.png,而运行时
key_name_candidates(0x28) 也查这个名字——两者自洽,但 Return 不是 HID 语义的名字。

更实际的缺口在小键盘:小键盘 Enter 是另一个物理键(HID 0x58,Windows Raw Input 的
make code 0x1c + E0 扩展位,macOS keycode 76),bongocat-platform 在两个平台上都把它
正确映射为 0x58,但 key_name_candidates 对 0x58 没有任何候选——按下小键盘 Enter 什么都不
画。旧词汇表里也没有这个名字:翻遍本机全部真实样本(送葬人、经典小键盘、Bongo Cat v0.16),
没有任何模型为小键盘 Enter 配图,旧产品对它是完全无感的。

两个决定本 ADR 形态的事实:

  1. Num 前缀在产品词汇表里已被数字行占用。 key_name_candidates(0x1e..=0x27) 输出
    Num1..Num0(主键区数字行),预置模型与社区模型按这个约定画图。小键盘 Enter 不能叫
    NumEnter,否则和既有语义冲突。
  2. BongoCatMver 的键位表不区分两个 Enter。 legacy 表用 Windows 虚拟键码 + GetKeyState
    查询,VK_RETURN(0x13 之前的 0x0D,教程编号 13)对主 Enter 与小键盘 Enter 都为真,
    与 VK_MENU 之于左右 Alt 完全同类(ADR-0038 决策 4 的先例)。真实样本
    (bongo_cat_mver_0.1.6_64)的 keyboard/standard 绑定不含 13,gamepad 的 13 是十字键下,
    所以这次改动不影响真实样本的转换产物。

决策

1. 命名方案

物理键 HID usage canonical 名 说明
主 Enter 0x28 Enter 取代 Return,与 HID 语义一致
小键盘 Enter 0x58 KpEnter 沿用 Mver 转换词汇表已有的 Kp 前缀(Kp0..Kp9、KpMultiply、KpPlus、KpMinus、KpDecimal、KpDivide)

两个名字分属不同 HID usage,候选列表互不重叠,不存在解析冲突。

2. 运行时:精确名优先,`Return` 是第二个旧名遗留

key_name_candidates 的候选顺序:

text
0x28 (主 Enter)   → Enter, Return
0x58 (小键盘 Enter) → KpEnter, Enter
  • Enter 排在 Return 之前:新导入与预置模型命中 canonical 名;未经归一化的已安装模型
    落到 Return.png,主 Enter 照常画图(与 ADR-0038 保留 AltGr 的理由相同——本产品没有
    就地迁移路径,旧安装只能靠运行时 alias 保持可用)。
  • KpEnter 排在 Enter 之前:模型画了专用图就用专用图;没画就回退主 Enter 的图。回退在
    key_name_candidates(资源加载层)实现,不要求模型额外提供图片;模型补了 KpEnter.png
    后自动优先。Return 不出现在 0x58 的候选里——它从来不是小键盘 Enter 的名字。

3. 预置模型重命名,冻结快照逐字段同步

resources/models/{standard,keyboard}/resources/left-keys/ 下 Return.png → Enter.png
(git mv,字节不变)。shared/fixtures/model-fixtures/preset-model3-index.json 同步更新
id 与 file 两处;条目按 id 字母序移到 Delete 与 Escape 之间。spikes/model-package
解析器的 preset_model_indices_match_frozen_snapshot 重跑通过即逐字段相等(顺带清掉了
工作树里未跟踪的 resources/models/standard/resources/.DS_Store,与 ADR-0038 记录的是
同一个干扰项)。预置模型不新增 KpEnter.png:没有专属 artwork,回退到 Enter.png 就是
期望行为。

4. 包导入归一化扩展到 `Return`

key_names::LEGACY_KEY_IMAGE_NAMES 增加第三项 ("Return", "Enter"),沿用既有规则:只处理
resources/{left-,right-}keys 一层常规文件,canonical 文件已存在时保留它、旧名原样留下,
用户选中的源永远是只读输入。

5. Mver 转换:`VK_RETURN` 展开成两个名字

legacy_key_names 对键盘模式的 0x0D 返回 ["Enter", "KpEnter"](legacy_key_name 单值
表从 Return 改为 Enter,供测试与文档固定词形);同一张合成图装到两个名字下,运行时按
实际按下的键各自解析。这与 VK_MENU → AltLeft + AltRight 的展开完全同类,gamepad 模式
不受影响(13 仍是 DPadDown)。

0x10(Shift)、0x11(Control)仍保持家族名不变——理由与 ADR-0038 决策 4 相同:改动会
移动真实样本的转换产物证据,留给单独的改动。

明确不做

  • 不引入 NumEnter 之类第三套拼写:Num 已被数字行占用。
  • 不就地升级已安装模型:既有安装靠运行时的 Return alias 保持可用(同 ADR-0038)。
  • 不在本次把 Kp0..Kp9、KpMultiply 等接入运行时候选:Mver 转换早已输出这些名字,但
    key_name_candidates 至今不解析它们(按下的 0x54..0x63 无候选、不画图)。这是 ADR-0037
    记录过的同类既有缺口(功能键 F1..F12 逐键图不可达同款),仓库内没有模型画过这些键,
    改动无真实数据可验证,记录为后续项。本次只接入小键盘 Enter,因为它有明确的产品需求。
  • 不做模型加载期的动态旧名映射:磁盘上的包应当是产品自己的词汇表(同 ADR-0038)。

残余风险与待验证项(不得当作已确认)

  1. Return alias 只在单元测试里验证:没有在真实用户数据根里已安装的 Return.png 模型上
    跑过整条链路。
  2. 小键盘 Enter 的实机事件未验证:0x58 在两个平台 adapter 上的映射是既有代码(Windows
    (0x1c, E0)、macOS keycode 76,各自有 contract 测试),本次未新增实机按键验证;Windows
    侧同样受既有的交叉编译限制。
  3. 社区模型里如果存在名为 Enter.png 的图(理论上不可能,旧词汇表从未用过这个名字),
    归一化会保留它并留下 Return.png——运行时优先 Enter,行为仍然正确。
  4. Mver 展开依赖真实样本不含 13:一旦将来出现键盘模式绑定了 13 的真实 Mver 模型,
    其转换产物会多出一张 KpEnter.png(与 Enter.png 同字节)。这是行为保持的设计结果,
    不是缺陷,但会改变该样本的产物证据。

验证

已完成(2026-09-17,本机 macOS / aarch64):

  • bongocat-live2d 47 测试(新增 2)。enter_keys_resolve_distinct_names_with_a_keypad_fallback
    固定两组候选(0x28 → [Enter, Return]、0x58 → [KpEnter, Enter])与四种模型的命中结果
    (canonical、双图专用、纯旧名 Return、空资源);
    shipped_keyboard_models_draw_both_enter_keys_from_the_renamed_artwork 用真实预置模型断言
    0x28 与 0x58 都命中 resources/left-keys/Enter.png 且不再存在 Return.png。

  • bongocat-model 93 测试(新增 1,改写 2):key_names 既有 3 个用例扩展到 Return → Enter(两侧目录改名、同存时 canonical 优先);mver 的
    alt_control_codes_never_collapse_left_and_right 扩展到 0x0D → [Enter, KpEnter](gamepad
    的 13 仍是 DPadDown)、新增
    one_shared_enter_binding_installs_the_same_overlay_for_both_keys(一次 13 绑定产出
    Enter.png + KpEnter.png 且字节相同)。

  • spikes/model-package 15 测试全绿:冻结快照与重解析产物逐字段相等。

  • 真实社区模型验证(BONGOCAT_PACKAGE_SAMPLE 门禁用例,目录与 .zip 各跑一遍):

    样本 旧名 结果
    送葬人 · 标准模式(目录 / zip) 3 Alt.png、AltGr.png、Return.png 改名,字节相同,源不被改写
    Bongo Cat v0.16/BongoCat - 标准模式 3 同上
    经典小键盘 · 标准模式.zip 0 无改名,15 张图逐字节相同
  • 真实 Mver 样本回归:cargo run -p bongocat-model-store --example model_conversion_smoke -- --source /Users/ayang/Downloads/bongo_cat_mver_0.1.6_64 逐模式输出与 ADR-0037 记录完全一致
    (standard 31 文件 / 1 081 672 字节、keyboard 23 文件 / 1 015 987 字节、gamepad 28 文件 /
    1 082 753 字节),确认 0x0D 展开与 Return → Enter 没有改变真实样本的产物。

未运行:Windows 构建与实机按键、真实旧版安装数据根上已安装模型的 Return 兼容路径、
任何 UI 实机点击(详见残余风险)。


Adr 0040 Keypad Key Image Naming (`docs/adr/0040-keypad-key-image-naming.md`)

ADR-0040: 小键盘键位图的 `Kp*` 命名与主键盘键位图回退

状态:已接受(2026-09-17)
依赖:ADR-0039(主 Enter 与小键盘 Enter 的键位图命名与旧名兼容)、ADR-0038(左右 Alt 的键位图命名与旧名兼容)、ADR-0037(应用内导入 BongoCatMver 模型)、ADR-0004(可校正输入状态)

背景

ADR-0039 把主 Enter(HID 0x28)与小键盘 Enter(HID 0x58)分开命名,并把"把 Kp0..Kp9、
KpMultiply 等接入运行时候选"明确记为后续项。本 ADR 就是那一步。

决定本 ADR 形态的事实:

  1. 小键盘是 HID 0x53…0x63 一整块,两个平台 adapter 早已把整块映射正确。
    Windows 的 map_scan_code(非扩展 0x37/0x47..0x53、扩展 (0x1c,E0)、(0x35,E0))与 macOS 的
    keycode 表(65/67/69/71/75/76/78/82..92)都产出这些 usage,各自有 contract 测试。
  2. 没有任何模型为小键盘画过专属图。 预置 standard/keyboard 的 left-keys 不含任何
    Kp*.png;ADR-0037/0038/0039 收集的真实样本(送葬人、经典小键盘、Bongo Cat v0.16)同样没有。
    小键盘 Enter 在 ADR-0039 之后靠回退到 Enter.png 才画得出来,其余小键盘键什么都不画。
  3. key_name_candidates 对小键盘几乎无候选。 改动前只有 0x58 → KpEnter, Enter 一条,
    0x53..=0x57 与 0x59..=0x63 全部落到 _ => function_key(None),候选列表为空。
  4. InputState::model_snapshot 会丢弃没有 hand 归属的按键。
    bongocat-app::input_bindings_for_model 的手表只有 0x04..=0x27、功能键两段、0x28..0x2c、
    0x35/0x38/0x39/0x4c、0x4f..=0x52(右手)和修饰键——小键盘整块一个都没有。因此
    ADR-0039 给 0x58 加的回退在实机上是不可达的:按下小键盘 Enter 的 KeyPress 在
    hand_for == None 的 None => {} 分支就被丢掉,resolve_key_overlays 从未被调用。
    这与功能键逐键图不可达是同一个缺口(已在功能键那一项里记录并闭合),也是本 ADR 必须
    同时改绑定的原因。
  5. Num 前缀属于主键区数字行,Kp 前缀属于小键盘。 key_name_candidates(0x1e..=0x27) 输出
    Num1..Num0;Mver 转换的 legacy 表(VK 0x60..0x6F)输出 Kp0..Kp9、KpMultiply、KpPlus、
    KpMinus、KpDecimal、KpDivide,ADR-0039 决策 1 已把 KpEnter 并入这套词汇。

决策

1. 小键盘与主键盘的对应清单

小键盘中凡是与主键盘产生同一个字符的键,都回退到主键盘那一张键位图;其余键没有对应关系。
(ADR-0041 补齐主键盘词表后,KpMinus/KpDecimal 也拿到了对应键,见下表。)

小键盘键 HID 主键盘对应键 主键盘 HID 可复用键位图 回退候选
NumLock 0x53 — — — 无
KpDivide / 0x54 / Slash 0x38 Slash.png Slash
KpMultiply * 0x55 Shift+8,无独立键 — — 无
KpMinus - 0x56 - Minus 0x2d 尚无模型提供 Minus.png Minus
KpPlus + 0x57 Shift+=,无独立键 — — 无
KpEnter 0x58 Enter 0x28 Enter.png Enter
Kp1 … Kp9 0x59..=0x61 1 … 9 0x1e..=0x26 Num1.png … Num9.png Num1 … Num9
Kp0 0x62 0 0x27 Num0.png Num0
KpDecimal . 0x63 . Dot 0x37 尚无模型提供 Dot.png Dot

HID 0x64(Keyboard Non-US \ and |)不是小键盘键,而是 ISO/ABNT2 的额外键,本 ADR 不处理。

2. 候选顺序:精确名在前,主键盘对应键在后

key_name_candidates 的候选顺序:

text
0x53         → NumLock
0x54         → KpDivide, Slash
0x55         → KpMultiply
0x56         → KpMinus, Minus
0x57         → KpPlus
0x58         → KpEnter, Enter
0x59..=0x62  → Kp1 … Kp9, Kp0,再补 Num1 … Num9, Num0
0x63         → KpDecimal, Dot
  • 精确名在前:模型画了专用 Kp*.png 就用专用图,与 ADR-0039 对 KpEnter 的规则完全一致。
  • 主键盘对应键在后:没有任何已知模型画过小键盘图,所以这条回退就是用户实际看到的东西。
    回退仍在 key_name_candidates(资源加载层)实现,不要求模型额外提供图片。模型补了 Kp*.png
    后自动优先,但必须放在 left-keys——resolve_key_overlays 只按按下的 side 查同目录资源
    (见决策 3)。
  • 没有对应键的三个键只保留精确名。* 和 + 在主键盘上只能通过 Shift 得到(NumLock 则完全
    没有对应键),主键区本身没有这两个键的图,因此它们缺图时照旧不绘制。不引入"借用 Num8 当
    *"这类会误导用户的映射。
  • 数字表复用:新增的 KEYPAD_DIGIT_NAMES(Kp1…Kp9, Kp0)与既有 KEY_NUMBERS(Num1…Num9, Num0)逐下标对齐,一个 hid_usage - 0x59 同时索引两张表,两张表不可能漂移。

3. 小键盘整块绑到左手

bongocat-app::input_bindings_for_model 为键盘模型(Installed 或预置 standard/keyboard)
新增 0x53..=0x63 → HandSide::Left。

  • 这一步是必要前提,不是可选优化:没有 hand 归属的按键在 InputState::model_snapshot 就被
    丢弃,回退永远不可达(事实 4)。只加候选不改绑定会得到一段在实机上跑不到的死代码。
  • 为什么是左手:resolve_key_overlays 只按按下的 side 查同目录资源,而可复用的
    Num*.png/Enter.png/Slash.png 只存在于 left-keys;standard 预置甚至没有 right-keys
    目录,keyboard 的 right-keys 只有四张方向键图。绑到右手会让本 ADR 的回退对每一个已知模型
    都不可达。代价是按下小键盘时动的是左爪——这是"显示降级"可接受的近似。
    同一条 side 严格性也意味着模型为小键盘补图时必须放进 left-keys:放在 right-keys 的
    Kp*.png 在左手绑定下不可达(见残余风险 2)。
  • 不碰 0x4f..=0x52:方向键保持右手,standard 照旧不绑方向键(与既有契约测试一致)。
  • gamepad 预置保持按钮专用映射,不绑键盘小键盘。

明确不做

  • 不给预置模型新增任何 Kp*.png:没有专属 artwork 时回退就是期望行为(同 ADR-0039 决策 3)。
  • 不引入文字/字形渲染管线:渲染器(bongocat-render、bongocat-overlay)目前只有带纹理的
    四边形,没有字体资源、字形图集或文字 shader。"显示其可读名称"因此落地为"按可读名解析到已有
    键位图",与 ADR-0039 的 KpEnter → Enter 完全同构;真正的屏幕文字需要字体资源加 D3D11/Metal
    两套文字管线,属于独立改动。
  • 不改 resolve_key_overlays 的 side 严格性:缺同侧资源时不绘制是既有契约
    (Technical Design §11),不为小键盘放宽成跨侧查找。
  • 不处理 HID 0x64:ISO/ABNT2 额外键,不是小键盘键。

残余风险与待验证项(不得当作已确认)

  1. 小键盘的实机事件仍未验证:两个平台 adapter 的映射是既有代码且有 contract 测试,本次未新增
    实机按键验证;Windows 侧同样受既有的交叉编译限制。
  2. 左手绑定是产品近似,并且决定了小键盘美术只能放 left-keys:按下小键盘会让左爪下压;
    放在 right-keys 的 Kp*.png 在左手绑定下不可达。若后续为小键盘出右手美术,需要同时把绑定
    改到右手(届时回退会失去 left-keys 里的 Num*/Enter/Slash),决策 3 需要重写。
    注意 bongocat-model-store 的 Mver 转换是按 legacy 的 lefthand/righthand 列表决定输出目录的
    (OUTPUT_LEFT_KEYS/OUTPUT_RIGHT_KEYS),因此手写的 legacy 表若把小键盘码放进右手列表,
    产物会落到 right-keys;已知真实样本不含小键盘码,所以当前不存在这种产物。
  3. NumLock、KpMultiply、KpPlus 仍然不画任何东西:主键盘上没有这三个键(*、+ 只能靠
    Shift 得到),因此没有可借的图。这不是本 ADR 引入的缺陷,但用户可能期望它们也有反馈。
  4. 0x64 仍然无候选:Windows 的 map_scan_code 会把扫描码 0x56 映射为 0x64(ISO/ABNT2
    额外键),该键在本 ADR 之后依旧不绘制,行为与改动前一致。

验证

已完成(2026-09-17,本机 Windows / x86_64-pc-windows-msvc):

  • bongocat-live2d 48 测试(新增 2):
    keypad_keys_name_themselves_and_fall_back_to_their_main_keyboard_twin 固定整块
    0x53..=0x63 的候选列表(含五个无回退键只列精确名);
    shipped_keyboard_models_draw_the_keypad_from_the_main_keyboard_artwork 用真实预置
    standard/keyboard 断言十二个小键盘键命中 resources/left-keys/ 的 Num1..Num9、Num0、
    Enter、Slash,并断言预置模型确实不含任何 Kp* 图、五个无对应键解析为空(不绘制)。
  • bongocat-app --lib 124 测试(新增 1):
    keyboard_models_assign_the_whole_keypad_block_to_the_left_hand 断言三种键盘模型的整块小键盘
    左手绑定,且方向键 0x4f..=0x52 在 Installed/keyboard 上仍是右手,gamepad 预置不绑小键盘。
  • just check 的六道门全过:cargo fmt --all -- --check;
    cargo clippy --locked --workspace --all-targets --all-features --exclude bongocat-app -- -D warnings;
    cargo clippy --locked -p bongocat-app --all-targets --features storage-test-injection -- -D warnings;
    cargo clippy --locked -p bongocat-app --all-targets --features production -- -D warnings;
    cargo test --locked --workspace;cargo check --locked --workspace --release。
    (注意 cargo clippy --workspace --all-features 不带 --exclude bongocat-app 会因为
    storage-test-injection 与 production 互斥而失败,这是既有约束,不是本 ADR 引入的。)

未运行:Windows 实机按键、macOS 构建与实机按键、UI 实机点击、真实社区模型上的小键盘回归
(无样本画过小键盘图,因此没有可对照的产物证据)。


Adr 0041 Complete Key Vocabulary (`docs/adr/0041-complete-key-vocabulary.md`)

ADR-0041: 完整键位词表与绑定覆盖

状态:已接受(2026-09-17)
依赖:ADR-0040(小键盘键位图的 Kp* 命名与主键盘键位图回退)、ADR-0039(主 Enter 与小键盘 Enter 的键位图命名与旧名兼容)、ADR-0038(左右 Alt 的键位图命名与旧名兼容)、ADR-0004(可校正输入状态)

背景

ADR-0040 把 Kp0..Kp9、KpMultiply 等接入了运行时候选,但当时的范围只覆盖小键盘,并且判断
依据里混进了"当前没有模型画这些键"这一事实。本 ADR 纠正这个思路:键位词表是与模型作者的契约,
不是"预置模型当前画了哪些图"的清单。

决定本 ADR 形态的事实:

  1. 词表原本是不完整的。 改动前 key_name_candidates 只命名了字母、数字行、Enter、Escape、
    Backspace、Tab、Space、BackQuote、Slash、CapsLock、四个方向键、小键盘、修饰键和
    F1..F24。以下 usage 没有 arm,落到 _ => function_key(None):
    0x2d Minus、0x2e Equal、0x2f LeftBracket、0x30 RightBracket、0x31 BackSlash、
    0x32、0x33 SemiColon、0x34 Quote、0x36 Comma、0x37 Dot、0x46 PrintScreen、
    0x47 ScrollLock、0x48 Pause、0x49 Insert、0x4a Home、0x4b PageUp、
    0x4c Delete、0x4d End、0x4e PageDown、0x64、0x65 Apps、0x67。

  2. 缺名字的代价不是理论上的。 0x4c(Delete)在 input_bindings_for_model 的左手表里,
    并且 resources/models/{standard,keyboard}/resources/left-keys/Delete.png 已随预置模型出厂——
    但因为没有名字 arm,key_name_candidates(0x4c) 返回空候选,Delete.png 从出厂那天起就
    永远画不出来
    。逐张核对两个预置模型的 55 张键位图,Delete.png 是唯一不可达的一张。
    这与功能键逐键图曾经的缺口(已在功能键那一项闭合)是同一类错误。

  3. 名字和绑定必须覆盖同一个集合。 InputState::model_snapshot 对 hand_for == None 的按键走
    None => {} 直接丢弃,resolve_key_overlays 从未被调用。所以"有名字、没绑定"和
    "没名字"一样不可达——只补名字不补绑定,等于把词表做成摆设。ADR-0040 已经踩过这个坑
    (KpEnter 有了名字却没有 hand 归属)。

  4. 范围由平台 adapter 的实际产出界定。 Windows 的 map_scan_code 产出
    0x04..=0x31、0x33..=0x45、0x46..=0x4e、0x4f..=0x64 与八个修饰键 usage
    0xe0..=0xe7(E0/E1 前缀与非扩展码);macOS 的 keycode 表产出
    0x04..=0x31、0x33..=0x45、0x4a..=0x63、0x67、0x68..=0x6f、0xe0..=0xe7。
    两者并集 = 0x04..=0x65 ∪ {0x67} ∪ 0x68..=0x73 ∪ 0xe0..=0xe7。0x66(Power)
    两边都不产出。

    修订(2026-09-17,同日): 本事实原先写的并集漏掉了修饰键 0xe0..=0xe7(它们不在
    0x04..=0x65、0x67、0x68..=0x73 任何一段里),决策 2 的绑定循环照着这个不完整的并集
    重写,于是八个修饰键 usage 失去了 hand 归属:Shift/Control/Alt/Meta 的按键被
    InputState::model_snapshot 丢弃,模型再也画不出 ShiftLeft.png、AltLeft.png、
    AltRight.png、Meta.png 等修饰键美术,爪子也不再为它们下压——即 ADR-0038 专门为 Alt
    图片做过的事被同时作废。修正:绑定循环补回 0xe0..=0xe7 → 左手,并把并集作为规范写进
    绑定契约测试(遍历 0x04..=0x65 ∪ {0x67} ∪ 0x68..=0x73 ∪ 0xe0..=0xe7,断言每个
    usage 的绑定等于"该模型能画则绑"),不再只遍历实现恰好用到的那几段。测试盲区是本次回归能
    溜过 just check 的直接原因:命名测试与绑定测试当时都只遍历前三段。

  5. 旧版键位映射图给出的名字是词表依据。 它覆盖标准 104/105 布局 + 小键盘,包含
    Minus/Equal/LeftBracket/RightBracket/BackSlash/SemiColon/Quote/Comma/Dot/
    PrintScreen/ScrollLock/Pause/Insert/Home/PageUp/Delete/End/PageDown/Apps
    这些当前代码没有 arm 的名字。图中的空格只是排版(Back Quote → BackQuote、
    Kp Divide → KpDivide),实际资源名无空格。

    读图约定(2026-09-19,与作者确认)。 参考图上有两类不承载实现语义的记号,另有一条
    关于范围的说明,核对时都不要当成缺口:

    • 多词标签的折行是排版,不是名字的一部分。 Back Quote、Control Left、Kp Divide
      都是换行显示的单段名字。
    • 右上角那三个绿点(Num Lock / Caps Lock / Scroll Lock)是纯装饰,只用来把三个锁定键标成
      一类。它不表示应用会显示锁定状态——当前实现没有任何锁定状态指示,也不打算加(首版
      范围之外)。核对时若把它读成"应显示 Caps Lock 灯",那是对图的过度解读。
    • 图是物理布局参考,不是"每个平台都画得出来"的承诺。 图上有几个键只在单个平台可达:
      PrintScreen/ScrollLock/Pause/Insert 仅 Windows,Globe 仅 macOS。
      照图做美术仍然正确——平台产不出的键只是那个平台画不出来,不是命名错了。平台边界见事实 4。

    三个锁定键本身在词表与绑定里都是正常成员:0x39 CapsLock、0x47 ScrollLock、
    0x53 NumLock 各有名字(bongocat-live2d::key_name_candidates),都被
    input_bindings_for_model 的 0x04..=0x65 循环绑到左手,是否真的画得出来只取决于模型有没有
    对应美术(ADR-0042;预置模型没出这三张图,所以当前画不出,这是设计如此)。
    可达性按两个 adapter 的实际产出:CapsLock(macOS keycode 57)与 NumLock(macOS keycode
    71)双平台可达,ScrollLock 仅 Windows——macOS 没有对应 keycode,见 macOS 侧那条
    穷举测试的期望集合里没有 0x47。

决策

1. 命名标准布局 + 小键盘的全部按键

key_name_candidates 补齐上述所有缺失 usage 的精确名:

text
0x2d Minus   0x2e Equal   0x2f LeftBracket   0x30 RightBracket   0x31 BackSlash
0x32 IntlHash   0x33 SemiColon   0x34 Quote   0x36 Comma   0x37 Dot
0x46 PrintScreen   0x47 ScrollLock   0x48 Pause
0x49 Insert   0x4a Home   0x4b PageUp   0x4c Delete   0x4d End   0x4e PageDown
0x64 IntlBackslash   0x65 Apps   0x67 KpEqual
  • 0x32/0x64 是 ISO/ABNT2 的额外键,旧图未命名。沿用 W3C UI Events KeyboardEvent.code
    的既有拼写 IntlHash/IntlBackslash,避免自创词汇;0x64 是 Windows map_scan_code
    真会产出的 usage,不命名就等于又留一个不可达的键。
  • 0x67(小键盘 =)只有 macOS 产出(keycode 81),按既有 Kp* 方案命名 KpEqual。
  • 命名不以美术是否存在为前提。 这些键今天都没有图,这是资源事实,不是命名依据。

2. 绑定覆盖同一集合

bongocat-app::input_bindings_for_model 的键盘模型分支收敛为一条循环:

text
0x04..=0x65(方向键 0x4f..=0x52 除外)→ 左手
F13..F24(0x68..=0x73)              → 左手
0x67                                 → 左手
0xe0..=0xe7(八个修饰键)             → 左手   ← 2026-09-17 修订补回,见事实 4

方向键仍归右手,且仍只对 Installed/keyboard/gamepad 生效(standard 预置照旧不绑方向键,
与既有契约测试一致);gamepad 预置仍是按钮专用映射。左爪画键盘区、右爪画方向键,这条分工不变。

修订(2026-09-17,同日,见 ADR-0042): 本节描述的是候选 hand 表,它仍然覆盖整块布局。
真正写入 runtime 的每模型绑定是该表与"该模型确实有的键位图"的交集:模型没有图的按键不再进入
InputState::model_snapshot,因此既不驱动 CatParamLeftHandDown/CatParamRightHandDown,
也不产生按键图层。残余风险 1 记录的"缺图也有爪部反馈"由 ADR-0042 消除;命名不以美术存在为
前提这一条不变——模型补图后绑定自动恢复。

3. `PrintScreen` 绑定,但明确不是功能键

FUNCTION_KEY_USAGES 的边界(0x3a..=0x45 与 0x68..=0x73)不变,PrintScreen 不享受
Fn 回退。它只是作为一张普通按键获得名字和 hand——这与"不享受功能键语义"并不矛盾,
原来的 must not bind PrintScreen 断言混淆了这两件事,本次改成断言
function_key_name(0x46) == None(真正的边界保证)+ hand_for(0x46) == Some(Left)(新的绑定事实)。

4. 已出厂的 `Delete.png` 恢复可达

0x4c => Some("Delete") 让两个预置模型已经出厂的 Delete.png 立即生效。这是本次唯一的
"修复既有不可达资源",不是新增能力。

明确不做

  • 不为这些键新增任何预置美术:名字先就位,图由模型决定;预置模型保持现状。
  • 不命名平台 adapter 产不出的 usage:0x66(Power)两边都不映射,保持无名(有测试断言)。

修订(2026-09-19): 上一条的原则是"不为纯理论 usage 造名字",但它当时的表述过宽,而且
没有被完全执行。Apps(0x65)被命名并绑定,却两个平台都产不出:Windows map_scan_code
的 E0 分支没有 0x5d(菜单键的扩展扫描码),macOS map_key_code 没有 110
(kVK_ContextualMenu)。产不出的键永远进不了绑定表,所以 Apps.png 一直画不出来——名字、
绑定、文档三者都"到位",只有平台层没有。同日补上两处映射(Windows 0x5d => 0x65、macOS
110 => 0x65),并给两个 adapter 各加一条穷举断言把"本 adapter 实际能产出哪些 usage"逐项
固定:bongocat-platform::macos::tests::this_adapter_reports_exactly_the_keycodes_the_platform_defines
与 bongocat-platform::windows::tests::this_adapter_reports_exactly_the_scan_codes_the_platform_defines
(见 TODO 第 90 项)。

修订后的原则分两层,不要再混为一谈:

  • 词表可以命名不可达的键。 名字是给模型作者的契约:模型提供美术就必须生效,与今天有没有
    硬件能按无关。IntlHash(0x32,macOS 把 ISO # 与 ANSI \ 给了同一个键码)与 F21–F24
    (0x70..=0x73,Carbon 无键码、Windows 无可依据的扫描码)属于这一类,维持现状。
  • "某个 adapter 能产出它"这句话必须有该 adapter 自己的断言支撑,不得从并集推断。本 ADR
    事实 4 的并集、以及两处 adapter_keyboard_usages() 的注释都曾经比事实宽。
  • 不改 resolve_key_overlays 的 side 严格性:缺同侧资源时不绘制是既有契约。
  • 不改 FUNCTION_KEY_USAGES 的边界:PrintScreen 依旧不是功能键。

残余风险与待验证项(不得当作已确认)

  1. 绑定集合扩大是行为变化:标点键、PrintScreen、导航键现在都会让左爪下压(此前完全无反应)。
    这是"名字可用"的必要代价,也让这些键在没有美术时仍有爪部反馈;但它确实改变了既有行为,
    尚未在实机上确认观感。
  2. 这些键位图仍然全部不存在:除 Delete.png 外,本次补的名字都不会画出任何东西,
    需要模型补图才有视觉效果。
  3. IntlHash/IntlBackslash 是本次新增词汇:旧图未命名,两个平台 adapter 也只有 Windows
    产出 0x64、0x32 两边都不产出。若将来确认了更合适的旧名,需要按旧名兼容流程处理。
  4. 实机按键仍未验证:两个平台 adapter 的映射是既有代码且有 contract 测试,本次未新增实机验证。

验证

已完成(2026-09-17,本机 Windows / x86_64-pc-windows-msvc):

  • bongocat-live2d 50 测试(新增 2):
    every_key_the_platform_adapters_can_report_has_a_name 断言 0x04..=0x65、0x67、
    0x68..=0x73 每个 usage 的候选列表非空,并断言 0x66(Power)保持无名;
    a_model_providing_a_named_key_image_draws_it 用合成资源断言 27 个新命名键在模型提供
    对应 PNG 时全部命中该图,并断言只提供出厂词汇的模型对这些键仍然解析为空。
  • bongocat-app --lib 124 测试:
    keyboard_models_bind_every_named_key_of_the_standard_layout(改写自原小键盘用例)
    断言三种键盘模型绑定 0x04..=0x65(方向键除外)、0x68..=0x73、0x67 到左手,方向键
    对 Installed/keyboard 仍是右手,gamepad 仍不绑键盘小键盘;功能键那条用例改为断言
    function_key_name(0x46) == None(真正的"不是功能键"边界)与 hand_for(0x46) == Some(Left)
    (新的绑定事实)。
  • just check 六道门(fmt、三组 clippy、workspace test、release check)。

未运行:Windows/macOS 实机按键、UI 实机点击。


Adr 0042 Key Press Requires Its Key Image (`docs/adr/0042-key-press-requires-its-key-image.md`)

ADR-0042: 键位图存在才触发按键动作

状态:已接受(2026-09-17)
依赖:ADR-0041(完整键位词表与绑定覆盖)、ADR-0040(小键盘键位图的 Kp* 命名与主键盘键位图回退)、ADR-0004(可校正输入状态)

背景

ADR-0041 把标点键、PrintScreen、导航键和小键盘整块都绑到了左手,并在残余风险 1 里记下了代价:
这些键现在都会让左爪下压,而预置模型对其中大多数键并没有图。用户把它报成了缺陷:
模型没有对应图片资源时,按下该键不应该产生任何动作。

决定本 ADR 形态的事实:

  1. 一次按键的"动作"是两件事,但只有一个开关。 按键会驱动
    ProductParameter::LeftHandDown/RightHandDown(即 Cubism 的 CatParamLeftHandDown/
    CatParamRightHandDown,见 bongocat-runtime::apply_model_input),以及按键图层
    (resolve_key_overlays)。两者都由 InputState::model_snapshot 里同一个 hand_for
    归属决定:有归属的按键才置 hand 标志并成为该侧的候选 press,None => {} 的按键直接被丢弃。
    所以"绑定"就是那个唯一开关,不需要第二套门禁。
  2. 图是模型包自带的,只有渲染侧知道。 bongocat-live2d-render::load_key_assets 扫描
    resources/left-keys / resources/right-keys,resolve_key_overlays 用
    key_name_candidates 的候选顺序在已加载的资产里找图;找不到就不画。
  3. 预置 standard 的 55 张键位图里没有 Dot.png、Minus.png、PrintScreen.png、
    NumLock.png、小键盘 */+/-/.
    ,也没有 right-keys 目录。按下 .、,、-
    或 PrintScreen 时,用户看到的是"爪子按下去但什么都没出现"——动作指向一个永远画不出来的东西。
  4. 判断必须发生在模型与绑定相遇的地方。 runtime 是平台无关 crate,只在 Windows/macOS
    依赖 bongocat-live2d 与 bongocat-live2d-render,且它的 model_snapshot 只接收 InputBindings;renderer 只消费不可变
    RenderSnapshot,不得决定动作(Technical Design §5.2/§5.3)。而 bongocat-app 在模型激活时
    本来就为每个模型计算绑定(input_bindings_for_model),手上正好有 CommittedModel。
  5. "可绘制"必须与实际绘制同源。 如果判断用的目录扫描或候选顺序与渲染侧不一致,就会出现
    "以为画得出来却没画"或"画得出来却不响应"。两者必须由同一段代码回答。

决策

1. `bongocat-live2d-render` 提供键位图清单

新增 KeyImageInventory:

  • read(root) 扫描 resources/left-keys 与 resources/right-keys,只取目录下的常规 .png,
    文件名主干即资源名,不解码任何图片。
  • provides(side, name) 回答某一手是否提供该名字的图。
  • can_draw(side, hid_usage) 用 key_name_candidates 的候选顺序回答"这个键在这一手能不能画出来"
    ——精确名、家族图(Fn、Control/Shift/Alt/Meta)、旧名别名(AltGr、Return)、
    小键盘回退(Kp1..Kp9→Num1..Num9 等)都算。
  • load_key_assets 与 KeyImageInventory::read 共用同一个私有目录扫描(key_image_files),
    所以"清单说有"与"渲染加载到"不可能漂移;这也是唯一的文件规则来源(目录、扩展名大小写不敏感、
    只认目录下的常规文件)。

2. 绑定按模型实际资源收窄

bongocat-app::input_bindings_for_model 保留 ADR-0041 的静态 hand 表作为候选集合(命名与
绑定仍覆盖同一集合,词表仍不以美术存在为前提),但每个键只有在 can_draw 为真时才写进
InputBindings。也就是说:

text
写入 runtime 的每模型绑定 = 静态 hand 表 ∩ 该模型的键位图
  • 模型补上 Dot.png 后,. 键在下次激活该模型时自动恢复绑定——仍然不需要改产品代码。
  • 判断在 bongocat-app 完成,prepare_model / select_model 两条激活路径都传入
    CommittedModel::root() 的清单,因此启动恢复、设置页切换和 overlay 预览走的是同一条规则。
  • 判断放在绑定层而不是渲染层:renderer 不得决定动作,runtime 也不应为了解图片资源而依赖
    Cubism/image 相关 crate。

3. 缺图即无动作

模型没有图的按键不写入绑定,于是 InputState::model_snapshot 在 hand_for == None 处丢弃它:
既不置 left_hand_down/right_hand_down(因此不驱动 CatParamLeftHandDown/
CatParamRightHandDown),也不产生按键图层。规则对所有缺少图片资源的按键一致,不区分
"词表新补的名字"和"出厂就有的名字"。

仍然生效的既有行为:

  • Delete.png(ADR-0041 恢复的可达资源)继续绑定并绘制。
  • 小键盘数字继续回退到 Num1..Num0,小键盘 Enter 继续回退到 Enter,KpDivide 继续回退到
    Slash。
  • 功能键继续优先专属 F1.png…F24.png,缺失时回退 Fn.png。
  • 左右修饰键继续使用各自精确名,回退家族图。

4. 作用范围是键位图

  • 鼠标不属于本规则:mouse_left_down/mouse_right_down 驱动的是
    ParamMouseLeftDown/ParamMouseRightDown,是指针状态而不是键位图,产品词表里也没有它们的
    图片名。
  • 手柄按钮不属于本规则:它们经由 hand_for_gamepad 驱动爪子,但键位图解析器没有手柄按钮
    的词表,手柄美术今天本来就不进入按键图层(预置 gamepad 的 South.png 等只被加载、不被绘制)。

明确不做

  • 不给预置模型补任何键位图:缺图就是缺图,本 ADR 只让缺图时不再假装有反馈。
  • 不改 key_name_candidates 的词表与 resolve_key_overlays 的 side 严格性:命名仍覆盖
    平台 adapter 能产出的全部按键,同侧资源缺失仍不绘制。
  • 不把判断搬进 runtime 或 renderer:runtime 需要图片资源信息就得依赖 Cubism/图片解码;
    renderer 决定动作会破坏"renderer 只消费 RenderSnapshot"的边界。
  • 不改鼠标与手柄输入。

残余风险与待验证项(不得当作已确认)

  1. 只有另一手有图时该键无动作:side 严格性是既有契约,现在"绑定"与"绘制"在这一点上也一致
    ——模型把小键盘美术放进 right-keys 时左手绑定下不可达(同 ADR-0040 决策 3)。
  2. 预置 gamepad 不再响应任何键盘按键:它没有键盘键位图(此前也不绘制任何键盘图层),
    键盘输入现在完全不驱动它;手柄按钮映射不受影响。
  3. 完全没有键位图的导入模型不再响应键盘:这类模型此前会让爪子下压但画不出图;现在键盘输入
    对它完全无动作,模型仍由指针、呼吸和眨眼驱动。这是本 ADR 的直接意图,但改变了观感。
  4. bongocat-overlay 的预览工具仍持有自己的静态绑定表(preview_input_bindings,停留在
    ADR-0041 之前的形态),不感知键位图:just preview gamepad 仍会绑定 A 并让左爪下压,
    与产品行为不同。它是人工诊断工具,本次未改;后续如要收敛,应与产品共用同一套绑定构造。
  5. 实机观感未验证:本次只跑自动化契约,未在 Windows/macOS 实机上逐键确认。

验证

已完成(2026-09-17,本机 Windows / x86_64-pc-windows-msvc):

  • bongocat-live2d-render 资源 contract 与 bongocat-live2d 集成测试:
    key_image_inventory_lists_exactly_the_assets_the_renderer_loads 对三个预置模型断言清单与
    load_key_assets 加载到的 (side, name) 集合逐侧相等("报告"与"绘制"同源);
    a_shipped_model_can_draw_only_the_keys_it_ships_artwork_for 断言 standard 能画 KeyA、
    Delete、功能键(Fn)、小键盘数字(回退 Num*)、小键盘 Enter(回退 Enter),不能画
    .、-、PrintScreen、NumLock、小键盘 .、小键盘 = 与方向键(含 right-keys 不存在),
    并断言 keyboard 的右手方向键可画、gamepad 连 KeyA 都不可画。
  • bongocat-app --lib 125 测试(新增 1,改写 3):
    a_key_the_active_model_cannot_draw_never_moves_the_paw 启动真实渲染应用、激活预置
    standard,断言按下 A(有 KeyA.png)、左 Shift(ShiftLeft.png)与右 Meta
    (无 MetaRight.png,回退共享 Meta.png)都置 left_hand_down 并进入 key_presses,而按下
    .(无 Dot.png)既不置爪也不产生按键 press;
    keyboard_models_bind_every_drawable_key_of_the_standard_layout 断言绑定等于"静态表 ∩ 模型
    键位图",遍历两个平台 adapter 的产出并集(0x04..=0x65 ∪ {0x67} ∪ 0x68..=0x73 ∪
    0xe0..=0xe7,见 ADR-0041 事实 4 修订),并对 KeyA/Num1/Delete/小键盘 1/小键盘 Enter/
    左右 Shift/Alt/Control/Meta 为真、./PrintScreen/NumLock/小键盘 . 为假做逐条
    抽查;功能键用例改为断言 PrintScreen 不再绑定(无图),
    installed_models_get_default_keyboard_bindings 断言 gamepad 预置不绑任何键盘键、手柄按钮
    映射不变。
  • just check 的六道门全过:cargo fmt --all -- --check;
    cargo clippy --locked --workspace --all-targets --all-features --exclude bongocat-app -- -D warnings;
    cargo clippy --locked -p bongocat-app --all-targets --features storage-test-injection -- -D warnings;
    cargo clippy --locked -p bongocat-app --all-targets --features production -- -D warnings;
    cargo test --locked --workspace;cargo check --locked --workspace --release。

未运行:Windows/macOS 实机按键与观感确认、UI 实机点击、真实社区模型回归。


Adr 0043 Startup Item Via Auto Launch (`docs/adr/0043-startup-item-via-auto-launch.md`)

ADR-0043: Startup Item Backend via auto-launch

状态:Accepted
日期:2026-09-17
取代:ADR-0013
修订:ADR-0051 取代了"Development 构建从此支持启动项"这一条决策——开发构建现在按构建环境汇报
Unsupported(BuildEnvironment),不再注册登录项。本文档的其余部分(auto-launch 后端、
LaunchAgent plist、HKCU Run、--run-seconds 0、契约变体保留、环境隔离命名)继续有效。

Context

ADR-0013 建立了环境隔离的双平台启动项能力:Windows 使用 HKCU Run,macOS 13+
Production .app 使用 SMAppService.mainAppService,macOS 12 与 Development
构建明确报告 unsupported。该实现(P7-STARTUP-ITEM-PLATFORM、
P5-STARTUP-ITEM-UI)已通过双平台 smoke 与 CI 验收。

产品决策改变:启动项是用户可直接感知的功能,macOS 12 用户(产品声明的首发
平台)和 Development 构建不应被排除。同时,维护两套平台后端(HKCU 注册表
raw binding、objc2 ServiceManagement)只为一项可选能力付出不成比例的成本。
经评审选择成熟的开源方案 auto-launch crate(crates.io 最新稳定版 0.6.0,
MIT,活跃维护)统一双平台后端。

Decision

  • 引入 auto-launch = "=0.6.0" 作为 bongocat-platform 的双平台启动项后端,
    替换 startup_item_macos.rs 与 startup_item_windows.rs 的手写实现。
  • macOS 使用 MacOSLaunchMode::LaunchAgent:enable 写
    ~/Library/LaunchAgents/{app_name}.plist(Label、
    AssociatedBundleIdentifiers、ProgramArguments、RunAtLoad),disable
    删除 plist,is_enabled 检查 plist 存在。该机制在 macOS 12/13+ 行为一致,
    无 TCC 授权、无特权要求、不触碰 SMAppService 状态。注册项在系统设置的
    后台项目列表中可见(经 AssociatedBundleIdentifiers 关联应用)。
  • Windows 使用 WindowsEnableMode::CurrentUser:写入
    HKCU\Software\Microsoft\Windows\CurrentVersion\Run,并同步写入
    StartupApproved\Run 启用标记,is_enabled 同时尊重任务管理器的禁用覆盖。
  • 环境隔离保留:StartupItemEnvironment 仍映射为不同的
    app_name(BongoCat Development / BongoCat Production)。macOS 上是
    不同的 plist Label 与文件名,Windows 上是不同的注册表 value name,开发与
    生产互不覆盖,Development 构建从此支持启动项。
  • 启动命令统一携带 --run-seconds 0(显式产品启动语义);Windows 侧由
    auto-launch 按 MSVCRT 参数规则对 executable 加引号拼接。
  • 共享 contract(StartupItemState、StartupItemError、
    StartupItemUnsupportedReason)保持不变以稳定 UI/typed command 边界;
    后端现只会产生 Enabled、Disabled 与错误。Stale、RequiresApproval、
    NotFound 与 Unsupported(OperatingSystem/BuildEnvironment) 保留为契约
    变体但当前无生产者,UI 对其的处理分支继续作为防御性路径存在。
  • 启用、禁用仍只能由显式用户 command 触发;失败不改变配置、不阻塞
    input/runtime/renderer,与 ADR-0013 的隔离与容错原则一致。

Consequences

  • macOS 12 获得启动项支持;macOS 13+ 的注册方式从 SMAppService 变为
    LaunchAgent plist,不再有 requires-approval 状态。
  • LaunchAgent 项随登录由 launchd 拉起(RunAtLoad),enable 后不立即启动、
    也无 launchctl bootstrap;ProgramArguments 直接运行 bundle 内可执行文件,
    与用户双击 .app 的启动语义存在差异(工作目录、环境变量更空),属已知
    且可接受的行为。
  • Windows 不再检测 stale(安装位置变化后 is_enabled 仍为 enabled,启动时
    才会失败);用户重新开关一次即可修复。ADR-0013 的 stale 状态由此退役。
  • plist 由 auto-launch 以字符串模板生成,路径含 XML 特殊字符时可能生成
    无效 plist;产品路径不含此类字符,作为上游已知边界记录。
  • objc2-service-management 依赖随 SMAppService 后端一并移除。

Verification

  • 单元测试覆盖环境命名隔离、错误 code 稳定性与后端构造。
  • opt-in smoke(cargo test -p bongocat-platform -- --ignored)驱动当前
    平台 disabled -> enabled -> disabled 并恢复原状态,且不触及另一环境的
    注册项。
  • macOS --startup-item-smoke 产品 smoke 继续验证 Production 安装态
    lifecycle(现映射到 LaunchAgent plist)。
  • 双平台完整门禁(fmt、严格 Clippy、workspace test)通过;Windows
    实机回归由 CI 承担。

Adr 0044 Global Hotkey Registration Backend (`docs/adr/0044-global-hotkey-registration-backend.md`)

ADR-0044: 全局快捷键改用 global-hotkey 注册制后端

状态:Accepted
日期:2026-09-17;2026-09-25 修订应用级输入门禁快捷键
取代:无(首次为快捷键子系统建立 ADR)

Context

BongoCat 的快捷键(应用命令与模型行为)此前由输入管线内匹配实现:
Windows Raw Input / macOS CGEventTap 把按键边沿映射为 HID usage 后喂给
ShortcutMatcher,ShortcutDispatcher 在进程内解析目标并分发。该匹配是
被动的:注册过的组合键仍会继续传递给前台应用,同时快捷键依赖输入服务的
生命周期(输入服务因权限或恢复失败退出时快捷键一并失效)。

产品需要对齐历史 Tauri 版本的全局快捷键语义:组合键由操作系统注册并
消费,应用失焦或处于后台时必须可靠触发。经评审选择 tauri-apps 维护的
global-hotkey crate(crates.io 最新稳定版 0.8.0,Apache-2.0 OR MIT,
活跃维护):Windows 走 RegisterHotKey,macOS 走 Carbon
RegisterEventHotKey,与历史实现的注册模型一致。

Decision

  • 引入 global-hotkey = "=0.8.0",仅作为 bongocat-platform 的
    macOS/Windows 目标依赖。组合键解析(ShortcutChord)留在
    bongocat-config,平台 token → keyboard_types::Code 映射与注册管理
    在 bongocat-platform/src/shortcut.rs。
  • 新增 GlobalShortcutService:单一 owner 线程持有
    GlobalHotKeyManager,以 50ms 轮询共享 ShortcutTable 并做增量
    register/unregister,消费 GlobalHotKeyEvent::receiver() 后把目标交给
    精简后的 ShortcutDispatcher::execute(runtime 触发 + application
    sink)。ShortcutTable::replace 的全部发布点(set_shortcuts、录制
    suspend/resume、行为快捷键开关、恢复默认)无需新增通知通道即被覆盖。
  • 平台线程模型:
    • Windows:WM_HOTKEY 投递到创建 manager 隐藏窗口的线程队列,owner
      线程运行 MsgWaitForMultipleObjectsEx + PeekMessageW 消息泵。
    • macOS:热键事件经 Carbon handler 在主事件循环投递(应用 runloop
      泵送);在 owner 后台线程上创建/注册/注销
      RegisterEventHotKey 已通过本机反向控制实验验证可行。
  • 删除 ShortcutMatcher 与输入管线中的全部快捷键喂点
    (apply/reset/reconcile),WindowsInputService /
    MacInputService 移除 _and_shortcuts 启动入口,
    OverlayInteractionSinks 移除 shortcut_dispatcher。app 生命周期:
    服务在产品 run 启动,begin_product_shutdown 中先于
    overlay.stop_input() 停止并注销全部注册。
  • 注册失败不阻塞其他绑定:被其他应用占用或平台无 scancode(macOS 的
    ScrollLock/Pause 没有 Carbon scancode)的组合键记入
    registration_failures 诊断,其余正常注册。
  • 配置 schema、行为动作与 UI 录制流程均不变。ShortcutCommand 的 application target
    在原有集合上增加 toggle_ignore_mouse_input、toggle_ignore_keyboard_input 和
    toggle_ignore_gamepad_input;三个 target 都经同一 settings-service typed handoff
    切换并持久化对应的模型输入门禁,不直接修改 runtime 或平台输入状态。

Consequences

  • 行为变化:注册的组合键会被操作系统消费,不再同时传递给前台应用
    (与历史 Tauri 版本一致,属于预期语义)。
  • macOS 上快捷键注册本身不要求辅助功能权限(Carbon 注册),但猫爪动画
    的全键盘监听仍依赖 CGEventTap,TCC 授权要求不变。
  • 快捷键不再随输入服务失败而失效;输入服务的恢复/重置路径与快捷键状态
    完全解耦,Issue #47 的 pressed-state 校正只服务动画输入。
  • 服务按 50ms 轮询感知改绑,录制 suspend/resume 的生效延迟 ≤ 100ms。
  • 三个输入门禁快捷键与普通应用快捷键共享同一录制、冲突和 commands_enabled 门禁;它们只切换模型输入投影,键盘/鼠标/手柄的可靠采集与 pressed-state 恢复不受影响。

Adr 0045 Overlay Placement Screen Bounds (`docs/adr/0045-overlay-placement-screen-bounds.md`)

ADR-0045: Overlay 放置约束改为屏幕范围并延迟收敛

状态:Accepted
日期:2026-09-18
取代:无(取代 P6-KEEP-OVERLAY-IN-WORK-AREA 确立的工作区约束行为,该行为未单独留下 ADR)

后续修订(2026-09-23):ADR-0054 后项目不再维护 AccessKit 标签同步;字段改名的当前收口是中英文案、共享 schema、fixture 与可见控件。

后续修订(2026-10-13):下面「always_on_top 开启时 overlay 可以覆盖 macOS 菜单栏」当时并不成立。
NSMainMenuWindowLevel(24)与菜单栏自身同层,同层级由菜单栏压住应用窗口,因此窗口被拖进菜单栏
区域后那部分不可见(issue #1155 报告的「无法移动到屏幕最顶」)。always_on_top 的层级改为
NSStatusWindowLevel(25)。实测依据(CGWindowListCopyWindowInfo 与直接调用
NSWindow.constrainFrameRect(_:to:)):菜单栏在 24、程序坞在 20、AppKit 的 NSMenu 弹出菜单在 101;
constrainFrameRect 只在层级低于 24 时把窗口顶边压到 visibleFrame 顶部,层级 ≥ 24 时原样返回,
所以当时挡住窗口的是层级本身,而不是拖拽约束。

Context

当前 v1 的 overlay.keep_inside_work_area 约束 overlay 窗口避开工作区之外的一切区域:
Windows 用 MONITORINFO.rcWork、macOS 用 NSScreen.visibleFrame,并在创建、需要资源重建的
设置/模型重建和每一次 frame tick 里收敛窗口原点。由此产生两类不符合预期的行为:

  • 模型窗口无法覆盖任务栏、程序坞和菜单栏所在的那一条区域。用户把猫放在任务栏上方这类明显
    合理的摆放会被立刻推回工作区内。
  • 收敛在每一个 frame tick 上执行,因此拖拽一结束(两个平台的原生拖动循环都会阻塞 frame
    source,所以“结束”就是在下一帧)窗口就被拉回最近的显示器。当窗口在相邻两块显示器接缝处、
    或交叠面积更大的其实是对面的显示器时,这一拉回会把用户刚拖到的位置直接抢走,跨显示器摆放
    因此难以完成。

Decision

  • 约束区域从“某一块显示器的工作区”改为所有已连接显示器矩形的并集:Windows 取
    EnumDisplayMonitors + MONITORINFO.rcMonitor,macOS 取 NSScreen.frame。允许窗口覆盖
    任务栏/Dock/菜单栏,负坐标保持有效。
  • 判定“窗口完整显示”的口径是并集覆盖而不是“落在单块显示器内”:一个横跨两块显示器、
    但每一部分都落在某块显示器上的窗口是合法摆放,不纠正。只有窗口真正越过桌面边界时才纠正。
  • 纠正目标是“与窗口交叠面积最大的显示器,无交叠时取中心距离最近者”,保持窗口尺寸不变;
    窗口大于显示器时把原点贴到显示器原点(与旧行为一致)。
  • 纠正延迟执行:窗口连续静止 1s(PLACEMENT_SETTLE_DELAY)后才移回显示器内,期间
    任何被观测到的位移都重新计时。创建、缩放/资源设置重建和模型重建这些没有拖拽在途的路径仍然
    立即收敛。
  • 放置检查最多缓存 500ms(PLACEMENT_INSPECTION_INTERVAL)后重新评估。这既让静止窗口不必
    每帧枚举显示器(macOS 会分配、Windows 会进窗口管理器),也让显示器拓扑变化——包括拔掉
    外接屏后窗口留在桌面外——在没有平台通知通道的情况下仍能被纠正。
  • 逻辑落在平台无关的 bongocat-overlay/src/placement 模块,平台适配器只负责枚举显示器与
    读写窗口矩形;判定与倒计时都可以在没有 GPU 和原生窗口的情况下测试。
  • 字段随语义改名:overlay.keep_inside_work_area → overlay.keep_inside_screen。当前 v1
    尚未发布,按 §4.1 直接改名,不引入 alias、迁移或旧键兼容。中英文案、共享 schema 与
    fixture、AccessKit 标签同步更新。

Consequences

  • 行为变化:任务栏、Dock 和菜单栏所在区域不再是禁区,窗口可以停留在其上。
  • 行为变化:窗口被拖出桌面后会有一段可见的“停留”,随后才自动回到显示器内;这是延迟的
    有意结果,也是跨显示器拖拽可用的前提。
  • 多显示器选择逻辑从此只有一份(原先 macOS 在 bongocat-overlay 里自算交叠,Windows 交给
    MonitorFromRect(MONITOR_DEFAULTTONEAREST));两者语义一致,行为差异只剩平台坐标原点
    方向(AppKit 底部原点、Win32 顶部原点),分类计算对两者等价。
  • 失去的能力:不再保证窗口避开系统栏;always_on_top 开启时 overlay 可以覆盖 macOS 菜单栏。
    需要“让开任务栏”时改由用户自己摆放。
  • 一个大于所有显示器的窗口永远无法“完整显示”,此时约束只会把它贴到显示器原点且不再产生
    新的移动(纠正结果与当前矩形相同即视为无事可做),不会每帧重复 SetWindowPos。
  • 纠正发生在 frame tick 内:overlay 不可见或 frame source 已停止时不会纠正,隐藏的窗口
    保留原位置。
  • 显示器热插拔靠 500ms 定期重评估自愈,没有监听 WM_DISPLAYCHANGE /
    NSApplicationDidChangeScreenParametersNotification;因此拓扑变化到窗口纠正之间最多有
    “重评估间隔 + 静止延迟”的延迟。若后续需要更快的响应,应加平台通知而不是缩短这两个常量。
  • 未验证项:两平台真实鼠标拖拽观感、多显示器实机摆放与显示器热插拔均未实机核验(本机只有
    macOS 单显示器环境,Windows 交叉工具链在本机无法构建 overlay 的 C 依赖)。

Adr 0046 Overlay Color Encode Contract (`docs/adr/0046-overlay-color-encode-contract.md`)

ADR-0046: Overlay 颜色契约以两端都执行 linear -> sRGB 编码为准

状态:Superseded by ADR-0063
日期:2026-09-18
取代日期:2026-09-24
取代:无(修正 Technical Design §Renderer 颜色段落与两条 Phase 3/5 验收证据的结论)

本文记录的是曾经用于补齐 Windows/macOS 线性编码不对称的修复决策与预期实现路径;
当时没有完成目标硬件的像素验证。它讨论了“两端是否都执行 linear -> sRGB 编码”,
但没有解决“是否要匹配 Bongo-Cat-Mver 的历史 encoded-space 观感”问题;当前产品目标由
ADR-0063 取代。

Context

Windows 与 macOS 上同一个模型出现明显色差:中间调整体不同,纯黑与纯白相同。排查后排除了
模型资源、纹理上传和 blend 模式,差异只落在像素管道的最后一步。

两端在编码之前完全一致:模型、背景和按键 PNG 都以 sRGB texture view 采样,shader 在解码后的
linear RGB 上执行 multiply/screen、mask 与预乘,blend 因子表也相同(normal 为
ONE / ONE_MINUS_SRC_ALPHA,additive 为 ONE / ONE,multiplicative 为
DST_COLOR / ONE_MINUS_SRC_ALPHA)。

分叉点在写入目标:

  • macOS 把 BGRA8Unorm_sRGB 同时设给 CAMetalLayer 和管线颜色附件,硬件在 store 时完成
    linear -> sRGB 编码。
  • Windows 的 flip presentation model 只能创建非 sRGB 的 B8G8R8A8_UNORM back buffer,
    代码据此把 swapchain 固定为 UNORM —— 但 back buffer 的 render target view 也是用
    CreateRenderTargetView(texture, None, …) 创建的,即继承 UNORM。于是 linear 预乘值被
    直接写进一个被 DirectComposition 当作 sRGB 解释的 surface:中间调输出约等于 v^2.2,
    黑白不变,且 alpha 边沿按 linear 权重加权。

Windows 官方文档给出的组合正是本例缺的一半:flip model 不允许 sRGB back buffer,正确做法是
UNORM buffer + _SRGB render target view。仓库原先把“DirectComposition 不接受 sRGB
swapchain format”当作完整结论,导致编码步骤从未执行,而 Technical Design 与两条验收证据都
断言这组格式已经避免了两平台颜色语义漂移。

Decision

  1. 两端都必须执行最终 linear -> sRGB 编码,语义以 macOS 现有的 linear 合成路径为基准。
    Windows 保留 B8G8R8A8_UNORM swapchain,改由 back buffer 的 render target view 使用
    DXGI_FORMAT_B8G8R8A8_UNORM_SRGB(COMPOSITION_RENDER_TARGET_FORMAT)承担编码。
  2. alpha-only clipping mask target 保持 linear UNORM。 mask 只携带 coverage,对它做编码
    等于二次施加 gamma。
  3. 不为对齐旧版观感而退回 gamma 空间。 当时审查的 BongoCatMver/OpenGL 参考路径没有显示
    sRGB texture view 或 framebuffer encode,因此本条选择继续采用 linear 合成(decode + linear
    运算 + encode);该来源链与最终像素当时均未完成验证,本条修复只补齐 Windows 缺失的编码。
  4. 本机不可验证的 Windows 侧改动必须留下可重复的形态证据。 由于 bongocat-overlay 在
    非 Windows 主机上无法为 Windows 目标构建,涉及 D3D11 调用形态的改动应以独立探针 crate 在
    --target x86_64-pc-windows-msvc 下通过 check/clippy,并在合并前删除探针。

Consequences

  • 按当时的设计,同一组 sRGB 贴图应在两平台产出相同的 surface 字节,并预期消除 Windows
    以往“整体偏暗、对比更强”的观感;该结果当时没有目标硬件 readback 证据。
  • 修复是格式层的,不改变 drawable 拓扑、遮罩算法、frame 调度或任何产品配置,因此不需要
    数据迁移,也不影响 schema_version: 1。
  • 未收敛的残差是系统级显示色彩管理:macOS 由 Core Animation 把 sRGB 内容转换到显示器色彩空间,
    Windows DirectComposition 不做这一步。广色域显示器上同一组 sRGB 值仍可能观感不同,这属于
    合成器行为,不属于 renderer 契约。
  • 旧版观感对齐(是否改用 gamma 空间合成)仍是独立问题。若将来决定对齐旧版,需要同时修改两端
    的纹理视图与附件格式,并同步本文第 3 条与 Technical Design。

Verification

以下记录的是 2026-09-18 当时的中间方案;其中测试名称和格式断言已被 ADR-0063 的
encoded-space contract 取代,不能作为当前实现的验证结果。

  • windows.rs 的 color_formats_decode_assets_and_encode_the_composited_frame_as_srgb 增加
    COMPOSITION_RENDER_TARGET_FORMAT 断言;macos.rs 的对应测试继续固定
    BGRA8Unorm_sRGB,两侧注释互相指向,防止再次单边漂移。
  • 本机(macOS 26.5.2 / Apple M1 Pro / Rust 1.97.1)通过:cargo fmt -p bongocat-overlay -- --check、cargo clippy -p bongocat-overlay --all-targets --all-features -- -D warnings、
    cargo test -p bongocat-overlay,以及完整 workspace 门禁的其余各项(三组严格 Clippy、
    cargo test --locked --workspace 738 项、cargo check --locked --workspace --release)。
    仓库级 cargo fmt --all -- --check 未执行:它在 HEAD 上已因 bongocat-platform 的两个
    无关文件失败,属于既有问题。
  • D3D11 调用形态(descriptor 字段、view 维度、CreateRenderTargetView 参数类型、DXGI 常量)
    以临时独立探针在 --target x86_64-pc-windows-msvc 下 check 与 clippy 通过。
  • 尚未完成:Windows 实机首帧、Windows job 的单元测试执行结果、同 snapshot 的两平台 readback
    色值对照。该历史任务已由 P3-MVER-COLOR-COMPATIBILITY 取代;在新的跨平台硬件证据完成前,
    两条任务都不得作为当前观感验收依据。

Adr 0047 Model Metadata Editing Boundary (`docs/adr/0047-model-metadata-editing-boundary.md`)

ADR-0047: 模型库只做选择与元数据编辑

状态:已接受(2026-09-18;2026-09-25 修订)
依赖:ADR-0036(模型导入的压缩包来源与解压边界)、ADR-0037(在应用内导入 BongoCatMver 模型)、ADR-0020(GPUI Kit 统一依赖入口)、ADR-0065(模型模式元数据与卡片 Badge)

修订(2026-09-25,任务导向导航):ADR-0066 将模型对象拆为 Model library 与 Model behavior
两个独立一级页面。因此本 ADR 中“模型管理页只做选择与元数据编辑”的准确范围收窄为
模型库页面:导入卡片和模型卡片仍只负责选择、标题、封面、位置与删除,不恢复行为预览、
动作/表情目录或快捷键编辑。独立的 Model behavior 页面不改变模型卡片的职责边界。

修订(2026-09-22):决策 1 与「不做拖拽导入」中描述的导入入口已变:导入卡片不再走
「选择文件夹 / 选择 .zip」两个按钮加标题输入,而是按下即打开一个文件夹选择器并自动开始导入
(ADR-0036 文首修订)。入口只接受文件夹:压缩包上传本次不做,选择器与文案都不再提及压缩包。
当时“不做拖拽导入”的结论仅适用于本修订之前的实现;窗口级拖放入口由 2026-09-24 修订取代。
本 ADR 关于页面只做选择与元数据编辑的决策未变。

修订(2026-09-24,窗口级文件拖放):设置窗口根视图现在接收 GPUI ExternalPaths,拖入任意页面都
显示覆盖整个窗口的导入蒙层。一次只接受一个文件夹;拖放路径先在 background executor 复验并
canonicalize,再进入既有的来源检查、转换选择、导入和封面截取流程。蒙层自身也注册 drag/drop handler,
避免渲染后遮住根视图导致真实松手失效;离开窗口则由 paint-phase 的 window listener 捕获
FileDropEvent::Exited,不依赖已不再 hovered 的 hitbox。正常、忙碌和多项目选择分别显示可操作、无等待和拒绝提示;
拒绝使用稳定 SettingsErrorCode::ModelImportDropInvalid 通知,不把路径带入 UI 文案或日志。

修订(2026-09-22,卡片编辑改为原地):决策 1 里卡片的「编辑」形态已变。编辑器不再把封面按钮、
标题输入框和保存/取消三行插在封面下面,而是在卡片已有的行里原地替换:

  • 标题行:卡片只建一个标题行(固定为控件高度,Size::Medium);显示态装模型名,编辑态装同一尺寸
    的 GPUI Kit Input(保留组件自身的边框、背景与聚焦环),所以标题行的盒子不变,多出来的只是
    那个输入框,以及名字自身向右让出的 10px 内边距。
  • 封面:更换封面的按钮改为叠加在封面右下角的浮层按钮,不再占一整行。
  • 操作行:「使用 / 打开模型文件夹 / 编辑 / 删除」在编辑态换成「保存 / 取消」,行本身不变。

因此卡片在两个面几何完全一致(同宽同高、标题行与封面位置不变),打开编辑器不会改变卡片高度、
不会拖动整行、也不会移动下面的卡片。卡片里唯一随编辑态变化的行内容是状态行:它两面都保留,
因为它在编辑态消失同样会改变高度。文案随之把 models.edit.cover.choose/.replace 合并为
models.edit.cover.label(「选择封面」/「Choose cover」)——这个按钮永远作用在卡片的封面
区域上(有封面图是它,只有占位符也是它),区分「选」和「换」已无意义。本 ADR 关于页面只做选择与
元数据编辑的决策未变。

背景

  1. 旧版模型页的交互是封面卡片网格:每张卡片显示模型的 cover.png 与标题,卡片底部只有
    「选中」「打开所在文件夹」这类入口。BongoCat 此前实现成「导入面板 + 行列表(选中/删除)+
    活动模型的行为预览列表」,与旧版页面的信息层级完全不同,用户要求在保持旧版交互风格的前提下
    重做,并明确移除表情入口。
  2. 页面当时拿不到封面与目录。 SettingsModelEntry 只有 id/title/origin/availability,
    既没有包目录(无法提供「打开所在文件夹」),也没有封面路径(无法画封面),于是页面既不能显示
    模型自己的图,也不能把用户送到模型文件夹。
  3. resources/cover.png 的布局事实当时只存在于转换代码里。 BongoCatMver 转换用私有的
    OUTPUT_RESOURCES/OUTPUT_COVER 常量把旧版 cat.png 装成 resources/cover.png;如果设置页
    自行拼这条路径,两处一旦漂移就会出现「转换装好了但页面找不到」。
  4. 标题是可编辑元数据,且只有导入模型有记录。 显示名来自
    config.model.imported_models[].title;预设模型没有记录,显示名回落到稳定 id,而预置模型本身
    位于随产品分发的 resources/models/。Application::delete_model 已经拒绝删除预设。
  5. 行为预览是当时模型页唯一的行为 UI,而快捷键页已经列出同一批行为。 快捷键页的模型页签从
    当前激活且 Ready 的模型目录生成 motion/expression 行用于绑定;模型页的预览列表是同一批数据的
    第二个入口,用户认为重复。
  6. 模型页的错误提示混用过四种呈现方式:导入失败是危险色 Tag、目录失败是危险色文本、
    行为预览失败与其余命令失败走通用 Notification、封面选择失败一度内联在卡片里。同一页面上
    同级别的失败有不同的样式与生命周期。

决策

1. 页面 = 网格首位导入卡片 + 封面卡片

模型库页面只承担两件事:选择模型 与 编辑该模型的标题/封面。导入卡片占据网格第一格,点击
后仍走既有的来源选择流程(撰写时为「选择文件夹 / 选择 .zip」按钮加标题输入,2026-09-22 起为
单一文件夹选择器加自动导入,压缩包入口暂缓)。设置窗口根视图同时接收 ExternalPaths 文件拖放,
因此拖入不必先切换到 Model library 页面,也不要求指针落在导入卡片上;拖放成功后仍由同一来源检查、导入、
取消和封面截取流程处理。其余每张卡片是
cover.png + 模式 Badge + 标题 + 可用性 + 操作行(选中 / 打开所在文件夹 / 编辑 / 删除,删除前弹出确认浮层)。

2. 预置模型可以改名和换封面,但与删除无关

预置模型随产品分发,它没有任何导入、删除或裁剪路径;改名与换封面则在两个 origin 上完全一致:
ModelRowActions::can_edit 不再区分 origin(!commands_blocked),can_delete 仍只对 installed
为真,Application::set_model_title/set_model_cover 也按 origin 路由而不是拒绝。

定制内容必须落在用户侧,因为预置包位于 app 包内(macOS Contents/Resources/models、Windows
安装目录 resources/models),签名包与 Program Files 都不可写:

  • 标题写进 config.model.built_in_models,该列表只保存 id 与 title,但列表各自独立
    判重、各自独立生命周期:导入创建 installed 记录、删除移除它,预置记录只由改名创建、谁都不删。
    列表为空表示所有预置都还用构建给的名字。预置模式由稳定 id 派生,不写入用户配置。
  • 模式由 SettingsModelEntry.input_mode 投影到封面上方的 GPUI Kit Badge + Tag 组合;
    页面不扫描资源、不解析标题。installed 的模式在导入 metadata 中持久化,普通包若无法通过
    left-keys/right-keys 资源判定则不会进入 store;详见 ADR-0065。
  • 封面写进 StorageLayout::model_overrides(<data>/model-overrides//resources/cover.png),
    由 bongocat-model-store::PresetCoverStore 提供唯一写入口,布局与包内封面共用
    package_cover_path。快照投影优先取这份替换封面,包内封面只作为回退。
  • 预置包本身永远不被写入:crates/bongocat-app/src/settings.rs 的
    service_renames_and_covers_a_model_of_either_origin 断言替换后包内 cover.png 逐字节不变。

3. 封面位置成为跨 crate 契约

bongocat-model 公开布局事实,bongocat-model-store 提供两个用户侧写入口:

  • PACKAGE_RESOURCES_DIRECTORY、PACKAGE_COVER_FILE、package_cover_path(root) 是唯一来源,
    BongoCatMver 转换改用同一组常量(原先的私有 OUTPUT_* 常量删除)。
  • ModelStore::replace_cover(id, bytes) 负责「同目录临时文件 + rename」的原子替换,并按既有
    Unix 私有模式落盘;失败时不留半写的封面。PresetCoverStore::replace_cover(id, bytes) 是同一
    形状的写入口,只是根目录在用户侧(决策 2),因此两边的封面路径都由同一个
    package_cover_path 推出。
  • 设置页不推导路径:它只消费 SettingsModelEntry.directory/cover,两者都由 app 层读取磁盘
    后填充(cover 为 None 表示这个模型没有任何封面,页面画占位符而不是猜一条不存在的路径)。

4. 封面只接受 PNG,字节原样落盘

封面是显示用美术,不是模型数据,因此校验只到「PNG 签名 + 不超过包的每文件上限
(ModelPackageLimits::maximum_file_bytes)」;通过后按原字节写入,与转换安装旧版 cat.png
的做法一致,不做重新编码。这样也避免为 JPEG 等格式引入解码 feature(workspace 的 image 只开
png)。

5. 「打开所在文件夹」沿用备份目录的能力注入

bongocat-platform::open_directory 已有实现(opener + 绝对路径/存在性校验),复用即可;
settings 服务通过 ModelLocationCapability 注入,与配置备份目录同一 seam。原因是单元测试必须能
断言「打开所在文件夹」的结果,而真实实现会启动系统文件管理器。

6. 模型页的错误只用通用 Notification 组件

页面级错误(源选择器失败、导入失败、封面选择器失败、目录读不出、改名/换封面/打开位置失败)一律
经 Window::push_notification + NotificationType::Error 呈现;内联 Tag/文本只表达进度与选择
状态
(未选择/已选择/正在选择/导入进度/完成/取消),失败状态的标签留空。目录读取失败只在
「从可读转为不可读」时推送一次,页面用 model_catalog_error_reported 记住已经说过,避免每次
快照重复同一条消息。

7. 行为预览退出模型页

模型页不再列出/预览行为,SettingsCommand::PreviewModelBehavior、
SettingsErrorCode::ModelBehaviorPreviewUnavailable/Failed 及其文案随之删除。行为目录仍由快捷键
页消费(未激活或未声明的行为不生成编辑入口)。runtime 侧的预览能力
(Application::preview_motion/set_expression)保留不动,恢复设置页预览是新增一个命令的事。

8. 窗口级拖放使用单文件夹契约

设置窗口的根视图和临时蒙层都注册 GPUI ExternalPaths 的 drag/drop handler;另有一个 paint-phase
的 window listener 接收 FileDropEvent::Exited,不依赖离开窗口后已经失效的 hitbox。蒙层只在文件
拖入期间存在,覆盖设置窗口的完整 viewport,因此当前页面、滚动位置和指针所在的子控件不会改变导入
语义;蒙层出现后仍需保留自己的 drop handler,不能只依赖根视图的命中测试。

  • Ready:恰好一个路径且当前没有其它模型来源操作;松手后先在 background executor 调用
    bongocat_platform::validate_model_folder,只把 canonical directory 交给既有 InspectModelSource
    流程。Ready、Busy 和 InvalidSelection 都只显示各自标题,不显示额外说明。
  • Busy:已有 picker、drop 校验、来源检查、转换对话框、导入或封面截取;松手不启动第二条流程。
  • InvalidSelection:零个或多个路径;蒙层立即说明“一次一个模型文件夹”,松手后通过稳定错误通知
    结束,不把用户路径写入通知或日志。单个路径若是常规文件、已不存在或无法 canonicalize,则在
    background 复验失败后走同一稳定通知。

拖放只改变来源的进入方式,不复制模型解析/复制逻辑,也不绕过 ModelStore 的安全校验、取消和
原子提交。拖放路径的目录/类型/canonicalize 检查属于平台 adapter;UI executor 不遍历、复制或解析
模型文件。

明确不做

  • 不为预置模型提供删除:delete_model 的预设拒绝(PresetModelDeletion)保持不变。改名与
    换封面自 2026-09-22 修订起对预置开放(决策 2),删除仍然只属于用户导入的模型。
  • 不把封面写进配置:封面是图片字节,不是配置字段;用户侧替换封面是布局相同的文件,配置里
    只有标题这一行文本。installed 的模式元数据是例外,它是强类型小字段而不是图片资源。
  • 不在 UI 重新判断普通包模式:ModelStore 在 staging 提交前按 right-keys、East、DPad*
    等资源规则完成一次判定并把结果写入 metadata;页面不重复扫描资源,判定失败直接拒绝导入。
  • 不重编码封面、不新增图片依赖:只校验 PNG 签名字节。
  • 不在模型页恢复任何行为/表情入口:表情列表已在快捷键页。
  • 拖放不支持多项目或常规文件:拖放契约一次只接受一个目录;多选在蒙层阶段拒绝,单路径的
    文件、缺失或无法 canonicalize 的目录在后台复验后拒绝,二者都以稳定通知说明边界。压缩包仍不属于
    当前来源。
  • 不让 renderer 或 runtime 参与封面:封面只被设置页读取。

残余风险与待验证项(不得当作已确认)

  1. 没有「恢复内置」入口:改名后只能自己再把名字打回去;换过的封面覆盖了内置封面在页面上的
    位置,包内那张原图仍在,但没有任何入口指回它。这与已安装模型是对称的(它的原封面被就地覆盖,
    同样拿不回来),但预置的原始封面其实还躺在 app 包里,所以这是有意为之的不对称而不是丢失。
  2. 配置备份不含用户侧封面:model-overrides/ 是新的用户数据根,配置备份只覆盖 config.json;
    一份恢复回来的配置会重新指向用户侧的覆盖文件。文件被删掉时页面回落到包内封面,不算错误。
  3. 预置元数据记录不做裁剪:某个预置从这个构建里消失时,它的记录仍然留着(不可见、无副作用);
    若将来某个构建又重新带上同一个 id,那时的名字会被这条旧记录接管。反向的取舍是:不裁剪就不会
    误删用户改过的名字。
  4. 封面必须是 PNG:选到 JPEG/WebP 会被拒绝(model_cover_invalid)。放宽需要为 image
    打开对应 feature 并决定是否重编码。
  5. 封面替换后必须显式失效图像缓存:替换保持同一路径(用户侧那份也是),GPUI 的资源缓存按路径
    命中,所以保存成功后由页面调用 ImageSource::remove_asset。这条依赖是隐式的,将来若把封面改成
    版本化文件名即可去掉。
  6. 封面按 object_fit(Cover) 裁切:非卡片比例的封面会被裁边而不是拉伸,极端比例会损失内容。
  7. 导入前的标题输入仍在:本项保留了在导入卡片里命名,因此「卡片上的改名」与「导入时的命名」
    都是标题来源;两者都写同一条元数据,不存在冲突,但不是一个入口。
  8. 实机 UI 未验证:未运行 --settings-window-smoke / 模型页 opt-in smoke,未在双平台实机
    点击卡片、选择封面或打开文件夹。
  9. Windows 的 open_directory 未实机验证:本机 macOS 只能证明调用形态与 macOS 行为。
  10. 编辑当前使用的模型时标题不再是强调色:输入框内的文字颜色由输入控件自身的 editor style
    (theme.foreground)决定,行的文字颜色管不到它,所以显示态的强调色在编辑态失效。
  11. 正文卡片高度 232 → 238:标题行从「一行文本」(约 26px)变成「一个控件高度」(32px)后,
    卡片自然高度增加 6px,MODEL_CARD_MIN_HEIGHT 同步改为 238 并由测试钉住。封面、操作行和
    间距都没变。
  12. 编辑态的标题停在原地,但文字会在框内让出内边距:输入框是 GPUI Kit 的 Input 原样
    (边框 + 背景 + 聚焦环),组件自带 px 10 / py 8 的留白,所以打开编辑器时名字会向右移动
    10px(垂直方向因为两侧对称且盒子同高,位置不变)。显示态的标题没有这段缩进——它和封面左边缘
    对齐——这是刻意保留的:给显示态也加内边距会让卡片标题永远比封面缩进 10px。
  13. 编辑态输入框的文字比组件默认大一档:为了让两个面的字一样大,输入框的文本用 text_base()
    (16px)而不是组件为该尺寸准备的 14px;在 32px 的框里垂直留白因此比组件的设计值紧一些。
    想回到组件原样,删掉 .text_base() 即可(代价是编辑时字号从 16px 变 14px)。
  14. 预置卡片的「打开所在文件夹」仍指向 app 包内的模型目录(只读):决策 5 把这个入口称作
    「手工替换美术」的退路,这对预置不再成立——在那里放一张 cover.png 不会生效,封面要走编辑器
    里的「选择封面」。按钮本身没有改(它打开的确实是这个模型所在的目录),因此这是一条已知的
    不对称
    ,而不是被忽略的缺陷;若将来要收口,方向是让预置的这个入口指向
    model-overrides//,代价是它与「模型在哪」不再一致。
  15. 拖放尚未完成双平台实机验收:当前已覆盖 headless GPUI 的完整窗口蒙层、离开清理、单目录
    进入既有检查命令和多项目拒绝;尚未在 Windows Explorer 与 macOS Finder 中完成真实文件拖放,
    也未做 125/150/200% DPI、Retina 和真实主题下的目视检查。实现和自动化 contract 不等于这两个
    平台门禁已经通过。

验证

已完成(2026-09-18,本机 macOS arm64,固定工具链 1.97.1。历史记录:「预置模型只读」相关的
几条已被 2026-09-22 的修订取代,见下文):

  • bongocat-model 95 测试(新增 2):a_cover_replacement_lands_on_the_package_cover_and_leaves_no_staging_file
    断言替换落在 <包根>/resources/cover.png、内容逐字节相等且不留 .cover.png.new;
    a_cover_cannot_be_replaced_on_a_model_that_is_not_installed 断言缺失模型返回 NotFound。
  • bongocat-platform 58 测试(新增 1,改写 1):a_cover_selection_is_a_canonical_regular_file
    断言封面选择器只要求「真实常规文件」并把选择 canonical 化,目录与缺失文件仍报
    SelectionInvalid;后台线程用例扩展到 pick_model_cover。
  • bongocat-app 130 测试(新增 2,改写 2):service_renames_and_recovers_an_installed_models_title_and_cover
    走完整服务链路断言改名持久化到配置、空标题报 ModelTitleInvalid、封面字节落到包内并出现在快照、
    非 PNG 报 ModelCoverInvalid 且原封面不变、预设改名/换封面报 PresetModelMetadataImmutable;
    service_opens_a_models_own_folder_without_advancing_revision 用注入的文件管理器断言打开的正是
    模型目录、配置 revision 不变、后端拒绝与模型目录消失都报 ModelLocationOpenFailed。
  • bongocat-ui 115 测试(改写 6):model_row_actions 断言预置恒不可编辑/删除、目录缺失不呈现
    打开入口;tab 顺序断言覆盖五个控件与确认删除态;导入状态断言改为「失败态内联文本为空、
    失败经通知呈现」;行为标识的作用域改由快捷键行断言(shortcut_behavior_rows 区分同一模型的
    motion/expression,也区分两个模型的同一行为)。
  • 文案:tools/validate-locales.py 通过(386 键 × 2,删除 12 键、新增 15 键);另以脚本核对
    Rust 中引用的 203 个字面量键全部存在于目录,且 models./errors.settings. 命名空间无孤立键
    (该脚本发现并修掉了一处 models.edit.cover.selected → models.edit.cover.replace 的静默失配,
    这正是 validate-locales.py 不覆盖的语义漂移)。
  • just check 的六道门全过:cargo fmt --all -- --check;三组严格 Clippy(workspace 排除 app、
    app storage-test-injection、app production);cargo test --locked --workspace 全绿
    (app 130 + bin 21、ui 115、model 95、platform 58、runtime 73、config 51、live2d 53、overlay 43、
    update 36、packaging 23、render 15、i18n 4 等);cargo check --locked --workspace --release;
    tools/validate-fixtures.py(9 输入 + 8 模型用例)、tools/validate-json-schema.py
    (14 config + 6 state + 9/9)与 tools/tests(63 项)。

未运行:双平台实机设置页点击(选中/编辑/换封面/打开文件夹)、--settings-window-smoke、
模型页 opt-in smoke、Windows 实机 open_directory、真实社区模型回归。

修订(2026-09-22,卡片编辑改为原地)已完成

本机 macOS arm64,固定工具链 1.97.1:

  • bongocat-ui 新增 opening_a_models_editor_does_not_change_the_card:用真实卡片渲染模型页,
    记录卡片与标题行的 Bounds,经 ModelRowAction::Edit 打开编辑器后重新渲染,断言卡片与标题行
    的 bounds 完全相等、输入框自身的盒子(含边框)等于标题行的盒子;另外断言卡片高度等于
    MODEL_CARD_MIN_HEIGHT(否则两面可能只是在被网格拉伸过的单元格里相等),以及封面按钮落在
    标题行上方(即画在封面上,而不是下方新增一行)。
  • 文案:models.edit.cover.choose/.replace 合并为 models.edit.cover.label,两个 locale 同步
    (239 键 × 2),tools/validate-locales.py 通过;bongocat-i18n 的两条目录扫描测试通过
    (新字面量可解析、无孤立键)。
  • just check 六道门全过:cargo fmt --all -- --check、三组严格 Clippy、cargo test --locked --workspace(bongocat-ui 141,全绿)、cargo check --locked --workspace --release。
  • Python:tools/validate-fixtures.py、tools/validate-json-schema.py 与 tools/tests(63 项)通过。

未运行:双平台实机点击(打开编辑器、点「更改封面图」、保存/取消)、深色主题下的实机外观检查、
--settings-window-smoke。

修订(2026-09-22,预置模型同样可编辑)已完成

本机 macOS arm64,固定工具链 1.97.1:

  • bongocat-model 新增 4 个 preset_covers 测试:替换落在
    <override 根>//resources/cover.png(与 package_cover_path 同形)、不留 .new 暂存文件、
    二次替换覆盖前一次、未替换时读路径不创建任何东西、且根目录/``/resources 为 0o700、
    文件为 0o600。
  • bongocat-app 改写 service_renames_and_covers_a_model_of_either_origin(原
    service_renames_and_recovers_an_installed_models_title_and_cover):走完整服务链路断言
    预置改名后快照标题变化、config.model.built_in_models 恰好一条记录(installed 记录不受影响)、
    换封面后快照指向用户侧覆盖文件且字节一致,并断言包内 cover.png 逐字节不变。
  • bongocat-ui:model_row_actions_preserve_origin_availability_and_active_identity 断言预置
    can_edit == true 且 can_delete == false;新增
    a_preset_models_card_opens_the_same_in_place_editor 打开预置卡片的编辑器,断言草稿的
    origin/id/初始标题正确、并在重新投影后存活,且卡片同时画出标题输入框与封面按钮。
    这条用例覆盖了两处编译器抓不到的 origin 守卫(begin_model_edit 直接拒绝预置、
    sync_model_row_focus 在下次投影时丢弃非 installed 的草稿);两处分别用变异验证:
    改回任一个即变红。smoke 的模型页检查由「预置不得暴露编辑」改为「必须至少有一个可编辑的预置」。
  • 配置契约:shared/config/config.schema.json 保留 built_in_models 的 id/title 形状,
    imported_models 使用含必填 input_mode 的专用定义;14 个既有 fixture 补 "built_in_models": [],
    另有 installed mode accept/reject fixtures,tools/validate-json-schema.py 的语义检查按列表
    各自判重并拒绝缺失/未知模式。
  • 错误口径:删除 SettingsErrorCode::PresetModelMetadataImmutable(ALL 36 → 35)与两个 locale
    键;ModelNotInstalled 因同时服务两个 origin 而改名 ModelNotFound(文案改为「找不到该模型」/
    "The model was not found"),ApplicationError 同名改动。
  • 顺带修掉一处既有假断言:selecting_a_model_keeps_the_behavior_bindings_the_user_recorded
    用 config_revision() > revision_before 判断「配置被重写」。配置 revision 是持久化文档的
    内容哈希(revision_for_bytes),大小关系没有语义;新增字段改变了哈希,这条断言随即失败。
    已改为 != 并注明原因。
  • just check 六道门全过(cargo test --locked --workspace:ui 142、app 138 + bin 22、model 88、
    config 57、runtime 75、platform 64、live2d 55、overlay 52、i18n 11 等,34 个测试二进制无失败);
    Python 三个校验器与 tools/tests(63 项)通过。
  • 实机:cargo run --locked -p bongocat-app --release --features storage-test-injection --target-dir target/storage-test-injection -- --models-page-smoke --run-seconds 4 退出 0 且
    stderr 为空。它用真实窗口、真实设置服务与仓库内三个预置跑完整条模型页检查,覆盖了本决策新增的
    「必须至少有一个可编辑的预置」smoke 断言。

未运行:双平台实机点击(改名/换封面预置卡片、确认包内文件未被写入)、深色主题实机外观、
--settings-window-smoke 与 --models-page-smoke 之外的 opt-in smoke。

修订(2026-09-24,窗口级文件拖放)已完成自动化部分

  • bongocat-platform 公开 validate_model_folder,复用原生文件夹选择器的绝对路径、目录类型和
    canonicalize 复验;调用点明确放在 background_executor,UI 不执行文件 I/O。
  • bongocat-ui 新增 ModelImportState::ValidatingDrop、窗口级 ModelDragOverlayState 和主题化蒙层。
    选择器取得路径后的 Inspecting 状态与拖放复验共用
    models.import.step.validating_source 的文件夹读取文案。
    根视图与蒙层都注册 ExternalPaths drag/drop handler,paint-phase window listener 接收离开事件;
    Ready、Busy、InvalidSelection 三种状态各有独立标题,多项目 drop 不启动导入并通过
    SettingsErrorCode::ModelImportDropInvalid 通知说明一次一个文件夹的边界。提示内容区去掉边框和
    背景色,只保留主题化图标与文字层次。
  • 自动化覆盖:完整 viewport 蒙层与离开清理(含指针移出 viewport)、忙碌状态不启动第二条来源流程、
    单目录进入既有 InspectModelSource command、多项目拒绝,以及选择器取得路径后与拖放共用文件夹读取
    文案和状态转换;bongocat-platform、bongocat-ui-protocol、bongocat-ui 定向测试和 locale validator
    已通过。
  • 未运行:Windows Explorer 与 macOS Finder 的真实文件拖放、拖动时不同页面的目视命中、双平台
    DPI/Retina 主题布局和真实键盘/指针交互。因此本 ADR 的实现决策已进入代码,但双平台实机验收仍未完成,
    不应把自动化测试结果写成跨平台行为已确认。

Adr 0048 Theme On Native Surfaces (`docs/adr/0048-theme-on-native-surfaces.md`)

ADR-0048: 主题跟随到原生表面

状态:已接受(2026-09-18)
依赖:ADR-0030(优先复用现有方案)、ADR-0031(托盘图标边界)、ADR-0020(GPUI Kit 统一依赖入口)

背景

  1. 产品要求:用户选择的浅色/深色要作用于产品没有自己绘制的那些表面——窗口标题栏、
    系统弹框、右键菜单、托盘菜单、文件选择框。这些表面的 owner 是操作系统,不是 GPUI。

  2. 哪些表面能跟随应用主题,由平台决定,不由产品决定:macOS 通过进程级
    NSApplication.appearance 可以让整棵继承链(窗口 → 弹框 → 菜单 → 面板)一起变;Windows
    只有窗口框有官方 API,ComCtl32 弹框、Win32 菜单和 shell 文件框没有任何官方接口可以跟随
    应用主题,最多只能跟随系统主题。

  3. 调研结论:不存在可以直接用的现成 crate。 逐个核查过的候选与摘要如下:

    候选 为什么不能直接用
    dark-light 只检测系统外观,不设置任何东西
    tao / winit 完整窗口库;引入等于替换 GPUI 的窗口层
    muda::MenuTheme 上游明确只作用于菜单栏,不含 popup;Windows 上等于没有
    tray-icon / rfd 没有主题 API
    windows / objc2-app-kit 是 binding,不是方案——但它就是我们需要的那一层
    winsafe 为一个窗口属性引入第二个 Win32 抽象层,不成比例
    任何 SetPreferredAppMode 封装 crate crates.io 上不存在

    所需能力全部已在依赖树内(windows 0.62.2、objc2-app-kit 0.3.2),因此按 §3.1 阶梯
    停在"已安装依赖 + OS 原生能力"这一级,不新增第三方依赖。

  4. 根因是结构性的,不是零散 bug。 bongocat-platform 此前没有主题入口
    (crates/bongocat-platform/src/ 下不存在 theme.rs),SystemMenu、picker、prompt 的构造
    签名里没有任何主题参数。于是主题知识只存在于 UI 层,而原生表面的 owner 在平台层——这是
    "主题 bug 很多、代码越写越乱"的结构性原因。

  5. 现状缺陷(带证据,完整清单见调研文档 §4.2):

    • B1(P0 / Windows) cx.set_window_appearance 在 Windows 上是 trait 默认空实现
      (gpui-pre-0.3.5/src/platform.rs:235;macOS 有覆写,Windows 后端 0 处覆写)。用户在
      Windows 上选暗色,任何原生表面都不会变。
    • B2(P0 / macOS) gpui 的 macOS 覆写写的是 NSApplication.setAppearance:(进程级),而
      update_window.rs 硬编码 SettingsTheme::System,其 System 分支会清掉覆盖 →
      更新窗口打开就把设置窗口刚设好的暗色清掉,下一次 render 再设回来(闪烁)。
    • B3(P1) 三套并行同步,幂等缓存各不相同(window/view_state.rs:4 无缓存、
      update_window.rs 用 applied_theme、window.rs 的 apply_optimistic_component_theme
      比对 cx.theme().mode 且 Theme::change(mode, None, cx) 不刷新任何窗口)。
    • B4(P1) 切回 System 时先清覆盖、紧接着读 window.appearance(),而它返回的是只在
      appearance_changed() 里更新的缓存字段,同一帧内读到的仍是覆盖前的值。
    • B5(P1) 原生弹框与 6 处 picker 全部没有 set_parent(全仓库 set_parent 命中 0 处)。
    • B6(P2) bongocat-platform 没有主题入口,原生表面若要跟随主题无处可接。
  6. 一个影响范围的事实:设置窗口在 Windows 上没有原生标题栏——gpui 对
    WindowKind::Normal 只设 WS_SYSMENU | WS_THICKFRAME | WS_MAXIMIZEBOX | WS_MINIMIZEBOX,
    从不设 WS_CAPTION。所以"标题栏跟随主题"在 macOS 是真实需求,在 Windows 只剩 DWM 的
    边框/圆角/阴影这一项。

决策

1. `bongocat-platform` 新增 `theme` 模块,作为原生主题的唯一入口

crates/bongocat-platform/src/theme.rs 公开四个类型和三个函数:

rust
pub enum AppTheme { Light, Dark }          // 已解析的取值,System 不往下传
pub enum SystemAppearance { Light, Dark }  // 系统当前外观
pub enum NativeThemeError { .. }           // 稳定 error code,不进 config
pub fn apply_process_theme(theme: Option<AppTheme>) -> Result<(), _>
pub fn apply_theme(window: &impl HasWindowHandle, theme: Option<AppTheme>) -> Result<(), _>
pub fn init_native_theme() -> Result<(), _>
#[cfg(target_os = "macos")] pub fn system_appearance() -> SystemAppearance

apply_process_theme 是启动入口:bongocat-app 在把 Application 移入 settings service
之前读取持久化的 appearance.theme,并在 gpui_application.run 的主线程回调中、创建 overlay
窗口之前调用它。这样 macOS 的 NSApplication.appearance 先于模型窗口和其右键菜单生效;
主题不依赖设置窗口是否曾经打开。窗口级 apply_theme 仍用于 Windows DWM 窗口框以及设置/更新
窗口创建后的幂等重应用。Windows 的 apply_process_theme 是 no-op,系统菜单/弹框/文件框由
init_native_theme 预置为跟随系统主题。

AppTheme 是已解析的取值:调用方决定 System 是什么意思,平台层永远收不到 System,
所以两处调用不可能各自推导出不同答案。

2. `System` 的解析口径收归平台层,并且全 crate 只有一处解析

SettingsTheme::System 不再读 window.appearance()。macOS 走
bongocat_platform::system_appearance()(NSApplication.effectiveAppearance),其余平台沿用
gpui 的值(它们从不安装应用级覆盖,gpui 的答案就是系统值)。这直接修掉 B4:平台层查的是
当前生效的外观,不是需要等回调刷新的缓存。

bongocat-ui 内只有 resolved_theme_mode(theme, window, cx) 一个入口做这个解析,render 路径与
smoke 断言都调它。smoke 此前用 component_theme_mode(theme, cx.window_appearance()) 独立推导
期望值
,与产品实际走的路径不是同一条;smoke 的职责是证明产品做了什么,用自己的第二套公式去
推导期望值只会让两者悄悄漂移,所以改成调用产品自己的入口。

为什么 macOS 不复用 gpui 已有的查询(§3.1 评估记录):gpui 的 App::window_appearance() 与
Platform::window_appearance()(gpui-pre-macos-0.3.5/src/platform.rs:718)确实已经实时读
NSApplication.effectiveAppearance,形式上够用。但它的名字映射
(gpui-pre-macos-0.3.5/src/window_appearance.rs)只识别 Aqua、DarkAqua、VibrantLight、
VibrantDark,其余一律打印到 stdout 并回退成 Light。macOS 开启「提高对比度」后 AppKit 报的
是 AccessibilityHighContrastDarkAqua,gpui 会把暗色系统判成浅色,产品于是会在暗色系统里画出
浅色 UI。这是 gpui 的既存缺陷,产品不能继承,所以这一处自研(约 15 行)而不是复用。

3. macOS:进程级 `NSApplication.setAppearance:`

这是唯一能覆盖全部四行表面的做法。按窗口设置只会让菜单和面板继续留在系统主题上,因为
NSWindow.appearance 不会被窗口自己弹出的菜单继承。

NSAppearanceName* 常量以字符串字面值写入(appearanceNamed: 与 -isEqualToString: 都按内容
比较),避免为一个常量引入 unsafe。深色判定接受 DarkAqua、VibrantDark 和
AccessibilityHighContrastDarkAqua 三个名字。

4. Windows:窗口框用官方 API,其余表面接受"跟随系统主题"

  • 窗口框:DwmSetWindowAttribute + DWMWA_USE_IMMERSIVE_DARK_MODE(唯一有文档的做法)。
    theme = None(跟随系统)时以 gpui 同源的 UISettings 查询重推导系统外观并显式写入
    该属性——该属性一旦被写过便不再自动跟随系统偏好,且写入必须与 gpui 的写入同源,否则两个
    写入方会互相覆盖。见文末「修正(2026-09-18)」。

  • 弹框 / 菜单 / 文件框:产品不自绘,接受跟随系统主题。为了让它们在系统为暗色时真的变暗,
    进程在创建任何窗口之前调用一次未文档化的 SetPreferredAppMode(uxtheme.dll 序号 135),
    取 PreferredAppMode::AllowDark(= 1)。

    选 AllowDark 而不是 ForceDark 是刻意的:这些表面的契约是"跟随系统主题",不是"跟随
    应用主题"。用 ForceDark 会让系统为浅色的用户在这些表面上看到暗色,与第 4 条的边界矛盾。

    调用点在 main 里、RunOptions::parse 之后、任何窗口之前,一次,且不撤销。

5. 启动时先应用进程级主题

主题的全局事实来源是持久化的 appearance.theme,不是设置窗口的生命周期。bongocat-app
在创建 overlay 之前解析该字段并调用 bongocat_platform::apply_process_theme:macOS 直接
设置 NSApplication.appearance,因此模型窗口及其右键菜单从第一帧起就继承正确外观;Windows
该入口不做应用级强制,继续由 init_native_theme 让系统-owned 表面跟随系统。设置/更新窗口
打开后仍会调用 apply_theme,但那是幂等补偿,不是首次生效的前置条件。

6. 原生主题失败不致命

apply_theme / init_native_theme 的失败一律降级为"该表面保持系统外观",也就是每个平台的
文档化兜底。UI 层因此 let _ = 丢弃结果,不向用户报错、不改配置、不阻止启动。理由:能拒绝的
表面恰好就是本来就无法跟随应用主题的那些,为一个外观开关拒绝启动或弹出错误更糟。

7. 更新窗口改为携带应用外观,而不是硬编码 `System`

UpdateView 增加 appearance_theme 字段,open_update_window 增加同名参数,ProductCoordinator
缓存它并在系统菜单轮询里同步。更新窗口的 observe_window_appearance 回调改为读
appearance_theme:只有当前偏好是 System 时才响应系统变化,固定的偏好已经交给平台,不会因为
系统变了而改变。这修掉 B2。

明确不做

  • 不新增任何第三方依赖:能力已在 windows / objc2-app-kit 内,见背景第 3 条。
  • 不自绘菜单或弹框:那等于重写系统控件,且会失去平台的无障碍与输入法集成。
  • 不使用 muda::MenuTheme:上游明确它不覆盖 popup,用它只会让人误以为已经解决。
  • 不做 Linux:首发范围外(ADR-0006);Linux 分支的 apply / init 是有文档的 no-op,
    不是待办。
  • 不在平台层做颜色:这个模块只回答"哪个外观",不回答"什么颜色"。
  • 不给原生表面加配置字段:schema_version: 1 不变,appearance.theme 仍是唯一来源。
  • 不在本次修 B5(picker/prompt 的 set_parent):它是模态归属与 UX 缺陷,不是主题缺陷。
    macOS 上无 parent 的弹框继承 NSApp 外观,恰好已经能跟随应用主题;Windows 上跟随系统主题。
    修它属于独立改动。

残余风险与待验证项(不得当作已确认)

  1. SetPreferredAppMode 是未文档化的序号导出。 Microsoft 从未发布头文件或函数名;序号在
    未来的 Windows 版本或补丁里可能被移除或改变语义。已做的处置:用
    LOAD_LIBRARY_SEARCH_SYSTEM32 解析(防止被同名 DLL 劫持)、解析结果缓存、缺失时返回
    NativeCallFailed 并静默退化。这不能证明它在所有受支持 Windows 版本上行为一致。
  2. Windows 分支只经过隔离 crate 编译验证,不是真实目标构建。 workspace 无法交叉编译到
    x86_64-pc-windows-msvc(libdeflate-sys,来自 oxipng,需要 Windows C 工具链)。已用
    /tmp/theme-win-check 隔离 crate(raw-window-handle 0.6.2 + windows 0.62.2,同 features)
    抽取 Windows 分支单独编译,cargo check 与 cargo clippy -- -D warnings 均通过。类型正确
    不等于运行时正确。
  3. 双平台实机均未验证。 未在 Windows 上确认 DWM 暗色边框、暗色弹框/菜单/文件框实际生效;
    未在 macOS 上确认标题栏、弹框、托盘菜单、文件面板随应用主题切换。
  4. NSApplication.appearance 的作用面比需求更宽。 它是进程级的,会作用于产品没有列出的
    表面(例如 AppKit 自己画的一切)。当前没有观察到副作用,但这是一个"比要求做得更多"的选择。
  5. 更新窗口/设置页的原生表面有滞后。 设置页下拉切换主题时
    apply_optimistic_component_theme 只改组件主题、不碰原生表面(该函数拿不到 Window),
    原生表面要等配置往返 + 下一次 snapshot 轮询才更新。滞后一个轮询周期,最终一致。(修正后
    System 的组件半边在点击瞬间就解析生效——见文末修正第 2 条;原生窗口框仍滞后一个往返,
    该边界不变。)
  6. AccessibilityHighContrastDarkAqua 只在提高对比度时出现。 把它算作暗色是有意为之
    (见决策 2:gpui 会把它判成浅色),但它没有在提高对比度 + 暗色的实机上验证过。
  7. apply_theme 要求主线程(macOS),但 UI 层丢掉了结果。 非主线程调用返回 WrongThread
    而不是 panic,system_appearance() 在非主线程返回 Light(中性兜底,不是真实答案)。
    两者都是刻意的降级,但 apply_native_theme 用 let _ = 丢弃返回值,所以一次线程模型的
    回归会表现为"主题悄悄不生效"而不是任何信号
    。当前 apply_component_theme 只从 render
    调用,不存在这个路径;若要让它可诊断,需要按 ADR-0017 的有界日志契约新增一个固定 code,
    本次未做。
  8. 本 ADR 只覆盖"哪个外观",不覆盖颜色。 主题色不拆独立 crate,在 bongocat-ui 内收口。
  9. apply_optimistic_component_theme 与 resolved_theme_mode 仍是两个函数。 前者只服务
    "用户刚点了下拉、配置还没往返" 这一个场景,且拿不到 Window,因此没有并入统一入口。B3 的
    三套并行同步已收敛为「一处解析 + 一处乐观」两条职责清晰的路径,但没有收敛成一条。
  10. macOS 的 AppKit 退出路径不会回到 main,所以失败必须在可达边界处理。 App::quit()
    走 msg_send![NSApplication, terminate:](gpui-pre-macos-0.3.5/src/platform.rs:557-575),
    NSApplication::run() 不返回;末尾的 Arc::try_unwrap(failures) 汇总对 macOS 永远不可达。
    原先 smoke 记录失败却 exit 0,已由 TODO 第 87 项修复:应用自有的 macOS 退出路径在
    on_app_quit 中先 begin_product_shutdown,await 完整的 ProductShutdown::finish(),再按
    最终 accumulator std::process::exit(product_failures_exit_code(&failures));finish() 内
    新产生的失败因此也会传播。exit_after_automated_smoke 仅保留给 AppKit 自行终止、异步
    shutdown 来不及完成的兜底路径。干净 --settings-window-smoke 得到 exit 0;在
    ProductShutdown::finish() 内临时注入 forced finish failure 得到 exit 1 与
    product run failed: forced finish failure,探针已还原。
    剩余边界:AppKit 自行终止且 shutdown future 未完成时,兜底 helper 只能看到调用时已记录的
    失败;该路径仍受 OS 终止时序限制。
  11. 收敛成单一解析入口后,smoke 的断言只能验证"一致",不能验证"正确"。 产品设的值与 smoke
    期望的值来自同一个 resolved_theme_mode,所以把一个错误的解析同时喂给两边时断言仍然通过
    (已用变异确认)。解析本身的正确性依赖 NSApplication.effectiveAppearance 与
    appearanceNamed: 这两个文档化 API,以及两个不变量单测;没有端到端的正确性测试。

后记(2026-09-18):启动权限弹框的例外已修复

本文档的「alerts | application theme」一行只覆盖经 NSApplication.appearance 继承链的 AppKit
弹框。落地当天的实机截图显示启动权限提示在应用深色下仍是浅色:该提示当时走 rfd 的无父窗口
消息框 = CFUserNotification,完全不经过 AppKit,不在继承链上——ADR-0048 的实现没有错,是
这个提示本身不在被修复的路径上。已按 ADR-0032「主题外观修正(2026-09-18)」把它改为主线程
NSAlert,本表格自此对产品全部系统弹框成立。

修正(2026-09-18):Windows 上 `System` 回退不再是 no-op,乐观路径覆盖 `System`

实机(Windows,浅色固定 → 切「跟随系统」且系统为暗色)发现两个回归,均由本文档错误的前提
引起,已修复;修复本身又暴露出第二个写入源问题,同日二次修正:

  1. 标题栏在手动切换主题后不再跟随系统。 根因:DWMWA_USE_IMMERSIVE_DARK_MODE 一旦被
    显式写入(例如选过浅色),Windows 就按写入值绘制窗口框,不再自动跟随系统偏好;初次
    启动能跟随只是因为 gpui 后端在窗口创建时以 WinRT UISettings 查询系统外观并显式写入了
    正确值。决策 4 原文「theme = None 时什么都不做」的前提因此不成立。修复:Windows
    apply 在 None 时以 gpui 同源的 UISettings.GetColorValue(Foreground) 查询(同一
    API、同一亮度公式)重推导系统外观并显式写入该属性,WinRT 不可用时回退
    AppsUseLightTheme 注册表值、再失败回退浅色(文档化兜底)。偏好为 System 期间系统偏好
    翻转时,设置窗口的 observe_window_appearance 回调会重新调用该入口重推导。写入后以
    SetWindowPos(SWP_FRAMECHANGED) 触发非客户区重绘,保证可见窗口上的实时切换立即生效。
  2. 切换瞬间出现浅色留白/闪烁。 根因:apply_optimistic_component_theme 此前只对固定的
    Light/Dark 生效,System 分支完全不动组件主题,UI 要等配置往返 + snapshot 才变暗(即
    残余风险 5 的滞后,在最常见路径上被放大成可见闪烁)。修复:乐观路径对 System 也立即
    解析——macOS 先清应用级覆盖再解析(与 apply_component_theme「先原生后解析」同序,否则
    读到的还是刚被清除的固定值);Windows 无进程级覆盖可清,apply_process_theme(None) 为
    no-op,窗口框由往返后的 apply_component_theme 修正。请求失败时原有
    applied_theme = None 的重同步路径保证回滚。
  3. 二次修正:跟随系统的查询必须与 gpui 同源。 第一版修复用 AppsUseLightTheme
    注册表值推导,实机发现标题栏仍为浅色:gpui 创建窗口时已按 UISettings 写入暗色,而
    注册表推导给出了浅色,后写的注册表结果覆盖了 gpui 的正确值。两个写入源不一致时,
    时间在后的一方总是获胜,所以"跟随系统"的推导不允许存在第二个事实来源——现统一为
    gpui 所用的 UISettings,注册表值仅作 WinRT 不可用时的兜底。

验证:cargo check / cargo clippy -D warnings(platform、ui、app)与
cargo test -p bongocat-platform -p bongocat-ui(52 + 117 通过)全绿;Windows 实机切换
浅色 → 跟随系统(暗)的标题栏与无闪烁表现待复测。

验证

已完成(2026-09-18,本机 macOS arm64,固定工具链 1.97.1):

  • just check 六道门全绿:cargo fmt --all -- --check;三组严格 Clippy(workspace 排除 app、
    app storage-test-injection、app production);cargo test --locked --workspace
    749 passed / 0 failed;cargo check --locked --workspace --release。
  • cargo check --locked --workspace --all-targets 通过,bongocat-platform / bongocat-ui /
    bongocat-app 无告警。
  • Windows 分支交叉编译:/tmp/theme-win-check 的 cargo check --target x86_64-pc-windows-msvc
    与 cargo clippy --target x86_64-pc-windows-msvc -- -D warnings 均通过(以临时注入
    compile_error! 的方式确认该文件确实被编译,随后还原)。
  • crates/bongocat-platform/src/theme.rs 单测:error_codes_are_stable_and_unique(4 个 error
    code 唯一且带 native_theme_ 前缀)、only_dark_reports_itself_as_dark。
  • crates/bongocat-ui 新增 2 个不变量测试(共 117):
    only_the_system_choice_lets_the_system_decide 断言固定偏好对
    Light/Dark/VibrantLight/VibrantDark 四种系统外观都解析到同一个模式、且只有 System
    允许跟随系统;the_native_and_component_halves_pin_together 断言 pinned_native_theme 与
    pinned_theme_mode 同时固定或同时不固定。两者都用变异测试验证过有牙齿:把
    pinned_theme_mode(SettingsTheme::Light) 改成返回 Dark 后两个测试都失败,随后还原。
  • 依赖 feature 变更已记录用途:objc2-app-kit 增加 NSAppearance,windows 增加
    Win32_Graphics_Dwm。
  • 主题全局启动路径实测:--run-seconds 4 --hidden-model-switch-smoke(不打开设置窗口)
    exit 0,模型 overlay 正常启动并完成 hidden model switch;启动时已在 overlay 创建前调用
    apply_process_theme,因此模型窗口及其右键菜单不再依赖设置窗口。
  • macOS 运行时冒烟(just dev-smoke = --run-seconds 4 --settings-window-smoke)跑通,
    exit 0
    :设置窗口打开、辅助功能桥挂上、show_general_page_for_smoke 在主线程上执行完
    apply_component_theme → apply_native_theme → NSApplication.setAppearance /
    effectiveAppearance,无 panic、无 WrongThread、正常 shutdown。现在 exit 0 有基本的
    失败传播保证:automated smoke 若在 quit 前记录失败会 exit 1。
  • 退出码变异验证:2026-09-18 在 smoke 分支注入 forced smoke failure 得到 exit 1;
    2026-09-23 在 ProductShutdown::finish() 内注入 forced finish failure,同一 --settings-window-smoke
    命令得到 exit 1 且 stderr 输出 product run failed: forced finish failure;两次还原后均得到
    干净 smoke exit 0。这证明应用自有退出路径在最终 shutdown 阶段也有牙齿。
  • 主题断言的强度仍有限:resolved_theme_mode 同时被产品路径和 smoke 期望值调用,因此
    smoke 验证的是一致性;解析本身由 macOS effectiveAppearance/appearanceNamed: 文档化 API
    与不变量单测约束,不是端到端的肉眼主题验证。

未运行:双平台实机主题切换(肉眼确认标题栏/弹框/托盘菜单/文件面板的深浅色);Windows DWM
暗色边框与暗色弹框/菜单/文件框实测;SetPreferredAppMode 在 Windows 10 1903 / 11 各版本上的
行为;运行中切换系统主题后的跟随行为;提高对比度 + 暗色组合;AppKit 自行终止且 shutdown
future 未完成时的 macOS 退出码时序。


Adr 0049 Globe Key And Function Row Separation (`docs/adr/0049-globe-key-and-function-row-separation.md`)

ADR-0049: 地球键与 `Fn` 的语义分离

状态:已接受(2026-09-19)
依赖:ADR-0041(完整键位词表与绑定覆盖)、ADR-0042(键位图存在才触发按键动作)、ADR-0038(左右 Alt 的键位图命名与旧名兼容)、ADR-0037(应用内导入 BongoCatMver 模型)

背景

需求是"F1–F24 与 macOS 左下角地球键都要完整支持,且不能共用一张图"。考古后有一处前提需要修正:
今天并不存在共用,Fn 也从来不是地球键的名字。

决定本 ADR 形态的事实:

  1. Fn 是"F 编号通配符",不是 Fn 键。 唯一产生点是旧版
    pre-refactor:src/composables/useDevice.ts:105-110:

    js
    if (key.startsWith('F') && unsupportedKey) {
      nextKey = key.replace(/F(\d+)/, 'Fn')
    }

    只对"模型没逐键画图"的 F<数字> 生效。两个预置键盘模型出厂的
    resources/models/{standard,keyboard}/resources/left-keys/Fn.png(各自 55 张键位图之一)
    就是这张共享图。物理 Fn 键在 rdev 里叫 Function,F 后没有数字,命不中该正则。

  2. 旧版地球键的名字是 Function,而且它是可达的。 rdev 的 macOS 表
    const FUNCTION: CGKeyCode = 63; → Key::Function(上游 rdev/src/macos/keycodes.rs:36,274),
    pre-refactor:src-tauri/src/core/device.rs 用 format!("{:?}", key) 取名;旧版 model store 的键集
    就是模型包自己的文件名主干(pre-refactor:src/pages/main/index.vue:82),所以一个提供
    Function.png 的模型在旧版里确实能画出地球键——只是已知的预置与社区模型都没有这张图。

  3. 地球键在 BongoCat 里完全不可达。 bongocat-platform/src/macos.rs 的 map_key_code
    没有 keycode 63 的 arm,而 FlagsChanged 分支先查表再解码(同文件 1636-1641),查不到就计入
    unmapped_keys 丢弃。缺的只是这一个 arm:ModifierDecoder 早就认识 63(modifier_family_bit
    里有 63 => MaskSecondaryFn)。

  4. Fn 不能同时是两个键的名字。 若地球键取名 Fn,旧包里的 Fn.png(功能键共享图)与新地球键
    美术就同名,导入归一化无法区分二者;而本产品不做迁移(AGENTS.md §4.1),用户数据根里已安装的
    模型不会被重新归一化。结果是按 F3 画出地球图、按地球键画出功能键图——双向回归,且没有能同时满足
    两边的归一化规则。这不是取舍,是方案本身不成立。

  5. 地球键不在 HID Keyboard/Keypad 页。 它是 Apple 厂商页 0xFF 的 usage 0x03(KeyboardFn)。
    任何 0x04..=0xe7 式区间都覆盖不到它,所以命名、绑定、两处契约并集都必须显式写出。

决策

1. 地球键 = `Globe`

bongocat-render 新增 GLOBE_KEY_USAGE: u16 = 0xff03(0xff00 | 0x03 的折叠值)作为六处共用的
唯一来源;bongocat-runtime::PhysicalKey::GLOBE 就是它,bongocat-platform 的 keycode 表也引用它
而不是重写数值。

不叫 Fn:见事实 4。不叫 GlobeKey:词表里没有 *Key 后缀,而 KeyA…KeyZ 是 W3C
KeyboardEvent.code 的既有拼写,加后缀会被读成同一族。Function 是旧名,但它与功能键回退名 Fn
只差两个字符而意思不同——这个仓库已经被 Backslash/BackSlash 这类近似名坑过一次(ADR-0050),
词表里不该再放一对。

2. `Fn` 保持原义,`Fn.png` 不改名、不迁移

Fn 是 F1–F24 的共享回退图名,语义本来就正确。改名会把每个现存模型的功能键美术挪到地球键上,
是对全部现存模型的行为回归,也与"导入要兼容旧图片资源"直接冲突。

地球键与功能键的候选列表完全不相交:

text
F1 … F24 (0x3a..=0x45, 0x68..=0x73) → F1 … F24, Fn
0xff03   (Globe)                    → Globe, Function

模型只提供 Fn.png 时地球键画不出来;只提供 Globe.png 时功能键画不出来。

3. 地球键的候选与旧名兼容

Function 是旧版对同一物理键的可达名,排在最具体名之后。导入归一化把
resources/{left-,right-}keys/Function.png 改名为 Globe.png;运行时的 Function 候选保证
已经安装过的包(不会被重新归一化)仍然能画。这与 ADR-0038 对 AltGr 的处理同构。

4. macOS 映射 keycode 63

map_key_code 加 63 => PhysicalKey::GLOBE.hid_usage()。Windows 不加:Fn 键由键盘固件处理,
Raw Input 从不报告它,没有可映射的码。

5. Mver 转换补齐 F13–F24

legacy_virtual_key_name 加 0x7C..=0x87 → F13…F24。VK_F13…VK_F24 = 124…135 是
windows crate Win32/UI/Input/KeyboardAndMouse 里的连续常量(可离线交叉验证),不是猜的硬件表。
地球键在旧版码空间里不存在,转换永不产出 Globe.png。

明确不做

  • 不重命名 Fn.png,理由见决策 2。
  • 不为地球键新增任何预置美术:两个预置键盘模型与 gamepad 都保持现状,按下地球键不产生任何动作
    (ADR-0042 的缺图规则)。
  • 不加 Windows 映射:Fn 由固件处理,Raw Input 不报告。
  • 不猜 Windows 的 F13–F24 扫描码:仓库此前已在 TODO P4-MODEL-LEGACY-SOURCE 判定"缺可依据的
    扫描码,不猜值",本次沿用;macOS 侧 Carbon 没有 kVK_F21…kVK_F24。
  • 不改 FUNCTION_KEY_USAGES 的边界:Fn 回退仍只覆盖 0x3a..=0x45 与 0x68..=0x73。

残余风险与待验证项(不得当作已确认)

  1. F21–F24 是"命名到位但两个平台都不可达":Windows 扫描码不补(见上),macOS 没有对应 keycode。
    词表保留它们是为了让模型可以先画图,不是声称平台能报。
  2. macOS 实机地球键未验证:CGEventSourceKeyState(63) 的周期校正是否会误释放地球键,只有合成事件
    证据(见验证),真实 HID 状态没测过。
  3. Function.png 兼容路径只有单元测试覆盖:没有真实用户数据根里的旧模型样本,Function.png 在
    野外是否存在也不确定(它从未随任何已知模型出厂)。
  4. Fn 这个名字仍然容易误读:它读起来像 Fn 键。本 ADR 选择不动它(改名会回归现存模型),代价是
    文档必须反复强调;bongocat-live2d::key_name_candidates 的文档注释已写明这一点。
  5. 地球键归左手:与其余键盘区一致,但爪部落点没有实机观感确认。
  6. bongocat-overlay 的预览工具不认地球键(preview_input_bindings,
    macos/switch_preview.rs / windows/switch_preview.rs)。该表停留在 ADR-0041 之前的形态,
    连 F1–F12 都没有,也不感知键位图,
    ADR-0042 残余风险 4 已记录它是人工诊断工具、本次未收敛。just preview 因此不覆盖地球键。

验证

已完成(2026-09-19,本机 macOS / aarch64):

  • bongocat-live2d 55 测试(新增 2):
    the_globe_key_never_shares_an_image_with_the_function_row 断言 0xff03 的候选恰为
    ["Globe", "Function"]、F1–F24 的候选不含 Globe/Function,并用合成资源断言 Fn.png 画不出
    地球键、Globe.png 与 Function.png 都画不出功能键;
    every_key_image_name_the_conversion_can_install_resolves_to_a_key(见 ADR-0050)。
  • bongocat-platform 62 测试(新增 1):
    this_adapter_reports_f1_through_f20_and_the_globe_key_only 遍历全部 u16 keycode,断言可达功能键
    恰为 0x3a..=0x45 ∪ 0x68..=0x6f(F1–F20),且 0xff03 是唯一不在 0x07 页的产出;
    mac_key_codes_map_to_usb_hid_usages 补 map_key_code(63) == Some(PhysicalKey::GLOBE)。
  • bongocat-runtime 74 测试(新增 1):
    the_globe_key_projects_through_the_model_snapshot_like_any_other_key 走完
    input → binding → model_snapshot,证明这条路径不按下标索引 usage、不窄化成 u8,且未绑定时完全惰性。
  • bongocat-app --lib(新增 1):the_globe_key_binds_only_for_a_model_that_ships_its_image 断言
    四个预置/已安装模型都不绑地球键,而提供 Globe.png 或 Function.png 的模型绑到左手。
  • bongocat-model(新增 2):the_globe_key_image_is_renamed_from_its_pre_rename_stem 断言
    Function.png → Globe.png 且 Fn.png 原样保留。
  • 变异验证:6 处改动逐个还原(删命名 arm、删 keycode arm、删归一化项、把 BackSlash 改回旧拼写、
    删 F13 arm、删绑定语句),对应测试全部变红(exit 101),随后逐个还原并复核文件内容一致。

未运行:macOS 实机地球键与爪部观感、Windows 实机、真实社区模型回归、UI 实机点击。


Adr 0050 Converted Key Image Name Spelling (`docs/adr/0050-converted-key-image-name-spelling.md`)

ADR-0050: 转换输出的键位图名必须用产品拼写

状态:已接受(2026-09-19)
依赖:ADR-0037(应用内导入 BongoCatMver 模型)、ADR-0038(左右 Alt 的键位图命名与旧名兼容)、ADR-0039(主 Enter 与小键盘 Enter 的键位图命名与旧名兼容)、ADR-0049(地球键与 Fn 的语义分离)

背景

ADR-0049 要求"键位事件、键名、资源名称、图片回退、BongoCat 模型导入与 Mver 转换之间的映射完全一致"。
把这句话做成可检验的检查时,发现两张表已经各自演化出了一处拼写漂移。

决定本 ADR 形态的事实:

  1. 漂移是程序化 diff 找出来的,不是肉眼看到的。 正则抽出 mver::legacy_virtual_key_name 与
    live2d::key_name_candidates(含 KEY_LETTERS、KEY_NUMBERS、KEYPAD_DIGIT_NAMES、
    bongocat-render::FUNCTION_KEY_NAMES)里所有 "名字" 字面量求差集:114 个转换输出名里恰好一个
    漏网——Backslash。
  2. 漏网的那个是死键。 mver.rs 的 0xDC => Some("Backslash") 照抄参考工具
    BongoCat-Converter/src/utils/keyMap.ts 的 220: "Backslash",而产品词表是
    0x31 => Some("BackSlash")。转换安装的 Backslash.png 从功能上线起就永远选不中;按 ADR-0042,
    该键完全不产生动作——不画图,爪子也不下压。这与 ADR-0038 记录的 AltGr 是同一类错误。
  3. 只差大小写的改名在大小写不敏感的文件系统上是空操作。 macOS 与 Windows 都不区分路径大小写,
    所以 Backslash.png → BackSlash.png 的"改名"里 destination.exists() 恒为真(目标其实就是同一个
    文件),归一化直接跳过,包保留旧拼写。真正让这类包能画的是运行时候选别名,归一化只在大小写
    敏感的文件系统(非首发平台)上才真正改名。
  4. 只断言"能解析"的契约测试挡不住这类漂移。 别名一旦存在,旧拼写也能解析,于是"转换输出能解析"
    这条断言在漂移回退时仍然通过。变异验证证实了这一点:先把 BackSlash 改回 Backslash,只查
    "能解析"的版本没有变红。

决策

1. 转换输出改产品拼写

legacy_virtual_key_name 的 0xDC 输出 BackSlash。这与 ADR-0038 的 Alt/AltGr → 产品名、
ADR-0039 的 Return → Enter 是同一套做法:转换写产品词汇表,旧拼写由运行时候选兜底。

2. 运行时候选把旧拼写加成末位别名

text
0x31 (BackSlash) → BackSlash, Backslash

canonical 在前。已经安装过、不会被重新归一化的包(无迁移路径)因此仍然能画。

3. 导入归一化登记该拼写

LEGACY_KEY_IMAGE_NAMES 加 ("Backslash", "BackSlash")。按事实 3,它在两个首发平台上通常是空操作;
登记它的价值是让"旧拼写 → canonical"这件事只有一个来源,而不是散落在运行时别名里。

4. 把"转换能安装的名字"做成可遍历契约,并同时断言两件事

  • bongocat_model::legacy_keyboard_key_image_names():遍历两种键盘模式的全部虚拟键码,返回去重排序后的
    输出名集合。手柄按钮名不在其中(它们是 gamepad 模型的美术名,不进入键位图,ADR-0037)。
  • bongocat-live2d::every_key_image_name_the_conversion_can_install_resolves_to_a_key:对每个输出名断言
    既能解析、又是 canonical 名(即某个可达 usage 的候选列表第一项)。只查前者不够(事实 4)。
    Shift 与 Control 是有意的非 canonical 名(旧表给两侧同一个码,ADR-0038 决策 4),用显式例外
    列表记录,并断言这两个例外仍然必要,避免列表变成死代码。

这样下次漂移会被测试发现,而不是被用户发现。

明确不做

  • 不把游戏按钮名纳入该契约:它们不进入键位图解析器(ADR-0037)。
  • 不新增第三套拼写(如 BackSlashKey):ADR-0038 已经明确不做同类事。
  • 不把 Shift/Control 展开成两侧:那会同时作废 ADR-0037 记录的真实样本转换证据。

残余风险与待验证项(不得当作已确认)

  1. 已安装的旧包靠别名工作,磁盘上没有被重写:Backslash 是运行时候选表的一部分,属于永久词汇。
  2. 例外列表是硬编码的两个名字:如果将来 Shift/Control 有了 canonical 拼写,例外断言会先变红
    提醒删除,但删除本身是人工动作。
  3. 该漂移没有真实样本覆盖:仓库里唯一真实 Mver 样本(bongo_cat_mver_0.1.6_64)的键位表不含
    0xDC,所以这条路径只有合成测试。
  4. 大小写不敏感文件系统上的行为只在 macOS 上验证过:Windows 侧同样是大小写不敏感,但没有实机跑过
    这条归一化。

验证

已完成(2026-09-19,本机 macOS / aarch64):

  • 先红后绿:修复前 every_key_image_name_the_conversion_can_install_resolves_to_a_key 变红并精确
    报出 no key resolves these conversion outputs: ["Backslash"];修复后绿。
  • 变异验证(关键):把 0xDC 改回 Backslash 后该测试变红(exit 101),证明断言有牙齿;同时确认
    了"只断言能解析"的初版不会变红,因此把断言加强为"同时是 canonical 名"。
  • bongocat-model-store 新增 the_conversion_emits_the_product_spelling_for_every_key_image:对
    BackSlash/Backspace/Enter/AltLeft/AltRight 成对断言"新拼写出现、旧拼写不出现",并断言输出名
    都是合法资源主干。
  • bongocat-model-store 新增 a_case_only_legacy_stem_keeps_its_artwork_under_the_canonical_name:断言
    canonical 名可读到该美术(不断言旧名消失——在大小写不敏感的文件系统上它本来就不会消失)。
  • cargo test --locked -p bongocat-model-store -p bongocat-live2d 全绿。

未运行:真实 Mver 样本回归(样本键位表不含 0xDC)、Windows 实机归一化路径。


Adr 0051 Login Startup Unavailable In Development Builds (`docs/adr/0051-login-startup-unavailable-in-development-builds.md`)

ADR-0051: 开发构建不提供登录时启动

状态:已接受(2026-09-20)
修订:ADR-0043(仅取代其"Development 构建从此支持启动项"这一条决策);2026-09-23(设置项改为整行禁用呈现)
依赖:ADR-0008(应用身份与存储环境)、ADR-0043(启动项后端统一 auto-launch)

背景

ADR-0043 把双平台启动项后端统一到 auto-launch 时,把 Development 构建一并纳入支持范围:用不同的
app_name(macOS plist Label、Windows HKCU value name)隔离开发与生产的注册项,开发构建因此可以
在本机演练真实的注册生命周期。契约里随之保留了 StartupItemUnsupportedReason::BuildEnvironment,
但后端从不产生它(ADR-0043 原文:「保留为契约变体但当前无生产者,UI 对其的处理分支继续作为防御性
路径存在」)。

决定本 ADR 形态的事实:

  1. 注册项指向的是"当前可执行文件",而它的生命周期长于进程。 登录时启动把
    current_exe() 写进操作系统的启动配置,用户下次登录时由系统拉起该路径。
  2. 开发构建的可执行文件是构建产物,不是安装好的应用。 它位于 Cargo 的 target/ 下,
    cargo build、cargo clean、切换 profile 或直接删除目录都会替换或移除它。用户在开发构建里
    打开的开关会留下一个指向已过期或不存在的二进制的登录项,而产品里没有任何界面能解释这件事。
  3. 契约里已经有一条为这件事准备好的状态。 Unsupported(BuildEnvironment) 与
    SettingsStartupItemState::can_set_enabled() 早已把"这个构建不支持该能力"表达成不可变更的状态,
    UI 的处理分支也一直存在,只是没有生产者。
  4. 可见开关此前与它对外声明的语义不一致。 没有动作可做时(快照未就绪、有待处理操作、
    平台不支持),辅助功能节点报 disabled,而可见开关仍然可以点击并发出命令(用户报告的正是这类
    "能点但没意义"的控件)。

决策

1. 门禁放在应用层,按构建环境判定

bongocat-app 的 system_startup_item_state() 与 system_set_startup_item_enabled() 在
BUILD_ENVIRONMENT != Production 时直接汇报
SettingsStartupItemState::Unsupported(BuildEnvironment):

  • 读取方向返回 SettingsStartupItemStatus::State(Unsupported(BuildEnvironment)),不调用
    bongocat-platform,因此开发构建连"查询系统启动项"都不会发生。
  • 写入方向返回 Ok(Unsupported(BuildEnvironment))。这是一个 no-op 而不是错误:会发送该命令的开关
    已经被禁用,而残留窗口或脚本化客户端不应收到一个用户无法处理的失败。两个方向返回同一个值,
    客户端不会观察到能力在读取与写入之间变化。

判定写成"是否为 Production"而不是"是否为 Development",使将来新增的构建环境默认不可用,
而不是默认获得注册能力。

选择应用层而不是平台层的理由与既有先例一致:update_check_available()(开发构建不安装发布产物)
同样把"这个构建产物是否具备该能力"判定在 bongocat-app,bongocat-update 只被当作能力实现方使用。
bongocat-platform 及其 auto-launch 后端、StartupItemEnvironment、两套 app_name 与 opt-in
lifecycle smoke 全部不变,Production 路径继续使用它们。

2. UI:整行按统一禁用样式置灰,并保留可见原因

  • StartupItemPresentation 增加 disabled: bool,只有
    Unsupported(BuildEnvironment) 为 true;其余状态(初始化中、可重试、已启用、已禁用、需批准、
    登录项缺失、平台/系统不支持)都是 false。
  • 可见设置项只在"这个构建根本不提供该能力"时以
    SettingItem::disabled(startup_item.disabled) 整行禁用,与"鼠标悬停延迟"等项目的禁用呈现一致。
    这与"此刻能否操作"是两个问题:后者由 action 回答,仍然只由辅助功能节点声明为 disabled /
    不可点击。保持这两者分离,是为了让已发布构建在快照尚未就绪时仍把该行呈现为正常可用——
    这正是本次要求的边界。
  • 行文案继续复用目录里已有的 settings.application.startup.unsupported_build;开发构建的
    description 保留为可见说明,不再挂 Tooltip,也不再手写 Switch。

明确不做

  • 不删除平台契约里的 StartupItemEnvironment、Development app_name 或 opt-in smoke:
    Production 路径仍需它们,删掉会让"环境隔离"这件事失去唯一载体。
  • 不在平台层再拦一次:应用层是唯一生产者,重复判定只会产生第二个事实来源。
  • 不为该状态新增文案键:目录里已有同一句,第二条措辞只会带来漂移风险。
  • 不做自动清理:不在启动时扫描并删除历史上的 BongoCat Development 登录项。产品不删除它没有
    写入过的系统配置项(ADR-0008 的环境隔离原则),该项由用户手动移除。

后果

  • 开发构建不再写 BongoCat Development 登录项。已经存在的该登录项不会被自动清理:
    macOS 是 ~/Library/LaunchAgents/BongoCat Development.plist,Windows 是 HKCU Run 下的同名 value。
    本机(macOS)检查过该文件不存在,因此本次没有受影响的实际注册项。
  • 开发构建不再能演练真实注册生命周期。该覆盖由 Production 构建的 --startup-item-smoke
    (main.rs,本就要求 BUILD_ENVIRONMENT == Production)与 CI 承担。
  • StartupItemEnvironment::Development 在平台契约中仍然存在,但生产路径不会再构造它——应用层只会把
    Production 传下去。这条差异由 bongocat-app 的测试固定。
  • macOS 12 用户与 Windows 用户不受影响:门禁只看构建环境,与平台和 OS 版本无关。
  • 已发布构建里该行的行为不变:它只在"构建不提供该能力"时禁用,开发构建之外没有状态会让它变灰。
    初始化中与有待处理操作时,辅助功能节点仍报 disabled / 不可点击,而可见控件保持可用——这与改动前
    完全一致,本次只对齐了开发构建的整行呈现。

残余风险与待验证项(不得当作已确认)

  1. 整行禁用样式依赖 gpui-kit 的 SettingItem::disabled,没有像素级截图断言。
    当前测试覆盖 presentation 的 disabled 谓词和命令/无障碍语义;视觉一致性沿用 gpui-kit 既有设置项。
  2. 未在 Windows 实机确认:本机无法执行 cfg(windows) 路径。本项改动落在应用层与 UI 层,
    没有改平台代码。
  3. 历史上已注册的开发登录项需要用户手动移除,产品不提供入口,也不会提示。
  4. 平台侧的 Development 分支现在只被它自己的 opt-in smoke 驱动:
    startup_item_native::startup_item_lifecycle_smoke_restores_original_state 仍在
    StartupItemEnvironment::Development 上跑 disabled → enabled → disabled,而产品路径不会再走到
    那里。保留它是因为 Production 路径需要同一套后端构造,同时它仍是"环境命名隔离仍然成立"的证据;
    代价是本仓库里能观察到 Development 注册的地方只剩这个被 --ignored 标记的用例。

验证

已完成(2026-09-23,本机 macOS / aarch64):

  • bongocat-app:login_startup_is_gated_on_the_build_environment 断言
    startup_item_available() 等于 BUILD_ENVIRONMENT == Production(同一用例在开发与
    --features production 两种特性下都成立);
    a_development_build_reports_login_startup_as_unavailable 断言开发构建的读取返回
    State(Unsupported(BuildEnvironment)),且 true/false 两个写入方向都返回
    Ok(Unsupported(BuildEnvironment)) 而不是错误。
  • bongocat-ui:the_startup_row_is_disabled_exactly_where_the_build_cannot_offer_it 对全部平台
    状态 ×blocked 断言 disabled 恰好等于"构建环境不支持";
    a_development_build_disables_the_startup_row_with_visible_copy 固定开发构建的可见文案
    (中文目录原文);
    the_startup_row_stays_operable_in_every_actionable_state 断言已发布构建能产生的三个可操作
    状态都不禁用。
  • 设置窗口 smoke 的启动项断言不变(辅助功能节点按 action == None 报 disabled/clickable/focusable),
    与可见行级禁用规则由上面的 presentation 用例覆盖。

验证命令:just check(format、分段 Clippy、workspace tests、release check)通过。

未运行:视觉截图确认整行置灰、Windows 实机。


Adr 0052 Shortcut Gates Follow Their Group (`docs/adr/0052-shortcut-gates-follow-their-group.md`)

ADR-0052: 快捷键门禁各自跟随所属分组,并新增窗口快捷键开关

状态:已接受(2026-09-21)
依赖:ADR-0002(GPUI 设置界面)、ADR-0044(全局热键注册后端)

背景

维护者反馈(2026-09-21):模型行为快捷键的开关在「交互」页面,而它控制的绑定列表在「快捷键」页面,
两处隔着一整页,用户可能感知不到"这些组合键为什么按下去没有反应";要求把快捷键相关的开关收敛到
「快捷键」页面,并让两个作用域各自管理自己。

决定本 ADR 形态的事实:

  1. 开关与它控制的内容不在同一页。 shortcuts.model_behaviors_enabled 渲染在「交互」页「模型」
    分组的第一项,而 shortcuts.model_behavior_bindings 的行渲染在「快捷键」页「模型行为快捷键」分组里。模型
    默认关闭(false)加上自动分配会写满绑定,于是新安装的常态是"页面上一列组合键,按下去没有任何
    反应",而解释这件事的开关在另一页。第 91 项当时把这一点记为"未决观察",并说明"若维护者认为需要
    在快捷键页加一行提示,那属于独立的 UI 变更"——本 ADR 就是那次决策。
  2. 两个作用域本来就是并列的。 快捷键页面由两个带标题的 group 组成(窗口 / 模型),各自还是侧边栏
    的二级入口。每个 group 的第一行是"这一半的开关"的天然位置,不需要新增第三个分组(那会多出一个
    二级入口)。
  3. 应用级快捷键此前没有门禁。 Technical Design 原文要求模型开关"不得禁用 open_settings、
    overlay 显隐、镜像、穿透或置顶等应用级快捷键"。维护者要求"两个模块分别单独自己去管理",因此
    应用级快捷键需要它自己的开关,而不是被模型开关连坐——原约束因此只在模型开关上保留。
  4. 门禁的既有语义是"只过滤、不改写"。 shortcuts.model_behaviors_enabled 关闭时不删除绑定,只把它们
    排除在平台匹配表之外,重新打开无需重录。新门禁沿用同一语义,这也是"关掉不等于清空"能被用户接受的
    前提。
  5. ShortcutConfig 的派生 Default 会成为陷阱。 新字段若按"禁用"极性命名(disable_commands),
    派生 Default 恰好给出正确结果,但与 shortcuts.model_behaviors_enabled 的正向命名分叉;若按"启用"极性
    命名,派生 Default 会把全新配置判成"窗口快捷键已关闭"。两者都必须显式处理。

决策

1. 每个作用域的第一行是它自己的"启用"开关

shortcuts_page::ShortcutScope 新增两个方法,把开关需要的知识全部收进作用域本身:

  • gate_label(language) -> &'static str:该作用域的开关标题。可见开关与辅助功能节点读同一个
    返回值,页面上不再出现第二份措辞。
  • is_enabled(snapshot) -> bool:该作用域在配置里的真值,直接来自 commands_enabled /
    shortcuts.model_behaviors_enabled,不做任何取反。

group() 把 gate_item() 作为该分组的第一个 item,行列表仍是原来的自定义元素。开关标题是
启用窗口快捷键 / 启用模型行为快捷键,只带标题、不带描述:第 97 项已经把"标题已能表达清楚的
设置项不再重复写一遍描述"定成规则,"启用×××"正属于这一类(维护者原话:标题就已经表达了足够的
意思)。

2. 配置新增正向字段 `shortcuts.commands_enabled`,默认 `true`

  • 字段与 shortcuts.command_bindings 同段,命名与数组对齐;ShortcutConfig 改为手写 Default,使全新
    v1 配置是"窗口快捷键生效、模型行为快捷键不生效"——与改动前逐字段一致。
  • canonicalized() 原样透传该字段:设置页面提交绑定表时不得顺手把它改回默认。
  • 应用层 shortcut_config_from_settings 多接一个 commands_enabled 参数,由调用方(set_shortcuts、
    suspend_shortcut_capture)从当前配置传入。录制或清空快捷键不会把窗口快捷键在用户背后重新打开。

3. 门禁的唯一实现点是 `ShortcutConfig::active_bindings`

active_bindings 是"此刻生效的绑定"的唯一投影(模型侧的活动模型过滤也在同一处),命令侧门禁加
在这里,应用层与平台层不再判第二次。关闭时 commands 投影为空,model_behaviors 与配置里的绑定
一律不动。

4. 开关直接显示配置真值,任何一层都不取反

配置、SettingsSnapshot、settings command、SettingValue、PendingOperation 全是正向字段
(commands_enabled / command_shortcuts_enabled / SetCommandShortcutsEnabled),开关也按"启用"
方向渲染,因此可见开关、辅助功能节点的 toggled、开关发出的命令读的是同一个布尔值——不存在"某个
地方取反了没有"这类问题。开门时按"禁用"方向渲染的初版方案(取反收在 is_disabled 一处)已被替换:
两个开关各自只带标题,正向直出既更短也更好读。

5. 节点顺序跟着页面走

两个门禁节点从「交互」页的节点块移到「快捷键」页的节点块,并按页面顺序插在各自作用域的行之前:
窗口门禁、窗口行、模型门禁、模型行。行的切分点用 window_shortcut_rows(...).len(),与页面的
row_index_offset、tab 序号、无障碍节点编号同一条行序。

明确不做

  • 不合并成一个总开关。 维护者要的是两个模块各自管理;一个总开关会让"只想关掉模型行为快捷键"
    变成"连显示/隐藏窗口的快捷键也没了"。
  • 不改变任何默认值。 窗口快捷键默认生效(关掉它等于让产品失去唯一的快捷入口),模型行为快捷键默认
    关闭(第 91 项记下的刻意收窄,本 ADR 不反转)。两个开关因此默认状态不同(窗口开、模型关),这是
    有意的,也是"启用"方向比"禁用"方向更符合直觉的地方。
  • 不给两个开关写描述行。 第 97 项的规则:标题已能表达清楚的设置项不再重复一遍。
  • 不在关闭时清空或删除绑定。 与模型侧一致:门禁只决定是否进入平台匹配表。
  • 不给行列表加禁用态样式。 已被 ADR-0053 取代(2026-09-21 决策变更):门禁关闭时行
    现在置灰且不可交互,遵循统一的「开关门禁」禁用绑定规则(见 ADR-0053);当初"先把组合键排好再
    打开开关"的理由不再是产品方向。
  • 不为"开关状态与行状态不一致"再加提示。 开关现在就在行上方,它就是那个提示。

后果

  • 快捷键页面每个分组的第一行是开关(纯标题行),其后是该作用域的行;「交互」页不再出现任何快捷键开关。
  • 用户可以在不丢失绑定的前提下把窗口快捷键整体关掉。这是新增能力:此前应用级快捷键只能在
    逐个清除绑定(不可逆,且模型侧还会被下次激活补回来)之后才失效。
  • shortcuts.commands_enabled: false 会随配置落盘并跨重启保持;重启、切换模型、重新录制绑定都不会
    把它改回 true。
  • 无障碍树里「交互」页少一个 Switch、「快捷键」页多一个,两个门禁节点的标题与可见开关同源,
    且节点没有 value(行没有描述,读屏也不会把描述当值念出来)。
  • spikes/config-store 的配置副本需要同步该字段,否则它那条与共享 fixture 对拍的测试会失败
    (spike 是独立 workspace,不在 just check 范围内)。

残余风险与待验证项(不得当作已确认)

  1. 没有断言"侧边栏仍渲染出两个二级项、正文分组顺序不变"——本仓库的 smoke 不做布局断言
    (与第 95 项同一限制),分组数量与顺序都没有变化,靠组件既有机制与人工查看。
  2. 未做视觉确认:开关在分组标题下方与行列表之间的间距、Enable window shortcuts 这类较长的
    英文标题在窄窗口下的换行都没有截图证据;125/150/200% DPI 同此。
  3. 未在 Windows 实机验证。改动落在配置、应用层与 UI 层,没有改平台代码;cfg(windows) 的
    无障碍与设置窗口路径本机无法执行。
  4. 门禁关闭状态下再录制绑定只由 the_command_gate_keeps_the_recorded_bindings_and_only_leaves_the_table
    间接覆盖(关闭 → 表为空 → 打开 → 恢复)。"关闭期间录制新组合键"这条组合路径没有专门用例。

验证

the_command_gate_keeps_the_recorded_bindings_and_only_leaves_the_table。


Adr 0053 Setting Gate Disable Binding Rule (`docs/adr/0053-setting-gate-disable-binding-rule.md`)

ADR-0053: 「开关门禁」与被控设置项的统一禁用绑定规则

状态:已接受(2026-09-21;项目 AccessKit tree 部分由 ADR-0054 取代)
依赖:ADR-0052(快捷键门禁各自跟随所属分组)、ADR-0054(visual-first UI)

背景

维护者要求(2026-09-21):快捷键页两个门禁开关(启用窗口快捷键 / 启用模型行为快捷键)中任意一个
关闭时,应自动禁用其作用域的全部设置项(置灰且不可交互),参照「鼠标悬停时隐藏延迟」开关关闭时禁用
延迟行的既有实现;并要求把这类"开关控制下属设置项"的场景收敛为一套统一标准,此后每遇到新开关不再
单独编写一次禁用逻辑。

决定本 ADR 形态的事实:

  1. 参照实现本身是三层分散的逻辑。 鼠标悬停时隐藏延迟的禁用规则由三处各自表达:渲染层的
    .disabled(!hover_hide_delay_available)、无障碍层的 disabled || !hover_hide_delay_available、
    变更方法 adjust_overlay_hover_hide_delay 的守卫。三处判的是同一件事,却没有任何一处命名它——
    第三个门禁出现时就会各自再写一遍并开始漂移。
  2. ADR-0052 明确决策过相反方向。 其「明确不做」一节写明"不给行列表加禁用态样式",理由是用户
    可能想先把组合键排好再打开开关。维护者现在的要求直接推翻这一条;按工作协议,本 ADR 显式取代该
    条目而非留下两份互相矛盾的文档,ADR-0052 的其余内容(每分组一个正向开关、只投影不清空、
    active_bindings 唯一实现点、全链路不取反)全部保持不变。
  3. 自定义渲染行的"禁用"必须自己拼。 打包字段(SettingField::switch 等)由
    SettingItem::disabled 一并处理置灰与不可交互;快捷键行是 SettingItem::render 的自定义元素,
    置灰只是 opacity(0.5),on_click / on_key_down handler 仍会注册、命中测试仍有效(gpui-kit
    的 disabled 投影不吞事件)。"不可交互"必须通过不再注册 handler 来实现,这恰恰是最容易漏的一半。
  4. 两种"禁用"来源是正交的。 结构性编辑阻塞(无快照、配置不可用、模型导入进行中)与门禁关闭是
    两个独立布尔;渲染层与守卫层必须合成同一个值。若两层各判一次,任何一处漏加来源就会出现
    "看着灰但能改"或"能点但请求被拒"的不一致。
  5. pending 不能进门禁。 保存往返期间的 in-flight 标志每次保存都会翻转,接入后整页会随每次
    控件变更明暗一轮,读作页面在刷新(ADR-0052 前身已踩过:d4f0155f)。

决策

1. 统一机制:`bongocat-ui` 的 `window::setting_gate::SettingGate`

每个"开关门禁"场景构造一个 SettingGate::new(editing_blocked, enabled),其中 enabled 是该开关的
配置真值(正向、不取反),editing_blocked 只在结构性编辑不可能时为真(不含 pending)。规则由
两个方法固定:

  • disables_switch() = editing_blocked——开关自身永不被自己的状态禁用,否则关掉的门禁永远
    无法再打开;
  • disables_controls() = editing_blocked || !enabled——被门禁控件的禁用条件。

三条固有性质写进模块文档,对所有场景生效:

  • 门禁只投影可用性,从不改写被门禁的值:重开开关即恢复用户原值,无需重录(与 ADR-0052 的
    "只过滤、不改写"一致)。
  • pending 不进门禁:头部状态栏是保存指示器。
  • 三层读同一个 gate 值:可见行、无障碍树(disabled 且不可点击/聚焦)、变更方法守卫必须同源,
    以保证"在开关翻转前渲染的树上操作"也不生效。

2. 新场景的接入方式(标准流程)

  1. 定义一个从 snapshot 读开关真值的正向谓词(如 ShortcutScope::is_enabled、
    hover_hide_delay_applies),全链路复用,不取反;
  2. 渲染时为每个被门禁分组构造一个 SettingGate;
  3. 开关行走 disables_switch(),被控行走 disables_controls():打包字段用
    SettingItem::disabled(...);自定义行**整行置灰(标签与控件一起,与 SettingItem::disabled
    的整行置灰一致——禁用的是"这条设置",不是仅控件)**并停止注册交互 handler;
  4. 无障碍节点对被控行报 disabled 且不加 clickable().focusable();
  5. 变更方法以同一谓词做守卫。

3. 快捷键页的落地

  • ShortcutScope::for_target(target) 是"目标 → 门禁它的作用域"的唯一映射(Command → Window,
    ModelBehavior → Model),渲染层、无障碍层与变更方法都经它取谓词,不再各自 match 目标类型。
  • 每个分组渲染时构造自己的 SettingGate(editing_blocked 共享、enabled 各自取
    is_enabled(snapshot));行整行置灰(标签与控件一起),捕获按钮、清除按钮不再注册
    on_click / on_key_down。整行置灰与 SettingItem::disabled 对打包字段的处理一致
    (标题、描述、控件一起 0.5 透明度),两类行在外观上不可区分。
  • sync_shortcut_row_focus 按同一谓词决定 tab stop,并在门禁翻转后取消仍挂在该作用域目标上的
    捕获(与"目标离开行序即取消捕获"同一机制);finish_shortcut_capture_if_valid 再次守卫,
    门禁在捕获中途关闭时结束捕获而不是写入一条界面上不可见的绑定。
  • begin_shortcut_capture、begin_shortcut_capture_from_accessibility、clear_shortcut 增加同一
    守卫:结构性检查(shortcut_commands_available)之外再判目标作用域的门禁真值。
  • 鼠标悬停时隐藏延迟行改读同一规则:SettingGate::new(editing_blocked, hover_hide_delay_applies(..)) .disables_controls(),与其 mutator 守卫、无障碍节点同源。行为差异:结构性编辑阻塞期间
    (导入进行中等)该行现在也会置灰——此前它只随开关禁用;这是统一规则的直接结果,且与其变更守卫
    set_overlay_hover_hide_delay_value 的既有早退对齐。

4. ADR-0052 的对应修订

ADR-0052「明确不做」中"不给行列表加禁用态样式"一条由本 ADR 取代(维护者 2026-09-21 决策变更),
该条内注明的理由("先把组合键排好再打开开关")不再是产品方向;该 ADR 其余决策不受影响。

明确不做

  • 不改 runtime 投影语义。 门禁的唯一实现点仍是 ShortcutConfig::active_bindings;本 ADR 只把
    UI 层的可用性投影对齐到同一开关真值,配置、命令通道、平台表均不变。
  • 不在门禁关闭时清空或改写绑定。 置灰只是 UI 投影;落盘配置不动。
  • 不把 pending 计入门禁。 防整页明暗刷新;保存指示器在头部状态栏。
  • 不为打包字段重写禁用样式。 SettingItem::disabled 已同时处理置灰与字段不可交互,统一机制
    直接复用,不再自绘。
  • 不为开关关闭加提示行。 开关就在被控行上方,置灰即提示(延续 ADR-0052 的立场)。

后果

  • 快捷键页两个门禁关闭时,各自作用域的行整行置灰(标签与控件一起)且不可交互;无障碍节点报
    disabled、不可聚焦;键盘 tab 跳过这些行;辅助技术触发的捕获/清除请求被守卫拒绝。
  • 门禁关闭期间无法再通过 UI 录制或清除该作用域的绑定(ADR-0052 曾刻意保留这条路,本 ADR 收窄它)。
    绕过 UI 的路径(直接编辑配置文件)不受影响,配置层从不过滤或改写绑定。
  • 鼠标悬停时隐藏延迟行与两个快捷键分组共用同一条规则与同一份实现,新增门禁场景按第 2 节流程接入。
  • 捕获中途翻转门禁会取消捕获并恢复平台表(resume_shortcut_capture),不会留下悬空的录制会话。

残余风险与待验证项(不得当作已确认)

  1. 未在 Windows 实机验证。 改动全部在 bongocat-ui,无平台代码,但 cfg(windows) 的无障碍与
    设置窗口路径本机无法执行。
  2. 无布局/视觉断言。 置灰效果(opacity(0.5),与 editing_blocked 既有禁用态一致)依赖组件
    机制与人工查看;125/150/200% DPI 无截图证据。
  3. 无障碍树的门禁行为只有节点属性断言可覆盖,读屏器实际表现(VoiceOver/Narrator 对 disabled
    按钮的播报)未实测。
  4. "关闭期间录制"被收窄意味着喜欢"先排键再开闸"的用户失去这条路;若反馈需要恢复,可在
    disables_controls() 之外为捕获行单独放宽——那是独立决策,不预先实现。

验证

见 disables_controls() 相关用例与 ADR-0052 记录的验证状态。


Adr 0054 Visual First Config And Ui Contract (`docs/adr/0054-visual-first-config-and-ui-contract.md`)

ADR-0054: 配置损坏恢复收口为 back-to-back fallback,设置 UI 采用视觉优先契约

状态:已接受(2026-09-22)
依赖:ADR-0005(配置原子提交与备份)、ADR-0053(开关门禁规则,本 ADR 取代其中项目 AccessKit tree 部分)

背景

维护者要求(2026-09-22)把当前应用收敛到“能看见的功能就是功能”,不再为配置损坏维护恢复
窗口、恢复提示、恢复按钮或重启门禁,也不再把屏幕阅读器和项目自有的辅助技术树当成产品功能:

  1. 当前 config.json 可用时直接使用。
  2. 当前配置损坏时,从最新可用备份恢复。
  3. 当前配置损坏且没有可用备份时,写入并使用默认配置。
  4. Development、Production 和 smoke 的设置窗口必须使用同一套可见组成;不能再出现 smoke 有
    “重置偏好设置”、正常运行却没有的窗口。
  5. 只给用户看的文字和控件负责界面;删除只为辅助技术存在的隐藏文案、AccessKit 节点、action、
    native bridge 和直接依赖。

这里的“视觉优先”只描述当前设置 UI 的实现范围。项目只定义和绘制用户可见、可操作的
内容;以后不新增项目自有的无障碍树、原生桥接、隐藏文案或 action。主题、本地化、键盘
操作和 GPUI Kit 自身提供的默认语义仍可保留。

决策

1. 配置 fallback

ConfigStore::load_or_default 是配置加载的唯一入口,行为固定为:

  • 当前 v1 配置通过完整 typed validation 时,原样加载。
  • 当前文件 parse/validate 失败时,按新到旧读取本环境自有 config-*.json,使用第一份完整
    有效的当前 v1 备份;恢复后的当前文件必须重新读取并验证一致。
  • 当前配置损坏且没有有效备份时,直接原子写入当前 v1 默认配置,并返回该默认配置。
  • 当前配置缺失时直接写入并返回默认配置。
  • 每个阶段复用既有 writer lock、原子替换和损坏文件 quarantine;不再有“等待用户点恢复默认”的
    中间状态。

非 v1 当前文件仍按当前 v1 的严格版本入口处理,不属于自动 downgrade/migration。本产品不为旧
Tauri/Pinia、开发中间版本或未知未来版本增加转换器、alias、fallback UI 或兼容分支。

2. 设置窗口没有配置恢复 UI

  • 不创建 recovery-only 设置窗口,不创建受限 runtime,不做 application operational gate。
  • 不显示窗口级、页面级或分组级的配置恢复提示。
  • 不提供 RestoreDefaultConfiguration 按钮、command、snapshot 字段或“恢复后必须重启”流程。
  • ExportDiagnostics 和备份目录打开能力可以保留为排障入口,但不得重新长出配置恢复页面。
  • 成功加载、备份恢复和默认 fallback 都回到普通启动路径;正常关闭与重启语义不变。

3. 设置窗口的可见组成必须一致

  • Development、Production 和 smoke 使用同一个设置窗口视图及同一页面/分组/按钮组成。
  • smoke 通过测试 seam 触发导航、主题和窗口检查,不得通过隐藏或额外渲染用户控件来制造差异。
  • 更新入口若由 application 注入,smoke 也必须注入等价 callback,使其可见组成与产品一致;
    smoke 是否真正发起网络更新不属于可见组成条件。
  • 以后新增可见按钮或分组时,产品窗口、development smoke 和 release smoke 必须同时获得它;
    以后删除可见内容时也必须三处一致。

4. 设置 UI 的辅助技术范围

  • 设置 UI 是 visual-first:项目只定义并绘制用户可见、可操作的内容。
  • bongocat-ui 和 bongocat-platform 不维护项目自有的 AccessKit tree、Accessibility adapter、
    native view bridge、辅助技术 action channel、隐藏标签或 only-for-assistive-technologies 文案。
  • 除 gpui-kit 的传递实现外,workspace 不直接依赖 accesskit、accesskit_macos 或
    accesskit_windows。
  • 可保留的键盘行为包括 tab 顺序、可见 focus、Enter/Space 激活、Escape 取消;这些由可见控件
    和 gpui-kit 默认能力承担,不额外复制一份辅助语义状态。
  • Tooltip 服务鼠标/触摸等可见交互;若文案只为了让屏幕阅读器读出而存在,则不写。
  • ADR-0053 的“可见行、AccessKit tree、mutator 三层同源”收窄为“可见行与 mutator 同源
    SettingGate”;GPUI 若自带语义由其组件决定,不建立项目 contract。

5. 文档契约

以后实现或评审设置 UI 时:

  • 先问“用户是否看得见、是否可以操作”;看不见的实现默认删除。
  • 不以 screen reader、VoiceOver、Narrator、UIA、AX tree 或 Accessibility permission 作为
    功能完成条件。
  • 不在项目代码新增 *_accessibility/*_a11y 模块、隐藏 Status 节点、supports_click
    contract 或屏幕阅读器 smoke。
  • 不把 gpui-kit 内部可能携带的语义当作 BongoCat 的业务 contract。
  • 新增可见功能只保留实现功能所需的最小状态和测试,不预先建立恢复 UI、辅助桥或跨层协议。

6. macOS 前台窗口与后台 Dock 偏好

  • 设置或更新窗口打开期间(包括最小化)始终使用 Regular 并显示 Dock 图标;猫窗口由独立
    overlay 管理,不计入 GPUI 的普通窗口列表。
  • system.show_dock_icon 保留当前字段和默认值,仅决定普通窗口全部关闭后的 Dock 显示状态。
    UI 文案为「始终显示程序坞图标」;在设置窗口内关闭开关不切换到 Accessory,不影响窗口内容。
  • 复用普通窗口打开入口与关闭回调计算模式,避免通过枚举原生窗口、重新显示或恢复焦点补救
    切换模式的副作用。打开失败恢复背景模式;配置提交失败恢复旧偏好。

后果

  • 配置损坏的普通路径收敛为“备份优先,否则默认值”,没有额外窗口和用户操作。
  • 产品/开发/smoke 的可见窗口一致,按钮差异不会再来自运行模式。
  • 项目自有辅助功能代码、隐藏文案和直接 AccessKit 依赖被删除;gpui-kit 仍可透明使用其
    内部默认语义。
  • 自动化测试继续覆盖可见功能、输入、配置和 smoke;不再以辅助树断言证明 UI 完成。

明确不做

  • 不删除操作系统或 GPUI 的辅助能力;只是不把它列为 BongoCat 自有的 UI contract。
  • 不删除主题、本地化、键盘导航、可见 focus 和 tooltip。
  • 不因“以后可能需要”而提前保留恢复窗口、恢复状态、辅助桥、feature flag 或空模块。
  • 以后不在设置 UI 的项目代码里新增无障碍树、原生桥接、隐藏文案或 action。

验证

  • bongocat-config:有效当前配置、有效备份优先、无有效备份回落默认值。
  • bongocat-app / bongocat-ui:无 recovery-only window、无配置恢复 command/snapshot 字段。
  • Development、Production、smoke 的 Settings window 查看同一页面与更新入口组成。
  • workspace 与 spikes/ 都不再出现项目自有 accessibility、accesskit 模块、节点或直接依赖。
    spikes/gpui-settings 原本自建的 AccessKit bridge、action channel 与隐藏 Status
    节点已随该 ADR 删除;它的 CI smoke 改为纯生命周期检查,Windows 侧不再用 UI
    Automation 驱动探针。

Adr 0055 Imported Model Cover Captured From Render (`docs/adr/0055-imported-model-cover-captured-from-render.md`)

ADR-0055: 导入后的模型封面由渲染一帧截取

状态:已接受(2026-09-22)
依赖:ADR-0002(GPUI for Settings UI)、ADR-0003(Native Overlay Renderers)、ADR-0037(在应用内导入 BongoCatMver 模型)、ADR-0047(模型库只做选择与元数据编辑)

背景

  1. 转换出来的封面是源包里的同一张占位图。 BongoCatMver 转换按 ADR-0037 的输出布局,每个输入模式各取
    img/<模式>/cat.png 原字节装成 resources/cover.png。上游各模式的 cat.png 本身就是同一张手绘
    占位图,于是「标准 / 键盘 / 手柄」三个模型导入后封面逐字节相同,模型页看起来是三张重复卡片。
  2. 封面本来就可以由模型自己画出来。 overlay 的渲染路径能把一个模型渲染成完整一帧:模型自己的背景图、
    Live2D drawable,以及键位层;预置模型的卡片图就是这个构图。缺的不是渲染能力,而是「谁来截、在哪截、
    什么时候截」。
  3. 要被截的模型不是当前激活的模型。 刚导入的模型此刻并没有被 overlay 渲染,因此必须新起一个私有
    runtime 和一个自己的原生窗口——形态上就是既有的 run_model_preview,区别只在于窗口永不显示、帧在
    present 之前就被读回。原生窗口只能在拥有它的线程上创建:macOS 是 AppKit 主线程,Windows 是创建 HWND
    的线程;本产品里两者都是 GPUI 线程(overlay 也由它 tick)。
  4. 导入发生在 settings service worker 线程上。 该 worker 执行 import command,不能建窗口,因此
    「把模型渲染成封面」必须跨线程交接。而 bongocat-ui 不依赖 runtime / render / Live2D(ADR-0002 与
    Technical Design 的 crate 边界),协议层不能携带模型类型,交接只能走 app 自己拥有、两个线程共享的队列。
  5. ADR-0047 的「明确不做」里写着「不让 renderer 或 runtime 参与封面」。 本条明确推翻它:封面从此是
    渲染产物,而不是被原样搬运的源文件。ADR-0047 的其余决策(封面位置契约、PNG 契约、原子替换、
    错误只用通用 Notification)保持不变;其中「预置模型只读」一条后来由 ADR-0047 自己的 2026-09-22
    修订取代(预置可改名、可换封面,定制落在用户侧),与本条无冲突:本条只覆盖导入模型的截取。

决策

1. 导入成功后为每个安装的模型排队一次截取

ApplicationMainThreadSignals(由 ApplicationShortcutSignals 更名,因为它现在承载的不只是快捷键信号)
新增一条 cover capture 队列。settings worker 在 import command 成功后,把每个已安装模型连同它在
settings 协议里的身份(SettingsModelKey,origin 恒为 installed)入队。worker 只排队:它不渲染、不读
封面、不写任何字节,也不为截取失败而改变导入结果。

2. 由 GPUI 线程在 50 ms 轮询里完成渲染

新的 GPUI 任务每 COVER_CAPTURE_POLL_INTERVAL_MS = 50 毫秒取空队列,对每个请求:

  1. 用 bongocat_overlay::ModelCoverCaptureSession 逐帧截取(隐藏窗口 + GPU 读回,见决策 3 与
    2026-09-22 修订:每步只画一帧,帧间让出主线程,不整段阻塞);
  2. 用 SettingsCommand::ReplaceModelCover(bytes 载荷)把结果交回 settings worker 落盘;
  3. 让设置窗口 finish_model_cover_capture 丢掉该模型的图像缓存,并解除该次导入的显示门禁。

第 3 步与 ADR-0047 残余风险 3 是同一条依赖:替换保持相同的包内路径,GPUI 的图像缓存按路径命中,
不显式失效就会继续画旧字节。

设置页同时把本次导入安装的条目挡在网格之外,直到这次回调到达:模型页的导入
卡片把「正在导入模型…」替换为「正在截取封面…」(一次只显示当前一步,不逐步叠加),卡片下
出现的就是封面已就绪的模型,而不是先画源包占位图再换图。这个门禁属于设置页的展示状态,不进入
settings 协议。导入成功通知在本次导入最后一次封面回调到达、卡片揭示到网格时一起发出,而不是在包
安装结果首次到达时发出。截取失败同样完成该门禁并允许成功,保持既有静默失败行为。
(2026-09-23:失败仍完成该门禁,但不再允许成功——模型在这一步就被删除,见文末修订。)

3. 两个平台后端共用一份「帧 → 封面」逻辑

bongocat-overlay/src/cover.rs 承担与 GPU 无关的一半,因此能在任何平台上被普通单元测试覆盖:把一帧
预乘 BGRA 读回转成直通 alpha RGBA、按可见像素裁掉空白边、把最长边降到 640、编码 PNG。
macos.rs 与 windows.rs 各自只负责产生那一帧:

  • 私有 runtime 激活该模型(ActivateModel)并等待准备完成;
  • 建立尺寸取自模型画布、永不显示的窗口(macOS 从不 orderFrontRegardless,Windows 不设
    WS_VISIBLE 也从不 ShowWindow);
  • 首帧按已提交模型的标准做 smoke 校验(渲染不出内容必须失败,不能把空白图当成封面),随后绘制
    COVER_CAPTURE_FRAMES = 30 帧让 idle 姿态与物理收敛,每帧只读回、不 present;
  • 读回、裁切、编码后关掉 runtime 与窗口。整个过程的耗时上限是 COVER_CAPTURE_TIMEOUT = 5s。

捕获的是窗口显示的东西(背景图 + drawable + 键位层),也就是模型自己的样子。模式之间的差异来自模型的
背景图,因此不主动按下按键、不出现按键高亮。

4. 截取失败不改变任何东西(~~2026-09-23 起改为终止导入~~,见修订)

失败时保留源封面、不重试、不阻塞导入、不经 UI 报错。封面是显示用美术而非模型数据(ADR-0047 决策 4),
错误的图不是坏掉的模型。可观测性为零这件事是本节的一部分,见残余风险 2。

本节已被 2026-09-23 的修订取代:截取失败现在会撤销这次导入并报「模型导入失败」。原文保留为当时
的决策记录;取代它的理由(截取与激活共用同一条 GPU 准备路径)见文末修订。

5. 封面写入仍走同一条 store 通道

Application::set_model_cover_bytes 与 set_model_cover 共享同一个 install_model_cover 契约:PNG 签名

  • 不超过包的每文件上限,然后 ModelStore::replace_cover 原字节落盘。SettingsCommand::ReplaceModelCover
    与 SetModelCover 的差别只是载荷(bytes 还是 path),因为捕获出来的封面在被安装之前根本不是一个文件。

6. `capture-cover` 子命令作为可复现证据

bongocat-overlay capture-cover <standard|keyboard|gamepad> <out.png> 让整条链路(隐藏窗口、GPU 读回、
裁切、编码)脱离导入流程单独执行,因此平台 backend 可以在没有 UI 的情况下被检查与回归。

修订(2026-09-22,当日)

截取改为逐帧可让步会话,不再整段占用 GPUI 线程(消除残余风险 1)。用户实测:导入卡片在
「正在截取封面…」一步时 Spinner 停转,偏好设置窗口与 overlay 同时卡住。

  • 原因与决策 2、3 相同:截取窗口只能建在拥有产品窗口的线程上(macOS 主线程 / Windows 的
    overlay 所在线程),而整段同步的截取让该线程在约 30 帧内无法回到自己的事件循环。
  • 新增 bongocat_overlay::ModelCoverCaptureSession(macos.rs / windows.rs 各有
    CoverCaptureSession 后端):start 完成私有 runtime、隐藏窗口与首帧 smoke 校验,step 每次
    只泵一次事件、同步最新帧并画一帧,finish 关闭 runtime 并编码 PNG。帧数(COVER_CAPTURE_FRAMES)、
    超时(COVER_CAPTURE_TIMEOUT)、首帧校验、读回/裁切/编码与「失败不改任何东西」的语义全部不变。
  • app 侧的 GPUI 任务在两次 step 之间 Timer::after(frame_interval()).await,让出点落在
    foreground executor 上:主循环照常泵事件与重绘,设置窗口的 Spinner 与 overlay 帧循环在截取期间
    正常运行。
  • capture_model_cover 保留为阻塞形式(内部改为按会话逐步执行后 thread::sleep),仅供
    capture-cover 子命令等独占线程的调用方使用;其输出与修订前逐字节一致(keyboard:
    186,091 bytes / 640×352)。

修订(2026-09-23,截取失败改为终止导入)

决策 4 由「截取失败不改变任何东西」改为「截取失败即放弃这次导入」,决策 2 末句与残余风险 2 随之作废。

  • 触发:用户实测两个社区 MVer 模型(芙宁娜(按键气泡)、芙宁娜(无气泡))导入后启用报
    「无法启用所选模型」。根因不在截取本身,而在两个后端共用的渲染快照 preflight 把「有顶点、没有
    三角形」的 drawable 当成致命错误——芙宁娜(无气泡) 的 drawable #115 是 3 顶点 / 0 索引 /
    visible = false。Cubism 允许这种 drawable,官方渲染器只是不画它,而本项目的 preflight 让整个
    模型在 GPU prepare 阶段失败,于是同一个模型既截不出封面、也激活不了。修正见 TODO 中
    「D3D11/Metal 对相同 snapshot 行为一致」一项的 2026-09-23 状态。
  • 这条 bug 同时证明了一个原决策 4 没考虑的事实:截取与 overlay 激活走的是同一条 GPU 准备路径
    (GpuModel::prepare 及其渲染快照 preflight)。截不出封面的模型,激活一定也失败。把「封面截不出来」
    当成纯粹的显示问题,就会给用户留下一张点了只报错的卡片。
  • 因此新语义:截取失败时,app 主线程先 SettingsCommand::DeleteModel 撤销这次导入,再回调
    finish_model_cover_capture(.., captured = false)。模型自始至终没有进入网格(导入期间它一直在
    SettingsView::pending_model_reveal 里),用户看到的是一条「模型导入失败」通知,而不是一个坏卡片。
  • 失败通知走新增的 ModelImportFailedNotification 与 models.import.failed(zh「模型导入失败」/
    en「Model import failed」),与既有的 models.import.success 对称。它在成功通知之前推送:
    一次导入装了多个模型而其中一个失败时,读起来是「这个失败了」在前、「其余已就绪」在后。
  • 一次导入里只有部分模型的截取失败时,只有失败的那些被删除,成功的不受影响,两条通知都会出现。
  • capture-cover 子命令、ModelCoverCaptureSession 与「帧 → 封面」的接口都不变;变的是 app 主线程
    对失败结果的处理,以及设置窗口的发布逻辑。

明确不做

  • 不加偏好开关:用户要的是「导入后就是模型自己的样子」,而不是多一个开关。此前考虑过的「偏好设置
    增加一个封面字段」被否决。
  • 不放松 ADR-0047 决策 4 的 PNG 契约:仍然只校验签名与大小,不重编码,不为 JPEG/WebP 打开 image
    feature。
  • 不为已存在的模型回填封面:本次只覆盖导入路径(含 MVer 三模式各自的导入),不扫描历史模型,
    也不新增「重新生成封面」入口。
  • 不让 bongocat-ui 依赖 runtime / render / Live2D:协议只携带 Vec,交接走 app 侧共享队列。
  • 不改预置模型的包:预置包仍是 product files,永远不被写入。用户给预置换的封面存在用户侧
    (见 ADR-0047 决策 2 修订),本决策的截取路径只服务导入模型,不参与预置封面。

残余风险与待验证项(不得当作已确认)

  1. 截取在 GPUI 线程上同步进行 已消除(2026-09-22 修订):最初的实现把约 30 帧的截取整段
    同步跑在 GPUI 线程上,期间 overlay 帧循环与设置窗口都会停顿。同日即改为决策 2 所述的可让步
    会话,见下方修订。
  2. 失败是静默的 已消除(2026-09-23):截取失败现在撤销这次导入并报「模型导入失败」
    (models.import.failed,双语,tools/validate-locales.py 校验键集合)。当时记的是「报出失败需要
    新增 ApplicationLogCode、双语 i18n 文案或复用 models.cover 的失败通知,本次一条都没引入」——
    修订选了新增 i18n 文案这条路,并且不止于报告:失败的模型会被删除,见文末修订。
  3. Windows 后端未编译、未实机验证:本机 macOS 无法交叉编译 windows-msvc(libdeflate-sys 的 C 构建
    需要 Windows SDK),仓库的 Windows 验证在 windows-latest runner 上执行。Windows 侧的风险集中在
    Renderer::draw_inner 新增的 capture 分支与 read_staging_frame 的行拷贝。
  4. 截的是 idle 一帧:若模型第一秒的物理尚未收敛,截出来的姿态可能与用户随后看到的略有差异;
    30 帧与 5s 上限是为此设的收敛窗口,不是精度保证。
  5. 隐藏窗口仍需合成器环境:D3D11 + DComp 或一个可用的 Metal drawable。没有 GPU、没有窗口服务器的
    环境会失败;2026-09-23 起该模型不会被导入(原行为是保留源封面),因此在无 GPU 环境里导入会整体
    不可用。这条风险的代价变大了,但它同时也是「模型激活不了」的正确信号。
  6. 图像缓存失效依赖捕获方显式调用:设置窗口未打开(句柄为 None)时跳过,依赖窗口之后重新加载
    同一路径时读到新字节。
  7. 每次导入多一个隐藏窗口 + 一个独立 runtime:MVer 三模式会串行处理三次,模型越大耗时越长。

验证

已完成(2026-09-22,本机 macOS arm64):

  • 新增单元测试:cover.rs 覆盖预乘 BGRA 转换、行 pitch 校验、内容裁切、缩放与 PNG 编码(bongocat-overlay
    52 测试,此前 43);bongocat-app 新增
    service_queues_a_cover_capture_per_imported_model_and_installs_the_result,走完整服务链路断言导入后队列里
    恰好一个请求、其 id 与快照里 installed 条目的 id 一致、ReplaceModelCover 的字节落到
    <包根>/resources/cover.png 并出现在快照 cover 字段、非 PNG 被拒绝且原封面不变(app lib 139 测试)。
  • 端到端证据:cargo run --locked -p bongocat-overlay -- capture-cover keyboard /tmp/cover-check.png
    → 186,091 bytes / 640×352 PNG,与常量搬迁前的同一命令输出逐字节相同;standard / keyboard / gamepad
    三个预置模型各自产出不同构图的封面(此前三个模式共用一张占位图)。
  • just check 的六道门全过:cargo fmt --all -- --check;三组严格 Clippy(workspace 排除 app、app
    storage-test-injection、app production);cargo test --locked --workspace 全绿(816 项);
    cargo check --locked --workspace --release。

修订验证(2026-09-22,本机 macOS arm64):cargo fmt --all;三组严格 Clippy 全过;
cargo test --locked --workspace 全绿(34 目标 / 0 failed);cargo check --locked --workspace --release
通过;capture-cover keyboard 经新会话路径产出 186,091 bytes / 640×352,与修订前逐字节相同。

修订验证(2026-09-23,本机 macOS arm64):cargo test -p bongocat-ui --lib 158 通过——
model_import_success_waits_for_every_queued_cover_capture 改用成功截取表达原意,新增
a_failed_cover_capture_abandons_the_import(变异验证:去掉 model_import_failed_pending 赋值即报
「the failed capture must be reported to the user」);cargo check --workspace --all-targets 通过;
tools/validate-locales.py 2 locale × 243 键通过;just check 六道门全过。真实模型证据(本机 Metal):
把两个源各用 bongocat-model-store --example model_conversion_smoke 转成包后跑 capture_model_cover,修复
渲染快照 preflight 前报 drawable geometry is empty;修复后两个模型都截出封面——芙宁娜(无气泡)
384423 B / 625×640(2.10 s)、芙宁娜(按键气泡) 384116 B / 625×640(1.94 s)。GpuModel::prepare
(含 2 张 2048² 模型纹理与 76 张 1200×1040 键位图上传)实测 395 ms,远在 runtime 的 2 s 激活超时
之内——这也排除了「截取失败其实是超时」这一替代解释。
这条真实模型证据来自一次性探针,探针没有留在仓库里;留在仓库里的是上面两项自动化测试。

Windows 侧(2026-09-23):bongocat-overlay 整体无法交叉编译到 x86_64-pc-windows-msvc
(bongocat-model-store 拉进 libdeflate-sys,其 C 构建需要 Windows SDK),因此按
bongocat-rust-ci-triage §5.1 的手法搭隔离 crate,把 GpuModel::prepare 的缓冲创建与
sync_snapshot 的顶点上传守卫逐字抄入后 cargo check --target x86_64-pc-windows-msvc 通过,
并用 compile_error! 探针与一次 E0308 变异确认该检查真的在编译、也真的能抓住类型错误。
这只是类型证据,Windows 实机截取与启用仍未验证。

未运行:双平台实机导入(导入后观察卡片封面变化)、--settings-window-smoke、模型页 opt-in smoke、
Windows 实机截取与启用、真实社区 MVer 模型的批量回归;** Spinner 在截取期间持续旋转属目视行为,
未自动化验证,需人工观察导入过程确认。**


Adr 0056 Setting Group Variant Override Patch (`docs/adr/0056-setting-group-variant-override-patch.md`)

ADR-0056: SettingGroup variant 与上游 GPUI Kit 固定 revision

状态:已接受(2026-09-23;2026-09-24、2026-09-25、2026-09-28、2026-10-06、2026-10-12 修订)

背景

模型库页面的内容是自绘卡片网格。设置窗口的组容器使用官方
Settings::with_group_variant(GroupBoxVariant::Outline),因此该页在外层多出一个卡片容器:
边框、内边距与圆角包裹在自绘网格外面。SettingGroup::variant(GroupBoxVariant::Normal)
可以在不改变其它页面的情况下移除这一层容器。

gpui-kit 0.6.6 的文档已经承诺 group variant 可单独覆盖,但实现没有对应 API。维护者向
longbridge/gpui-kit 提交 issue #3202 与 PR #3203;PR 已于 2026-09-23 合并,merge commit
为 500852f449c05dc01920ec82f3ae2656a61d0387。同一上游 revision 还包含
Popover::arrow(bool),可供项目内 PopConfirm 转发。对应能力尚未发布到 crates.io。

修订(2026-09-25):ADR-0066 将模型对象拆为 Model library 与 Model behavior 两个独立页面。
SettingGroup::variant(GroupBoxVariant::Normal) 只用于前者——它承载自绘卡片网格;后者是
独立页面并使用窗口默认 Outline。这个 variant 边界不因模型行为独立成页而扩大。

修订(2026-09-28):上述能力已随 gpui-kit v0.7.0 发布到 crates.io,本 ADR 声明的临时
git source 条件因此解除。

决策

  • 根 workspace 直接依赖 gpui-kit 的 crates.io 精确 pin。2026-09-28 起该 pin 为 =0.7.0,
    2026-10-06 随 v0.7.1 补丁版升到 =0.7.1,2026-10-12 升到 =0.7.3,
    [patch.crates-io]、维护者 fork 依赖与 longbridge/gpui-kit git source 一并删除;仍禁止
    改用未固定的 main branch。
  • lockfile 中 gpui-kit、gpui-component、gpui-base、gpui-component-macros 与
    gpui-kit-assets 全部从同一 registry 版本解析;GPUI 本身仍来自 crates.io
    gpui-pre 同步包(=0.7.3 要求 0.3.9),不引入第二套 GPUI 类型。
  • Model library 页面继续调用 SettingGroup::variant(GroupBoxVariant::Normal);独立的
    Model behavior 页面不覆盖 variant,调用点与测试 harness 不因依赖来源或页面拆分而改变。
  • PopConfirm 增加 arrow(bool) 转发,模型删除确认在按钮上方显示并启用 anchor-aligned arrow。上游同时改为由 Root 自动挂载 dialog、sheet 与 notification layer,因此业务根视图删除旧的 Root::render_*_layer 调用。
  • deny.toml 2026-09-28 起只允许 https://github.com/ayangweb/gilrs;其它 git source 仍拒绝。

影响

  • 依赖来源最终从维护者 fork patch 切换为上游固定 commit,再切换为上游 crates.io 精确 pin;
    两者都不再维护或信任 fork 分支。
  • Root layer API 迁移只删除重复挂载;窗口仍使用 gpui_kit::component::Root,dialog、
    sheet 与 notification 的 owner 仍为 GPUI Kit。
  • PopConfirm::arrow 默认关闭以保持调用方显式选择;当前模型删除确认使用 BottomRight 向上显示并显式启用箭头。组件几何测试
    固定 arrow 会为 trigger 与 surface 预留额外间距。

Adr 0057 Overlay Resize By Right Button Drag (`docs/adr/0057-overlay-resize-by-right-button-drag.md`)

ADR-0057: 模型窗口用右键拖动缩放

状态:Accepted
日期:2026-09-23
修订(2026-09-24):缩放写回配置不再重建 HWND/NSPanel;统一改为在现有原生窗口上更新几何,
并在尺寸变化后立即填充新的 swap-chain/drawable,避免设置更新暴露未初始化透明帧。
取代:无(恢复旧版 pre-refactor 分支存在、当前产品尚未实现的窗口缩放交互)

Context

模型窗口尺寸只有一个来源:配置 overlay.scale_percent。在本 ADR 初始实现时,改变它意味着
重建整个窗口——OverlaySessionOptions::requires_window_recreation 把 scale_percent 列为重建条件,
重建会重新创建原生窗口与 GPU 资源,GpuModel::prepare 还会重新加载全部模型纹理。因此在这次改动之前,
调整窗口大小只有设置页滑块这一条路,而且每次都伴随一次可见的重建。

旧版(pre-refactor 分支 src/pages/main/index.vue)在主窗口上实现了这个交互:

js
if (buttons !== 2 || !shiftKey) return          // 右键 + Shift
const delta = (movementX + movementY) * 0.5
catStore.window.scale = round(clamp(scale + delta, 10, 500))

即按住右键并移动指针即可改缩放,但要求同时按住 Shift,范围 10–500,并且直接改
window.scale(与设置页滑块同源)。本产品没有移植这个交互。

同时,右键已经被上下文菜单独占:Windows 拦截 WM_CONTEXTMENU | WM_NCRBUTTONUP 并转发
成 OverlayContextMenuRequest,macOS 用 NSEvent local monitor 监听 RightMouseUp,两者都交给
应用的 muda 菜单。任何新的右键交互都必须先解决这个冲突。

两个平台都没有窗口 resize 通路:渲染尺寸在窗口创建时固化进 swapchain/RTV/mask 纹理
(Windows)或 CAMetalLayer 的 drawable size(macOS),overlay/src/windows/ 与
overlay/src/macos.rs 里此前不存在任何 resize 代码。

Decision

  • 触发:右键按下后指针位移(欧氏距离)超过 3px 才算缩放;未越阈值的右键仍然是上下文菜单。
    不要求按住 Shift——旧版的修饰键要求是当时用 mousemove 无法区分单击与拖动的权宜做法,而
    位移阈值直接解决了同一个问题。越阈值之后即使缩放没有实际变化,这次右键也不再弹菜单。
  • 映射:scale = clamp(按下时的缩放 + (dx + dy) * 0.5, 25, 400)。系数沿用旧版(向右下拖动
    放大、向左上拖动缩小,右上/左下相互抵消),范围收敛到当前配置契约
    (OverlayConfig::scale_percent、OverlaySettings::is_valid)而不是旧版的 10–500:拖动结果
    必须总能被配置接受,否则一次拖动会产生一个存不进去的值。位移始终相对按下点测量,因此缓慢
    拖动与手抖都不会改变判定。
  • 起点:按下时的缩放由窗口当前宽度反算(width / base * 100,同样钳制到 25–400),
    而不是读配置里的 overlay.scale_percent。窗口几何与配置并不总是同步——用户拖动过一次、
    显示器 DPI 变了、或者手工编辑过配置——用配置值当起点会让第一次指针移动就把窗口跳到另一个
    尺寸。反算让拖动始终从用户看到的大小继续。
  • 锚点:窗口左上角保持不动,与旧版 setSize 的观感一致。Windows 用
    SetWindowPos(..., SWP_NOMOVE);macOS 的 NSWindow frame 原点在左下角,所以按“顶边不动”
    修正 origin.y,两者在屏幕上等价。
  • 实时跟随:拖动过程中窗口逐帧改尺寸,渲染器就地 resize,不重建窗口、不重载纹理。
    Windows 走 IDXGISwapChain1::ResizeBuffers + 重建 RTV/staging/每个 mesh 的 mask target;
    macOS 走 setFrame:display: + 重设 drawable size + 重建 mask 纹理。其余与尺寸无关的资源
    (device、pipelines、composition graph、模型纹理、顶点/索引缓冲)全程保留。
  • 尺寸来源:100% 的基准尺寸是 default_overlay_window_dimensions(canvas),与窗口创建时
    使用的同一个值。Windows 的拖动状态机工作在物理像素(SetWindowPos 的单位),基准按窗口 DPI
    换算;macOS 工作在点。尺寸由基准与缩放重新算出,而不是在旧尺寸上累加像素,因此连续拖动不累积
    舍入误差。
  • 持久化:拖动结束时把最终缩放通过新的 OverlayInteractionSinks::resize_sender 报给应用,
    应用提交 SettingsCommand::SetOverlaySettings 写回 overlay.scale_percent;窗口几何仍由既有
    的 placement 通路(OverlayWindowPlacementDebouncer → OverlayWindowPlacementChanged)落到
    window-state.json。overlay 只提出请求,配置的写入方仍然是应用。
  • 幂等:缩放写回配置后,runtime snapshot 的变化会让 frame tick 走原地尺寸更新分支。该分支先
    判断窗口尺寸是否已经等于新缩放对应的尺寸(bounds_match_scale,容差 1px),相等时不再按
    比例重算——否则拖动刚设好的尺寸会被乘第二次;需要改变尺寸时只更新现有 HWND/NSPanel 与
    swap-chain/drawable,并在返回 frame loop 前立即绘制一帧,不替换原生窗口。
  • click-through:穿透模式下窗口返回 HTTRANSPARENT(Windows)或 ignoresMouseEvents
    (macOS),收不到指针消息,因此不可拖动,也没有右键菜单。这与既有的“非穿透模式才可拖动”
    一致,不是本次引入的限制。
  • 与 hover 隐藏的互斥:拖动进行期间 hide_on_pointer_hover 不再生效。该功能会把窗口淡出并
    让指针事件穿透,正好会中断窗口自己正在执行的拖动;两个平台都在计算 hover 状态时把“拖动中”
    当作“指针不在窗口内”,因此拖动期间窗口保持可见、拖动结束后恢复原有的 hover 行为。
  • 平台无关部分:判定、映射、钳制与尺寸计算落在 bongocat-overlay/src/resize_drag.rs,
    可以在没有 GPU 和原生窗口的情况下测试;平台适配器只负责读取指针、改原生窗口尺寸、以及把
    最终缩放报出去。

Consequences

  • 行为变化:右键不再无条件弹菜单。按住右键移动超过 3px 会开始缩放,松手后不弹菜单;原地
    按下松开仍然是原来的菜单。
  • 行为变化:缩放范围是 25–400%(旧版为 10–500%)。这是配置契约的范围,放宽它需要同时改
    schema、fixture、设置页滑块与校验,属于另一次契约变更。
  • 拖动期间每个 frame tick 都会做一次 GPU resize:Windows 会重建 render target、staging 纹理与
    全部 mask target,macOS 会重建全部 mask 纹理。这些资源都很小,但拖动时的帧率低于静止时是
    预期结果,尤其是在有 mask 的模型上。
  • 拖动结束后的写回不再重建 overlay 窗口;它只让 runtime snapshot 与已经完成的窗口几何对齐。
    若设置页直接改变缩放,现有窗口会在原地调整尺寸,并立即绘制新尺寸的第一帧,避免显示未初始化
    的透明 back buffer/drawable。
  • 缩放值从此有两个来源(设置页与右键拖动),两者都写入同一个 overlay.scale_percent,因此
    设置页滑块在拖动结束后会显示拖动结果。
  • 窗口位置与尺寸仍然分离:scale_percent 在 config.json,实际几何在 window-state.json。若两者
    不一致(例如手工编辑配置),下一次拖动会以当前窗口宽度反推的缩放为起点,并在结束时把两者
    对齐。
  • 未验证项:两平台的实机拖动观感、Windows 的 ResizeBuffers 路径、macOS 的 drawable resize 与
    mask 重建都未经人眼核验;当前代码已通过 Windows 构建检查,但真实拖动、显示器/DPI 切换
    与 compositor 透明帧仍需目标硬件 smoke。

Adr 0058 Platform Neutral Input Contract (`docs/adr/0058-platform-neutral-input-contract.md`)

ADR-0058: Platform-neutral input contract boundary

  • Status: accepted
  • Date: 2026-09-24
  • Depends on: ADR-0004, ADR-0007, ADR-0044

Context

bongocat-platform originally imported the public input types from
bongocat-runtime. That made a platform adapter depend on the complete runtime
graph, including Cubism, audio, model state, and worker lifecycle. The same
runtime crate also contained the pure input state reducer and the platform
input adapters, so the dependency direction did not express the actual
ownership boundary.

Decision

Create bongocat-input as a platform-neutral contract and transport crate.

It owns:

  • physical key, mouse, and gamepad identities;
  • InputEvent, sequencing, reset, and reconciliation vocabulary;
  • reliable input producer hand-off and anonymous transport diagnostics;
  • cursor and gamepad-axis latest-value transports;
  • platform-input diagnostic values and stable error-code validation;
  • the canonical GLOBE_KEY_USAGE constant.

bongocat-runtime continues to own the single mutable InputState, model
bindings and ModelInputSnapshot projection, command queue, worker, and
shutdown lifecycle. The input crate does not depend on runtime, renderer,
GPUI, configuration, or an operating-system API.

The runtime adapts its command producer through the input crate's
InputSubmitter trait. Platform input services use the input crate directly;
they do not depend on the normal bongocat-runtime library. Global shortcut
registration likewise accepts an application-owned typed callback, so the
platform crate does not construct runtime commands.

bongocat-render re-exports GLOBE_KEY_USAGE for existing model/render
vocabulary, but does not own the physical input contract.

Consequences

  • platform input code no longer pulls Cubism, model, audio, or runtime worker
    dependencies;
  • input transport and reducer tests are isolated from the runtime worker;
  • the runtime remains the only owner of mutable pressed state and shutdown
    ordering;
  • existing runtime re-exports may remain temporarily for source compatibility,
    but new consumers should depend on bongocat-input directly;
  • moving a reducer or model projection out of runtime requires a separate
    decision and is not implied by this crate.

Rejected alternatives

  • Moving InputState and ModelInputSnapshot into bongocat-input: rejected
    because it would make the public input crate depend on renderer vocabulary
    and blur the single runtime owner.
  • Keeping platform input types in runtime: rejected because it preserves the
    platform-to-runtime dependency and makes unsafe input adapters compile with
    unrelated Cubism/audio code.
  • Creating a separate crate for every transport or reducer submodule: rejected
    as unnecessary fragmentation; these types share one stable input contract.

Adr 0059 Ui Protocol Boundary (`docs/adr/0059-ui-protocol-boundary.md`)

ADR-0059: UI protocol boundary

  • Status: accepted
  • Date: 2026-09-24
  • Depends on: ADR-0035, ADR-0054

Context

The settings and update services exposed their typed DTOs, commands, replies,
snapshots, and bounded channel clients from bongocat-ui. That made the
application service layer depend on a GPUI crate even though the service never
rendered a window. It also put presentation-only policy (debounce timing,
localized labels, update polling cadence, and filesystem-backed title probing)
next to the wire contract.

Decision

Create bongocat-ui-protocol as the process-local contract crate. It owns:

  • settings and update snapshots, stable enums, error codes, and project-owned
    DTOs;
  • typed commands, replies, operation controls, state handles, and bounded
    service clients/endpoints;
  • SettingsWindowPlacement/SettingsWindowState, which are shared service
    state rather than GPUI entities;
  • pure, path-based model-source title normalization when the adapter supplies
    whether the selected path is a directory.

The crate has one direct external dependency, async-channel. It does not
depend on GPUI, an operating-system API, bongocat-config,
bongocat-platform, bongocat-i18n, bongocat-update, model, or runtime
crates. The application maps config/platform/update values into these project
types at its service boundary.

bongocat-ui keeps GPUI views and presentation policy: the settings patch
debouncer, language display names, update error message keys, the update
window poll interval, and filesystem inspection performed by the picker/view
adapter. It depends on the protocol crate and re-exports it only inside the UI
crate for the existing view-module wiring; application services import the
protocol directly.

The GPUI executable still depends on bongocat-ui for windows, views, and
handles. It imports settings/update protocol types from
bongocat-ui-protocol; this keeps the composition root explicit without
making the service layer depend on rendering.

Consequences

  • settings and update service tests run without a GPUI test platform;
  • app service code no longer obtains protocol DTOs through the rendering crate;
  • protocol tests pin stable codes, command ordering, bounded transport, and
    snapshot revisions;
  • GPUI tests retain rendering, localization, debounce, and window-handle
    assertions;
  • the protocol is in-process and typed; it is not a serialization or IPC
    schema, so no serde dependency is introduced;
  • changing a producer's public DTO now requires an explicit protocol change
    rather than an incidental UI module edit.

Rejected alternatives

  • Moving all of bongocat-ui into a protocol crate: rejected because it would
    preserve GPUI and presentation dependencies in the service contract.
  • Keeping protocol types in bongocat-ui with a facade: rejected because it
    leaves the application-to-rendering dependency intact.
  • Adding serde or serializing the command channel: rejected because the current
    boundary is an in-process typed queue, not a persisted or network protocol.
  • Moving debounce, localization, and render scheduling into the protocol:
    rejected because those are view policy and would make the contract depend on
    presentation details.

Adr 0060 Model Package Store Boundary (`docs/adr/0060-model-package-store-boundary.md`)

ADR-0060: Model package and model-store boundary

  • Status: accepted
  • Date: 2026-09-24
  • Depends on: ADR-0036, ADR-0037, ADR-0038, ADR-0041, ADR-0047, ADR-0050, ADR-0055

Context

bongocat-model combined two different responsibilities: read-only model3
package parsing and the mutable lifecycle that copies external sources into an
environment-owned store. The latter also owned UUID allocation, writer locks,
staging and atomic commit, legacy Mver conversion, key-image normalization, and
user-side preset cover replacement. As a result, parser-only consumers compiled
against image composition, UUID, and storage-write dependencies, while the
application's storage errors and import progress were mixed with package
validation types.

Decision

Create bongocat-model-store as the filesystem-facing persistence and import
layer. It owns:

  • ModelStore, its lock, recovery, staging, copy/validate/commit/delete flow,
    and installed catalog scan;
  • ModelStoreDiagnostic, ModelStoreError, import stage/progress, and
    user-side preset cover storage;
  • Mver detection/conversion and PNG composition;
  • normalization of legacy key-image names inside the store's own staging copy.

bongocat-model remains the read-only model domain layer. It continues to own
model3/sidecar parsing, package limits and path safety, ModelId, immutable
model metadata, PreparedModel, CommittedModel, ModelSnapshot, and the
read-only PresetModelCatalog/ModelCatalogEntry projection. The preset
catalog stays here so overlay, Live2D, and runtime consumers do not acquire the
mutable store's image/UUID dependencies just to inspect bundled models.

The production dependency direction is:

text
app ───────────────> model-store ───────> model
runtime/live2d/overlay ──────────────────> model

bongocat-model-store receives roots and lock paths from the application; it
does not read StorageLayout, select Development/Production, or depend on
config, platform, runtime, renderer, Live2D, or GPUI.

Commit ownership

Rust cannot express “only this sibling crate may call this constructor” without
adding another lower-level crate. The split therefore uses a narrow, documented
product seam instead of claiming compile-time sealing: PreparedModel::relocate
rebinds an already validated package after the store's atomic rename, and
InstalledModel::from_prepared is called by the store transaction before the
existing CommittedModel conversion. The model-store/app wiring is the only
supported caller. A future requirement for hostile third-party callers would
need a separate sealed commit type; this ADR does not pretend the current Rust
visibility provides that stronger guarantee.

Consequences

  • parser-only crates no longer compile the Mver image/PNG/UUID/storage-write
    dependency set;
  • model-store tests cover permissions, locks, staging, cancellation, recovery,
    Mver conversion, cover writes, and every registered shared fixture;
  • source directories remain read-only inputs; only store staging and committed
    destinations are written;
  • CommittedModel construction is a documented store/preset product invariant,
    not a compiler-enforced sealed type in this slice;
  • Cubism Core lifecycle and Core-coupled parameter application remain in bongocat-live2d; pure motion/expression playback is owned by bongocat-live2d-playback.

Rejected alternatives

  • Moving CommittedModel and all model consumers into the store crate:
    rejected because it would make every renderer/runtime crate depend on the
    mutable import layer merely to hold a parsed model.
  • Moving the read-only preset catalog into the store crate: rejected for this
    slice because it would unnecessarily broaden the dependency graph of
    overlay, Live2D, and runtime.
  • Keeping the old modules in bongocat-model behind re-exports: rejected
    because it would preserve the dependency direction this boundary is intended
    to remove.
  • Adding a new lower-level commit crate just to regain compile-time sealing:
    rejected as disproportionate for the current in-process, application-owned
    store; revisit only if an untrusted plugin boundary appears.

Adr 0061 Live2d Playback Boundary (`docs/adr/0061-live2d-playback-boundary.md`)

ADR-0061: Live2D playback boundary

  • Status: accepted
  • Date: 2026-09-24
  • Amended: 2026-09-25
  • Depends on: ADR-0060, ADR-0041, ADR-0050, ADR-0055

Context

bongocat-live2d combined Cubism Core ownership with motion3/exp3 parsing and
pure curve evaluation. The Core wrapper is tied to raw bindings, model
resources, parameter IDs, part-opacity sinks, and GPU-facing snapshots, while
the motion and expression parsers only need bytes, monotonic elapsed time, and
project-owned numeric results. Keeping both responsibilities in one crate made
pure playback tests compile the vendor SDK and obscured which layer owns
resource loading versus Core writes.

Decision

Create bongocat-live2d-playback for the pure clip layer. It owns:

  • motion3 metadata/curve/segment parsing, linear/Bezier/stepped/inverse-stepped
    evaluation, fade/loop time, PartOpacity samples, and UserData crossings;
  • exp3 parsing, fade weights, blend modes, and pure expression-layer mixing;
  • the playback-specific PlaybackError/PlaybackErrorCode for malformed
    motion or expression bytes.

The crate depends only on serde, serde_json, and the standard library. It
does not depend on bongocat-model, bongocat-render, bongocat-live2d,
runtime, GPUI, platform APIs, or the filesystem. MotionClip::load and
ExpressionClip::load are not part of this crate; bongocat-live2d reads the
committed model resources through the render-resource adapter and calls
from_slice.

bongocat-live2d remains responsible for:

  • CommittedModel resource lookup and bounded file reads;
  • Cubism Core loading, parameter ranges/defaults, Core writes, part-opacity
    sinks, dynamic flags, RenderResources, and model opacity;
  • adapting PlaybackError into the existing stable Live2dErrorCode catalog;
  • MotionApplyStatus and ExpressionApplyStatus, whose counts describe actual
    writes accepted by Core.

bongocat-runtime remains the owner of active playback state, priority,
single-loop product policy, stop/crossfade timestamps, command sequencing,
model-generation cleanup, and audio side effects. It may depend directly on the
playback value types, but playback never sees runtime commands or Core state.

The production dependency direction is:

text
bongocat-live2d-playback -> serde / serde_json / std
bongocat-live2d ----------> bongocat-live2d-playback
bongocat-runtime --------> bongocat-live2d + bongocat-live2d-playback

Consequences

  • motion/expression parser and numeric tests no longer compile the vendor SDK;
  • Core-coupled loading and application tests remain in bongocat-live2d;
  • runtime's existing frame order and playback ownership do not change;
  • no physics/pose crate or placeholder is introduced by this slice; those
    evaluators remain a future extension of the same pure boundary;
  • the old bongocat-live2d playback re-export is removed after runtime imports
    the playback types directly.

Amendment: completed motions hold their terminal pose

The original one-shot policy removed renderer playback and the public active-motion identity as
soon as a clip reached its declared duration. The following evaluation restored Core defaults, so
ordinary motion parameters disappeared one frame after the terminal sample. This solved accidental
looping but did not match the 2026-09-24 product requirement to stop on the action's final state.

A one-shot motion now transitions to an internal completed state at the clip duration. Its local
sample time is pinned to that duration, and the fully evaluated parameter, part-opacity, and
model-opacity samples remain a renderer-owned motion layer that is reapplied after each per-frame
default reset. "Terminal sample" includes the resource's natural model3/curve fade weights; completion
does not strip a natural fade-out to expose an earlier raw curve endpoint. Core part opacities are
restored to their fresh-model values before the active motion is applied, so removing or replacing a
motion cannot leave stale part visibility. The later reference-compatible amendment below updates
the automatic/product/physics sub-order; the completed-motion lifecycle itself is unchanged.

Completed playback is not advanced again and does not reserve motion priority. The worker derives
completion from the injected monotonic clock as well as the last delivered frame, so a hidden or
sleeping overlay cannot swallow a replay or lower-priority request sent after the duration. The public
active identity remains current so an explicit StopMotion can still fade and remove it; a
replacement, successful model commit, or shutdown removes the layer as before. StopMotion continues
to target the current identity: a different old ID cannot stop a newer motion, while a replayed run
with the same ID is intentionally the current target of a later same-ID stop.

Motion UserData crossing evaluation uses the runtime's effective playback mode. Playing a
Meta.Loop = true asset once therefore does not emit its time-zero event again at the duration
boundary. Expressions retain the already-correct stateful lifecycle: the newest expression remains
pinned at full weight after fade-in, including across a test-clock rollback, until another valid
expression replaces it, a model commit succeeds, or shutdown clears it. No expression clear,
duration, new crate, dependency, FFI call, or frozen whole-model snapshot is introduced.

Superseded amendment: automatic breath is an explicit weighted model-range group

This intermediate rule is retained as history only; the reference-compatible
amendment below supersedes it for the current runtime.

A parameter merely named ParamBreath is not sufficient intent: third-party models can use that
conventional ID for authored pose selection. A signed sine written through the generic [-1, 1]
normalized-parameter helper also turns a common one-sided 0..1 range into a long clamp at the
minimum followed by a full-range sweep. A model-authored drawable threshold can then switch large
groups of parts every cycle even when the package has no motion or expression.

Automatic breathing is therefore opt-in through the first model3 Parameter group named Breath,
with the same first-64-ID bound as EyeBlink and LipSync. A model without that group keeps its
authored parameter defaults. For declared IDs, the runtime emits a deterministic four-second
unit-interval sine phase; the Core-coupled adapter maps it across each parameter's declared range
and applies it with 0.5 weight after motion/expression. Eye blink keeps its existing normalized
open/closed input. This is a parameter-evaluation correction, not a motion or expression trigger,
and it does not add renderer-side state.

Superseding amendment: restore the Mver reference breath and physics path

The explicit-group-only rule above was sufficient to stop the imported model's full-pose threshold
crossing, but it also removed the model's authored idle hair movement. The fixed Mver source shows
that this is not an optional model-specific effect: myUserModel.cpp always creates a
CubismBreath instance with the conventional ParamAngleX, ParamAngleY, ParamAngleZ,
ParamBodyAngleX, and ParamBreath IDs, then loads and evaluates the declared physics3 resource.
The update order is motion/expression, product drag, breath, physics, and Core update.

BongoCat therefore applies the same five fixed breath targets with their reference offsets, peaks,
cycles, and 0.5 contribution weight after typed product input. The contribution is additive
(current + value * 0.5, followed by the Core range clamp), matching the reference
AddParameterValue call; it is not a blend toward the breath target, which would weaken a
mouse-driven angle before physics reads it. A model3 Breath group remains an optional source of up
to 64 additional IDs and retains its existing model-range blend behavior; it is no longer required
for the conventional targets, and fixed IDs are not applied twice. For the reported model, the
ParamBreath range is 0..1 with default 0; the reference contribution stays at or below 0.5
and does not cross its authored visibility threshold,
while the fixed angle targets feed the declared physics rig and restore the hair's idle motion.

Validated physics3 v3 definitions are parsed by the model contract and evaluated by a bounded Rust
runtime with fixed-step interpolation, inertia, delay, and typed parameter IDs. Legacy exported
resources may omit Meta.Fps; that representation uses the incoming monotonic frame delta rather
than inventing a fixed rate, while an explicitly supplied FPS must still be finite and positive.
Unknown input or output IDs are skipped rather than reaching Core. This is a compatibility path for
the declared physics resource, not a claim that every Cubism feature or every platform has
completed the full R5 black-box gate; pose evaluation remains separate and unfinished.

Rejected alternatives

  • Moving Cubism Core, model loading, or GPU resource preparation into the new
    crate: rejected because that would make pure playback depend on FFI and
    rendering.
  • Moving runtime MotionPlayback/ExpressionPlayback state into playback:
    rejected because those values encode product command, priority, generation,
    and audio/shutdown semantics.
  • Keeping file loaders in playback with a CommittedModel dependency:
    rejected because resource I/O and model identity are not part of numeric clip
    evaluation.
  • Creating empty physics/pose modules: rejected until authorized fixtures and
    the R5 compatibility gate define their contracts. A bounded physics evaluator
    is now allowed only for declared, validated v3 resources; pose remains gated.

Adr 0062 Live2d Render Resource Boundary (`docs/adr/0062-live2d-render-resource-boundary.md`)

ADR-0062: Live2D render-resource preparation boundary

  • Status: accepted
  • Date: 2026-09-24
  • Depends on: ADR-0042, ADR-0060, ADR-0061, ADR-0003

Context

bongocat-live2d combined the Cubism Core owner with model-package render
resource work. The Core owner needs a committed model, a Moc, Core parameter
IDs, and drawable snapshots. Key-image inventory, background discovery, PNG
dimension checks, and key-overlay resolution do not need Core or a GPU; they
only need the committed package and the immutable bongocat-render resource
types.

Keeping those CPU resource concerns in bongocat-live2d made the app and
runtime depend on the Core crate for key-image contracts, and made it harder
to test the package-to-render boundary without compiling the vendor SDK.

Decision

Create bongocat-live2d-render for the platform-neutral model-to-render
resource preparation layer. It owns:

  • construction of RenderResources from a CommittedModel index;
  • background and key-image discovery, regular-file filtering, and PNG
    dimension validation;
  • KeyImageInventory, the ordered HID-to-artwork candidate vocabulary, and
    resolve_key_overlays;
  • a small resource-preparation error that is mapped to the existing
    Live2dErrorCode::ResourceIo by the Core adapter.

The crate depends on bongocat-model, bongocat-render, image, and the
standard library. It does not depend on Cubism Core, bongocat-live2d,
runtime, GPUI, platform APIs, or a GPU backend. It does not decide input
bindings or mutate runtime state; the app still applies the inventory when
building bindings, and runtime still consumes only the resulting immutable
RenderSnapshot.

bongocat-live2d now asks bongocat-live2d-render to prepare resources during
model loading, then retains Core loading, parameter/part writes, Core error
mapping, and snapshot production. bongocat-app and bongocat-runtime import
the inventory and overlay resolver from the new crate directly.

bongocat-render remains the immutable snapshot/resource/transport contract.
The actual Metal and D3D11 GPU owners, window surfaces, and present loops stay
in bongocat-overlay; this slice does not move platform GPU code or make the
renderer read configuration or input services.

Consequences

  • model resource preparation and key-image contracts can be tested without the
    Cubism SDK;
  • bongocat-live2d no longer owns the image decoder or key-image directory
    scan;
  • app/runtime no longer acquire those contracts indirectly through the Core
    crate;
  • the existing "missing artwork means no binding" behavior and side-specific
    overlay rules remain unchanged;
  • no new renderer backend, window owner, or GPU lifecycle is introduced.

Rejected alternatives

  • Moving all of bongocat-overlay into a new crate: rejected because its
    platform files also own windows, input routing, placement, and shutdown;
    separating those owners requires a separate platform seam.
  • Moving PNG decoding into bongocat-render: rejected because that crate is the
    immutable contract and must remain independent of filesystem/image details.
  • Letting the renderer decide whether a key has a binding: rejected by ADR-0042;
    the app gates bindings using the shared inventory contract.
  • Reimplementing key-image scanning in app and runtime: rejected because the
    inventory and the loader must remain one source of truth.

Adr 0063 Legacy Mver Color Pipeline (`docs/adr/0063-legacy-mver-color-pipeline.md`)

ADR-0063: 模型渲染采用 encoded-space 兼容颜色管线

状态:Accepted
日期:2026-09-24
取代:ADR-0046
修订(2026-09-24):根据多层模型在逐 drawable 透明度下出现重影的实机反馈,presentation opacity
改为在内部 drawable 合成完成后由平台最终 surface 统一施加。

背景与证据边界

用户反馈 Windows 上的模型颜色与 Bongo-Cat-Mver 明显不同。固定上游 commit
4da0b9468ad3b6ffaa096eba3f080501d6ab0b5c 的源码树能直接确认以下有限事实:

  • myUserModel.cpp 使用官方 Cubism Framework 的 OpenGL renderer,并通过
    LAppTextureManager::CreateTextureFromPngFile 加载模型纹理。
  • myUserModel.cpp 的 PREMULTIPLIED_ALPHA_ENABLE 条件分支在宏未定义时调用
    IsPremultipliedAlpha(false)。
  • drawable 的 clipping、order 和 blend 交给官方 renderer;背景、设备和按键资源还在 mode 层
    另行组合。
  • 行为清单基线 44f44bc 的旧版 src/pages/main/index.vue 将 window.opacity / 100 施加到包含
    背景、Live2D canvas 和按键图的根容器;这支持“窗口 opacity 属于最终 surface”的产品语义,
    但它不是固定 Mver C++ commit 对 opacity 实现细节的证明。

该 commit 不包含 LAppTextureManager 的实现、官方 OpenGL shader、完整 build flags,也没有
固定 SFML 或 Cubism Framework 的实际版本。因此不能仅凭 Mver commit 断言最终二进制的纹理上传
格式、shader 数学、背景/按键上传语义或精确像素结果。

项目另行固定了 Cubism Native Framework R5 5-r.5 的行为来源
(docs/cubism/cubism-framework-behavior-sources.md)。其中 OpenGL 参考路径使用普通
GL_RGBA render target 与 encoded-space shader 颜色处理,但它是独立的 R5 oracle,不是 Mver
二进制来源的直接证明。
本 ADR 因用户报告、上述有限 Mver 证据和独立参考路径而选择兼容性方向;来源链和像素等价仍是
TODO 的验收门禁,不被本 ADR 的 Accepted 状态掩盖。

决策

  1. 两端统一采用 encoded-space compatibility contract。
    • Windows:模型、背景和按键纹理使用 DXGI_FORMAT_R8G8B8A8_UNORM;composition back
      buffer 与 render target view 使用 DXGI_FORMAT_B8G8R8A8_UNORM。
    • macOS:模型、背景和按键纹理使用 MTLPixelFormat::RGBA8Unorm;CAMetalLayer 和
      color attachment 使用 MTLPixelFormat::BGRA8Unorm。
    • alpha 仍按 premultiplied alpha 输出;clipping mask 仍使用 alpha-only UNORM target。
  2. 不在任一平台的 shader 中插入 sRGB decode/encode,也不对 blend color 做隐式转换。
    两端的 shader、filter、预乘和 blend factor 由同一平台无关 contract 驱动,避免只修一端。
    模型、背景和按键先按模型/资源 alpha 完成内部合成;窗口 presentation opacity 不进入逐
    drawable 的 fragment alpha,而是在最终 surface 合成后由 Windows DirectComposition visual
    effect 或 macOS NSPanel 统一施加一次。这样多层 Live2D 不会各自衰减并产生重影。
  3. 消费 Core 的 double_sided 状态。 D3D11 和 Metal 都对非 double-sided drawable(包括
    mask source)执行背面剔除;两端明确以 counter-clockwise 为正面。水平镜像会反转三角形
    winding,因此镜像时改用相反的剔除面;double-sided drawable、背景和按键 overlay 不剔除。
  4. 这是兼容优先的产品决策,不是 encoded-space 线性光度正确性的声明。 将来若要切换到
    linear-light,必须新增独立决策,同时修改两个 backend、pixel contract、截图基线和技术
    设计,不能只改 Windows 的 RTV 或只改 Metal 的 drawable。
  5. 不改变模型资源、schema 或用户配置。 这是 renderer-only 行为修复,不需要迁移、版本
    分支或旧配置转换。

影响

  • 在来源链尚未完全核实的前提下,优先选择能解释用户色差、且不改变模型资源的产品兼容语义;
    最终是否与实际 Mver 像素一致仍由硬件 readback 决定。
  • 两端使用相同的 encoded RGBA 输入和 blend state;presentation opacity 在内部 drawable 合成
    完成后由各自平台的最终 surface 机制统一施加,避免自动 gamma 转换或逐层衰减造成单边漂移。
  • macOS Core Animation 和 Windows DirectComposition 仍可能在广色域显示器上对最终 surface
    做不同的系统级显示转换;renderer 不承诺跨系统色彩管理绝对一致。
  • 不把 Cubism Framework 的 C++ 源码、shader 文本或厂商二进制复制进产品;实现只使用项目
    自有 contract,并遵守 Cubism 许可边界。

当前验证与待完成门禁

已完成的自动化验证:

  • cargo fmt --all -- --check;
  • cargo clippy -p bongocat-overlay --all-targets --all-features --locked -- -D warnings;
  • cargo test -p bongocat-overlay --locked(当前 72 项通过);
  • cargo test -p bongocat-render --locked(16 项通过)与
    cargo test -p bongocat-live2d-render --locked(3 项通过);
  • cargo check -p bongocat-overlay --target x86_64-apple-darwin --locked;
  • cargo check --workspace --release --locked;
  • Windows bongocat-overlay model-switch preview(包含 D3D11/DirectComposition visual
    opacity effect 的创建、提交和资源切换)通过;这只是 renderer lifecycle smoke,不是 Mver
    像素验收;
  • 共享 culling decision、blend-factor contract 和两端 format contract 单元测试。

完整 cargo test --workspace --locked 当前在 bongocat-app 测试二进制中报告 136 passed、1 failed:
settings::tests::service_renames_and_covers_a_model_of_either_origin 的 Windows 路径分隔/前缀断言;
命令随后停止,不能把它写成整个 workspace 已通过。cargo clippy --workspace --all-targets --all-features 还会触发仓库现有的 storage-test-injection 与 Production 互斥 feature
compile_error!,因此 workspace 静态检查使用不含 --all-features 的命令并单独记录该限制。

尚未完成、因而不能宣称兼容验收通过:

  • Mver 实际 build 的 Cubism/SFML 版本、texture manager 和 shader provenance;
  • Windows/macOS 目标机首帧、模型截图与同 snapshot readback;
  • encoded RGB、premultiplied alpha、mask、blend、presentation opacity 和颜色 tolerance 的
    跨平台对照;
  • mipmap 生成/缩小过滤、背景与按键层顺序及复杂 mask channel parity;
  • 真实 GPU/驱动、显示器色彩管理和 mirror/culling 的硬件验证。

在这些证据完成前,P3-MVER-COLOR-COMPATIBILITY 与相关 TODO 保持未勾选。


Adr 0064 Human Readable Bounded Logging (`docs/adr/0064-human-readable-bounded-logging.md`)

ADR-0064: Human-readable bounded application logging

状态:已接受(2026-09-24)

背景

BongoCat 已由 bongocat-log、bongocat-app::ApplicationLogHandle 和
bongocat-live2d::CoreLogHandle 提供环境隔离、固定事件字段、文件权限、按 UTC 日/文件大小轮转、
目录级容量上限、Core callback 队列和 panic 脱敏。现有输出仍采用 application/Core JSONL;机器解析
较严格,但普通用户难以直接阅读,现有事件目录也只覆盖少量生命周期与导出失败路径,不能充分支持
模型、配置、输入、窗口、权限、更新和跨层交互排障。

本决策不重新引入第二套日志框架。现有 writer 已拥有项目需要的生命周期、隐私、容量和非阻塞 panic
边界;缺口是输出格式、可配置过滤、更多有界事件以及 application/Core 对同一文本格式与 retention
policy 的复用。

决策

1. 单一人类可读文本方案

application 与 Cubism Core 日志都使用 UTF-8 单行文本,不再写 JSONL。每条记录固定包含:

text
<UTC timestamp> <LEVEL> [<module>] <stable-code> | <message> | <bounded context>

时间使用带 Z 的 UTC 时间戳;级别为 ERROR、WARN、INFO、DEBUG 或 TRACE;module、stable
code 和 message 分离,便于用户阅读和开发者检索。文件名统一使用 .log:

  • application-YYYY-MM-DD.log
  • cubism-core-YYYY-MM-DD.log

同一 application/Core writer 共享文本格式化、过滤、单文件大小、rotation、数量、目录总容量和 retention
实现。Core callback 仍只做有界复制与非阻塞入队,文件写入只在专用 worker 中执行。

2. 日志过滤与保留策略

当前 v1 配置新增:

json
{
  "logging": {
    "level": "info",
    "retention_days": 7
  }
}
  • logging.level 是写入阈值,接受 error、warn、info、debug、trace,默认 info。
  • logging.retention_days 接受 1..=30,默认 7;配置降低后立即收敛旧文件。
  • 日志首先按 UTC 日切换;单个活动文件超过 1 MiB 时继续切分编号文件,防止单个文件无界增长。
  • 当前 application 与 cubism-core 日志目录合计最多 8 MiB 和 32 个文件;超限只删除最旧的
    已轮转文件,当前 UTC 日活动文件始终保留。
  • 不提供 rotation mode 配置。日分文件是用户组织日志的稳定边界,大小上限是异常流量保护;只允许用户在
    “按天”和“按大小”之间二选一会制造无实际价值的状态组合。

3. 事件和级别

事件仍由 app-owned 闭合 event catalog 产生,不用自由文本作为公共日志协议。级别按以下语义分配:

  • error:关键操作失败、panic、shutdown 失败、数据损坏或不可恢复的资源/解析错误。
  • warn:程序继续但已降级、fallback、重试、回滚、权限不足、临时 GPU/输入服务异常。
  • info:启动/退出、用户可见的模型/配置/窗口状态变化、更新阶段结果和恢复结果。
  • debug:服务 start/stop、候选模型 prepare、输入 reset/reconcile 状态变化、窗口状态 flush。
  • trace:有界、低频且不含输入内容或资源正文的开发诊断;不用于逐事件输入、逐帧渲染或下载进度。

启动、退出、配置读写/恢复、状态持久化、模型准备/切换/导入/删除、资源解析、窗口创建/显隐、
输入服务与权限、更新检查/下载/验签/安装、平台文件/网络错误和 Rust→GPUI command 失败都必须在
app/service 边界进入日志。底层返回稳定错误码后由最近的 app owner 记录一次,避免同一错误在低层、
service 和 UI 重复输出。

4. 隐私和性能边界

日志不得包含用户路径、真实按键序列、剪贴板、配置原文、模型正文、URL、密钥/凭据、签名材料或动态 OS 错误文本。
允许的上下文只包括闭合 stable code、匿名/可移植 model ID、origin、阶段、计数、字节数、revision、
能力/服务状态和构建版本;文本统一转义换行/控制字符并限制长度。panic hook 仍不读取 payload,
使用非阻塞记录。

默认 info 只写低频业务和错误边界。输入事件、frame tick、cursor/axis 样本、下载进度、轮询 snapshot
和可恢复的 temporary presentation unavailable 不逐次记录。轮转与清理失败只进入匿名 writer 统计,
不得阻止 overlay/runtime 继续运行。

5. Diagnostics preview

匿名 diagnostics JSON 保持不变。preview bundle 中的 application lifecycle 历史从新的 .log 文件
严格解析固定 code,并重新输出为脱敏的 application-events.log;每条固定为
<LEVEL padded> [<module>] <code>,不复制原始日志行、消息、上下文、路径或时间戳。输入文件或
其中任一记录无法通过共享文本语法、闭合 code/module/level 契约时,整份来源文件跳过并只增加匿名计数。
Cubism Core 原文仍不进入 preview,只保留匿名
written/dropped/rotated/pruned/bytes/file 统计。

影响

  • 不新增第三方 logging 依赖,也不增加 GPUI/runtime/OS 类型到公共日志 API。
  • application/Core 输出、文件发现、retention helper、diagnostics preview 和相关测试必须一起迁移;
    活跃代码不再保留 JSONL writer/解析分支。
  • .log 是用户可支持格式,不承诺稳定的第三方日志解析 schema;需要机器处理时使用匿名
    diagnostics.json 或 preview bundle 中的严格重写记录。
  • 日志设置是首版 v1 字段,遵循仓库规则直接更新 schema/default/fixture/实现,不增加迁移或旧字段
    fallback。

Adr 0065 Model Mode Metadata And Card Badge (`docs/adr/0065-model-mode-metadata-and-card-badge.md`)

ADR-0065: 模型模式元数据与卡片 Badge

状态:已接受(2026-09-25)
依赖:ADR-0037(在应用内导入 BongoCatMver 模型)、ADR-0047(模型元数据编辑边界)、ADR-0059(UI protocol 边界)

背景

模型管理页需要让用户直接看出每个模型属于标准、键盘还是手柄模式。模式必须跨重启稳定,不能只从标题或目录名临时推断。

普通 BongoCat 模型包没有独立的模式字段,但产品现有的键图资源约定提供了可验证的形状:

  • resources/left-keys 的 PNG 键图是标准模式的基础;
  • resources/right-keys 的 PNG 键图表示额外的右手输入资源;
  • 手柄模式使用 DPad*、*Trigger*、South/East/West/North、Start/Select 等手柄专用键名。

这些是导入时的分类规则,不是运行时每次渲染都重新执行的猜测。分类在模型 staging 通过完整包校验后、原子提交前完成;没有任何可用键图、无法归入三类资源形状的普通包直接拒绝,不写入 store,也不写入配置。

BongoCatMver 是例外:它有明确的 legacy standard、keyboard、gamepad source section,所选 section 比转换后目录形状更权威。构建预置模型则以稳定 id standard、keyboard、gamepad 标识自身模式。

决策

1. installed 模型元数据持久化 `input_mode`

当前 v1 的 model.imported_models[] 每条记录包含:

json
{
  "id": "model-storage-id",
  "title": "User-facing title",
  "input_mode": "standard"
}

input_mode 是必填的 standard、keyboard 或 gamepad。它与标题一起在导入事务完成后写入 config.json,重启时从配置读取。模型改名只修改 title,必须保留原 input_mode;删除模型随整条元数据删除。

model.built_in_models[] 继续只保存 id 与 title。预置模式由构建拥有的稳定 id 派生,不在用户配置里重复保存。

input_mode 不加入 { id, source } 模型身份,不新增模式编辑 command,也不从显示标题反解析。

2. 导入时判定普通包模式

bongocat-model-store 在普通包复制、键名归一化和第二次 PreparedModel 校验之后扫描 staging 树,使用与 runtime 键图读取相同的直接 .png/文件名语义:

  1. 任一手存在手柄专用键名 → gamepad;
  2. 否则任一手存在 right-keys → keyboard;
  3. 否则存在 left-keys → standard;
  4. 两者都不存在 → InvalidPackage,导入事务失败。

判定结果和模型一起从 store API 返回,Application 在同一导入循环中写入 metadata。模式判定失败时,用户源目录、store 目的地、staging 和 config 都保持不变。right-keys 单独存在即可判定 keyboard;手柄判定不只依赖 East,还检查完整的预置/转换手柄词汇子集。

Mver 转换不重新用上述启发式覆盖 source section,而是把用户选择的 legacy mode 传入同一提交尾部。预置模型也不从资源目录反推,直接由稳定 preset id 映射。

3. settings snapshot 投影强类型模式

bongocat-ui-protocol 独立拥有 SettingsModelMode。Application 把 config/model 值穷尽映射为该协议类型,并把它放进 SettingsModelEntry.input_mode。正常导入的模型一定有该值;只有手工放入 store 且无法通过包校验的异常目录才会没有模式 Badge。

GPUI 页面只消费 snapshot,不读取 config、不扫描模型目录,也不自行解释 id 或标题。

配置、model-store、UI protocol 各自保留自己的稳定枚举,避免跨层依赖或第三方类型泄漏。Application 的映射必须覆盖全部枚举值。

4. 卡片右上角使用无图标、中性色的 GPUI Kit 标记

固定 GPUI Kit revision 的 Badge 原生支持数字、圆点和图标,不是文字标签 primitive。Models 页面保留 Badge 作为封面覆盖层容器,但不再使用它的 icon/number overlay;可见内容改为与原实现一致的 compact secondary Tag:

  • 标准、键盘、手柄使用相同的中性主题色;
  • 模式差异由本地化文字表达,不增加彩色背景噪声。

标记定位在封面右上角,不新增卡片行,也不改变封面、标题编辑面或卡片高度。模式文字始终可见,标记是只读展示,不加入 Tab 顺序。

5. 本 ADR 不改变 runtime 输入绑定

input_mode 在本阶段是导入来源/模型类别元数据,用于配置完整性、快照和模型卡片展示。它不在本 ADR 中切换 runtime 的 key/gamepad binding;现有 installed 模型默认键盘绑定及预置 gamepad 特例仍按其独立任务维护。

因此“卡片显示 Gamepad”表示该模型来自或被构建标识为 gamepad 模式,不等于本 ADR 已完成 installed Mver gamepad 的 runtime 输入接线。未来若让该字段驱动输入行为,必须单独修改 runtime/overlay contract、迁移既有绑定测试并完成双平台手柄验证,不能在纯 UI 改动中静默发生。

6. 当前 v1 直接增加必填字段

完整 v1 尚未发布,因此不增加 schema 版本、migration、alias 或旧字段 fallback。缺少 input_mode 的 installed metadata、未知枚举值或其它旧开发结构都不是当前完整 v1,按现有严格配置恢复边界处理。Development 与 Production 使用相同结构、不同存储根。

结果

  • 普通模型不会以“未分类”进入 store 或 config;无法判定就是导入失败。
  • Mver 三种转换模式在配置、快照与卡片之间有强类型闭环。
  • 普通包的标题、目录名和具体美术文件不会改变已保存的模式;改名、删图、目录变化和重启不会重新推断。
  • 预置模式不与用户可编辑标题或封面元数据混在一起。
  • UI 使用官方 GPUI Kit 组件、主题色和本地化可见文案,没有自绘状态;模式标记无图标、位于封面右上,并保持中性 secondary 颜色。

未验证 / 后续

  • Windows/macOS 设置窗口中右上角模式标记的真实观感、Retina/DPI 和长本地化文本仍需平台 smoke 与截图检查。
  • installed Mver gamepad 的 runtime 输入接线不属于本 ADR;如产品要让模式 Badge 同时代表实际输入行为,需要独立任务。

Adr 0065 Random Model Behavior Scheduler (`docs/adr/0065-random-model-behavior-scheduler.md`)

ADR-0065: Runtime-Owned Random Model Behavior Scheduler

状态:已接受(2026-09-25)

部分被 ADR-0072 取代:model.random_behavior.enabled 已改为
model.random_behavior.mode(off 为其中一个取值),候选集合不再是固定的
motion 与 expression 合并列表。间隔字段、RandomBehaviorSettings、
Idle priority、seed 注入与 admission gate 的决策不变,仍然有效。

背景

模型同时声明 motion 和 expression。设置页需要一个正交的开关和时间间隔,让用户在不让
GPUI 或 renderer 自己决定业务行为的情况下启用周期性随机播放。两个字段必须进入当前 v1
配置、typed settings protocol 和 runtime,而不能作为 UI-only 草稿或由 renderer 读取配置文件。

决策

  • model.random_behavior.enabled 是唯一的启用门禁,默认 false;
    model.random_behavior.interval_seconds 默认 30,持久化范围为 1..=3600。
  • bongocat-runtime::RandomBehaviorSettings 是 runtime 的唯一设置类型。
    SetRandomBehaviorSettings 携带 expected config revision 对应的强类型 command,并在
    RuntimeSnapshot 中回显;非法间隔在 runtime 和 config 边界都拒绝。
  • runtime 从当前已成功提交的 model index 声明的 motion 与 expression 中均匀选择一个;
    这是合并后的行为列表等权,不是先以 50% 选择 motion 再以 50% 选择 expression。第一次选择等待
    一个完整间隔。成功模型切换、设置变更和重新启用都会重锚定时器;长暂停只选择一次,不追赶补发,
    时钟回退不会制造过去的触发。
  • 自动 motion 使用 Idle priority,不替换正在进行的 Normal 或 Force 产品 motion。
    expression 仍遵守现有 expression 淡入、替换和模型 commit 清理语义。空行为列表是 no-op。
  • 选择器是 runtime 内部的固定 seed PRNG;生产 seed 只用于进程内变化,测试可以注入 seed
    并用 MonotonicClock 得到可重复序列。renderer、GPUI Entity 和平台 callback 不参与选择。
  • 自动行为的 runtime event sequence 与 audio command sequence 分离;所有生产 audio command
    使用 MotionAudioClient 的独立序号分配器,并在同一 publish lock 下完成分配与入队,避免自动
    行为的高位 event sequence 让产品音频 waiter 误判低序号 command 已完成。
  • 自动行为和 shutdown request 通过同一 admission gate 排序:shutdown request 立即记录状态并
    开始有界等待,已接纳的自动副作用在 worker 进入 shutdown drain 前完成,后续请求不会插入新的
    随机行为。显式停止的非零 motion fade 即使 overlay 隐藏、没有下一帧,也在注入单调时钟达到
    fade duration 后视为 settled。
  • settings 页面把两个字段放在独立的 Model behavior 页面中(ADR-0066);间隔控件在
    开关关闭时置灰但保留已保存的值。配置写入仍使用原子、revision-checked 的 settings service。

验证

  • bongocat-config fixture 覆盖默认 round-trip 和非法间隔;协议测试覆盖 typed command。
  • runtime scheduler 单元测试覆盖固定 seed、间隔等待、禁用/模型切换重锚和时钟回退;另一个
    Core-backed runtime 集成测试验证模型在一个间隔后确实产生 motion 或 expression。固定 seed 的
    Core-backed 构造器仍属于后续测试基础设施,不把生产 seed 当作可重复性证据。
  • audio/runtime 回归测试覆盖独立音频序号分配、序号回绕比较、overflow recovery 保留模型
    Prepare、隐藏 fade 的逻辑 settled,以及 shutdown admission barrier 和非阻塞 request;这些测试
    不替代双平台实机和长时间 soak 证据。
  • 仍需双平台实机观察、长时间随机序列/时钟变化 soak,以及正式发布门禁;本 ADR 不把
    scheduler scaffold 宣称为完整 Live2D 兼容或平台发布证据。

后果

随机行为不再由第三方 easy-live2d helper 或旧版配置隐式继承;v1 明确拥有该产品
行为。配置初始关闭不会改变现有启动观感,用户打开后需要等待一个完整间隔才看到第一次选择。


Adr 0066 Gilrs Gamepad Backend (`docs/adr/0066-gilrs-gamepad-backend.md`)

ADR-0066: gilrs 手柄后端与平台窄适配边界

状态:已接受(2026-09-25);fork 已补齐 callback lifetime、bounded queue/epoch、authoritative reset、bounded shutdown、compile guard 与 xinput 回归修复;2026-09-26 追加 macOS IOHID worker run-loop 空转修复(空闲 CPU 98% → 0%);2026-10-02 追加 WGI 焦点门控读取修复与 WGI raw analog axis 取值域修复;2026-10-05 追加 WGI raw element 顺序修复与 SDL 轴槽位翻译的实测回退(见 Issue #1099);双平台物理设备与长期证据仍阻塞完成声明

背景

BongoCat 此前在 bongocat-platform 内分别维护 Windows XInput 轮询和 macOS
GCExtendedGamepad callback。两套代码重复承担设备发现、按钮映射、连接 generation、axis
归一化和 callback 生命周期,且产品兼容面被 XInput 0–3 slot 与 Apple extended profile 限制。手柄兼容、
驱动修复和平台 backend 问题应集中维护在第三方边界,而不是继续在 BongoCat 内扩展两套系统 API。

项目已有强类型 InputEvent、可靠 producer、generation-keyed gamepad-axis latest-value transport 和
runtime dead-zone;这些是产品语义,不能由第三方库类型替代。按 ADR-0030 的复用阶梯,应选择成熟的
跨平台 gamepad backend,再只保留 BongoCat 所需的窄适配。

决策

  • 根 workspace 精确固定 https://github.com/ayangweb/gilrs 的 commit
    9a5ee3d6c36db25871f6ea67eafaf09c637a0c50,package 版本为 gilrs 0.11.2 /
    gilrs-core 0.6.8。该 commit 包含 callback context ownership、bounded queue/epoch、authoritative
    reset、WGI/XInput bounded shutdown、macOS IOHID stop/join、target-scoped compile guard 与 xinput
    extreme-axis regression 修复;再追加 macOS IOHID worker 的 run-loop 空转修复:worker 曾以 null
    mode 调用 CFRunLoopRunInMode,CoreFoundation 会立即返回而不等待,使无条件创建的空闲
    backend 持续占满一个 CPU 核;修复传入真实 run-loop mode,空闲 CPU 由 98% 降到 0%,
    reset 仍在约 8ms 内 ack、shutdown 约 105ms join,并新增 idle_backend_does_not_spin
    回归测试;再追加 WGI 焦点门控读取修复:Windows 只把 mapped 的
    Windows.Gaming.Input.Gamepad 读取投递给拥有前台窗口的进程,而 gilrs 在
    Gamepad.FromGameController 成功(即 XInput 设备)时固定选它,使这类设备只在 BongoCat
    窗口获得焦点时才有输入,HID 设备不受影响因为它们走 RawGameController。修复改为设备只要
    暴露任何 raw report 就优先用 RawGameController,mapped 读取仅作为完全无 raw report 时的
    回退;该选择同时决定 element 列表、AxisInfo 取值域和 SDL mapping 查询键,因为三者都描述
    raw 布局;再追加 WGI raw analog axis 的取值域修复:backend 曾用 SDL 的预居中换算
    (value * 65535.0) - 32768.0 生成 raw sample,同时把 EvCodeKind::Axis 声明为 i16
    取值域,而 gilrs 会再归一化一次,导致静止摇杆落在 i16::MIN 并被读成 -1.0、
    负方向一半行程被钳制为最大偏移,且映射到按键的模拟扳机按有符号域读取时静止值正好是半程、
    压在 axis-to-button 阈值上。最后追加 WGI raw element 顺序修复(Issue #1099):
    native_ev_codes 沿用上游 gilrs 的 evdev 下标,而 RawGameController 按 Windows Gaming
    Input 自己的顺序报告元素,Mapping::default 又按 element code 身份匹配,于是四个面键整体错位
    一位、把 D-pad 报告成按键的设备十字键四个方向全部落到表外而失效、两个模拟扳机分别落到右摇杆
    Y 轴和左扳机上——静止摇杆因此正好压在 axis-to-button 阈值上而让被它驱动的按键逐采样抖动,
    这就是 Switch 模式下"模型鬼畜地疯狂按下所有按键"。修复是让 native_ev_codes 改带 WGI 原始下标
    (面键 SOUTH/EAST/WEST/NORTH = 0..3,D-pad 落在 10..13,Guide 之后的元素排在 15 之后以免借用
    别的按键下标),这一半只影响没有 SDL mapping 的设备与 mapped 回退路径。曾同时尝试把
    Gamepad::axes 按 SDL 映射的槽位顺序重排(六轴时 [0, 1, 4, 2, 3, 5]),使 lefttrigger:a2
    解析到左扳机;用整库实测后回退,见阻塞 0b。后续修复仍必须形成可审计的 patch series、
    推送到 fork master 并再次精确固定 commit。
    deny.toml 只放行该精确 git source;fork 的 SDL mapping submodule 由该 commit 固定为
    15b5e9f4abfb1c5c691c468799816755a91a2e11。deny.toml 通过 required-git-spec = "rev" 拒绝
    branch/tag git source。workspace dependency 不启用平台 feature;bongocat-platform 的 Windows target
    table 启用 wgi,macOS target table 保持 featureless 并由 gilrs-core 选择 IOHID。
  • Windows 使用 gilrs 的 wgi backend;macOS 使用 gilrs 的 IOHID backend。BongoCat 不再声明
    objc2-game-controller,不直接加载 XInput DLL,也不自行维护 SDL mapping、hat/D-pad 解码或设备
    backend。
  • GilrsGamepad 是 bongocat-platform 私有且双平台共用的窄 adapter。gilrs::GamepadId、
    Button、Axis、Event 和错误只在该文件内出现;runtime/UI 只接收项目自有的
    GamepadConnection、GamepadButton、GamepadAxis、InputEvent 和
    GamepadAxisSample。
  • builder 关闭 gilrs 默认 jitter/dead-zone filter、force feedback 和
    SDL_GAMECONTROLLERCONFIG 环境映射,保留 fork 内置 SDL mapping,并关闭自动 state update 后由
    adapter 显式更新。D-pad axis 使用 gilrs 自带 axis_dpad_to_button,BongoCat 不复制 hat 解码。
    产品 dead-zone 仍只由 runtime 的 GamepadAxisSettings 决定。
  • 一次 drain 最多消费 256 个 gilrs event,剩余 backlog 留在 gilrs 的 bounded backend/high-level pending
    queue,不能让手柄洪峰阻塞 Raw Input/CGEventTap。最大活动设备数仍为 4,与现有 24-key axis transport
    容量一致;gilrs
    backend id 不进入项目 API,adapter 分配 0..3 项目 slot,每次连接只通过
    GamepadAxisProducer::connect 分配新 generation。
  • gilrs 的位置名与项目词表并非逐字相同:left/right trigger 逻辑按钮映射为项目 shoulder,
    LeftTrigger2/RightTrigger2 的连续值同时映射为项目 trigger button 与 trigger axis。连续值采用
    产品阈值 value >= 0.5 press、< 0.5 release;stick 使用 [-1, 1],trigger 使用 [0, 1]。
    C、Z、Mode、Unknown 和未知 axis 不伪造项目 identity,只进入匿名拒绝/解码计数。
  • 连接成功和任何已提交的全局 input Reset 后,adapter 调用 gilrs reset epoch,清空 fork queue 与
    cached state,要求 backend 重播 authoritative held button 与六轴 latest value;不分配新 generation。
    fork overflow 通过强类型 BackendOverflow { dropped } 事件可见,adapter 计数并触发同一 reset/reseed
    恢复。断开先淘汰该 generation 的 pending axis,再可靠发布 GamepadDisconnected。shutdown 清理项目
    connection/axis state并消费 gilrs 的 bounded shutdown acknowledgement,最终 Reset 仍由 input owner
    发布。
  • gilrs 构造返回错误或构造阶段 panic 只禁用 gamepad adapter并增加一次匿名 backend failure;键鼠
    service 继续运行。fork worker 的 bounded reset/stop acknowledgement、overflow marker 和 callback
    quiescence 通过强类型 gilrs API/事件进入 adapter;旧 XInput 轮询次数/查询错误、GameController
    background policy 和 callback 临时 snapshot 等 backend 专用诊断从项目 contract 删除,只保留连接、
    断连、容量拒绝、按钮、axis、拒绝、backend failure、backend event discard 与通用 input service 指标。
  • 后续手柄兼容、驱动差异、backend queue、callback 和 shutdown 修复全部在该 fork 完成并提升精确
    commit;BongoCat 不加入平行 XInput/GameController workaround。升级 fork 后必须重跑双平台 adapter
    contract、物理手柄矩阵、100-cycle restart 与长时间压力。

依赖审查

  • gilrs 0.11.2 的许可证为 Apache-2.0 OR MIT,MSRV 1.84,低于 workspace 的 Rust 1.97。
  • Windows WGI 和 macOS IOHID 的系统绑定及 unsafe 面积被限制在第三方包内;项目 adapter 保持
    safe Rust,第三方对象和错误不进入项目公共 API。替换边界是私有 gilrs_gamepad.rs,不会改变
    runtime/config/UI contract。
  • 内置 SDL mapping 来自 fork 固定 submodule;该 database 有独立于 gilrs dual-license 的许可,
    构建和分发前必须把 gilrs/SDL_GameControllerDB/LICENSE、revision 和 attribution 纳入第三方
    合规清单。

已知阻塞

  1. Windows WGI 焦点矩阵(已在 fork 修复,实机证据仍缺): gilrs 文档提示 Windows Gaming Input
    可能需要关联且获得焦点的窗口。Issue #1082 证实了这个门控真实存在:BongoCat Raw Input window 为
    hidden,overlay 默认为 click-through,设置窗口也不保证聚焦,因此 mapped 读取让 XInput 模式手柄
    只在 BongoCat 窗口获得焦点时才有输入。fork 已改为优先使用不受焦点门控的 RawGameController,
    但仍必须在 Windows 10/11 实机验证 settings 开关、焦点/失焦、click-through、启动时已连接、重连和
    多手柄;在取得该证据前不得把焦点矩阵写成已验证,若 raw 路径在这些状态仍不可靠,应继续修复 fork