Desktop Sticker:Windows 桌面收纳、拼音搜索与可交互动态壁纸
项目地址:GitHub - Desktop-Sticker 平台:Windows 11 x64 · C++20 + WinUI 3 · 免安装自包含
Windows 桌面用了几年之后基本都长一个样:左边一列快捷方式,右边一堆临时文件、截图、下载的安装包,找东西全靠肉眼扫。
Desktop Sticker 试着把桌面这一层重新组织一遍:图标自动分类进可拖动的磁贴卡片,双击空格就能全局搜索,桌面上挂一个时钟,再加上能接收鼠标键盘的动态壁纸。所有窗口都寄生在系统桌面之下,用起来仍然像"桌面本身",而不是又一个悬浮应用。
一、它由哪些部分组成
主程序是一个常驻托盘的 WinUI 3 应用,功能拆成三个可独立加载的模块,外加一个壁纸编辑器:
| 模块 | 形态 | 负责 |
|---|---|---|
| 主程序 | WinUI 3 EXE | 托盘、设置窗口、启动器窗口、模块装配 |
| Features | Win32 DLL | 分区卡片、图标收纳、搜索索引、拼音、热键、桌面时钟 |
| WallPaper | Win32 DLL | 视频/动图/网页/3D/场景壁纸、帧调度、鼠标键盘转发 |
| ResMon | Win32 DLL | 只读资源管理器(WebView2 界面 + 原生采样) |
| Wallpaper Studio | 独立 EXE | 壁纸编辑器的宿主,加载内嵌 Godot 运行库 |
桌面分区收纳
- 首次启动按关键词规则把桌面图标分到 应用 / 开发工具 / 办公软件 / 浏览器 / 影音娱乐 / 实用工具 / 社交聊天 / 游戏 / 文件夹 / 其他 十个分区;新丢到桌面的文件由目录监视自动归档。
- 四列布局:左右各两列贴边,卡片高度动态铺满到任务栏;每列 4 张或 5 张可切换。
- 卡片可拖动、可缩放、双击标题折叠、滚轮滚动;磁贴双击打开、拖到别的分区即移动、拖出到空白即还原成原生图标。
- 双击桌面空白切换"干净桌面"(磁贴与原生图标一起隐藏),托盘一键把所有图标放回原位。
搜索启动器
双击空格唤起(可改为 Alt+Space 等组合键),搜索范围覆盖桌面、文档/下载/图片/视频/音乐目录、开始菜单应用与手动添加的条目;支持拼音首字母(wx → 微信)、方向键选择、回车打开,结果按来源分组。
桌面时钟
顶部居中的悬浮卡片:模拟表盘 + 大号数字时间 + 地区显示,下面是太阳—地球轨道动画(地球位置 = 今天已过去的时间,夜间走暗弧),再挂当前小时的天气。定位走 IP 地理服务(直连取真实 IP,不走系统代理),天气用 Open-Meteo 免费接口,不需要任何 API Key,后台 30 分钟刷新。
动态壁纸
四类渲染来源,外加一种打包格式,统一在原生图标与分区卡片之下播放:
- 视频:MP4 / WebM / MOV,循环播放,0.25×–4× 调速、声音开关;
- 动图 / 图片序列:GIF、动态 WebP,或一个图片文件夹按自然序播放;
- 网页:HTML/CSS/JS 页面跑在 WebView2 沙箱里,透明处露出原桌面壁纸;
- 3D / 着色器:Vulkan 自持交换链直接呈现,内置全屏等离子着色器与 32 面截角二十面体两个场景;
- 场景壁纸:用随包的 Godot 编辑器 fork 做 2D/3D 场景,导出
.dswall包,由内嵌运行库在进程内渲染,用户机器不需要装 Godot。
资源管理器
基于 WebView2 的只读查看器,三页签:按线程的 CPU 明细(线程名可读)、按模块的内存占用(只列本程序自己的模块)、存储构成分类。只 stat 不写,扫描全在工作线程。
二、架构:一个 EXE 与三条 ABI 边界
Desktop_Sticker.exe (WinUI 3 / C++/WinRT)
│ LoadLibrary + 三个接口
├── DesktopSticker.Features.dll IFeatureModule
├── DesktopSticker.WallPaper.dll IWallPaperModule
└── DesktopSticker.ResMon.dll IResMonModule
│
└── libgodot(场景壁纸运行库,进程内动态加载)
dstk-studio.exe (壁纸编辑器宿主) ──> 同一个 libgodot
模块之间不互相 include 头文件,只通过三个 I*Module 接口 + extern "C" 的 Create*Module / Destroy*Module 通信:接口就是 ABI,改字段两边必须一起重编。这样做的直接收益是降级粒度:
- 壁纸模块加载失败 → 托盘里的壁纸菜单变灰,分区和搜索完全不受影响;
- 资源管理器初始化失败(比如机器上没有 WebView2 运行时)→ 只灰掉这一项,不当成加载失败;
- 场景壁纸缺运行库 → 只报"场景壁纸不可用"。
反过来,壁纸模块还刻意不复用 Features 的接口(例如两者都要用的桌面壳层集成各写一份),就是为了不让"壁纸崩了"顺着共享代码烧到分区收纳上。
接口之外的约定也同样重要:WallPaperEvents 的回调可能来自渲染线程,主程序必须自己 DispatcherQueue 回 UI 线程;GetIcon 返回的 HICON 由模块缓存持有,调用方不许 Destroy。
三、把壁纸塞到桌面图标下面
这是整个项目最反直觉的一块。Windows 没有"动态壁纸"这个能力:壁纸 API 只吃静态图片。Wallpaper Engine 这类软件的做法是寄生 Explorer 的桌面窗口树——让 Progman 现场造一个 WorkerW 窗口,把自己的渲染窗口塞进去,位置在 SHELLDLL_DefView(桌面图标宿主)之下,于是看起来就是壁纸,实际是实时渲染的窗口。
流程(与 Wallpaper Engine 2.8 反编译出的行为一致):
progman = FindWindowW(L"Progman", nullptr);
SendMessageTimeoutW(progman, 0x052C, 0, 0, SMTO_NORMAL, 2500, &result); // 让 Explorer 造 WorkerW
EnumChildWindows(GetDesktopWindow(), find_host_cb, ...); // 找"图标层后面那个兄弟"
关键是回调里的判定:持有 SHELLDLL_DefView 的 WorkerW 是图标层,它 z 序后面那个兄弟 WorkerW 才是能画的画布;找不到就重试(先 50ms×10,再 300ms 轮询),仍不行就让 Explorer 重建桌面。拿到 HWND 之后:
| 动作 | 作用 |
|---|---|
SetWindowLongW(worker, GWL_EXSTYLE, WS_EX_NOACTIVATE | LAYERED | TOOLWINDOW | TRANSPARENT) | 不抢焦点、不进 Alt+Tab |
宿主窗口先建 WS_POPUP,SetParent 之前改成 WS_CHILD | 不改样式直接 SetParent,重设父亲不生效 |
壁纸窗口 HWND_BOTTOM + HTTRANSPARENT | 在图标与分区卡片之下,永不抢点击 |
| 分区 z 序变化后重新压到底 | 否则切一次卡片就被顶上来 |
两条工程结论:不写注册表壁纸、不调 SPI_SETDESKWALLPAPER;以及认领不到 WorkerW 就干脆不显示——一个全屏顶层窗口会把整个桌面盖住,比没有壁纸糟糕得多。所以 Create() 在那种情况下会销毁窗口并返回 false,日志里只留一句"wallpaper disabled"。
四、五种渲染后端,一套帧调度
所有后端实现同一个 IWallpaperBackend,由帧调度器统一推进:
| 后端 | 呈现方式 | 输入 |
|---|---|---|
| VideoBackend | D3D11 交换链 + NV12 全屏三角形 | 鼠标 |
| ImageSequenceBackend | WIC 解码 + D3D11 | 鼠标 |
| WebBackend | WebView2 视觉托管 + DirectComposition | 仅鼠标 |
| VulkanBackend | Vulkan 自持交换链(Win32 surface) | 鼠标 |
| GodotEmbeddedBackend | 内嵌 Godot 渲染到自己的 WorkerW 子窗口 | 鼠标 + 键盘 |
几个只有踩过才知道的点:
网页壁纸必须用视觉托管,不能托管子窗口。 窗口化托管的 WebView2 一旦宿主窗口位于桌面 WorkerW 内部就不合成:环境、控制器、导航、标题、尺寸全部正常,屏幕上却一个像素都没有(用一个纯红探针页面验证过)。所以网页后端走 CreateCoreWebView2CompositionController + put_RootVisualTarget,再由 D3D 上下文接管根视觉。
WebView2 还会偷偷在屏幕坐标 (0,0) 建一个顶层窗口。 Chrome_WidgetWin_1,WS_EX_NOREDIRECTIONBITMAP|LAYERED、alpha 0,看不见但命中测试有效——它盖住整个桌面之后,图标、卡片、时钟全部点不动,WindowFromPoint 每个点都返回 Chrome_RenderWidgetHostHWND。解法是把它挪到 -32000,-32000,页面照样通过我们的 DComp 视觉合成;导航或改尺寸它会自己跑回来,所以每秒重新检查一次。隐藏那个窗口反而会把壁纸变黑。
Godot 是进程内单例。 libgodot 不能销毁重建,所以 Close() 只是暂停/隐藏,换壁纸包走 dstk_godot_load_pack(挂新 PCK + 换主场景)。引擎渲染到自己创建的隐藏子窗口里,而且必须在窗口第一次 Show 之前就把 AccessKit 适配器装好,否则 Rust 侧会 panic 直接带走进程。
五、输入:桌面本来收不到你的鼠标和键盘
壁纸窗口在图标下面、又是 HTTRANSPARENT,系统永远不会把输入交给它。所以只能自己搭通道:一个 WH_MOUSE_LL 低级鼠标钩子 + 一个 WH_KEYBOARD_LL 键盘钩子,同一线程、同一个开关。
鼠标这条链要回答三个问题:
- 这个点算不算"空白桌面"?
WindowFromPoint拿到窗口后判类名(SysListView32/SHELLDLL_DefView/ 根Progman、WorkerW),排除本进程自己的窗口,再用跨进程LVM_HITTEST排除桌面图标——而且只在图标列表真的可见时才做这一步:正常模式下整个列表是隐藏的,对隐藏列表做命中测试会把每个图标格都变成"看不见的死区"。 - 要不要吞掉这次事件? 点击与滚轮一律吞(不吞就会一边操作壁纸一边在桌面上拖框选、弹右键菜单),移动永远不吞。
- 什么时候转发? 悬停移动节流到 8ms 一次,按住按钮时不节流(拖动要跟手)。
inline int64_t relay_move_interval_ms(bool capturing) { return capturing ? 0 : 8; }
inline bool relay_swallows(RelayEventKind kind, bool capturing) {
(void)capturing;
return kind != RelayEventKind::Move; // 移动永远不吞
}
真正让"点一下同时移动就抽搐"这个 bug 修掉的是匹配引擎的消息泵语义:Godot 的 process_events() 每帧先把离散消息(按键、滚轮、系统消息)处理完,最后才派发至多一条 WM_MOUSEMOVE。如果一帧的批次里 move 和 button 混在一起,按钮会先执行、那条更旧的 move 后执行,画面就在按下/抬起那一帧往回跳。规则因此变成:同一批里"后面还有按键/滚轮"的移动直接丢弃(relay_move_superseded),并且每帧最多转发一条移动——posted 消息不像硬件移动那样会被系统合并,多发只会让队列无限涨。
还有一条:引擎的窗口过程收到按下会 SetCapture,钩子转发完必须立刻 ReleaseCapture(),否则 Windows 还会把真实鼠标流也送给那个窗口,每次拖拽都有两条交错的流——这正是点击拖拽抽动的根因。
键盘走同一条链:只有桌面是前台时才把 WM_KEYDOWN/UP 投给引擎窗口(前台判定:Progman / WorkerW / 我们自己的窗口),转发后吞掉按键,避免输入法在壁纸上弹候选框;Win、PrintScreen 和 Ctrl/Alt 组合照常交给系统。低级键盘结构体没有"重复"标志,自动重复得自己在钩子里数,再编码进 lParam 的 bit 30。
至于"让壁纸窗口获得焦点"这条路——走不通:它是 explorer WorkerW 的 WS_CHILD,SetFocus 直接返回 ERROR_INVALID_PARAMETER (87),AttachThreadInput 也不行。
六、视频解码:Media Foundation 为主,FFmpeg 兜底
系统解码器优先;系统解不了的素材(冷门编码、损坏容器)自动落到随包的 FFmpeg 共享库。两个刻意的决定:
按显示尺寸解码,而不是按源尺寸。 4K 素材在 1080p 屏上按原分辨率解码,等于每帧多转三倍像素再被 D2D 丢掉——单这一条就把帧率从幻灯片拉回正常。
硬解走 FFmpeg 的 CUDA hwaccel,而不是 Vulkan Video。 选型理由很朴素:-hwaccel cuda → scale_cuda → hwdownload → sws 这条链在 4K60 素材上跑到约 175 fps(5.7 ms/帧),60fps 不需要额外线程。但这套链路有五个全都会静默失败的坑,每一个都是真机验出来的:
- 只设
hw_device_ctx没用,必须同时装get_format回调返回AV_PIX_FMT_CUDA,否则拿回来的是系统内存帧; - 按名字选解码器(
h264_cuvid)更差:它总是下载回系统内存,只有通用解码器 +-hwaccel cuda才输出pix_fmt: cuda; scale_cuda只能输出 yuv 格式,后面接format=bgra会在图配置阶段失败;hwdownload的输出格式必须钉成帧的sw_format(nv12),NV12→BGRA 得自己做;- FFmpeg 8.1 之后,
buffer源的创建方式变了:必须alloc_filter→av_buffersrc_parameters_set(格式、尺寸、时基、hw_frames_ctx一起给)→init_dict,任何"用字符串参数建源再补 hw_frames_ctx"的老写法都会Unspecified pixel format+ EINVAL。
因此代码里强制打印每一条的错误原文(av_strerror)、首帧真实像素格式、每 120 帧的链路命中统计——一个裸 AVERROR 数字查一晚上也查不出来。
另外还有一条与 D3D 相关的硬约束:和硬件解码器共享的 D3D11 设备必须带 D3D11_CREATE_DEVICE_VIDEO_SUPPORT 创建,并且调用 ID3D11Multithread::SetMultithreadProtected(TRUE)。MF 的 DXVA 和 FFmpeg 的硬件变换都在各自的线程碰立即上下文,不序列化就是"播几帧之后卡死在读帧调用里"。
七、可测性:把决策抽成纯头文件
桌面程序最麻烦的是"跑起来才知道对不对"。这个项目的做法是把所有策略与几何计算从 OS 调用里剥出来,写成 include/desktopsticker/** 下的纯头文件函数,src/ 只留 D3D / MF / Vulkan / 子进程这类适配:
| 纯函数头文件 | 钉住的规则 |
|---|---|
DecodeTarget.h | 解码输出尺寸永不超过窗口 |
FrameAdvance.h / ClockPolicy.h | 音视频时钟主从、追帧与丢帧边界 |
MouseRelay.h | 空白桌面判定、吞/不吞、节流、批次内 move 丢弃 |
Polyhedron.h | 32 面截角二十面体(12 五边形 + 20 六边形,V-E+F=2) |
MatMath.h | 列主序矩阵、右手系旋转方向 |
WallPaperPackage.h | .dswall 清单解析与校验 |
resmon/*.h | 分类、CPU 差分、格式化、JSON 组装 |
测试框架也是自己写的(dtest,不引 gtest),当前 308 项全部通过,不需要 GPU、不需要桌面环境。壁纸编辑器那侧同理:Godot 项目里文档/构建/导出逻辑是纯 GDScript,另有 12 个无头测试脚本;着色器这类只能真渲染才能验的东西,交给 render_smoke.gd:真渲染器、离屏小窗跑 6 帧再读回像素("左半边区域压黑"的探针文档),因为跨 viewport 采样拿到空贴图时着色器照样编译通过,只有像素能看出来。
八、踩坑清单(挑几条最有代表性的)
| 现象 | 根因 | 修法 |
|---|---|---|
| 帧率完美但屏幕全黑 | NV12 全屏三角形的顶点在 NDC 里是逆时针,默认光栅化状态是 CULL BACK | 显式 CULL_NONE 光栅化状态 |
| 自呈现后端只有 6fps | wait_ticks 参数是 100ns 单位,写 1600000 "当 16ms" 实际等了 160ms | 统一单位并给每个后端打诊断行 |
| 播几帧后卡死在读帧 | D3D11 设备没开多线程保护,硬解线程与渲染线程抢立即上下文 | SetMultithreadProtected(TRUE) |
| 播着播着变成 7fps 幻灯片 | 一次 tick 把积压帧全解了,tick 越拉越长形成固定点 | 每 tick 消费上限;剩余交给截止时间重锚 |
| 从场景壁纸切回 Vulkan 偶发崩溃 | NVIDIA 驱动在 vkDestroy* 里 AV/挂死 | 设备"驻留"复用,重新打开时只重建场景资源 |
| 设置窗口关一次就再也打不开 | WinUI 的 XAML Window 收到 WM_CLOSE 是销毁不是隐藏 | 每个子窗口订阅 Closed、置空、下次整体重建 |
| 桌面所有东西点不动 | WebView2 的隐藏顶层窗口在 (0,0) 命中测试吃掉点击 | 把它挪到屏幕外并周期复查 |
| 壁纸编辑器打开文件对话框后整窗未响应 | 原生子窗口在同进程再建一条 Vulkan 交换链,本机驱动在这条路径上有问题 | 对话框改为内嵌子窗口,全进程一个真窗口 |
九、工程数据与许可
| 项 | 数值 |
|---|---|
| C++(不含第三方与 vendored 引擎) | 191 个文件、约 2.7 万行(含测试) |
| 壁纸编辑器(GDScript) | 约 1.67 万行 |
| 资源管理器前端(原生 HTML/CSS/JS) | 548 行,无框架、无构建步骤 |
| 单元测试 | 308 项,Release x64 全通过 |
| 提交 | 144 次 |
| 配置 | %APPDATA%\DesktopSticker\ 下的 config.json / layout.json / apps.json,全部容错,缺失或损坏自动重建 |
编译只需要 VS2022 + MSBuild,仓库根目录 build.bat 一键完成还原、编译、关停运行实例、跑测试;产物是免安装目录,Desktop_Sticker.exe 直接跑。第三方组件各自独立授权:
- Godot Engine 4.7.2(MIT) —— 场景壁纸的编辑器与运行库基于源码分支,运行时以
LoadLibraryEx动态加载,不解包不安装; - FFmpeg(LGPL v3) —— 只用未修改的构建:子进程跑
ffmpeg.exe生成性能副本,动态加载共享库做解码兜底;不静态链接、不注册系统解码器; - OpenH264(BSD)、nlohmann/json(MIT)、MotionWallpaper(MIT)、天气图标(CC BY 4.0,和风天气 WeatherIcon);
- WebView2 运行时 与 Vulkan 运行时 为系统依赖,后者只在需要 3D 壁纸时动态加载,不链接 SDK。
十、总结
Desktop Sticker 想证明的其实是一件事:Windows 桌面这一层是可以被重新设计的,只要愿意钻进那些没有文档的地方——
- 寄生而不是覆盖:
0x052C+ WorkerW 让壁纸成为桌面的一部分,而不是盖住桌面的窗口; - 自己造输入通道:壁纸永远拿不到焦点,只能靠低级钩子 + 前台判定 + 精确的转发语义;
- 降级要早:三个可选模块各自的加载失败都只影响自己,主功能永远可用;
- 把决策变成纯函数:308 项不需要 GPU 的测试,让"改一行策略"不用整个桌面跑一遍;
- 失败要响:解码链路、着色器、合成,几乎每个环节都会静默失败,所以日志必须打印错误原文与首帧真实状态。
下一步计划是补 PTS 帧丢弃(目前靠消费上限兜底)、壁纸包的分层与转场,以及把设置页里那些"仅场景壁纸生效"的开关重新梳理一遍。代码和示例壁纸包都在仓库里,欢迎试用和提 issue。
评论 (0)