桌面壁纸层监控HUD踩坑实录


最近在做一个 Windows 桌面小工具:一块半透明的系统监控面板(CPU / 内存 / GPU / 显存 / 磁盘 / 网络),要“沉”到桌面壁纸层之上、桌面图标之下——按 Win+D 不消失,鼠标点击完全穿透,不进任务栏也不进 Alt+Tab。产物是一个纯原生、静态链接、约 2 MB 的单 exe,除系统 DLL 外零依赖,默认完全离线。源码已开源:yanghaoi/AuraUI(MIT 许可)。

目标是它看起来的样子:

半透明面板嵌在桌面壁纸层上

这个需求听起来像是“设几个窗口样式”的事。真正动手之后才发现,Windows 并没有任何公开 API 表达“把窗口放在壁纸和图标之间”这件事,于是整条路径只能靠未文档化的消息、跨进程 SetParent、以及对 shell 行为的实测推断来拼。拼出来的过程中踩到一串坑,共同点是——API 全部返回成功,而结果都是错的:

  • SendMessageTimeout(progman, 0x052C, 0, 0, …) 返回成功,但 shell 什么都没做,窗口静默隐身;
  • UpdateLayeredWindow 每秒返回成功,窗口属性读数全部健康,屏幕上却什么都没有;
  • 解码“注册表里那个壁纸文件”得到的像素,和屏幕上真实显示的差了 ~15/255;
  • 子窗口隐藏后,它的最后一帧永久僵死在桌面上;
  • 托盘的左键、右键“全部无响应”,原因是把回调参数读反了。

下文先把可直接取用的结论列出来,再逐条展开现象、定位过程和最终方案,最后附上代码级问题清单与调试方法论。所有结论都在 Windows 10 19045(部分场景经 RDP 会话)上实测验证;第 13 节那五处修改另在 MinGW-w64 下实际构建过(零警告),其中 13.3 做了修改前后 --dump 逐像素对比,13.2 用独立测试程序验证了契约告警,并跑过端到端验证脚本。

1 结论

如果也在做类似的事,以下几条可以直接取用:

  1. 0x052C 的 wParam 必须是 0xD。写 (0, 0) 时 shell 静默无操作,壁纸层保持隐藏,挂上去的窗口属性全对但不可见——这是本项目最大的坑。
  2. 不要挂 Progman。SHELLDLL_DefView 会抓取父窗口的图像缓冲画自己的背景,它下面被盖住、上面盖住图标,没有可用位置。
  3. 按类名找 WorkerW 不可靠:窗口类按进程注册、类名可以重名。宿主必须同时满足「可见 + 属于 Explorer + 不含 SHELLDLL_DefView」。
  4. 跨进程 SetParent 之后,分层子窗口在本机不可靠,观察到三种互相独立的失效模式。挂载态只能用普通 GDI 重定向路径(非分层 + WM_PAINT/BitBlt)。
  5. 半透明靠“壁纸垫底”实现:解码壁纸、按摆放样式裁成面板尺寸的不透明裁片垫在面板下,再画半透明面板——表面不透明,BitBlt 安全,视觉等价透明。
  6. 垫底必须解码 shell 实际显示的那一层缓存(Themes\CachedFiles\CachedImage_*),不是注册表解析出的原图。用错层,圆角切除区会把“另一代编码”的裁片直接贴在真实桌面上。
  7. 桌面层 WorkerW 从不重绘被腾出的区域,子窗口移动/隐藏后会留下永久残影。修复首选“直接把正确的壁纸像素画到宿主表面”,SPI 重放只作兜底。
  8. 托盘回调在 NOTIFYICON_VERSION_4 下 wParam 是坐标、lParam 是事件,且同一次点击会同时投递裸鼠标消息和抽象事件——只处理抽象事件,否则每个动作执行两遍。
  9. ICO 的每个条目必须是完整 DIB(BITMAPINFOHEADER + XOR 像素 + AND 掩码)。缺头部时 windres 原样嵌入,LoadImage 静默失败并回退系统默认图标。
  10. MinGW-w64(binutils ≥ 2.44)会自动链接一份 RT_MANIFEST ID 1,项目自带的 manifest 必须放 ID 2,再用 CreateActCtxW 显式激活。
  11. GPU 不要引入 SDK:nvml.dll 运行时 LoadLibraryExW + GetProcAddress(符号带 _v2 回退),没卡就显示 N/A——产物才能保持零依赖。
  12. 速率类指标的基线要带适配器标识(网络用 LUID):换网卡时重新起步,否则两块卡各自的累计计数相减会报出 TB/s 级假尖峰。

概括为一句话:窗口属性读数、API 返回值、乃至“设置成功”的回读,都不能作为“屏幕上真的对了”的依据。

2 目标与总体结构

2.1 桌面层的窗口拓扑

desktop/desktop.cpp 负责让 HUD 真正“沉”到桌面图标下面。壁纸层建好之后的拓扑是:

WorkerW  (图标宿主)   <- 内含 SHELLDLL_DefView -> SysListView32
WorkerW  (壁纸层)     <- HUD 挂这里
Progman

壁纸层 = 全屏、可见、属于 Explorer、且不含 SHELLDLL_DefView 的 WorkerW。它的位置随 shell 版本变化:Windows 10 ~ 11 23H2 是顶层 WorkerW,而 Windows 11 24H2+(或 Win10 上发过一次 0x052C 之后)它会变成 Progman 的直接子窗口——所以两处都要找。

桌面窗口拓扑与宿主筛选条件

图里右侧那三个条件,每一条都对应一次“窗口看不见”的实测:

  • 可见:父窗口隐藏时,子窗口自己的 WS_VISIBLE 仍是 TRUE,但屏幕上什么都没有。而壁纸层是懒加载的,系统刚启动时它可能根本不存在。

  • 属于 Explorer:窗口类按进程注册,类名可以重名,而 FindWindowExW(..., L"WorkerW", ...) 只比对类名字符串。实测本机 18 个 WorkerW 里有 3 个属于第三方:

    0x5108e0  WorkBuddyAI.exe     visible=False  Explorer的吗=NO
    0x2505ec  Everything.exe      visible=False  Explorer的吗=NO
    0x160170  RuntimeBroker.exe   visible=False  Explorer的吗=NO
    
  • 不含 SHELLDLL_DefView:含 DefView 的是图标宿主,不是壁纸层。

挂载本身还有个顺序要求(MSDN 明文):先把窗口样式从 WS_POPUP 改成 WS_CHILD,再 SetParent。跳过这一步,窗口会停在“半挂载”状态,GetParent() 返回 NULL,z 序与坐标换算全乱。

2.2 线程模型

采集全部在后台采样线程完成,UI 线程只做一次 Snapshot 拷贝与绘制;壁纸解码另起一个 worker。

线程模型与源码模块划分

跨线程只走消息,不共享可变状态:采样线程 PostMessage(kMsgSnapshot) 通知 UI 重绘,垫底 worker PostMessage(kUnderlayReadyMsg) 通知 UI 套用新裁片。健康检查每 2 秒跑一次,负责“重挂 / z 序自愈 / 显示器变化 / 托盘补挂”。

3 采集:把机器状态读出来

前两节讲的是「窗口怎么摆」。但 HUD 的正事其实是另一件事——把当前环境的资源占用读出来。这一节记录五个指标的取数方式、必须处理的边界,以及两块容易被略过的东西:静态硬件身份和失联自愈。

3.1 Snapshot 与节拍

