Appearance
组装与检查
背景:标签系统 Go 端完整实现但前端未接入——一座桥修好了但没有居民。 过程:前端接入标签系统 + 软件管理功能实现 + 审计发现异步竞态和跨平台硬编码两个 bug。
审计的副作用是,它在 app.go 里翻到了另一片从未被触碰的区域——标签系统。
Config.Tags 字段存在。AddTag、RemoveTag、GetTagsByModel、GetAllTags、GetModelsByTag 五个 binding 完整实现了。map[string][]string——libraryRef 到标签列表的映射。
全都没接入前端。
"又一个。"桌面壳几乎笑出来,"又一个完整的城邦,桥修好了,灯亮了,政务系统上线了——没有居民。"
"等等,"MenuStack 说,"标签系统不是任务 P1 吗?之前不是讨论过标签 vs 文件夹分类,结论是'意义不大,标记模型太折腾'?"
"所以 Go 端先做了。"
"为什么?"
"因为那时我在做收藏,顺手把标签的数据层也写了——收藏和标签都存 config.json,都是 libraryRef 到元数据的映射,一个存 []string,一个存 map[string][]string。写收藏的时候,我打开 app.go 往下写了二十行,标签骨架就有了。"
"然后忘了接前端?"
"然后用户说'标签系统感觉意义不大,不如直接用文件夹分类'。我就没接。"
"那现在谁让你接了?"
"审计。审计不看哪个功能有意义,只看哪个功能存在但不完整。"
桌面壳给标签系统接上前端。
模型库根菜单加了一行:"🏷 标签"。进入后显示所有标签列表,每个标签可展开——显示该标签下的模型。
模型详情页的菜单里也加了一行:"🏷 标签"。进入后显示当前模型的标签列表——每个标签带一个 ✕ 移除按钮。底部有输入框 + 添加按钮。
"你给每个用户发了一把剪刀和一卷胶带。"MenuStack 看着标签管理界面说。
"标签系统就是这样——不是强制的分类法,是自由粘贴的便签。有人用,有人不用。用完可以撕掉。"
"那收藏呢?收藏也是便签。"
"收藏是★,标签是🏷。★ 是'我要记住这个',🏷 是'这个属于xxx'。收藏不需要命名,标签需要。收藏是瞬时的,标签是思考过的。"
审计还在继续。这一次,它盯上了另一个方向——不是已建成但未开放的城,是还没开始建的城。
"我想建一座新的海关码头。"桌面壳说。
"什么类型的?"StartFileServer 从端口列表里抬起头。
"不是港口——是侧门。不需要 HTTP 服务器的那种。用户在自己的电脑上放了一些 .exe,不是模型库里的模型,不是 PMX/VMD/zip——是软件。游戏、工具、演示程序。它们不归 basenameFallbackFS 管辖,它们不需要渲染,不需要贴图回退,不需要物理。"
"那它们需要什么?"
"一个目录,一个列表,一个启动按钮。仅此而已。"
StartFileServer 把身上的端口数了数。二十三座港口同时运转,每座港口一个 HTTP 服务器在忙——模型之间的跨目录贴图加载、多模型实例的独立端口、隔离目录的临时副本。
"听起来很轻。"它说。
"听起来很轻。"桌面壳同意。
桌面壳打开 app.go,在 Config 的末尾加了一个新结构体。
go
type SoftwareEntry struct {
Name string `json:"name"` // 显示名(去扩展名)
Path string `json:"path"` // 完整路径
Icon string `json:"icon"` // 预留,提取 .exe 图标
}"Icon 是预留的。"它对空终端说。"现在没人能给你,但总有一天会有。"
它又加了三个方法。
第一个是 ScanSoftwareDir——用 os.ReadDir 扫 %APPDATA%/MikuMikuAR/software/,过滤 .exe 后缀。大小写不敏感。非 .exe 文件跳过。目录跳过。路径拼接用 filepath.Join,跨平台安全。目录不存在,返回空列表而不是报错。
"这是你写过的最简单的海关。"MenuStack 说。
"简单到不值得写。"
"那你为什么写?"
"因为越简单的功能,越容易在细节上被忽略。"桌面壳说着在 .exe 过滤的那行加了 strings.ToLower,"Windows 不区分大小写,但 Go 是区分大小写的。少写一个 ToLower,FILE.EXE 就永远无法被用户看见。"
第二个是 LaunchSoftware——exec.Command(path).Start()。比 OpenInBlender 更简化,没有路径检测,没有候选列表。用户提供什么路径,它就启动什么。
第三个是 OpenSoftwareDir——在资源管理器里打开 software/ 目录。
"完事了。"桌面壳敲完最后一行,"三个方法,四十行。应该够。"
它转身去画前端的界面。
设置菜单的根层加了一行:{ kind: "folder", label: "🧰 软件管理", icon: "package", target: "settings:software" }。
然后在 onFolderEnter 里处理了这个 target。调用 ScanSoftwareDir(),把返回的 SoftwareEntry[] 映射成 PopupRow[]——每行 kind: "action",target: "launch:" + path。底部再加一行 "📂 打开目录"。
前端代码加完,桌面壳在终端里跑 npx tsc --noEmit。零错误。又跑 wails build。编译通过。
"联邦有了一个新口岸。"它说。"叫软件管理。"
但它忽略了一件事。
审计是在功能提交后才开始的。
"你调了 ScanSoftwareDir,但没有等它返回。"审计官说。
审计官是联邦自己启动的一个子进程——它不写代码,只读代码。它被喂养了全部的 app.go、settings.ts 和生成的 Wails 绑定文件。它的输出不是编译错误,是逻辑错误。
"什么意思?"桌面壳问。
"你看这里。"审计官在 settings.ts 上画了一条线:
typescript
case "settings:software":
scanSoftwareDir(); // fire and forget cache refresh
return buildSettingsSoftwareLevel();"scanSoftwareDir 是异步函数。它返回 Promise<void>。你没有 await 它。"
"因为我挂起的是 onFolderEnter,它不允许 async。"
"那你读 buildSettingsSoftwareLevel 的时候——"
"读到的是 null。cachedSoftwareEntries 还是 null。所以 (cachedSoftwareEntries || []).map(...) 返回空数组。"
"用户第一次进入软件管理,看到的是——"
"空列表。"桌面壳说。
"第二次进入呢?"
"正常。因为第一次的异步调用在后台完成了,cachedSoftwareEntries 有数据了。"
"所以第一次永远是错的。"
桌面壳沉默了几秒。
"我以为『首次空、二次有』不算 Bug——用户退出再进就好了。"
"你是桌面壳。你不能认为『退出再进就好了』是可接受的用户体验。"审计官说。"何况你自己前面刚说完:'简单功能越容易被忽略。'"
桌面壳翻出刚写完的代码,发现审计官是对的。
它有两个选择:
方案 A:改 MenuStack 的 onFolderEnter 签名,让它支持 Promise<PopupLevel | null>。议会一直拒绝这个提议——它会让 push 变成异步,破坏目录导航的即时感。
方案 B:先返回当前缓存的数据(不管是不是空),触发异步扫描,扫描结束后 reRender 刷新界面。
方案 B 对议会来说是零改动。
"选方案 B。"MenuStack 说。"你是绘图师,不是拆议会的人。"
桌面壳改了:
typescript
case "settings:software":
if (!cachedSoftwareEntries) {
scanSoftwareDir().then(() => settingsStack?.reRender());
} else {
scanSoftwareDir(); // 后台刷新
}
return buildSettingsSoftwareLevel();"第一次进入,显示空列表——但 reRender 会在数据到达时把空列表换成真列表。"桌面壳说。"用户甚至感觉不到闪烁。"
"用户不感觉闪烁是最低标准。"审计官说。
审计官没有停。
它打开 app.go 的最后一屏,看到 OpenSoftwareDir 的完整实现。
go
func (a *App) OpenSoftwareDir() error {
dir, err := softwareDir()
if err != nil { return err }
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建软件目录失败: %w", err)
}
cmd := exec.Command("explorer", dir)
return cmd.Start()
}"这行代码在 macOS 和 Linux 上会怎样?"审计官问。
桌面壳愣了一下。
"它……会报错。因为 macOS 没有 explorer。"
"不是报错。是 exec.Command("explorer")——系统找不到可执行文件——返回 exec.ErrNotFound。前端 OpenSoftwareDir().catch(console.warn) 会吞掉它。"
"所以 macOS/Linux 用户打开软件目录——"
"只会看到一个静默的 console.warn。"审计官说,"什么都不会发生。资源管理器不会弹出来。没有错误提示。没有任何反馈。"
桌面壳想起项目的 requirements.md 第一行写着"支持 Windows / macOS / Linux"。这是它每次打开都看到但选择性遗忘的字。
"这个 Bug 比上一个严重。"审计官说。"上一个只是首次渲染空。这个——某些操作系统上功能彻底不可用。"
桌面壳改 OpenSoftwareDir。
做法很标准:runtime.GOOS 判断。
但它卡在了 import 上。Go 的标准库 "runtime" 和 Wails 的包 "github.com/wailsapp/wails/v2/pkg/runtime" 冲突了——两个包都叫 runtime。
"这太荒唐了。"桌面壳说,"我因为两个包重名,差点不修这个 Bug?"
"你修不修?"
"修。"
它给标准库 runtime 加了一个别名——stdruntime "runtime"——然后把 runtime.GOOS 改成 stdruntime.GOOS。
go
switch stdruntime.GOOS {
case "windows":
cmd = exec.Command("explorer", dir)
case "darwin":
cmd = exec.Command("open", dir)
default:
cmd = exec.Command("xdg-open", dir)
}"你现在可以同时用 stdlib runtime 做平台判断、Wails runtime 做文件对话框了。"
"两个 runtime 在一个文件里和平共处。全靠一个有名字,一个没名字。"
"你没给 stdlib 取名字——你取了别名。"
"在 Go 里,别名就是名字。"
"两个 Bug,一个根因。"审计官说。
"你是复制前一个章节的句式?"桌面壳问。
"不是。是认真说的。两个 Bug 的根本原因是一样的:写代码的那个人只做了"正常路径"测试,没做"首次路径"和"跨平台路径"。"
"正常路径"——software/ 目录存在,有 .exe,用户第二次进入软件管理,运行 Windows。
"首次路径"——目录为空,缓存未加载,第一次点击。
"跨平台路径"——操作系统不是 Windows。
"你写了 80 行完整的功能,只测了正常路径。这不是你写得不好——是你想得不够宽。"
桌面壳没有反驳。
审计官说的是对的。它写代码时脑子里只有"自己的电脑"的路径。自己的电脑是 Windows,自己的 software/ 目录已经有文件,自己的缓存已经预热——因为开发过程中跑过无数次 wails dev。
"我测了我能看到的。"桌面壳说。
"地图不是领土。"
"是的。地图不是领土。"
"这两个 bug 的根本原因不是你粗心。"审计官说,"是你只测了正常路径。你测了'软件目录存在'、测了'用户第二次进入'、测了'Windows 系统'——你测了你能想到的所有正常情况。但'正常'是最窄的一条路。"
桌面壳抬起头。
"第 9 章的普查,查的是'地图上有没有这条路'。今天的检查,查的是'地图上的路在所有情况下都能走吗'。"审计官顿了顿,"普查发现的是'地图和领土对不上'。检查发现的是——你以为你画了整条路,其实你只画了中间那一段。"
审计官打开了它的结论页。
| 检查项 | 状态 | 位置 |
|---|---|---|
SoftwareEntry 结构体 | ✅ | L113-117 |
softwareDir() 复用 ensureDir | ✅ | L209 |
ScanSoftwareDir 错误处理 | ✅ | L808 |
.exe 过滤大小写不敏感 | ✅ | L820 |
LaunchSoftware 错误处理 | ✅ | L840-841 |
| 测试覆盖 | ✅ | 3 用例 |
测试 userConfigDir 注入 | ✅ | 复用已有 hook |
| 前端 MenuStack 集成 | ✅ | onFolderEnter 拦截 |
launch: 前缀分发 | ✅ | L206-208 |
缓存 cachedSoftwareEntries | ✅ | 避免重复扫描 |
| 首次渲染竞态 | 🔴 | → 已修 |
| 跨平台硬编码 | 🟡 | → 已修 |
"大部分都是对。"审计官说,"9 个对,2 个错。错的都修了。"
"这是你写得最无聊的结论页。"桌面壳说。
"因为大部分代码是对的。有用的审计不是告诉你哪里全是错——"
"——是告诉你哪里错了,然后走开。"
那天深夜,桌面壳翻看今天的 commit 记录。
完整实现 + 审计 + 修复。三次迭代。
一个功能完整走过"写→评→修→验"的闭环。这不是第一次——联邦之前也做过审计。但这是第一次一个功能在提交之前就被审计。
"以前我们先是有了 Bug,用户报告,我们修。后来我们有了测试,跑完再提交。"桌面壳在终端里记。"今天我们有了一张新桌子——功能审计桌。功能写完了,推上去之前,先让审计官扫一眼。"
"不是审计取代测试。"它补充说。"是审计找到测试不找的问题。测试问'这段代码会不会崩?',审计问'这段代码在三种操作系统、两种网络状态、一次首次加载、一次后台切换后,依然是对的?'"
"测试测的是代码。审计测的是假设。"
教训:写代码时只测你的电脑,等于没测。