LittleBigMouse Windows 持久化重构验收指南:共享 LayoutPersistence 引擎与 RegistryLayoutStore 端到端测试
LittleBigMouse Windows 持久化重构验收指南:共享 LayoutPersistence 引擎与 RegistryLayoutStore 端到端测试
本指南以仓库文档 docs/test-session-persistence-windows.md 为骨架,完整覆盖 Windows 端持久化重构(用共享
LayoutPersistence引擎 +RegistryLayoutStore取代旧的PersistencyExtensions)的 11 组端到端验收场景:从既有用户数据兼容、byte-for-byte 保存格式、首次安装、旧配置迁移,到自启动提权、按显示器边框、插拔恢复与即时保存。读者按本指南操作即可系统验证重构正确性,并理解每个断言背后的源码依据;文末还列出了 7 条重构后被接受的语义变化,避免在验收时把它们误报为缺陷。
一、重构背景:535 行注册表扩展如何被一个共享引擎取代
在本次重构之前,Windows 的配置持久化由 PersistencyExtensions 承担——约 535 行直接操作注册表的扩展方法,负责模型与注册表之间的双向映射。Linux 侧则另有一套 JSON 存储逻辑。两套代码各自演进,模型↔存储的映射规则在平台间极易漂移。
重构后的架构把映射与语义收敛到一个平台无关的引擎中,Windows 与 Linux 只各提供一个"哑存储"后端:
| 层次 | 组件 | 职责 |
|---|---|---|
| 引擎(唯一入口) | LayoutPersistence | 平台无关的持久化引擎:决定加载顺序、什么算已保存、Save/SaveEnabled/SaveLive 语义、虚拟布局守卫 |
| 存储接口 | ILayoutStore | 极简契约:Read / WriteGlobalOptions / WriteLayout / WriteModels,不感知布局模型 |
| Windows 后端 | RegistryLayoutStore | 读写 HKCU\SOFTWARE\Mgth\LittleBigMouse,与历史格式 byte-for-byte 兼容 |
| Linux 后端 | JsonLayoutStore |
同一 DTO 的 JSON 文件实现(LittleBigMouse.Core/LittleBigMouse.DisplayLayout.Tests/JsonLayoutStoreTests.cs 为其测试孪生) |
| 模型↔DTO 映射 | LayoutDtoMapper | 唯一的双向映射,Apply(读入模型)与 ToDto(写回)同处一个文件 |
| 历史数据解读 | LayoutMigrations | 按写入年代解读旧值的迁移(Sections 整边阻力、0x0 占位尺寸、旋转方向归一化) |
| 排除进程列表 | ExcludedListPersistence | Excluded.txt 文件及其一次性默认值 top-up |
其中,DTO 是所有平台共享的存储文档(LayoutDtos.cs),其属性名在两个平台上都是承重的——它们就是注册表值名和 JSON 属性名,重命名一个属性就等于一次数据迁移,而不是普通重构。
Windows 侧的具体接线在 WindowsLayoutPersistence:它继承共享引擎、注入 RegistryLayoutStore,并把自启动接到 Windows 任务计划程序(AutostartExtensions)。
当前验证状态:Linux 侧已全部通过——46 个绿灯测试(覆盖内存 store + JSON 格式的完整引擎)、全部项目编译通过、Linux 行为不变(DTO 原样搬移)。唯一待验证的是真实 Windows 运行时:
RegistryLayoutStore依赖 Windows-only 的注册表 API,无法在 Linux 上执行,只能靠本指南的实机验收来覆盖。
二、测试前的准备与一键回滚
开始任何测试前,先建立可回滚基线(对应文档第 0 节):
:: 1. 导出整个配置注册表键
reg export "HKCU\SOFTWARE\Mgth\LittleBigMouse" %USERPROFILE%\lbm-backup.reg
:: 2. 备份排除进程列表文件
copy "%LOCALAPPDATA%\Mgth\LittleBigMouse\Excluded.txt" "%USERPROFILE%\lbm-excluded-backup.txt"
:: (同时记录该目录下还有哪些其他文件)
:: 3. 记录任务计划程序当前状态
schtasks /query /fo list | findstr LittleBigMouse
另外需要在 Windows 上编译完整解决方案:文档特别注明 Server 项目在 Linux 下因 SDK 环境原因(NETSDK1226)编译失败,因此必须确认它在 Windows 下能正常编译,才能验证本轮重构没有破坏任何工程。
任何时候回滚:先关闭应用,然后:
reg import %USERPROFILE%\lbm-backup.reg
:: 还原 Excluded.txt
三、测试一:既有用户兼容性(最重要的测试)
针对注册表已被旧版本填满的机器,逐项断言重启后一切恢复原状:
- 布局恢复:各显示器毫米位置(
XLocationInMm/YLocationInMm)、物理比例(PhysicalRatioX/Y)、BorderResistance、活动 source、被排除的显示器; - 布局级选项恢复:
Enabled、Algorithm、MaxTravelDistance、Freelook*、LoopX/Y、AdjustPointer/AdjustSpeed、AllowOverlaps/AllowDiscontinuity; - 全局选项恢复:
Pinned、AutoUpdate、StartMinimized、StartElevated、DebugTools、ShowMonitorActionWarning、BorderValues、HideTrayIcon。
上述字段与 LayoutDtoMapper.Apply 的读取清单一一对应,null 字段的语义是"存储中不存在 → 保留模型当前值"。
纯读取契约(本组测试的关键):在不点击保存的前提下,reg export 启动前/后各一次并 diff——除 ExcludedDefaultsVersion 可能被写一次(若缺失,属于排除列表 top-up 迁移,见第八节)外,diff 必须为空。旧代码用 GetOrSet 在加载时给注册表播种值;新代码的读取必须是纯净的:
- ILayoutStore 契约明确要求:"Reads are PURE: they must never create keys nor seed values in the store";
- RegistryLayoutStore.Read 通过
OpenRoot(create: false)打开注册表,键不存在就直接返回空数据; - 对应测试 Read_NeverCreatesAnything 断言读取后根键根本不存在。
最后做一次 sanity 检查:引擎启动、鼠标正常跨屏(daemon 端到端冒烟)。
四、测试二:保存格式 byte-for-byte
移动一个显示器 → 保存 → 到 regedit 中核对写入值:
- 所有值都是
REG_SZ字符串; - 十进制数使用不变文化的小数点(如
12.5,而不是区域设置下的12,5); - 布尔值写成
"1"/"0"。
保存前后各做一次 reg export 并 diff,应只出现预期中的变化;随后重启应用,全部内容应被正确读回(完整 round-trip)。
这与 Values_AreStoredAsTheHistoricalInvariantStrings 测试完全一致——该测试特意把当前文化切到 fr-FR(逗号小数)再写入,断言注册表里仍是 "12.5"、"1"/"0",因为旧版本在这些键上读到的必须是同样的字节,否则在逗号小数环境下 ToString() 写出的 12,5 会被旧构建读成 0。
重构后的注册表树结构(由 RegistryLayoutStore 的读写方法归纳):
HKCU\SOFTWARE\Mgth\LittleBigMouse
├── 根键(全局选项)
│ ├── Priority / PriorityUnhooked / HomeCinema / Pinned / AutoUpdate
│ ├── StartMinimized / StartElevated / DebugTools / ExperimentalFeatures
│ ├── VcpControl / ShowMonitorActionWarning / BorderValues / RescueShortcut
│ └── HideTrayIcon / ExcludedDefaultsVersion
├── Layouts\{layoutId}
│ ├── AllowOverlaps / AllowDiscontinuity / Algorithm / MinimalEdgeOverlap
│ ├── MaxTravelDistance / FreelookCheckInterval / FreelookEnabled
│ ├── LoopX / LoopY / Enabled / AdjustPointer / AdjustSpeed
│ └── PhysicalMonitors\{monitorId}
│ ├── XLocationInMm / YLocationInMm / PhysicalRatioX / PhysicalRatioY
│ ├── ActiveSource / SerialNumber / ExcludedFromLayout
│ ├── BorderResistance\{Left|Top|Right|Bottom}\Sections\{0,1,…}
│ │ └── From / To / Move / MoveBlock / Drag / DragBlock
│ ├── Borders\Left / Top / Right / Bottom (仅"自定义过边框"的显示器)
│ └── {sourceId}\PixelX / PixelY / PixelWidth / PixelHeight
│ / Orientation / DisplayName / Primary
└── monitors\{pnpCode}
├── Size\Width / Size\Height
├── Borders\Left / Top / Right / Bottom
└── PnpName
存储键名与 255 字符上限(#589):布局 id 由显示器 id 用 + 拼接而成,9 台显示器时 id 会超过注册表键名 255 字符上限,旧代码首次读取就抛异常、UI 直接起不来(表现为"8 显示器上限")。LayoutStoreKey 的解法是:id 能放下就原样存(保证既有配置键名不变);放不下则保留可读头部 + ~ 分隔 + 整个 id 的 SHA-256 摘要,保证同显示器组合恒定映射、不同组合不冲突。对应测试 WriteThenRead_NineMonitorId_RoundTrips。
五、测试三:Priority 持久化修复
这是重构过程中顺手修掉的 bug,验收时需确认已修复:
- 旧行为:
Priority写在根键,但加载时被 layout 键里播种的"Normal"覆盖 → 用户设的优先级从未真正持久化; - 验证步骤:在 UI 中把 Priority 设为 ≠ Normal → 保存 → 重启 → 值被保留;
- 到 regedit 核对写入位置。
当前仓库的最终实现把 Priority/PriorityUnhooked 视为应用级选项:读取时先从根键取,根键没有则回退到 layout 键(ReadGlobalOptions 的 fallback 链);写入时只写根键,并在 WriteLayout 中删除 layout 键里的残留值——因为 Set() 对 null 是跳过而非删除,残留值不清掉就会在每次加载时永远覆盖根键。测试 WriteLayout_DeletesTheLegacyPriorityFromTheLayoutKey 与 ReadGlobalOptions_FallsBackToTheOptionsThatUsedToLiveInTheLayoutKey 分别锁定了这两个方向。
六、测试四:全新安装(首次执行)
把 HKCU\SOFTWARE\Mgth\LittleBigMouse 重命名(如加 .bak 后缀)、移走 %LOCALAPPDATA%\Mgth\LittleBigMouse,模拟纯净机器:
- 应用以默认配置启动、无错误;
Excluded.txt被创建,内含 4 个 Windows 默认排除项:\Epic Games\、\steamapps\、\Riot Games\、\XboxGames\(与 ExcludedProcessDefaults.Windows 一致);- 注册表保持(几乎)空,直到用户真正保存——加载不再播种;
- 显示器从系统配置自动摆放(
Placed为 false → 自动放置)。
被接受的语义变化:第 2 次未保存的启动中,显示器仍然是自动摆放的。旧代码的播种行为会在第 2 次启动时就"冻结"当时的摆放;新语义与 Linux 一致——Placed 只在一次真实保存后才变 true(见 LayoutDtoMapper.Apply:只有读到存储中的 XLocationInMm/YLocationInMm 时才置 Placed = true)。
保存一次后:注册表键被完整填充;再启动 → 一切恢复。
七、测试五:旧配置迁移(legacy migrations)
在 regedit 中手工构造一个"老版本留下的"配置文件:
- 选项迁移:把
Priority、Pinned、AutoUpdate、StartMinimized、StartElevated、HomeCinema从根键删掉、改放到Layouts\{id}下 → 加载时这些值仍被正确读回(根键优先、layout 键回退);保存后它们回到根键; - 改名迁移:把
ShowMonitorActionWarning改名为旧名ShowAttachDetachWarning→ 值被读回(ReadGlobalOptions 明确把ShowAttachDetachWarning作为 fallback);保存后写的是新名。
除了注册表层的"形状兼容"迁移(裸数值 vs 对象、旧键名),LayoutMigrations 还集中了三种按年代解读的迁移,全部"只读不写",迁移结果在下次保存时以当前形状落盘,此后读回即为永久 no-op:
- 整边阻力 → Section:section 编辑器出现之前,每条边只有一个 Move/Drag 数值;现在读到一个裸数字就生成一个覆盖全边的 Section(
From=0、To=边长),边长为 0 或全零无阻力时丢弃,避免给每个布局都塞满无意义的全边 Section; - 0x0 占位尺寸(#419):EDID 缺失回退出现之前的版本会持久化虚拟显示器的 0x0 GDI 占位尺寸,存储的非正尺寸不得覆盖新算出的尺寸;
- 旋转方向归一化(#507):5.4.1 之前的模型尺寸按保存时的旋转方向存储,读到方向与内禀尺寸矛盾时转置回来,否则升级后旋转显示器会在 LBM 里呈现镜像颠倒。
八、测试六:排除列表一次性 top-up(#494)
场景设计(用 regedit / 记事本构造):
- 场景 A(补全默认值):
Excluded.txt只写 3 个旧默认项(Epic、steamapps、Riot),并删除ExcludedDefaultsVersion值 → 启动后\XboxGames\被追加进文件、ExcludedDefaultsVersion被写进根键; - 场景 B(用户自定义列表):列表已去掉某个旧默认项 → 不追加任何内容,但版本号照样写入;
- 场景 C(幂等):再次启动 → 迁移不重复执行(每个版本只跑一次)。
机制在 ExcludedListPersistence.MigrateDefaults:只有当列表仍包含全部历史默认项(说明用户保留了出厂默认)才 top-up;AppliedDefaultsVersion 在加载时从 store 读入、在每次全局选项写入时原样带回,从而保证一次性。当前代码中 ExcludedProcessDefaults.Version 已是 2(历史:1 = 新增 \XboxGames\ #494;2 = 按 OS 拆分列表、补 Linux 条目 #515),文档示例写于 Version 1 时代——验收时以当前 Version 常量实际值为准,语义不变:迁移按版本号只执行一次,且对 : 前缀的注释行(如文件头 :Excluded processes)保持不动、写回时置顶重发。
九、测试七:自启动 / SaveEnabled / 提权
- LoadAtStartup:勾选并保存 → 任务计划程序中出现
LittleBigMouse_<用户名>任务;取消勾选 → 任务被删除; - StartElevated 变体:任务以
RunLevel Highest创建; - 提权加载场景(
IsLoading守卫):LoadAtStartup+StartElevated同时开启,重启应用 → 任务保持提权。加载选项的过程中不得"中途重新排期"而丢掉提权; - 引擎开关(
SaveEnabled路径):不点保存、直接切换引擎 on/off → 只更新Layouts\{id}\Enabled,layout 键的其他值保持原样且不被破坏;任务计划与选项对齐。
源码侧的关键实现:
- 任务创建/删除在 AutostartExtensions:
RunLevel按elevated取Highest或LUA;若请求提权但注册失败,会降级为普通权限任务而不是完全不注册; - LayoutPersistence.SaveEnabled 是 read-modify-write:先整篇读回
LayoutDto,只改Enabled再整体写回,其余值原样保留——这正是"切换引擎不丢其它值"的保证;同时它把Priority/PriorityUnhooked置 null 再写,避免把全量保存刚迁走的应用级选项又写回 layout 键; IsLoading由 LayoutPersistence.Load 在加载期间置位,供 UI 的 live-save 循环识别"正在加载",避免启动时产生写回回声(详见第十一节);SaveEnabled还在每次 Stop 时执行,因此 RefuseVirtual 守卫在这里尤为关键——没有它,一个虚拟布局会在 Stop 时给本机 store 创建一个以客户端显示器组合命名的条目(现实中观察到 61 字节的{"Enabled": false}文件)。
十、测试八:按显示器边框(PR #503,重构的初衷)
- PerModel 模式:保存 →
PhysicalMonitors\{id}下不出现Borders子键; - 不重启切到 PerMonitor:边框从当前模型值出发(LayoutDtoMapper.Apply 注释明确:"switching to PerMonitor is live (no restart required)",首次切换即从当前 PerModel 边框起步);
- 只编辑一台显示器的边框并保存 →
Borders\Left/Top/Right/Bottom只为该显示器写入; - 重启 →
BordersCustomized正确恢复、值正确,另一台显示器仍跟随其模型; - 之后在 PerModel 模式保存 → 这台显示器的按显示器边框仍然存活(LayoutDtoMapper.ToDto:"Stored whatever the current mode is, but only once the monitor owns them");
- 模型边框(
monitors\{pnp}\Borders+Size\Width/Height):编辑 → 保存 → 重读 OK。
Borders 子键的存在本身就是"该显示器拥有自己的边框"标志(BordersCustomized),且以 Borders\Left 是否存在为准——只有 Top 的残缺子键不算数(测试 Borders_AreOnlyReadBackWhenLeftIsThere)。未自定义的显示器不写任何内容,下次加载继续实时镜像模型值。
十一、测试九:Sources / 插拔恢复
ActiveSource、DisplayName、Primary写入PhysicalMonitors\{id}\{sourceId}\下(后两者为 WRITE-ONLY,见 LayoutDtos.SourceDto 注释:主屏归属是桌面的决定,存储它们只是为了 bug 报告可读,绝不回灌);- 把显示器从桌面拔出再插回 → 其像素几何从 store 恢复(LayoutDtoMapper.Apply:只有未挂接的 source 才应用存储的
PixelX/Y/Width/Height/Orientation,挂接中的 source 保留实时几何,store 仅是备份); - 被接受的语义变化:几何只在保存时写入,不再在加载时写。因此"第一次保存之前就拔掉显示器"会无可恢复——这是 Linux 行为,现实中很少发生。
十二、测试十:SaveLive 即时保存
- 不点保存按钮,直接修改
StartMinimized/AutoUpdate/Pinned/DebugTools→ 立即写入根键,下次启动生效; - 被接受的语义变化:SaveLive 现在写全部全局选项(Linux 行为),而不再只是历史的那 6 个——验收时留意副作用(例如某个只编辑未保存的值如
HideTrayIcon也被持久化了,属于预期); - live-save 循环在加载期间不响应——启动时不得出现写回回声。
实现上 LayoutPersistence.SaveLive 直接委托 SaveGlobalOptions,而 WriteGlobalOptions 无条件写全量全局字段;IsLoading 守卫保证加载期间 UI 的自动保存路径被抑制。
十三、测试十一:多布局与杂项
- 多布局隔离:改变拓扑(拔/插屏幕、接扩展坞)→ 每种组合保留自己的
Layouts\{id}键与值,无交叉污染; - DaemonPort 残留:既有用户注册表中若存在
DaemonPort→ 被忽略且不报错,残留值原样保留(它配置的 TCP 传输自 5.6 起已不存在,本地 IPC 不占用端口)——当前 GlobalOptionsDto 中根本没有该字段,天然不读; - 管理员启动 →
Elevated被检测到、UI 一致(WindowsLayoutPersistence.IsElevated 用WindowsPrincipal判定管理员角色); - 进程排除:在某个被排除路径下启动游戏 → LBM 不跨屏(daemon 始终读取同一个
Excluded.txt,路径未变)。
十四、七条被接受的语义变化(不要误报为 bug)
验收结束时请对照此清单,以下差异属于预期:
| # | 变化 |
|---|---|
| 1 | 加载不再向注册表播种——值在第一次保存时才出现 |
| 2 | Placed 只有经过一次真实保存才为 true(全新安装:未保存前每次启动都自动摆放) |
| 3 | SaveLive 写所有全局选项,不再只是历史 6 个 |
| 4 | SaveEnabled 重写完整 layout 文档(值相同),而非只写 Enabled 一个值 |
| 5 | HomeCinema 不再写入 layout 键(仅全局;旧 layout 键值仍在读取时作 fallback) |
| 6 | Priority/PriorityUnhooked 现在以应用级为准持久化(即第三节修复;layout 键残留会在保存时被删除) |
| 7 | 已挂接 source 的几何不再在加载时保存(只在保存时写入) |
十五、总结:验收矩阵与证据索引
将文档的 11 组测试与源码/测试证据对照如下:
| 验收域 | 关键断言 | 证据 |
|---|---|---|
| 兼容既有用户 | 全量恢复、读取纯净 | RegistryLayoutStore.Read、Read_NeverCreatesAnything |
| 保存格式 | REG_SZ / 不变文化 / "1"/"0" | Values_AreStoredAsTheHistoricalInvariantStrings |
| Priority 修复 | 不再被 "Normal" 覆盖 | WriteLayout_DeletesTheLegacyPriorityFromTheLayoutKey |
| 全新安装 | 无播种、4 默认排除 | ILayoutStore、ExcludedProcessDefaults |
| 旧配置迁移 | 键位回退、改名回退 | ReadGlobalOptions、LayoutMigrations |
| 排除 top-up | 每版本一次、自定义列表不动 | MigrateDefaults |
| 自启动/提权 | RunLevel 对齐、IsLoading 守卫 | AutostartExtensions、SaveEnabled |
| 按显示器边框 | 存在即自定义、PerModel 下存活 | Borders_AreOnlyReadBackWhenLeftIsThere |
| Sources 插拔 | 仅未挂接 source 恢复几何 | LayoutDtoMapper.Apply |
| SaveLive | 全量全局选项、加载期不写回 | SaveLive |
| 多布局/杂项 | 键隔离、DaemonPort 忽略、Elevated | GlobalOptionsDto、WindowsLayoutPersistence |
按本文档顺序完成全部 11 组验收并对照第 14 节清单后,即可判定 Windows 端持久化重构达成与 Linux 侧同等水平的正确性:共享引擎 + 哑存储后端的架构让模型↔DTO 映射只存在一份,注册表读写保持历史 byte-for-byte 兼容,所有迁移只读不写、在首次保存时收敛为当前形状。对普通用户而言,这些测试所保障的行为可以一句话概括:升级后配置原样保留,第一次保存前不碰注册表,保存之后一切照常。