所有指标打包进一个 Snapshot,由采样线程产出、UI 线程按值拷贝消费:

struct Snapshot {
    CpuInfo     cpu;      // usage + 型号 / 核心 / 线程
    MemoryInfo  memory;   // total / used / available / percent + "DDR4 2133MHz 32GB"
    GpuInfo     gpu;      // name / usage / vram / temp / power(各带 has* 有效位)
    DiskInfo    disk;     // valid + letter / total / free / used / percent
    NetworkInfo network;  // downBps / upBps + 适配器 / IPv4 / IPv6 / MAC / 网关
    unsigned long long sampleIndex;
    double             elapsedSec;   // 距上一拍的秒数,速率类指标要用它
};

每项都带自己的「有没有值」标志(valid / hasUsage / hasVram / hasTemp…),渲染端据此决定画进度条还是显示 N/A。没有 NVIDIA 显卡、没有网卡、磁盘不存在时全部走 N/A,不崩——这是「离线小工具」的基本体面。

采样线程的节拍是绝对的:

auto next = steady_clock::now() + milliseconds(intervalMs);   // 起点
while (!stopping_) {
    cv_.wait_until(lock, next, ...);
    ...
    next += milliseconds(intervalMs);                          // 下一拍 = 上一拍 + interval
    if (next <= steady_clock::now())                           // 落后就跳过,不补跑
        next = steady_clock::now() + milliseconds(intervalMs);
}

写成「采样结束 + interval」的话,每一轮都会把采样耗时累积进去(NVML 调用、网卡枚举都不便宜),跑久了节拍就漂。暂停时不采样,但仍响应唤醒(改间隔 / 恢复监控),并把节拍重新锚定,避免 wait_until 在一个已经过去的时间点上空转。

还有一个容易忽略的细节:Start() 里先同步采一次,再起线程。这样首帧——以及「启动时就处于暂停态、永远收不到 tick」这种情形——拿到的是真值而不是一排 0。差值类指标(CPU、网络速率)第一拍天然是 0%,从第二拍才动。

采集节拍数据源与失联自愈

3.2 CPU:两次采样求差

GetSystemTimes 返回三个累计时间:idle、kernel、user。这里有个经典陷阱——kernel 已经包含 idle 时间,所以:

total = dKernel + dUser;                 // ✅ 不要再加 dIdle
busy  = total - dIdle;
pct   = 100.0 * busy / total;

按「idle + kernel + user」求和会把空闲时间算两遍,负载被系统性低估。另一个边界是 total == 0(两次采样之间系统时间没动,比如刚从休眠醒来),此时直接沿用上一拍的读数,而不是除零。

第一次调用只用来建立基线(记下三个累计值并返回上次读数),所以进程刚起来那一拍的 CPU 是 0%。

原始差值在 100 ms 这种短间隔下相当跳,所以做了一次轻量指数平滑:last_ = last_ * 0.35 + pct * 0.65。新值权重 0.65——既要压住抖动,又不能让数字看起来「慢半拍」。

3.3 内存与磁盘

内存直接取 GlobalMemoryStatusEx:used = ullTotalPhys - ullAvailPhys。注意用的是 ullAvailPhys(可用)而不是「空闲」,那才是用户视角的剩余内存。

磁盘用 GetDiskFreeSpaceExW,盘符从 GetWindowsDirectoryW 取而不是硬编码 C——系统装在别的盘上的机器不少。拿不到(卷消失、无权限)就把 valid 置 false,整行显示 N/A,不抛也不崩。

3.4 网络:速率与适配器

网络这项信息量最大,一次采样要同时产出速率和适配器信息。

速率来自 GetIfTable2 的 64 位计数器 InOctets / OutOctets,两次采样求差再除以 elapsedSec。这里有三条边界,每条都对应过一次真实故障:

  • 基线必须携带适配器 LUID。 切换网卡(拔插、VPN 出现、重新排名)后,新网卡的计数器是从开机起累计的另一条序列,拿它去减旧网卡的读数会得到一个 TB/s 级的假尖峰。检测到 LUID 变化就丢弃基线、重新起步。
  • GetIfTable2 失败也要重置基线。 否则下一次成功的采样会把「两个间隔的流量」除以「一个间隔的时间」,速率虚高一倍。
  • 计数器可能回绕。 网卡重启后计数器归零,差值取 in >= prev ? in - prev : 0——宁可这一拍显示 0,也不报一个负数或天文数字。

适配器信息来自 GetAdaptersAddresses,需要一层过滤加一层排序:

if (a->IfType == IF_TYPE_SOFTWARE_LOOPBACK) continue;   // 回环
if (a->OperStatus != IfOperStatusUp) continue;          // 未启用
// 地址层面再剔掉 APIPA(169.254.x.x) 与链路本地(fe80::/10)

排序打分是「有网关 +4 / 有 IPv4 +2 / 有 IPv6 +1」,取最高分的那块——也就是「最像正在上网的那块」。但选中的适配器不能每轮重选:列表重新枚举时,一块短暂出现的 VPN 可能排到前面,监控对象就跳了。所以按 LUID 优先、名字兜底沿用上一轮的选择。

适配器列表本身有 5 秒刷新节流;只有在「本轮没找到该适配器」时才置脏、下一拍强制重枚举。

3.5 GPU:动态绑定 NVML

GPU 是唯一需要「额外能力」的指标。这里刻意不把 CUDA / NVML SDK 作为构建依赖——nvml.dll 在运行时用 LoadLibraryExW 加载,每个入口点用 GetProcAddress 解析,ABI 子集自己写在 system/nvml_api.h 里(9 个函数指针)。产物因此仍然是「除系统 DLL 外零依赖」的单个 exe。

几个工程细节:

  • 只从绝对路径找 dll(%SystemRoot%\System32\nvml.dll、%ProgramW6432%\NVIDIA Corporation\NVSMI\nvml.dll),避开工作目录的 DLL 劫持。
  • 符号带 _v2 回退:nvmlInit_v2 找不到就用 nvmlInit,nvmlDeviceGetCount_v2 → nvmlDeviceGetCount,以此类推——老驱动只导出旧名。
  • 必需符号用 Api::Complete() 一次性校验,缺一个就 FreeLibrary 走人,不留下半个可用的句柄。
  • 采集项彼此独立:使用率(getUtilizationRates)、显存(getMemoryInfo)、温度、功耗(毫瓦 → 瓦)各判各的返回值。温度传感器在某些卡上不可用,不该连带把使用率也变成 N/A。

非 N 卡的回退:用 DXGI 的 EnumAdapters1 拿适配器描述,跳过 DXGI_ADAPTER_FLAG_SOFTWARE,于是 HUD 能显示 Intel(R) UHD Graphics N/A 而不是一行空白——用户至少知道程序看见了显卡。

3.6 静态硬件身份

HUD 标题行还有一行「这是什么机器」,全部取自本机、不联网:

项 来源 处理
CPU 型号 HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0\ProcessorNameString 去掉 @ 3.20GHz 尾巴;按词边界删掉独立的 CPU / Processor / APU 记号(Core、Ryzen 必须保留)
核心 / 线程 GetLogicalProcessorInformation 数 RelationProcessorCore 条目得物理核,再对每个核的 ProcessorMask 求位计数得逻辑核
内存型号 / 频率 GetSystemFirmwareTable("RSMB") 解析 SMBIOS Type 17 见下

