Appearance
ADR-176: 前端 Backend 适配器双实现(Web/Desktop 通杀)
状态: 已完成(2026-07-23;Phase 1-3 全部落地。Phase 3 已完成 web-loader 准完整网页入口、IndexedDB 模型库、能力徽章、lastModel 恢复引导和库面板;验证:tsc 0 错、backend 16/16、契约 17/17、全量回归绿) 日期: 2026-07-23 关联: ADR-011(Wails 版本策略)、ADR-017(安卓适配,platform 探测范式)、ADR-159(桥接注入范式)、
src/web-loader/(⚠️ 纯浏览器 PMX 原型,已随 ADR-177 终态整目录删除,能力并入主应用统一 web 入口index.web.html+vite.web.config.ts+src/core/runtime-stub.ts) 审核记录: 2026-07-23 架构审核(数据流/生命周期/降级契约)— 有条件通过,全部 P1×2 + P2×3 已回填修订并同步至蓝图:P1① 调用集实证见文末章节(业务真实调用 106/139);P1②resolveBackend()async + 桥接短路;P2① 接入点改造第 0 步首屏链;P2② 新建isWebPlatform()+ 扩展guardExternalAction;P2③ 三态能力矩阵节。状态可由「规划」推进至「待实施」。 详见「审核发现」。
背景
MikuMikuAR 当前是 Wails v3 桌面/安卓应用,前端通过 src/core/wails-bindings.ts 聚合层调用 Go 生成的 @bindings/... 函数,并经 window.wails 桥接访问原生能力(文件系统、配置持久化、场景存档、AR、外部程序等)。
用户诉求:能否一个前端同时跑在浏览器(零后端)和 Wails(含 Go 后端)两种环境,而非维护独立分支或仅靠 web-loader 原型。
关键量化发现(2026-07-23 核查):
- 直接
import '@bindings/'的文件仅 2 个:wails-bindings.ts(聚合层)与plaza-browser.ts(模型广场代理,独立页面)。 - 业务代码对 Go 的调用高度收敛在聚合层,且已大量使用
window.wails?.xxx?.()可选链兜底。 src/web-loader/main.ts(⚠️ 已随 ADR-177 终态删除)曾证明:PMX 加载 + JSZip 解压 + babylon-mmd 在零后端下完全可用(该验证结论已支撑本 ADR 双环境通杀决策)。
结论:耦合面窄、浏览器侧最难一环已有验证,引入适配器层实现双环境通杀工程可行且改造量小。
决策
引入 BackendService 适配器层,将前端对 Go 后端的依赖收敛为统一接口;运行时按 window.wails 是否存在注入 GoAdapter 或 BrowserAdapter。目标不是 100% 功能对等,而是能力探测 + 优雅降级:原生独占能力(AR、外部程序、系统级文件访问)在浏览器侧显式降级。
建筑蓝图
⚠️ 接口规模预警(2026-07-23 实测):
BackendService绝非蓝图示例的 ~10 方法。业务真实调用 Go 函数 106 个(占契约测试 139 全量的 76%)。实施时接口须覆盖这 106 个函数、或对其中的 17 个原生独占函数显式降级,并逐一定义浏览器降级返回契约(抛错 / no-op / null)。完整清单见文末「调用集实证」。
接口契约(src/core/backend/types.ts)
typescript
export interface BackendService {
readonly kind: 'go' | 'browser';
// —— 文件系统 / 模型 ——
readFileBytes(path: string): Promise<Uint8Array | null>;
// 浏览器侧:基于用户授权目录或 IndexedDB 缓存,path 为逻辑 key
listModels(): Promise<ModelEntry[]>;
extractArchive(buf: Uint8Array, dest: string): Promise<ExtractResult>;
// —— 配置 / UI 状态持久化 ——
getConfig(): Promise<Config>;
setConfig(cfg: Partial<Config>): Promise<void>;
getUIState(): Promise<UIState>;
setUIState(s: Partial<UIState>): Promise<void>;
// —— 场景存档 ——
saveScene(name: string, data: Uint8Array): Promise<void>;
loadScene(name: string): Promise<Uint8Array | null>;
// —— 能力探测 ——
// 三态能力矩阵见下方「能力矩阵(三态 × 能力键)」;浏览器侧按此显式降级
capabilities(): BackendCapabilities;
// BackendCapabilities = {
// ar: boolean; // AR(ARCore/Vuforia)—— 原生独占
// externalApps: boolean; // 外部程序(Blender/MMD/LaunchSoftware)
// plazaWindow: boolean; // 模型广场窗口控制(Navigate/Close/Fetch/Download)
// fsAccess: boolean; // File System Access API(showDirectoryPicker 等)
// watchDir: boolean; // 目录监听(StartWatchDir/StopWatchDir)
// proxyServer: boolean; // 代理(StartProxy/StopProxy)
// fileServer: boolean; // 静态文件服务(StartFileServer/StopFileServer)
// systemDirOpen: boolean; // 系统文件管理器打开(OpenCacheDir/OpenScreenshotDir)
// storageMode: boolean; // 存储模式切换(GetStorageMode/SetStorageMode 有意义)
// }
// —— 原生桥(可选,浏览器侧为 no-op)——
events(): typeof Events | null; // 事件总线
}适配器实现
| 文件 | 职责 |
|---|---|
src/core/backend/go-adapter.ts | 将现有 wails-bindings.ts 的导出函数搬入,实现 BackendService;capabilities() 全开 |
src/core/backend/browser-adapter.ts | 浏览器实现:File System Access API(showDirectoryPicker)+ IndexedDB(配置/存档/模型缓存)+ JSZip(解压);capabilities() 按 navigator 探测 |
src/core/backend/index.ts | resolveBackend(): Promise<BackendService>:先 await awaitWailsBridge()(ADR-017/159 桥接注入范式,消除 Android 冷启动竞态)再判定 window.wails 存在与否注入 goAdapter/browserAdapter,惰性单例。禁止模块顶层同步 export const backend = detectBackend()——Android 冷启动 window.wails 尚未注入会被误固化成 browser。业务统一 const backend = await resolveBackend()。为消除纯 Web 入口下 awaitWailsBridge 的 3s 超时等待,web 入口需置短路标记(如 globalThis.__MMKU_WEB__ = true 或 import.meta.env.MODE === 'web')直接返回 browserAdapter。 |
接入点改造(按序,低风险)
实施现状(2026-07-24 核查):接入点改造采用绞杀者模式(Strangler Fig),第 1-2 步已合并实现——
wails-bindings.ts未退化为纯透传壳,而是保留export * from '@bindings/...'兜底 33 个零业务调用函数的同时,对 106 个业务真实调用函数以_p('FuncName')工厂逐个覆写为resolveBackend()代理导出。业务代码 30+ 文件 零改动 即完成双环境路由:import { AddTag } from '@/core/wails-bindings'调用的已是代理,桌面走 goAdapter、浏览器走 browserAdapter。此模式规避了"30 文件逐个改 import + 测试 mock 全迁移"的推倒重来风险,是长治久安方案。验收:grep "from '.*wails-bindings'"在业务代码中仍有 30 处命中,但命中的是代理导出而非直连 Go binding。
- 首屏数据源切换(最高优先):
init.ts首屏链GetConfig/GetSystemA11ySettings/CheckForUpdate/initLibrary(Go 扫描)是后端调用最密集处。改为const backend = await resolveBackend()后取数;否则浏览器侧仍走fire-and-forget → swallowError空壳路径(启动不崩但无配置/UI 状态/场景/模型库的跛脚壳)。 wails-bindings.ts采用绞杀者模式:保留export * from '@bindings/...'兜底透传 33 个零业务调用函数,同时对 106 个业务真实调用函数以_p<K>(name)工厂覆写为resolveBackend()代理导出(本地具名导出优先于export *)。业务代码 import 路径零改动,内部自动路由到 goAdapter/browserAdapter。❌ 禁止在此新增绕过_p工厂的直连导出。- 业务代码
import { xxx } from '@/core/wails-bindings'无需改动——绞杀者模式保证具名导出已是代理。新增业务调用应继续从wails-bindings导入(保持单一入口);仅在需要capabilities()或readFileBytes等接口专属方法时,才直接import { resolveBackend } from '@/core/backend'。 - UI 降级:新建
isWebPlatform()(与isAndroidPlatform()并列,见platform.ts),guardExternalAction扩展为同挡 Android + Web;启动期 backend 选型必须用await resolveBackend()(异步),运行时 UI 降级判定可用isWebPlatform()/isAndroidPlatform()+backend.capabilities()(后者已稳定无竞态);按capabilities()隐藏/禁用 AR、外部程序入口; web-loader升级为准完整页:复用browser-adapter接入模型库/设置/存档,作为网页侧主入口(web-loader.html);入口处置globalThis.__MMKU_WEB__ = true短路桥接探测。
能力矩阵(三态 × 能力键)
平台由二态(desktop-wails / android-wails,二者均有 window.wails)升为三态(新增 browser-web)。单一 isWeb 布尔不足,故以能力键矩阵表达,缺失时 UI 统一降级。
⚠️
isWebPlatform()为同步判定,Android 冷启动window.wails未注入时会被误判为 web;因此它只用于运行时已稳定的 UI 降级判定,启动期 backend 选型必须用await resolveBackend()(异步 +awaitWailsBridge)。
| 能力键 | desktop-wails | android-wails | browser(web) | 缺失时 UI 降级表现 |
|---|---|---|---|---|
| ar(ARCore/Vuforia) | ✅ | ✅ | ❌ | 隐藏 AR 入口 |
| externalApps(外部程序) | ✅ | ❌ | ❌ | 禁用 + tooltip「仅桌面支持」 |
| plazaWindow(广场窗口控制) | ✅ | ✅ | ⚠️ 部分(PlazaGo* 网页可控,窗口级不可) | 隐藏窗口级控制,保留前进/后退/缩放 |
| fsAccess(File System Access API) | ❌(原生对话框) | ❌ | ⚠️ Chrome/Edge 支持,Firefox/Safari 不支持 | 回退上传/拖拽,或不支持浏览器禁用 + tooltip |
| watchDir(目录监听) | ✅ | ✅ | ❌ | 隐藏「下载自动导入」监听开关 |
| proxyServer(代理) | ✅ | ❌ | ❌ | 隐藏代理设置 |
| fileServer(静态文件服务) | ✅ | ❌ | ❌ | 隐藏 |
| systemDirOpen(系统文件管理器打开) | ✅ | ✅ | ❌ | 隐藏「打开目录」按钮 |
| storageMode(存储模式切换) | ✅ | ✅ | ❌(固定 'web') | 隐藏存储模式切换 |
| screenshotSave(截图保存) | ✅ | ✅ | ✅(Canvas.toBlob + download) | — |
| cacheManage(缓存清理) | ✅ | ✅ | ✅(IndexedDB) | — |
| configPersist(配置持久化) | ✅ | ✅ | ✅(IndexedDB) | — |
| modelScan(模型库扫描) | ✅ | ✅ | ⚠️ FSA 授权目录替代 | 引导用户授权目录 |
原生独占须降级的函数清单见文末「调用集实证」③组(17 个)。
capabilities()须如实反映上表,UI 屏蔽规则据此生成,避免「幽灵入口」。
测试
backend接口契约单测:两套 adapter 对同一组输入产出可比对结果(mock 文件系统 / fake-indexeddb);resolveBackend()选型单测:window.wails存在/缺失、Android 冷启动(桥接未注入)三路径;验证awaitWailsBridge+ Web 入口短路标记避免纯 Web 入口 3s 阻塞;- 能力降级单测:浏览器 adapter 下
capabilities().ar === false、.externalApps === false,相关 UI 不渲染; guardExternalAction单测:Android 与 Web 均返回 false,desktop 返回 true。
边界条件
- 不追求功能全等:AR(ARCore/Vuforia)、外部程序(Blender/MMD)、系统级文件遍历属原生独占,浏览器侧必须降级,不得伪造。
- 聚合层不可再被业务直接 import:业务只依赖
BackendService接口,避免绕过适配器造成双源耦合。 plaza-browser.ts独立保留:模型广场走代理/iframe,属 WebView 内独立页面,不强制纳入适配器(仅其 Go 调用点如存在需同步收敛)。- 持久化语义差异:Go 侧为单机文件,浏览器侧为 IndexedDB(同源隔离),跨设备不互通——文档需明示。
- Wails 生成的
@bindings仍由wails3 generate bindings -ts维护,go-adapter.ts仅消费,不手改生成物。
审核发现(2026-07-23)
审核结论:有条件通过。方向与 chokepoint 判断正确,以下需在实施前回填修订。
数据流追踪
- 🔴 P1 接口覆盖落差:
wails-bindings.ts:4为export * from '@bindings/app'全量透传(契约测试实测 139 函数;注释原称 122、AGENTS 文档原称 116(均已于 2026-07-26 去数字化修正,函数数随契约演进以测试运行时报告为准)),业务 40+ 文件消费具体函数;BackendService仅列 ~10 方法。「改造量小」建立在「接口窄」假设上,实际调用面远宽。修订:见文末「调用集实证(2026-07-23 实测)」——已 grep 业务对 139 函数的真实调用集为 106(非 ~10),BackendService须覆盖 106 函数或显式降级 17 个原生独占函数;各函数的浏览器降级返回契约(抛错/no-op/null)须逐一定义。 - 🟠 P2 接入清单遗漏 init.ts:首屏
GetConfig/GetSystemA11ySettings/CheckForUpdate/initLibrary是后端调用最密集处,未列入「接入点改造」。修订:init.ts列为第 0 步(首屏数据源切换),否则浏览器侧仍走 swallowError 空壳路径。状态:已回填——「接入点改造」新增第 0 步首屏数据源切换(await resolveBackend()取数)。
生命周期
- 🔴 P1 detectBackend 时序竞态:
platform.ts:26 awaitWailsBridge证明 Android 冷启动window.wails异步注入。若index.ts顶层export const backend = detectBackend()同步求值,Android 首个 import 该模块的业务会把 backend 固化为 browserAdapter → 误降级。修订:detectBackend()改 async 内部await awaitWailsBridge(),或导出getBackend(): Promise<BackendService>惰性单例;禁止模块顶层同步求值。状态:已回填至蓝图——「适配器实现」index.ts 行改resolveBackend(): Promise<BackendService>(async +awaitWailsBridge+ Web 入口短路标记),禁顶层同步求值;测试节同步改为resolveBackend()三路径单测。 - 🟢 P4 IndexedDB/句柄释放:补 browser-adapter 的 dispose/close 契约(IndexedDB 连接、FS 目录句柄),与资源配对纪律对齐。
降级契约
- 🟠 P2 isWeb 判定不存在:
platform.ts仅isAndroidPlatform(),guardExternalAction仅挡 Android。ADR L69「复用 isWeb 判定」表述与现实不符。修订:改为「新建isWebPlatform()」,guardExternalAction扩展为同挡 Android+Web。状态:已回填——步骤 3 改为新建isWebPlatform()并扩展guardExternalAction同挡 Android+Web;并注明isWebPlatform()同步判定在 Android 冷启动有竞态,仅用于运行时 UI 降级,启动期选型用await resolveBackend()。 - 🟠 P2 三态平台 capabilities 矩阵缺失:平台由二态(desktop-wails/android-wails)升为三态;单一 isWeb 布尔不足,Android 既有降级会与 Web 降级叠加。修订:补 capabilities 能力矩阵表(三态 × 各能力键)+ 能力缺失时 UI 统一降级表现(隐藏/禁用+tooltip)。状态:已回填——新增「能力矩阵(三态 × 能力键)」节,含 13 能力键 × 三态 + UI 降级表现 +
isWebPlatform()竞态警示。 - 🟡 P3 持久化边界:补 IndexedDB 配额上限 + eviction 风险(模型数百 MB)、File System Access API 在 Firefox/Safari 不支持的兼容矩阵。
- 🟡 P3 契约测试边界:明确 go-adapter 仍受 139 函数 + FNV-1a 契约约束,browser-adapter 只受
BackendService接口契约约束(接口须覆盖 106 个真实调用函数)。
与现有架构的关系
- 复用 ADR-017 的
isAndroidPlatform()/window.wails探测范式与 ADR-159 桥接注入范式; - 复刻
web-loader已验证的浏览器侧 PMX/zip 路径,避免重复造轮子; - 不改变 Wails v3 桌面/安卓生产链路,仅在其外包裹一层可替换的 backend 抽象。
签名对齐(2026-07-24 实施)
目的:修复 browserAdapter 的 34 处签名不匹配,消除"绞杀者模式代理转发后参数错位"的运行时隐患。
背景
绞杀者模式(接入点改造第 1-2 步)完成后,wails-bindings.ts 的 106 个 _p('FuncName') 代理导出会将调用转发到 browserAdapter[FuncName]。但 browserAdapter 早期实现为快速验证 Phase 2 数据链,大量方法的参数个数/类型/返回类型与 Go 接口签名不符,被 as unknown as BackendService 双重断言掩盖。
风险机制:调用方按 Go 接口签名传参(如 AddTag(libraryRef, tag)),代理透传参数到 browserAdapter.AddTag,但 browserAdapter 只收 (tag) 单参 → libraryRef 被丢弃 → 浏览器端标签功能静默失效。
修复清单(34 处)
| 类别 | 数量 | 代表方法 | 修复方式 |
|---|---|---|---|
| base64 转换类 | 3 | SaveScreenshot/SaveThumbnail/GetThumbnail | 对齐 (path, base64) 签名,内部 atob/btoa 转 bytes |
| 预设 JSON string 类 | 8 | SaveModelPreset/LoadModelPreset/SaveScenePreset/SaveEnvPresetAuto 等 | 对齐 Go string 传输(旧实现误用 Uint8Array) |
| 标签/最近/浏览目录参数补全 | 6 | AddTag/RemoveTag/AddRecentModel/GetLastBrowseDir/SetLastBrowseDir | 补全双参签名 |
| SetEnvState/SetUIState 类型对齐 | 2 | SetEnvState/SetUIState | Partial → 完整类型,保留 merge 语义 |
| ImportLocalFile/BundleScene/GetDownloadWatchStatus | 3 | — | 对齐 Go 签名 |
| 降级方法占位符 | 17 | NotSupportedError 系列 | 全部对齐参数签名 + 返回类型 |
as unknown as 保留原因
尝试改用 satisfies BackendService 暴露 80+ 处类型错误,全是 Promise<T> vs CancellablePromise<T> 差异(Wails 专属类型带 cancel/cancelOn)。browser 侧无法返回 CancellablePromise,运行时调用方仅 await 不调 cancel,故不影响功能。这是 Wails 类型系统的固有限制,双重断言保留,但签名参数已对齐。
验收
- tsc 0 错误
- 2048 单测全绿(含 backend.test.ts round-trip 测试)
- 细粒度 UI setter 改为 merge 语义(读当前 UIState → 合并单字段 → 写回),通过 SetUIAccent/SetUIScale round-trip 测试
调用集实证(2026-07-23 实测)
目的:核验 P1①「改造量小」假设。方法:从
app.contract.test.ts解析契约测试锁定的全量 Go 绑定函数,在业务源码中 grep 真实调用,按浏览器侧可行性分类。
测量口径
- 扫描范围:
frontend/src下全部.ts,排除bindings/(生成物)、__tests__/(测试)、web-loader/(⚠️ 已随 ADR-177 删除,原独立另起炉灶实现)、wails-bindings.ts(聚合层自身)。 - 业务文件数:247。
- 契约测试基准集:139 函数(撰写时实测快照;AGENTS 文档原称 116、注释原称 122,均已于 2026-07-26 去数字化修正 —— 函数数随契约演进,以测试运行时报告为准)。
核心结果
| 指标 | 数量 | 占比 |
|---|---|---|
| Go 绑定函数总数(契约测试) | 139 | 100% |
| 业务真实调用函数数 | 106 | 76% |
| ├ 浏览器侧可真实实现(IndexedDB / JSZip / Canvas) | 81 | 58% |
| ├ 经 File System Access 对话框替代 | 8 | 6% |
| └ 原生独占、须显式降级 | 17 | 12% |
| 未被业务调用的函数(可安全忽略) | 33 | 24% |
结论:ADR 蓝图原「~10 方法」假设被推翻。BackendService 接口须覆盖 106 个函数(81 实现 + 8 FSA 替代 + 17 降级),而非 10。改造面较原提案估算显著放大,但收敛性仍成立(chokepoint 在聚合层,业务调用集中于 106 函数)。
分组清单
① 浏览器侧可真实实现(81)
AddRecentModel, AddTag, BundleScene, CheckForUpdate, CleanOrphanCache, ClearAllCaches,
ClearExtractCache, ClearThumbnailCache, DeleteEnvPreset, DeleteModelPreset, DeletePresetScene,
ExtractZip, FileExists, GetAllTags, GetBuildInfo, GetCacheStats, GetConfig, GetDownloadAutoImport,
GetDownloadWatchEnabled, GetDownloadWatchStatus, GetLastBrowseDir, GetLibraryIndex, GetModelMetaBatch,
GetModelPresets, GetModelsByTag, GetPresetScenes, GetPresetScenesDir, GetRecentModels, GetRenderPresets,
GetStorageMode, GetSystemA11ySettings, GetTagsByModel, GetThumbnail, ImportLocalFile, ImportZip,
IsolateModelDir, ListDirRecursive, ListEnvPresets, ListSubDirs, LoadEnvPreset, LoadLastScene,
LoadModelPreset, LoadModelPresetFromLib, LoadOutfitFile, LoadSceneFile, SaveEnvPresetAuto, SaveLastScene,
SaveModelPreset, SaveModelPresetToLibAuto, SaveRenderPreset, SaveScenePreset, SaveScreenshot, SaveThumbnail,
ScanModelDir, SetBlenderPath, SetDisplayNamePriority, SetDownloadAutoImport, SetDownloadWatchEnabled,
SetEnvState, SetLastBrowseDir, SetMMDPath, SetOverridePath, SetPerformanceMode, SetResourceRoot,
SetStorageMode, SetUIAccent, SetUIAnimations, SetUIAutoUpdate, SetUIBlurBg, SetUIFontFamily, SetUIPopupWidth,
SetUIScale, SetUIState注:
PlazaGoBack/Forward/Reload/Zoom*归此类(网页内 iframe 可控),但其依赖的窗口级 Plaza 能力见③。
② 经 File System Access 对话框替代(8)
SelectBundleSaveFile, SelectDir, SelectExeFile, SelectImportFile, SelectPresetOpenFile,
SelectPresetSaveFile, SelectRetargetFile, SelectSceneOpenFile浏览器侧用
showOpenFilePicker/showSaveFilePicker/showDirectoryPicker替代原生文件对话框。
③ 原生独占、须显式降级(17)
AddCustomSoftware, ClosePlazaWindow, DownloadFromPlaza, FetchPlazaConfig, GetCachedPlazaConfig,
LaunchSoftware, NavigatePlazaWindow, OpenCacheDir, OpenScreenshotDir, OpenWithSoftware,
RemoveCustomSoftware, ScanSoftwareDir, SetDownloadWatchDir, StartFileServer, StartProxy, StopProxy,
UpdateCustomSoftware降级契约:
capabilities().externalApps === false/.plazaWindow === false时 UI 隐藏或禁用 + tooltip 说明;调用抛NotSupportedError或静默 no-op(依调用语义定)。
④ 零调用(33,可安全不实现)
DeleteRenderPreset, GetAppVersion, GetModelMeta, GetPath, GetThumbnailBatch, ListDir, OpenInBlender,
OpenInMMD, ReadFileBytes, RenameModelPreset, SaveEnvPreset, SaveModelPresetToLib, SaveSceneFile,
SelectAudioFile, SelectEnvTextureFile, SelectPMXFile, SelectVMDMotion, SelectVPDPose, SetWailsApp,
StartWatchDir, StopFileServer, StopWatchDir, ToggleFavorite, DeleteMotionPreset, GetMotionPresets,
LoadMotionPreset, LoadMotionPresetFromLib, RenameMotionPreset, SaveMotionPreset, SaveMotionPresetToLib,
SaveMotionPresetToLibAuto, SelectMotionPresetOpenFile, SelectMotionPresetSaveFile对实施的直接约束
browser-adapter.ts的方法表 = ① + ② + ③(共 106);④ 不在接口内(go-adapter 仍按契约测试 139 全量实现)。- 每个 ③ 函数须定义显式降级行为,写入
capabilities()矩阵与 UI 屏蔽规则。 - 分类为粗粒度语义判定,实施前建议逐函数确认(尤其 ② 中
SelectExeFile浏览器选 exe 无意义,可能改降级;PlazaGo*与 ③ 的边界)。