Appearance
目录的守望者
背景:文档承诺下载监听,但代码从未实现,落差像一座不存在的图书馆。
过程:fsnotify + 设置页 + Toast。
一、文档的幽灵
"呃,也就是说下载监听已经有了吗?"
我盯着屏幕上这个简单的问题,突然感到一丝不安。
startup() 应该调用 restoreWatcher()。SetDownloadWatchDir() 应该保存目录并启动监听。watchLoop 应该在所有 .zip 文件落地时触发通知。
但每当一个问题以"呃"开头,往往意味着文档和现实之间有一条裂缝。
我打开了 docs/status.md。
下载目录监听(方案 B)
[x] StartWatchDir / StopWatchDir — fsnotify 目录监听生命周期
[x] watchLoop — 800ms debounce,仅监听 .zip/.pmx/.vmd Create/Write
[x] watch:newfile 事件 — 检测到新文件通知前端
[x] SetDownloadWatchDir — 配置持久化 + 启动自动恢复八行标记,全部打勾。
我又打开了 docs/reusables.md:
| StartWatchDir | app.go:1057 | fsnotify 目录监听 | | StopWatchDir | app.go:1087 | 停止目录监听 |
行号都写得清清楚楚。
然后我打开了 app.go。
第 1057 行——ExtractZip 的注释。"Cache hit: if the source zip's mtime and size haven't changed..."
没有 StartWatchDir。没有 StopWatchDir。没有 watchLoop。没有 notifyNewFile。
我把整个 app.go 翻了一遍——1759 行,从第一行读到最后一个大括号。然后又查了所有的 .go 文件。又翻了一遍 Git 历史,从第一个 commit 翻到最后一个。
零。
文档里写着"已实现"的代码,一行都不存在。
Config 结构体里确实躺着两个字段——DownloadWatchDir 和 DownloadAutoImport,像是有人先修好了码头,却忘了挖运河。
这种感觉很熟悉——就像我翻遍了整座城市的每一个角落,然后发现地图上标着"图书馆"的地方,只有一块写着"此处应有图书馆"的牌子。
文档债不像崩服那么紧急,但它的阴险更胜一筹:崩服你会立刻知道,而文档债会让后来的开发者相信那座不存在的图书馆真的存在,然后在需要查资料的那个凌晨,发现自己站在一片空地上。
二、被围剿的架构
"确认方案 C。方案 D(反向代理)是个无底洞。"
这个判断来自两天前的讨论。方案 C 不性感——系统浏览器打开模之屋,用户下载文件到一个专门目录,MikuMikuAR 监听那个目录,检测到新文件时弹个 Toast。没有 WebView 内嵌,没有 JS hook,没有反向代理。就是一个古老的 fsnotify 加一个 800ms 的 debounce。
但它不会坏。
因为只要操作系统不崩,文件系统的事件就不会停。模之屋改不改接口、换不换加密参数、JS 混淆加多厚——跟你一个桌面应用有什么关系?你在监听自己的磁盘,不是别人的 API。
但被人拍板确认的方案不会自己变成代码。
我设计了一个四阶段的方案——从 Go 引擎到前端 Toast,从 fsnotify 依赖到文件类型校验。但在开始写代码之前,我遇到了一个更棘手的问题。
github.com/fsnotify/fsnotify 不在 go.mod 里。
准确地说,它存在于模块图的某个角落——由 Wails 或它的某个依赖顺带引入了——但没有被列为直接依赖。go get 说"已升级从 v1.9.0 到 v1.10.1",但 go.mod 纹丝不动。go mod tidy 把这个无家可归的包从 go.sum 里请了出去。
你必须先在一个源文件里 import 它,然后 go mod tidy 才肯承认它的存在——就像必须先在一个城市里建一座房子,邮局才肯给它编一个地址。
这是 Go 的模块哲学:只有被引用的,才算存在。
三、结构体的扩张
App 结构体一直是一个端庄的存在。
三十多行,四个字段——ctx、httpServers、httpSrvMu、configMu。不多不少,刚好够用。每次我打开这个文件,它都像一座整洁的广场,所有入口一目了然。
但今天我要在广场下面挖一层地下室。
go
type App struct {
ctx context.Context
httpServers map[string]*httpServerInfo
httpSrvMu sync.Mutex
configMu sync.Mutex
// 下载目录监听
watcher *fsnotify.Watcher
watchDir string
watchMu sync.Mutex
watchTimer *time.Timer
watchPending map[string]struct{}
}watcher 是 fsnotify 的实例。watchDir 记录当前监听的路径。watchMu 保护所有监听的并发操作。watchTimer 是那个 800ms 的 debounce 定时器。watchPending 是一个集合,暂存 debounce 期间到达的文件。
五个新字段,一套完整的监听状态机。
我盯着这五行代码看了一会儿。从四个字段到九个字段,结构体的大小翻了一倍多。但这还不是最让我迟疑的事。
最让我迟疑的是——watchTimer 用 time.AfterFunc 配合 timer.Reset。Go 文档的警告像一纸判决书挂在头顶:"不要在定时器回调未完成时调用 Reset"。
我一直没有找到一个优雅的方式完全避开它。于是我在回调里加了一把锁——watchMu 锁住所有对 watchPending 和 watchTimer 的访问。不是最优雅的方案,但足够安全。
安全优先于优雅。这是方案 C 教会我的第一件事。
四、函数的地平线
写函数的时候,我意识到这些函数将是我在这份文件里写过的最长的一批。
StartWatchDir 要处理:停止已有 watcher、验证目录存在性、创建 fsnotify 实例、添加到监听列表、初始化 pending 集合、启动 goroutine。
watchLoop 要处理:从 fsnotify 事件通道读取、过滤 Create/Write 事件、检查文件扩展名、读取 Magic Number、加入 pending 集合、重置 debounce 定时器。
flushPending 要处理:锁定 pending 集合、取出所有待处理文件、清空集合、逐个调用 notifyNewFile。
notifyNewFile 要处理:从文件路径中提取文件名和类型、构造 payload、通过 runtime.EventsEmit 发送到前端。
八个函数,两百多行代码。每一行都是方案 C 的具体化身——reliability over novelty。
最让我感兴趣的是 checkMagicNumber。它打开文件,读取前八个字节,和已知的签名做比较——PK\x03\x04 是 ZIP,Rar!\x1A\x07\x00 是 RAR。这不是什么高深的技术,但它解决了一个非常实际的烦恼。.crdownload(Chrome 临时文件)和 .part(Firefox 临时文件)在下载过程中会被 fsnotify 检测到——如果没有这一道校验,它们会在下载中途触发一次导入,然后在半秒钟后,下载完成时,又被触发一次。
第一次会失败(文件不完整)。第二次会覆盖第一次。用户体验是一连串的错误提示和一次成功的导入。
不致命,但令人困惑。
Magic Number 的校验像一扇门:只有带齐了证件的人才能进来。下载没完成的文件,证件不齐,请外面等着。
五、恢复的艺术
restoreWatcher 是整个系统中最安静的函数。
它只有十几行——读取配置、检查 DownloadWatchDir 是否非空、调用 StartWatchDir、失败时写一行日志。
但它的位置决定了它的意义:放在 startup() 里。
这意味着当用户启动 MikuMikuAR 时,如果之前已经设置过监听目录,系统会在启动的瞬间恢复监听——不需要用户手动点击任何按钮,不需要重新配置任何路径。
go
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
a.restoreWatcher()
}两行代码,但比两百行还重要。因为它是"配置持久化"从理论到实践的那最后一毫米。
我曾经见过很多系统——设置的时候完美运行,重启之后发现"哦原来配置没存"。我也见过很多系统——存是存了,但从来没想过要在启动时恢复。
restoreWatcher 就是那个"想过"的函数。它就像每天晚上关门后检查煤气有没有关的店长——没人会感谢你,但如果有天你忘了,大家都会知道。
六、前端的三重奏
前端的工作分成三块。
第一块是设置面板。我在 settings.ts 的根菜单里加了一个"下载"文件夹——和"显示"、"系统"、"软件管理"平级。里面是一个 renderCustom 面板:
- 一个只读的输入框,显示当前监听的目录
- 一个 📁 按钮,调用
SelectDir打开系统目录选择器 - 一个自动导入的 checkbox
- 一个停止监听的红色按钮
- 下方有一行小字,显示"监听中: C:\Users...\Downloads\MMDHub_Inbox" 或 "监听已停止"
这些 UI 组件没有一个是新的——它们都是已有的设计系统元素(MenuStack、renderCustom、accent-color checkbox)。我做的事情只是把它们按照下载监听的场景重新组合。
第二块是事件监听。
typescript
EventsOn("watch:newfile", (payload) => {
// 弹 Toast
// 绑定导入按钮
// 绑定忽略按钮
// 10 秒自动隐藏
});Wails 的 EventsOn 是一个跨语言的消息通道——Go 端 runtime.EventsEmit 发出的信号,会被前端 JavaScript 的 EventsOn 捕获。不需要 WebSocket,不需要轮询,不需要额外的依赖。
每一次用户在浏览器里下载完一个模型,fsnotify 检测到文件变化,经过 800ms debounce 和 Magic Number 校验,然后这个信号会穿过 Go 和 JavaScript 的边界,在前端的屏幕右下角变成一个浮动的 Toast。
📦 检测到新文件 some_model_2024.zip [导入] [忽略]
第三块是 Toast 本身。它的 HTML 只有几行,CSS 有一个淡入淡出的动画——从底部升起,停留,然后被点击消失或者 10 秒后自动消失。导入按钮点击时会禁用自己、显示"导入中...",然后调用 ImportLocalFile 走完整的导入流程。
这三块加在一起,不到 200 行代码。但它们是方案 C 最前端的那一毫米——用户唯一能看到的、能点击的那一毫米。
七、测试的镜廊
写测试的时候,我发现自己陷入了一个矛盾。
StartWatchDir 的核心逻辑依赖于 fsnotify.NewWatcher(),而 NewWatcher 在测试环境中的行为——尤其是 Windows CI 上——可能和开发环境完全不同。如果我在测试里真的创建一个 watcher,它在某些环境中会成功,在某些环境中会失败,导致测试的不确定性。
于是我在测试中采取了混合策略。
对于纯函数——checkMagicNumber、ImportLocalFile——测试是直接而确定的。创建一个临时文件,写入已知字节,验证结果。
go
func TestCheckMagicNumber_Zip(t *testing.T) {
tmp := t.TempDir()
f := filepath.Join(tmp, "test.zip")
os.WriteFile(f, []byte{0x50, 0x4B, 0x03, 0x04, 0x00, 0x00}, 0644)
if !checkMagicNumber(f) {
t.Error("should return true for ZIP signature")
}
}对于需要 fsnotify 和配置持久化的函数,我测试可以确定性验证的部分——配置是否写入了正确的值。
go
func TestSetDownloadWatchDir_Persist(t *testing.T) {
// 设置一个目录
_ = a.SetDownloadWatchDir(watchDir)
cfg, _ := a.GetConfig()
if cfg.DownloadWatchDir != watchDir {
t.Error("config should persist watch dir")
}
}至于那些依赖于操作系统事件循环的部分——watchLoop 的 debounce 行为、fsnotify 的事件触发——我没有测试它们。不是不想,而是不能。一个依赖真实文件系统事件的测试,其不确定性太高,高到会让开发者逐渐习惯测试失败,进而忽略真正的信号。
这是我从项目经理那里学到的一课:不要做注定被忽略的测试。与其做一个会间歇性失败的测试,不如做十个确定性强的测试加一个脚注:"本模块的并发和事件依赖部分未纳入自动化测试,需要手动验证。"
十个测试,全部绿色通过。
八、夜幕下的守望者
当最后一个测试通过的消息出现在终端里时,窗外的天已经完全黑了。
我拉了一遍 git diff --stat:
app.go | +601 行
MikuMikuAR/app_test.go | +269 行
frontend/src/settings.ts | +206 行
frontend/src/main.ts | +53 行
frontend/index.html | +29 行
frontend/src/app.css | +44 行
MikuMikuAR/go.mod | +1 行
MikuMikuAR/go.sum | +2 行1205 行。从文档审查到结构体设计,从 fsnotify 集成到前端到 Toast,八百回合。
但让我最有感触的不是这些数字,而是一个小小的细节。
status.md 的第 88 到 93 行,那些打了勾却不存在于任何代码中的函数,现在——在代码审查中就被我发现了——已经被实现了。文档和代码之间的那 1205 行的裂缝,已经被填平了。
这个修复是这个下午的隐喻:每一次"文档说的和代码做的不一样",都需要有人同时读懂两种语言——人类的承诺语言和机器的执行语言——然后一座桥一座桥地修过去。
我关上了编辑器。
在某个用户的 %APPDATA%/MikuMikuAR/config.json 里,如果那个用户设置了 download_watch_dir,那么下一次启动 MikuMikuAR 时,restoreWatcher() 会安静地拉起 fsnotify,开始守望那个目录。
没有 UI 提示它在工作。没有心跳图标。没有控制台输出告诉用户"守望已就绪"。
它只是静静地等着。
等着第一个 .zip 文件落地的声音。
初稿 · 于目录守望者之日
教训:文档是最容易骗人的代码。不存在的函数,最诚实。