SMBIOS 那段是这一节里最「脏」的部分,因为它要面对厂商各写各的表:

  • 表区是 8 字节头 + Length 字节。扫描终点必须是 data + 8 + Length,少加那 8 字节会静默漏掉表尾的最后一条记录。
  • 逐结构遍历:长度在偏移 1,字符串区从 p + len 开始(不是 p + 4),以双 NUL 结束。
  • 容量一律以 OS 为准。 现场报告过真实 64 GB 的机器被厂商表算成 0 GB / 16 GB——所以显示的 GB 数取自 GlobalMemoryStatusEx,SMBIOS 的容量求和只作诊断日志里的交叉校验。
  • 内存类型字节会移位。 规范说在偏移 0x11,但实测有一张自称 SMBIOS 8.0 的厂商表在 Form Factor 之后多插了一个字节,真类型跑到了 0x12。做法是:只有当 0x11 明确是 Other(01) / Unknown(02) / 未填,且 0x12 是一个已知类型时,才采用偏移后的位置。
  • 频率(含 configured speed)钳到 400–9600 MHz——超出这个范围的数不是时钟。
  • 单条模组容量钳到 128 MB – 2 TiB,其余当表里的噪声丢弃。

最终显示成 DDR4 2133MHz 32GB 这样一行,同时在日志里留一条 ram modules=… smbiosSum=… osTotal=… type=… speed=… 的交叉校验记录——出问题时这一行就是判据。

3.7 失联自愈

采集代码里最容易被忽略、但在真实机器上最影响体验的是失联。三类都自己兜住了:

  • NVML 失联(驱动重置 / TDR)。 句柄作废了,但 nvmlUp_ 还是 true,于是每次调用都失败、读数一直是 N/A。做法是核心调用连续失败约 5 拍(1 Hz 采样 ≈ 5 秒)就卸载 NVML、重新初始化;重试本身再节流 30 秒,避免半死的驱动被反复 LoadLibrary。另外 getCount() == 0 时打一个闩锁,让无卡机器不再走恢复路径空转。
  • 网卡切换(见 3.4):LUID 变化 → 丢弃速率基线。
  • 卷消失 / 无权限:valid = false,整行 N/A。

配合第 2.2 节的线程模型,整条链路的行为是:采集永远不阻塞 UI;任何一项拿不到就降级显示,绝不崩、绝不弹窗。

4 挂载:0x052C 参数

壁纸层是懒加载的,不存在时必须发 0x052C 让 shell 把它建出来。参数写错时窗口一切正常但完全不可见,且没有任何报错。

// ❌ 静默无效:shell 什么都不做,壁纸层保持隐藏
SendMessageTimeout(progman, 0x052C, 0, 0, SMTO_NORMAL, 1000, nullptr);
// ✅ 正确
SendMessageTimeout(progman, 0x052C, 0x0D, 0x1, SMTO_ABORTIFHUNG, 1000, &ignored);

实测(Windows 10 19045)发送前后的全屏 WorkerW:

发送前:  [0x14028c  visible=False]                        <- 壁纸层隐藏
发送后:  [0x14028c visible=True, 0x2d02fe visible=True]    <- 出现可见的壁纸层

发送 (0, 0) 时没有任何变化。此时若把窗口挂到那个隐藏的 WorkerW 上,窗口自身的 IsWindowVisible 仍是 True,但父窗口不可见 → 屏幕上什么都没有。网上大量示例都写成 (0, 0)。

催生壁纸层的参数实测对比

两个配套的工程约束也来自实测:

  • 用 SMTO_ABORTIFHUNG 而不是 SMTO_NORMAL——shell 半死时立刻返回,不必等满 1 秒超时。
  • 催生要限流:每次调用会让 shell 多建一个 WorkerW。健康检查(2 秒一次)传 allowSpawn=false,绝不反复催生;需要催生的路径限流到 30 秒一次。

5 分层子窗口的三种故障

背景:挂载 = SetParent 到 Explorer 的壁纸层 WorkerW(跨进程 graft)。实验与现场日志证明,跨进程 reparent 之后,分层子窗口在本机不可靠,共观察到三种互相独立的表现。

分层子窗口的三种失效模式

# 故障 现象 证据
A ULW 合成 wedged UpdateLayeredWindow 每秒返回成功,WS_VISIBLE 在、父窗口正确,但屏幕上什么都没有 日志无任何 ULW 失败记录;窗口结构探针全部正常;用户目视不可见
B SLWA 状态不合成 GetLayeredWindowAttributes 读回 ok=TRUE, alpha=已设置,同样不上屏 同上,SLWA/ULW 模式切换均无效
C LAYERED 位无法恢复 子窗口的 WS_EX_LAYERED 被清除后,SetWindowLongPtr 加回静默失败(读回为空,重试 3 次全丢) 日志 WS_EX_LAYERED re-set lost, retrying ×3

三种模式的共同点:所有窗口属性读数全部正常,唯独 DWM 不合成。这直接让“属性探针”(样式位、父窗口、z 序、可见位)全部失效,只能靠像素级探针或人眼判定。

结论:挂载态(跨进程子窗口)只能使用普通 GDI 重定向路径(非分层 + WM_PAINT/BitBlt),它是唯一被长期实证稳定可见的形态。顶层弹窗(移动态)不受此限制,保留 UpdateLayeredWindow 逐像素渲染。

还有一个容易忽略的副作用:在窗口可见状态下清除子窗口的 WS_EX_LAYERED,会把当前帧“烤”进桌面合成,留下永久残影。任何样式位操作都必须在窗口隐藏时进行。

6 半透明:壁纸垫底

挂载态的窗口是普通 GDI 子窗口,它的表面是不透明的,没法直接做逐像素透明。方案是把 HUD 后面的那块真实壁纸按 shell 的摆放样式裁成面板尺寸的不透明裁片垫在面板下,再以 bg @ opacity 画半透明面板——合成结果与“透明面板叠在壁纸上”一致,而且表面不透明、BitBlt 安全。

关键工程细节:裁片在后台线程生成(latest-wins 队列 + 完成后 PostMessage 回 UI 线程套用),大图解码(30–100 ms)不卡渲染;而且永不保留全分辨率像素——面板尺寸裁片约 400 KB,而不是全图的约 16 MB。

面板的圆角与右对齐数值

图中面板的圆角、进度条、右对齐的百分比,全部绘制在一块不透明的 GDI 表面上:垫底那张裁片来自面板正后方的真实壁纸,所以圆角外面透出来的仍然是原桌面。

6.1 壁纸的三层缓存链

这是本项目最反直觉的一处。注册表 / COM 解析出的原图不是桌面显示的内容——shell 会连续重编码两层缓存。用 PrintWindow 捕获壁纸层合成画面、与候选文件逐像素比对(mean|diff|/255)得到:

层 文件 与屏幕的误差
1 注册表 / COM 解析出的原图 ≈ 15/255
2 Themes\TranscodedWallpaper(第一次重编码,489 KB → 266 KB) ≈ 15/255
3 Themes\CachedFiles\CachedImage_<W>_<H>_POS*.jpg(已按显示器预裁剪、1:1 直接铺放) 0.65/255 ← DWM 显示的就是它

壁纸的三层缓存链与显示源

垫底若解码第 1/2 层,约 80% 不透明的面板主体能掩盖差异;但圆角切除区让裁片直接紧贴真实桌面,差异就显形为“半透明的角”,圆角越大越明显。这个缺陷自项目诞生即存在:第一轮只修到第 2 层,仍差 ~15;第二轮修到第 3 层后,角部误差降到 0.81/255(右下角 0.04)。

