EN TL;DR — An imported appearance package can be imported successfully but can never be activated. AppearanceService persists the selection via configAPI.setConfig('appearance.selection', id) → Tauri set_config, where the value is validated against the product-control enum (the 10 builtin ids only), so any imported package id is rejected. The runtime actually accepts the package (it is applied and rendered for ~370 ms), then the transaction rolls back. Per docs/architecture/appearance-package-system.md, imported and builtin appearances are supposed to share the same contract, compiler and commit path — so the static enum whitelist looks like the defect.
环境
- OpenBitFun 1.0.1 桌面版
- Windows 10 22H2(19045)/ WebView2
- 外观包 schema:
openbitfun.appearance v2(由内置 create-openbitfun-skin skill 生成)
- 包已在本地通过校验:
validate 返回 VALID,零警告;build 成功,归档约 0.5 KB
复现步骤
- 用内置 skill 初始化并生成一个最小外观包:
python scripts/openbitfun_appearance.py init <dir> --id <custom-id> --name <name> --mode dark
python scripts/openbitfun_appearance.py validate <dir>
python scripts/openbitfun_appearance.py build <dir> --output <file>.openbitfun-appearance
manifest 只声明一个组件、两个部件、三个属性:
"components": {
"markdown": {
"parts": {
"codeContent": { "base": { "whiteSpace": "pre-wrap", "overflowWrap": "anywhere", "wordBreak": "break-word" } },
"codePre": { "base": { "whiteSpace": "pre-wrap", "overflowWrap": "anywhere", "wordBreak": "break-word" } }
}
}
}
- 设置 → 外观 → 导入外观包 → 选择该归档文件
- 包导入成功,出现在外观列表中
- 选中该包
实际结果
弹出错误,且选择被回滚到原主题:
⚠️ 外观包应用失败
Failed to set config: value must be one of: "system", "openbitfun-light", "openbitfun-monochrome",
"openbitfun-slate", "openbitfun-dark", "openbitfun-midnight", "openbitfun-china-style",
"openbitfun-china-night", "openbitfun-cyber", "openbitfun-tokyo-night"
对应日志(webview.log / app.log,毫秒级连续):
21:01:46.990 [INFO ][webview][AppearanceRuntime] Appearance applied {"appearanceId":"<custom-id>","revision":2}
21:01:46.991 [ERROR][openbitfun_desktop_lib::api::config_api] Failed to set config through product control:
path=appearance.selection, error=value must be one of: "system","openbitfun-light",...
21:01:47.351 [INFO ][...service::config::providers] Appearance config changed: selection = system
21:01:47.358 [INFO ][webview][AppearanceRuntime] Appearance applied {"appearanceId":"openbitfun-dark","revision":3}
也就是说:外观包已经被成功 apply 进 runtime(revision 递增到 2),约 368 ms 后因持久化失败,事务执行补偿回滚,回到原主题。
另有两个相关现象:
- 若把包 id 改为某个内置主题 id 以复用命名空间,导入阶段会被拒绝:
Imported appearance cannot replace a builtin appearance: openbitfun-dark
(见 src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.ts)
即:自定义 id 存不住,内置 id 不许占,两条路都不通。
- 已成功导入的包在界面上无法删除:点击删除后无任何反馈,且日志中零记录。
预期行为
按仓库自身的架构文档 docs/architecture/appearance-package-system.md:
AppearanceRuntime 是唯一视觉 owner。内置外观和导入外观使用同一个 AppearancePackage 契约、同一个 compiler、同一个事务提交路径。选择值由正式配置键 appearance.selection 持久化;IndexedDB openbitfun-appearance 只保存导入包和 catalog,不保存当前选择。
即:导入外观应与内置外观享有同等地位,其选择值应当可以被持久化。当前行为与文档描述不一致。
根因定位(调用链)
从当前版本源码看,同一配置键存在两条口径不一致的通道:
| 通道 |
位置 |
对任意外观 id 的行为 |
| 配置服务 |
src/crates/assembly/core/src/service/config/providers.rs → AppearanceConfigProvider::validate_config |
接受(仅校验非空) |
| 产品控制 |
src/crates/contracts/product-domains/src/product_control.rs(错误文案 "value must be one of: {}") |
拒绝(静态枚举,仅 10 个内置 id) |
而界面写入走的是后者:
src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.ts:504
await configAPI.setConfig(APPEARANCE_SELECTION_CONFIG_PATH, selected);
src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts:160
await api.invoke('set_config', { ... })
- 后端
openbitfun_desktop_lib::api::config_api 对 appearance.selection 走产品控制校验 → 命中枚举白名单 → 拒绝
- 回到
AppearanceService.ts:524,catch 分支执行补偿回滚:
await configAPI.setConfig(APPEARANCE_SELECTION_CONFIG_PATH, previousPersistedSelectionId);
因此:合法导入包在"提交事务的最后一步"必然失败,失败与包本身无关(包已通过 schema 校验、compiler 与 runtime preflight,且 runtime 已经接受并渲染过它)。
建议
两种可能的修复方向(取决于设计本意):
- 让
appearance.selection 的产品控制 value_schema 接受动态集合(内置 id ∪ 已安装外观包 id);
- 或者让界面自身的配置写入不经过产品控制的枚举校验——该枚举更像是对 AI/自动化改设置的安全约束,而配置服务通道本就允许任意 id。
另外建议:持久化失败触发回滚时,错误信息最好带上"哪个键、哪个值、被哪条规则拒绝"。当前文案对用户和模型都不可定位,用户只能看到"应用失败",无法知道是自己的包有问题还是产品的问题。
补充
导入包的持久记录(IndexedDB openbitfun-appearance)中包含 localOverride、marketOrigin、importedAt 字段,说明系统对"本地导入"与"市场来源"是有区分的。
想确认一下:本地导入的包在当前版本的预期行为是什么?是否必须通过 Skin Market 安装才能被激活? 如果确实如此,建议在 UI 上明确提示(例如导入时就说明"本地包当前不可激活"),而不是在激活阶段失败并回滚。
环境
openbitfun.appearancev2(由内置create-openbitfun-skinskill 生成)validate返回 VALID,零警告;build成功,归档约 0.5 KB复现步骤
manifest 只声明一个组件、两个部件、三个属性:
实际结果
弹出错误,且选择被回滚到原主题:
对应日志(
webview.log/app.log,毫秒级连续):也就是说:外观包已经被成功 apply 进 runtime(revision 递增到 2),约 368 ms 后因持久化失败,事务执行补偿回滚,回到原主题。
另有两个相关现象:
Imported appearance cannot replace a builtin appearance: openbitfun-dark(见
src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.ts)即:自定义 id 存不住,内置 id 不许占,两条路都不通。
预期行为
按仓库自身的架构文档
docs/architecture/appearance-package-system.md:即:导入外观应与内置外观享有同等地位,其选择值应当可以被持久化。当前行为与文档描述不一致。
根因定位(调用链)
从当前版本源码看,同一配置键存在两条口径不一致的通道:
src/crates/assembly/core/src/service/config/providers.rs→AppearanceConfigProvider::validate_configsrc/crates/contracts/product-domains/src/product_control.rs(错误文案"value must be one of: {}")而界面写入走的是后者:
src/web-ui/src/infrastructure/appearance/runtime/AppearanceService.ts:504await configAPI.setConfig(APPEARANCE_SELECTION_CONFIG_PATH, selected);src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts:160await api.invoke('set_config', { ... })openbitfun_desktop_lib::api::config_api对appearance.selection走产品控制校验 → 命中枚举白名单 → 拒绝AppearanceService.ts:524,catch 分支执行补偿回滚:await configAPI.setConfig(APPEARANCE_SELECTION_CONFIG_PATH, previousPersistedSelectionId);因此:合法导入包在"提交事务的最后一步"必然失败,失败与包本身无关(包已通过 schema 校验、compiler 与 runtime preflight,且 runtime 已经接受并渲染过它)。
建议
两种可能的修复方向(取决于设计本意):
appearance.selection的产品控制 value_schema 接受动态集合(内置 id ∪ 已安装外观包 id);另外建议:持久化失败触发回滚时,错误信息最好带上"哪个键、哪个值、被哪条规则拒绝"。当前文案对用户和模型都不可定位,用户只能看到"应用失败",无法知道是自己的包有问题还是产品的问题。
补充
导入包的持久记录(IndexedDB
openbitfun-appearance)中包含localOverride、marketOrigin、importedAt字段,说明系统对"本地导入"与"市场来源"是有区分的。想确认一下:本地导入的包在当前版本的预期行为是什么?是否必须通过 Skin Market 安装才能被激活? 如果确实如此,建议在 UI 上明确提示(例如导入时就说明"本地包当前不可激活"),而不是在激活阶段失败并回滚。