LittleBigMouse Windows 持久化重构验收指南:共享 LayoutPersistence 引擎与 RegistryLayoutStore 端到端测试

原创2026-10-03 09:55:471,181 阅读
文章标签:桌面应用

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 在加载时给注册表播种值;新代码的读取必须是纯净的:

最后做一次 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:

  1. 整边阻力 → Section:section 编辑器出现之前,每条边只有一个 Move/Drag 数值;现在读到一个裸数字就生成一个覆盖全边的 Section(From=0、To=边长),边长为 0 或全零无阻力时丢弃,避免给每个布局都塞满无意义的全边 Section;
  2. 0x0 占位尺寸(#419):EDID 缺失回退出现之前的版本会持久化虚拟显示器的 0x0 GDI 占位尺寸,存储的非正尺寸不得覆盖新算出的尺寸;
  3. 旋转方向归一化(#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 兼容,所有迁移只读不写、在首次保存时收敛为当前形状。对普通用户而言,这些测试所保障的行为可以一句话概括:升级后配置原样保留,第一次保存前不碰注册表,保存之后一切照常。

登录后查看全文
LittleBigMouse