判定“屏幕显示的是哪一层”靠的是三个纪律,都是多轮误诊换来的:

  1. 屏幕 vs 候选文件用比值而非差值:角部裁片与桌面同源时 mean(screen)/mean(file) ≈ 1.000,受壁纸明暗影响小,是最灵敏的判据。
  2. 单次截屏内自洽对比:用远离面板的干净区拟合“屏幕壁纸映射”,再预测角部——同源同捕获,对全局色调/残影免疫。
  3. 解析 GetDIBits 缓冲按 BGRX(按 RGBX 解析 = 通道颠倒,曾让所有文件比对得出 ~48 的伪误差,推翻过一整轮结论)。

6.2 三条硬约束

  • 裁片键携带源文件 LastWriteTime:这些缓存会被原地重写(换壁纸、幻灯片轮换、SPI 兜底重放都会删除重建 CachedFiles),只比路径字符串会漏掉内容变化。
  • 命中第 3 层时把摆放样式置为 stretch:显示器尺寸的图片做 stretch 就是精确 1:1,解码数学零改动。
  • 回放壁纸只能回放原始路径,绝不能是缓存层文件——否则 shell 对已重编码两代的 JPEG 再编码,桌面每重放劣化一代(实测 CachedImage 296033 → 292907 字节)。

7 残影:WorkerW 从不重绘

7.1 机制

DWM 下每个顶层窗口只有一张 GDI 重定向表面,子窗口树的所有 GDI 输出(包括 HUD 的 BitBlt)都写入 WorkerW 顶层窗口自己的表面。当子窗口被移动 / 隐藏 / 销毁后,暴露的矩形需要 WorkerW 自己重绘——但它是 Explorer 的内部窗口,对“寄宿的访客子窗口”从不重绘(跨进程 graft 树的绘制同步是坏的)。于是子窗口的最后一帧永久僵死在桌面上,也就是“分身”。

实证:EnumWindows 始终只有 1 个活窗口(排除窗口泄漏);重启 Explorer 重建整个场景后分身消失(表面被重建)。这也解释了“隐藏 HUD 后面板不消失、数据不更新;再显示后恢复更新”——窗口藏了,但它烤在 WorkerW 表面里的最后一帧还在。

残影成因与三级清除策略

7.2 三级清除策略

  1. 绘制覆盖(主路径):按当前壁纸状态解码腾出区域应有的像素,经跨进程 GetDC(host) 直接 BitBlt 到壁纸 WorkerW 的表面盖掉冻结帧。不经 Shell、零缓存流转、像素级正确(实测覆盖后旧区残影 mean|d| = 0.01/255,缓存文件 mtime 不变)。
  2. 精准失效(绘制失败时伴随):RedrawWindow(host, &rect, nullptr, RDW_INVALIDATE | RDW_ERASE | RDW_FRAME | RDW_NOINTERNALPAINT)。单独使用不足以清残影(实测 mean|d| = 35.8 vs 干净区 0.12)——桌面类窗口只经 WM_ERASEBKGND 重绘;禁止 RDW_UPDATENOW / RDW_ERASENOW(同步标志对其它进程拥有的窗口无效)。
  3. SPI 重放(仅兜底):绘制失败(无可解码壁纸)或桌面级刷新(vacated == nullptr,会话重连——RDP 客户端缓存里的旧帧需要它)时,SystemParametersInfoW(SPI_SETDESKWALLPAPER, 真实路径, SPIF_SENDWININICHANGE)。三条铁律:重放原始路径、不带 SPIF_UPDATEINIFILE(否则会写用户注册表、打断幻灯片、把“每屏不同壁纸”场景的其它屏重置)、重放会让 shell 重建缓存 6–20 秒(调用方据此让垫底进入“跟随模式”)。另:SPI(NULL) 会把部分配置刷成纯黑,禁用;Win11 24H2 的 raised desktop 下完全跳过——会直接毁掉壁纸 WorkerW(Lively 对同款调用同样门控)。

7.3 去抖合并

HUD 的每次“腾出矩形”先进入 App 的脏矩形并集(UnionRect),250 ms 静默后才真正刷新一次——几何滑块拖动每秒触发数十次腾出报告,不再逐次刷新。其中桌面级请求(nullptr)是粘性标志:去抖窗口内后续的矩形报告不得把它降级成单矩形刷新,否则其余区域会留下幽灵帧。退出前必须 flush,保证不留尾巴。

7.4 两条被否定的路线

  • “外科式 RedrawWindow 足以清残影、可去掉 SPI 重放”:实测残影 35.8 清不掉。重放必须保留为兜底,但改为绘制覆盖为主。
  • “渲染管线回归导致残影”:同半径下新旧二进制角部像素逐位一致——残影与渲染代码无关,回退代码作诊断不能定位此类问题。

8 移动模式

托盘菜单“移动 HUD 位置…”的完整生命周期:

  1. 进入(Hud::BeginMove):desktop::Detach 脱离桌面层(child → popup,坐标需在 Detach 前后手动保持,SetParent 会按新父重解坐标);ex 样式去掉 WS_EX_TRANSPARENT | WS_EX_NOACTIVATE、加 WS_EX_TOPMOST | WS_EX_LAYERED;渲染模式切到 ULW;若 HUD 原本隐藏则强制显示并持久化。
  2. 拖动:WM_NCHITTEST 在移动模式下返回 HTCAPTION,整个面板即标题栏,走系统原生拖动循环(单个拖动内 Esc 可取消)。移动期间快照刷新照常渲染内容,但跳过 PlaceWindow(否则每秒被拽回配置坐标);健康检查也全部跳过。
  3. 完成:WM_NCLBUTTONDBLCLK / WM_NCRBUTTONUP → 记录 GetWindowRect,换算为“显示器序号 + 显示器相对像素”写回配置。
  4. 重建:回调投递 kMsgRebuildHud,延迟销毁旧窗口、按新位置重建(不在 HUD 自己的 WndProc 里同步销毁)。

移动模式的两种渲染形态

避免的坑:

  • 移动态弹窗不要调用 SetLayeredWindowAttributes 之后又切回 ULW(文档:SLWA 之后 ULW 失效,直到样式位清/置——而子窗口清了就回不来)。
  • WS_EX_TOPMOST 归窗口管理器管:清除必须用 SetWindowPos(HWND_NOTOPMOST),直接改样式位无效。
  • 移动期间 HealthCheck 与 TaskbarCreated 处理必须旁观(hud_.InMoveMode() 守卫),否则 2 秒一次的健康检查会把窗口拽回去。

9 托盘回调 v4 的参数布局

NOTIFYICON_VERSION_4(NIM_SETVERSION 设置成功后)的回调消息布局与旧版完全不同:

wParam lParam
v4 打包的屏幕坐标:LOWORD=x,HIWORD=y LOWORD=事件,HIWORD=图标 ID
旧版 图标 ID 鼠标消息

托盘回调参数的两种布局

要点:

  • LOWORD(lp) 在两种布局下都是事件,事件读取与布局无关;只有坐标来源依赖 version4_。早期版本把两者读反(以为事件在 wParam),导致右键/左键全部无响应。
  • v4 模式下 shell 对同一次点击同时投递裸鼠标消息和抽象事件。 日志实录(一次右键):lp=0x10204 → 0x10205 → 0x1007B,即 WM_RBUTTONDOWN → WM_RBUTTONUP → WM_CONTEXTMENU。只处理抽象事件,否则每次点击动作执行两遍——表现为菜单选中后“闪一下”又弹出来。
  • NIF_SHOWTIP 必须带上:v4 图标不给这个标志,shell 接受 szTip 却永远不显示悬停提示。
  • 菜单弹出的前置条件(各自失效时都表现为“右键没反应/闪一下就没”):owner 窗口不能是隐藏窗口,也不能是 message-only 窗口(从未显示过的窗口无法成为前台窗口,菜单会在出现的瞬间被“点击在菜单外”关掉);用屏幕外 1×1、已 ShowWindow、带 WS_EX_TOOLWINDOW 的顶层窗口最稳;SetForegroundWindow 单独调用常失败,要先 AttachThreadInput 再设前台。
  • 回调日志记录原始 wParam/lParam,是定位这类问题最快的手段。

10 MinGW 的两个坑

10.1 ICO 条目必须是完整 DIB

ICO 的每个图像条目必须是完整 DIB:40 字节 BITMAPINFOHEADER(biSize=40、biHeight=2×宽)+ XOR 像素 + AND 掩码。

图标生成脚本曾漏写头部(条目直接以裸像素开头,biSize=0)。windres 不解析 ICO 内部结构、原样嵌入,于是 exe 里有了无法解码的图标资源:LoadImageW 静默失败 → 回退到系统默认图标 → “托盘图标不是自己的”。更麻烦的是 ExtractAssociatedIcon 同样返回默认图标——它对坏数据的“成功”没有诊断价值。所以生成脚本务必在写入像素前补齐头部,并用 biSize==40 && biHeight==2*宽 断言自检。

10.2 manifest 的两处坑

binutils ≥ 2.44 会自动链接 lib/default-manifest.o(自带 RT_MANIFEST 资源 ID 1),项目自己的 .rc 若也用 ID 1 会直接报 multiple non-default manifests。本项目把自有 manifest 放在 ID 2,运行时用 CreateActCtxW 显式激活拿到 Common Controls v6。

这里有一个更隐蔽的坑,值得单独记一笔:CreateActCtx 激活的 manifest,只覆盖 SxS 依赖解析(也就是 comctl32 v6 那条 <dependency>),不覆盖加载器时(loader-time)的设置。 因此 manifest 里的 <windowsSettings>(dpiAware / dpiAwareness / longPathAware)和 <compatibility> 段在 ID 2 上是惰性的——写了也不生效。DPI 感知必须改用 API 在启动时设置:

// DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 == (HANDLE)-4
pfn(SetProcessDpiAwarenessContext, reinterpret_cast<HANDLE>(static_cast<INT_PTR>(-4)));

另外,manifest 里故意不写 activeCodePage = UTF-8:实测 profile/INI API 在 ACP 65001 下会截断多字节值(L"微软雅黑" 只写出 "微软"),会毁掉 config.ini。配置保持系统代码页(CP936 下往返验证通过)。

11 文字宽度保护

右对齐的值文本以 NO_WRAP + CLIP 绘制,一旦比预留区域宽,溢出部分向左伸出、被裁掉的是开头字符。这类问题复发了两次:

共享 56px 值区:      "x.x GB / y.y GB"        ->  ".0GB"
VRAM 140px 固定值区: "612.4 MB / 12.0 GB"    ->  ".4 MB / 12.0 GB"

魔法数追不胜追,最终方案是三层测量保护:

面板文字宽度三层保护

  1. 值区域实测宽(RenderToTarget 的 valueZoneW):max(行下限×s, 实测宽 + pad + 2s)。区域矩形是 [width-w, width-pad],因此 w 必须覆盖文本加 pad——第一版修复漏加 pad,仍被边际裁切。
  2. 内容最小宽(Hud::ContentMinWidth):逐行算 pad + labelW + gap + valueW + pad,面板宽 = max(80, 配置宽, widthFloor_)。widthFloor_ 增长即时、收缩需连续 5 帧保持更小——数值逐秒变长变短,无迟滞的地板会让面板呼吸并反复重裁壁纸垫底。
  3. 设置护栏(SettingsWindow::EnsureMinWidth):用与渲染端同一套 BuildItemsFor + ContentMinWidth + 字体构造,按草稿配置、目标显示器的实际 DPI(GetDpiForMonitor 的 MDT_EFFECTIVE)计算最小宽;应用/确定时若草稿宽度不足,自动抬高(编辑框同步)并弹窗说明。

设置窗口的实时预览界面

设置窗口本身也是几轮实测的产物:控件表在 96-dpi 设计单位里描述、按当前 DPI 统一缩放(WM_DPICHANGED 时整表重排),窗口不用 CW_USEDEFAULT——否则首次 ShowWindow 会用系统缓存的创建时尺寸覆盖掉隐藏期间设好的尺寸,缩放显示器上的“确定/应用”按钮直接跑到客户区外面。

测量一致性陷阱:比较字符串宽度必须在同一 DPI 感知环境下进行。独立探针进程若不声明 DPI 感知,LOGPIXELSX / DWrite 都被虚拟化到 96,而应用进程是 Per-Monitor-V2(缩放显示器上真实 120)——测出的宽度差一个缩放系数。这不是应用的 bug,探针要加 SetProcessDpiAwareness。

跨进程 UI 自动化坑:GetWindowTextW 对其他进程的控件返回空串(它不发 WM_GETTEXT,只读本进程缓存的窗口标题);驱动设置窗口做验证要用 SendMessage(WM_GETTEXT) 直取。

12 配置与生命周期

这一节的问题都属于“假设错了但表面正常”的同一族。

12.1 INI 编码锁 CP_ACP

为修“西文系统上把宋体存成 ??”,曾把 config.ini 改为 UTF-16LE+BOM 写盘(“GetPrivateProfileStringW 认 BOM”是流传很广的说法)。用 15 行最小程序在 Win10 19045 上实测:profile API 按原始 ANSI 字节读文件,完全找不到节,全部返回默认值——若上线会直接毁掉配置读取,冒烟测试都没拦住。结论:INI 编码锁定 CP_ACP,这是硬约束不是偏好;编码问题的修法放在 UI 端(字体下拉过滤掉 CP_ACP 不可表示的名字)。

教训:跨 API 的行为假设先写最小程序实测,且冒烟测试要覆盖“写回再读入”的环路。

12.2 注销时消息循环不退出

Shutdown() 先清 GWLP_USERDATA 再 DestroyWindow,WM_DESTROY 在静态 WndProc 里取不到 self,走到 DefWindowProc——PostQuitMessage 永远不执行,消息循环不退出:每次注销/关机应用都拖到系统强杀超时。修复:WM_ENDSESSION 处理完直接 PostQuitMessage(0),不依赖 WM_DESTROY。

教训:“经 userdata 分发”的静态窗口回调,销毁路径要专门走查一遍(成员清理与消息派发的先后顺序)。

12.3 去重吞掉失败重试

垫底解码失败后注释写着“渲染节流会重试”,但重试入口被 key == underlayKey_ 去重拦下(失败不改键),“重试”落空。修复:worker 失败置 underlayReqFailed_,调度端见标志放行同键重排。

教训:“稍后会重试”的注释必须指出具体调用路径;latest-wins 去重的队列必须有失败旁路。

12.4 其余几处

  • 两个显示器枚举的顺序没有文档保证:Config::monitorIndex 是 EnumDisplayMonitors 序,曾被直接当 IDesktopWallpaper::GetMonitorDevicePathAt 的下标用,多屏不同壁纸时会解码另一台显示器的图。改为按显示器矩形几何相等匹配。
  • 自启动只加不删:启动路径 if (autoStart) SetAutoStart(true) 让手改 INI AutoStart=0 永远清不掉已有的 Run 键;改为无条件 SetAutoStart(cfg_.autoStart) 双向对齐。
  • RawSMBIOSData 表区 = 8 字节头 + Length 字节:扫描终点是 data + 8 + Length(少加 8 会漏掉表尾),Length 需按 API 返回值夹取。
  • GetPrivateProfileStringW 最多返回 nSize-2 字符:缓冲增长判据是 n == size-2(用 n < size-1 时增长分支是死代码,长值静默截断)。
  • 网络速率基线要携带适配器 LUID:切换网卡时重新起步,不跨卡相减(否则一个 tick 的 TB/s 级假尖峰)。
  • NVML 失联(驱动重置/TDR)后句柄作废但 nvmlUp_ 仍为 true:核心调用连续失败约 5 秒后卸载重初始化;getCount()==0 闩锁避免无卡系统空转。
  • 日志轮转 MoveFileW 会因外部程序占用失败:失败时截断兜底,512 KB 上限继续生效;轮转在锁内,告警只能走 OutputDebugString(log::Warn 会递归死锁)。
  • 被后台线程 PostMessage 的 HWND 要在销毁路径上提前解绑(窗口句柄可能被复用);共享状态用同一把互斥量保护读写。

13 代码级隐患

上面各节是已经踩过并修好的坑。在通读代码的过程中,还有几处今天不炸、但结构上迟早会炸的地方,一并记下来。

这一节的五处全部已修(附补丁与验证方式)。其中 13.1 / 13.4 / 13.6 是写文章时顺手的定点补丁;13.2 / 13.3 / 13.5 涉及结构性改动,放到编译环境就位后完成——13.3 靠修改前后 --dump 逐像素对比证明行为不变,13.2 靠独立测试程序证明契约破坏会被大声报错。

13.1(已修)两份镜像的校验

Hud::DecodeUnderlayCrop(垫底裁片)与 desktop::DecodeRegionToBGRA(残影覆盖)是两段几乎逐行相同的摆放数学:center / tile / stretch / fit / fill / span 六种样式、信箱钳制、WIC 裁剪 + 缩放、以及 CopyPixels 步长的绕法。源码注释自己写着:

// Placement math mirrors Hud::DecodeUnderlayCrop - keep the two in sync.

这确实是 bug 的温床——CopyPixels 步长那个 bug 当初就是靠“两处同步”修好的。但两处的图片尺寸合理性校验并不一致:

// Hud::DecodeUnderlayCrop —— 有上限
if (iw == 0 || ih == 0 || iw * ih > 8192u * 8192u) hr = E_INVALIDARG;

// desktop::DecodeRegionToBGRA —— 只有零值检查
if (iw == 0 || ih == 0) hr = E_INVALIDARG;

一个超大或畸形的壁纸文件会让后者按 dstW × dstH × 4 分配出巨量内存。

修法:把上限校验补齐,与镜像实现逐字一致:

if (SUCCEEDED(hr) && (iw == 0 || ih == 0 || iw * ih > 8192u * 8192u))
    hr = E_INVALIDARG;

但根因没有解决:摆放数学仍然是两份副本。同一份逻辑写两遍,改一处忘另一处只是时间问题;更稳的形态是把它抽成一个纯函数,两处都调用它,校验只写一遍。

13.2(已修)RamDesc 的静态缓存

const std::wstring& RamDesc(unsigned long long totalPhysBytes) {
    static const std::wstring desc = BuildRamDesc(totalPhysBytes);  // 参数被忽略
    return desc;
}

今天安全——唯一调用点在 memory_.Sample() 填充 total 之后。但它把“调用顺序”变成了隐式契约:任何一处提前用 0 调用,就会永久显示空描述。

修法:首调记住入参,之后再调时校验容量一致,不一致则打 ERR 日志(硬件容量运行期不会变,出现不同值必然是新调用点违反了顺序):

static unsigned long long seenBytes = 0;
static bool               primed    = false;
static const std::wstring desc      = BuildRamDesc(totalPhysBytes);
if (!primed) { seenBytes = totalPhysBytes; primed = true; }
else if (totalPhysBytes != seenBytes)
    log::Error(L"RamDesc called with a different capacity than the first call ...");

验证:独立测试程序先以 64GB 调用、再以 32GB 调用——日志出现 [ERR ] RamDesc called with a different capacity than the first call (34359738368 != 68719476736),返回值保持首值。契约被破坏时大声失败,而不是静默显示错误数据。

13.3(已修)测宽与绘制

ContentMinWidth(测宽)和 RenderToTarget 里的 valueZoneW lambda(绘制)是同一套公式的两个副本,靠注释互相提醒。第 11 节那三个坑(漏 pad、地板呼吸、跨 DPI 测量)本质上都是“两份实现漂移”的症状。

修法:抽出 Hud::ValueZoneWidth 静态函数,作为 zone 公式的唯一实现:

float Hud::ValueZoneWidth(IDWriteFactory* dw, IDWriteTextFormat* fmtValueR,
                          const std::wstring& value, float floorW,
                          float pad, float s) {
    return std::max(floorW, MeasureTextWidth(dw, fmtValueR, value) + pad + 2.0f * s);
}

测宽(ContentMinWidth)与绘制(RenderToTarget 的 Meter / Text 两个分支)都改为调用它,公式只写一遍,漂移在结构上不再可能。

验证:修改前后各编译一版,--dump 逐像素对比——全部差异均为 CPU 占比、网速等实时数据(30%→31%、9.5→1.8 KB/s),静态结构零差异。编译环境就位后补做的这项,0 警告。

13.4(已修)Stop 的早退分支

void Sampler::Stop() {
    std::lock_guard<std::mutex> lock(waitMutex_);
    if (!worker_.joinable()) return;   // <- 早退:gpu_.Shutdown() 被跳过
    ...
    gpu_.Shutdown();
}

今天由 ~GpuMonitor 兜底,所以没有实际后果。但把资源释放放在 join() 之后的函数体尾部、而入口又有一个早退分支,是很容易漏的模式。

修法:把早退换成条件赋值,让释放无条件执行:

void Sampler::Stop() {
    {
        std::lock_guard<std::mutex> lock(waitMutex_);
        if (worker_.joinable()) {
            stopping_ = true;
            wake_     = true;
        }
    }
    cv_.notify_all();
    if (worker_.joinable()) worker_.join();
    gpu_.Shutdown();          // 现在一定会执行
}

GpuMonitor::Shutdown() 本身是幂等的(内部就是卸载 NVML + 重置重试节流),而 Start() 开头会先调一次 Stop()——所以这条路径重复调用是安全的。改之前要把这两点确认清楚,否则「顺手补一行」可能变成「每次启动都多卸载一次驱动句柄」。

13.5(已修)设计上界两处硬编码

cornerRadius 的设计上界 32 同时出现在 Config::Sanitize 和 SettingsWindow::WriteControls 的 TBM_SETRANGE(0, 32) 里。改一处忘另一处,就会出现“滑块能拖到 40 但存下去被夹回 32”这类不一致。

修法:上界提升为 Config 的常量,两侧都引用它——

// config.h
static constexpr int kCornerRadiusMax = 32;  // arc must stay clear of the text column
static constexpr int kPaddingMax      = 64;
static constexpr int kOpacityMax      = 255;

Sanitize 的 clamp 与设置窗口的 TBM_SETRANGE(0, Config::kCornerRadiusMax) 现在指向同一个名字,界限改动只碰一处。验证:增量构建 0 警告,--dump 渲染无变化(界限值本身没动,只是收敛了来源)。

13.6(已修)–dump 的相对路径

这一处是发布前实测撞出来的,不在最初通读代码的清单里。--dump 是 README 里公开的诊断入口:

D:\...\AuraUI> build\AuraUI.exe --dump rel_test.bmp
D:\...\AuraUI> echo %ERRORLEVEL%
0
D:\...\AuraUI> dir rel_test.bmp
找不到文件
:: 但 C:\Users\<用户>\AppData\Roaming\AuraUI\rel_test.bmp 出现了

不是失败,是静默写到了别的目录。 根因在 wWinMain 的语句顺序:

if (!appDir.empty()) ::SetCurrentDirectoryW(appDir.c_str());   // ← 先切工作目录
const Options opts = ParseCommandLine();                        // ← 再解析命令行

把工作目录切到 %APPDATA%\AuraUI 本身是对的(挡 NVIDIA 驱动往用户的启动目录丢 umdlogs),但 --dump 的路径是用户给的,相对路径于是跟着新工作目录跑偏。

修法:切目录之前记下启动目录,解析完命令行后再把相对的 dump 路径补成绝对路径:

std::wstring launchDir;      // 在 chdir 之前取;写法与 paths::ExePath() 一致(缓冲不够翻倍重试)
...
Options opts = ParseCommandLine();
if (opts.dump && !opts.dumpPath.empty() && !launchDir.empty() &&
    !IsAbsolutePath(opts.dumpPath))
    opts.dumpPath = launchDir + L"\\" + opts.dumpPath;

附带一个观察:相对子路径(--dump temp\x.bmp)的表现还不一样——它不会静默落盘,而是因为 %APPDATA%\AuraUI\temp 不存在直接返回 2。同一个根因,两种症状。

教训:路径类参数要明确「相对谁」。 进程一旦改过工作目录,“相对路径”的语义就变了;面向用户的路径参数应当在解析时就锚定基准,而不是留给下游 API 去猜。

14 调试方法论

排查“窗口属性全部正常却看不见”时的判定顺序:

  1. 排除窗口泄漏(EnumWindows 计数)。
  2. 排除 cloaked / 隐藏 / 父链不可见 / 位置离屏。
  3. 排除配置状态(HudVisible、Paused)。
  4. 剩下的就是合成层问题——分层子窗口的故障模式 A/B/C 之一。

像素级取证的主力手段是 PrintWindow(PW_RENDERFULLCONTENT):它对壁纸 WorkerW 调用可以拿到含 HUD 子窗口的合成画面,且在“BitBlt 整段黑帧”的 RDP 会话里依然可用。注意它对 ULW 子窗口直接调用是盲的——要对宿主 WorkerW 调用;解析 GetDIBits 缓冲按 BGRX。

手段 适用 注意
结构探针(EnumWindows + 样式/父窗口/rect) 窗口是否存在、挂载、可见位 无法判定渲染内容;找挂载后的 HUD 要走窗口树(FindWindowW 只搜顶层)
GetLayeredWindowAttributes 读回 SLWA 状态是否设置成功 设置成功 ≠ DWM 合成(故障模式 B)
DwmGetWindowAttribute(DWMWA_CLOAKED) 是否被 DWM 遮蔽 本例全为 0,排除用
PrintWindow(PW_RENDERFULLCONTENT) 壁纸 WorkerW 的合成画面 本项目像素取证主力
CopyFromScreen / GetPixel 合成后像素 RDP 会话可能整体失效(返回黑/CLR_INVALID)
运行日志 事件序列、布局判定 裸鼠标消息别刷屏(按 WM_MOUSEMOVE 过滤)

项目里还沉淀了一套端到端验证脚本(纯 Python 标准库,不依赖鼠标键盘):挂载与样式位、z 序、实时预览收缩/恢复、z 序破坏自愈、Win+D 存活,逐项 PASS 时输出 RESULT: ALL PASS;另有托盘回调、移动模式、宽度护栏等专项脚本。跨进程读写控件文本必须用 WM_GETTEXT/WM_SETTEXT。

15 问题清单

15.1 采集与硬件信息

  1. ⚠️ GetSystemTimes 的 kernel 已含 idle:total 只能是 kernel + user,否则负载被系统性低估。
  2. ⚠️ 速率类指标(网络)的基线必须携带适配器标识(LUID),换卡时重新起步,否则报出 TB/s 级假尖峰。
  3. 计数器会回绕:差值取 in >= prev ? in - prev : 0,不要输出负数。
  4. GetAdaptersAddresses 返回 ERROR_BUFFER_OVERFLOW 时要按回填的 size 重新分配再调(缓冲增长重试)。
  5. ⚠️ nvml.dll 用 LoadLibraryExW 绝对路径加载 + GetProcAddress 解析(符号带 _v2 回退),别把 SDK 变成构建依赖。
  6. NVML 句柄会因驱动重置 / TDR 失效:核心调用连续失败若干拍后要卸载重初始化,且重试要节流;getCount()==0 时闩锁。
  7. SMBIOS 表区 = 8 字节头 + Length;Length 要按 API 返回值夹取。
  8. SMBIOS 的内存类型字节可能移位(0x11 → 0x12),只在 0x11 是 Other / Unknown 时才偏移。
  9. 硬件容量以 OS 为准(GlobalMemoryStatusEx),SMBIOS 只信型号与频率,并留一条交叉校验日志。
  10. 磁盘盘符取自 GetWindowsDirectoryW,不要硬编码 C。

15.2 桌面层挂载

  1. ⚠️ 0x052C 的 wParam 必须是 0xD,lParam 0x1;写 (0,0) 静默无效,窗口隐身。
  2. ⚠️ 壁纸层是懒加载的,不存在时要催生;催生要限流(每次多建一个 WorkerW)。
  3. ⚠️ 按类名找 WorkerW 不可靠,必须加「属于 Explorer」过滤。
  4. ⚠️ 不要挂 Progman:SHELLDLL_DefView 会抓父窗口缓冲画背景。
  5. ⚠️ 先改样式(清 WS_POPUP、置 WS_CHILD)再 SetParent,否则停在“半挂载”状态。
  6. 壁纸层的位置随 shell 版本变化(顶层 WorkerW / Progman 直接子窗口),两处都要找。
  7. SetWindowPos 用 HWND_BOTTOM 或插到 SHELLDLL_DefView 正下方;绝不 HWND_TOPMOST。

15.3 渲染与合成

  1. ⚠️ 跨进程 SetParent 之后分层子窗口不可靠(故障模式 A/B/C),挂载态只用普通 GDI。
  2. ⚠️ 在可见状态下清除 WS_EX_LAYERED 会把当前帧“烤”进桌面合成,留永久残影。
  3. 挂载态的半透明只能用“壁纸垫底”模拟;裁片在后台线程生成、latest-wins、永不保留全图。
  4. ⚠️ 垫底必须解码 shell 实际显示的第 3 层缓存(CachedImage_*),否则圆角区显形。
  5. 裁片键必须携带源文件 LastWriteTime(缓存被原地重写)。
  6. CopyPixels 按 cbStride 步长写输出,与目标缓冲真实行距不等时会产生对角错切——先拷进连续临时缓冲再逐行 memcpy。
  7. D2DERR_RECREATE_TARGET 重试时,垫底 bitmap 必须在重试循环内懒创建(旧的随 target 一起失效)。

15.4 残影

  1. WorkerW 从不重绘被腾出的区域;修复以“绘制覆盖”为主,RedrawWindow 与 SPI 重放为兜底。
  2. SPI 重放必须回放原始路径、不带 SPIF_UPDATEINIFILE;SPI(NULL) 禁用;24H2 raised desktop 下跳过。
  3. 腾出矩形要 250 ms 去抖合并;桌面级请求是粘性的,不能被单矩形报告降级。

15.5 交互与资源

  1. ⚠️ 托盘 v4 的 wParam 是坐标、lParam 是事件;同一次点击会同时投递裸消息与抽象事件,只处理抽象事件。
  2. 菜单 owner 不能是隐藏/message-only 窗口;SetForegroundWindow 前先 AttachThreadInput。
  3. ⚠️ ICO 每个条目必须是完整 DIB(biSize=40、biHeight=2×宽),否则 LoadImage 静默失败。
  4. ⚠️ MinGW-w64 自带 ID 1 的 manifest,项目 manifest 放 ID 2 + CreateActCtx 激活。
  5. ⚠️ CreateActCtx 激活不覆盖 <windowsSettings> 这类加载器时设置,DPI 感知要另用 API 设置。
  6. 移动态不要 SLWA 后切回 ULW;WS_EX_TOPMOST 清除必须走 SetWindowPos(HWND_NOTOPMOST)。

15.6 配置与生命周期

  1. ⚠️ profile API 不认 UTF-16 BOM,INI 编码锁 CP_ACP;activeCodePage 不能设 UTF-8(会截断多字节值)。
  2. ⚠️ WM_ENDSESSION 要直接 PostQuitMessage,不能依赖经 userdata 分发的 WM_DESTROY。
  3. latest-wins 去重必须有失败旁路,否则“稍后重试”落空。
  4. 两个显示器枚举的顺序没有文档保证,按几何匹配。
  5. 自启动要双向对齐,不能只加不删。
  6. GetPrivateProfileStringW 最多返回 nSize-2 字符,增长判据要用 == size-2。
  7. ⚠️ 面向用户的路径参数要在解析时就锚定基准:进程一旦改过工作目录,“相对路径”的语义就悄悄变了(--dump 曾静默落到 %APPDATA%\AuraUI,见 13.6)。

16 总结

这轮实践中最主要的收获,是对一条原则的反复验证:

窗口属性全都对,屏幕上却什么都没有。

第 1 节列出的十二条结论,没有一条是通过返回值发现的——SendMessageTimeout 成功、UpdateLayeredWindow 成功、SetWindowLongPtr 成功、GetLayeredWindowAttributes 回读成功,而结果全是错的。真正把问题钉死的是两类证据:跨进程的像素级探针(PrintWindow 宿主 + 按 BGRX 解析 + 比值判据),以及保留原始参数的运行日志(托盘回调的 wParam/lParam 原样记录,是定位“左右键全部无响应”最快的手段)。

第二个收获是:当两套实现“必须保持同步”时,它们迟早会漂移。 摆放数学写了两份、测宽与绘制写了两套、设计上界写在两处——它们大多今天都不炸,但正是同类 bug 的固定产地:第 13.1 节那处尺寸校验的不一致,就是“漂移”的直接产物。把重复逻辑收敛成唯一实现,比每次复发再补一条规则划算得多。

第三个收获来自第 7 节那次误诊:“属性探针全部正常”时,不要再沿着属性这条线推测。 跨进程 graft 的绘制同步、shell 内部的缓存流转、窗口管理器对样式位的所有权——这些都不在文档里,也不体现在任何返回值上。该做的是把观测手段升级到像素层,然后让证据说话。

第四个收获来自第 3 节的采集部分:一个常驻工具好不好用,很大程度上取决于它「拿不到数据」时怎么表现。 没有 NVIDIA 显卡、网卡被拔掉、卷不存在、驱动被 TDR 重置——这些都不是异常路径,而是真实机器上的日常。做法是让每一项指标都自带「有没有值」,拿不到就降级显示 N/A;会失联的部件(NVML 句柄、网卡速率基线)自己检测、自己重建。整条链路因此不需要用户做任何事,也不会因为某一项读不到就整块面板失效或者弹窗。

希望这些记录对同样在做桌面层 / Win32 合成相关工作的读者有所帮助。

17 项目与源码

源码已开源:https://github.com/yanghaoi/AuraUI(MIT 许可)。

  • 构建:CMake ≥ 3.20 + MinGW-w64(不需要 Visual Studio),cmake --build 后得到单个静态链接的
    AuraUI.exe(约 2 MB)。版本号的唯一来源是 core/version.h;同一个版本号还出现在
    assets/app.manifest 与 CMakeLists.txt 里,改的时候三处要一起改。
  • 代码构成:9 个模块目录(app/ core/ config/ desktop/ hud/ monitor/ system/ settings/ tray/)
    • src/main.cpp,外加 assets/(图标与 manifest)与 docs/technical-notes.md
      ——那是本文深坑细节的完整版,含逐条实证数据与参考链接。
  • 依赖:零第三方库——全部使用 Windows 系统 API/DLL;GPU 遥测在运行时从显卡驱动目录
    动态加载 nvml.dll,ABI 子集自己声明,因此不把 CUDA / NVML SDK 变成构建依赖。
  • 离线:程序不访问任何网络接口,公网 IP 不在功能范围内。

18 封面与图片来源

正文中的运行截图与设置窗口截图,均为项目在 Windows 10 19045 上的实拍(桌面壁纸为系统自带示例图)。

本文封面沿用「鲸鱼娘 DeepSeek」同人形象二次创作:角色原作 上善无形(Bilibili),DeepSeek 元素女仆版二次设计 ZipZipPipe(Bilibili),素材取自同人整理站 蓝色大肥鱼档案馆。原作与二次设计均依 CC BY-NC-SA 4.0 授权,本文封面为非商业使用,并遵循相同协议。


项目:AuraUI —— 原生 Win32 桌面系统监控 HUD(C++20 / Direct2D / DirectWrite / Common Controls,MIT 许可)。除系统 DLL 外零依赖,静态链接约 2 MB,默认完全离线。


文章作者: YangHao
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 YangHao !
评论
 本篇
桌面壁纸层监控HUD踩坑实录 桌面壁纸层监控HUD踩坑实录
把一块半透明监控面板“沉”到桌面壁纸层与桌面图标之间,按 Win+D 不消失、鼠标点击完全穿透。听起来像个几十行的窗口技巧,实测却是一连串“API 返回成功、结果却是错的”:0x052C 的参数写错时窗口静默隐身、跨进程分层子窗口有三种互相独立的失效模式、桌面显示的壁纸其实是三层缓存链的最后一层、子窗口腾出的区域会留下永久残影、托盘 v4 回调的参数含义完全变了……本文逐条记录现象、定位过程、实证方法与最终方案。
2026-10-04
下一篇 
PE文件版本资源注入问题排查与工具实现 PE文件版本资源注入问题排查与工具实现
给 PE 文件批量写入版本信息,按文档描述应是调用三个资源 API 的问题;实测遇到四个行为问题,共同点是 API 返回成功而结果错误。本文给出完整的排查过程、VS_VERSIONINFO 的内存布局、24 条问题清单、一个无 CRT 的工程实现,以及将 6 个 Python 验证脚本集成为 C 原生 PE 查看器(peview.exe)的过程——含双路资源视图设计动机与多模块工程结构。
2026-10-01
  目录