所有文章

给 DeepSeek Harness 桌面版换上 Windows 11 系统材质

用插件和一次长度不变的 asar 补丁,把 Windows 11 的 Mica / Acrylic 材质接到 DeepSeek Harness 桌面版,并记录状态栏磨砂效果失败的排查过程。

DSH 桌面版是个 Electron 应用,默认窗口是一块标准灰底。既然系统是 Windows 11,那套随壁纸着色的 Mica / Acrylic 其实是"免费"的——只是没被用上。

这篇写一下我是怎么把它接上去的:插件 + 一次长度不变的窗口补丁,以及过程中一个不太顺利的支线——为什么状态栏的「磨砂模糊」在这台机器上怎么都做不出来。

项目地址:https://github.com/CoraxEldridge/dsh-win11-material

环境:Windows 11(build 22621+)、DSH Desktop 0.2.1-rc.2、Electron 44


一、先看效果

装好之后,窗口底会透出桌面壁纸的模糊版本,并且页面本身是半透明的:

  • 材质:关闭 / Mica / Acrylic / Tabbed,四选一,切换即时生效,不用重启
  • 页面不透明度:整体半透明程度可调,侧栏和内容区始终同色
  • 输入框 + 状态栏:输入框和底部那条状态栏(tok/s、缓存命中、余额)用同一套半透明表面,状态栏收成贴合内容的胶囊
状态栏与输入框
状态栏与输入框:同一套半透明表面,状态栏收成贴合内容的胶囊。

二、原理一:DWM 材质其实只要一个 API

Windows 11 22H2 起,系统材质是窗口属性,不是绘制出来的:

DwmSetWindowAttribute(hwnd, 38 /* DWMWA_SYSTEMBACKDROP_TYPE */, &type, 4)
// 1 = 无  2 = Mica  3 = Acrylic  4 = Tabbed

配套再来两条:圆角(DWMWA_WINDOW_CORNER_PREFERENCE = 2)和「把标题栏条带也纳入材质」(DwmExtendFrameIntoClientArea(-1,-1,-1,-1))。

问题在于 Electron 没给这个 API 的入口,所以要在 Host 进程里直接调 Win32。这里用 koffi(纯 FFI,无需编译原生模块),并且优先复用 DSH 自带的那份:

// 优先 profile 安装的 koffi,其次用 Desktop 内置的
candidates.push('koffi')
candidates.push(join(resources, 'app.asar.unpacked', 'dsh', 'node_modules', 'koffi', 'index.js'))

DWM 只负责"窗口里那层材质",页面如果是不透明的,你照样什么都看不见。所以还要第二步。


三、原理二:为什么必须动一次 app.asar

Electron 的 BrowserWindow 只有在构造时接受 backgroundColor 带 alpha、以及 backgroundMaterial。DSH 的主窗口没开这两个,于是:

  • 页面再怎么 background: transparent,窗口底层仍然是不透明的
  • 没有 backgroundMaterial,Electron 也不会去设 DWM 材质

改 app.asar 通常是件讨厌事:一改就得重新算完整性哈希,而且默认不可逆。我用的办法是长度不变的手工补丁:

  1. 在 BrowserWindow({...}) 选项里插入 backgroundColor:"#00000000", backgroundMaterial:"acrylic",
  2. 在附近一段注释里删掉等长的字节
  3. 重算 asar header 里的 SHA256 integrity
  4. 整个过程只依赖 tools/window-bridge.mjs,随时可以 restore 回到原始字节
node tools/window-bridge.mjs status    # 看当前是否已打补丁
node tools/window-bridge.mjs apply     # 打补丁(DSH 需先退出)
node tools/window-bridge.mjs restore   # 一键还原

补丁只改文件内容、不改文件长度,所以不需要额外处理 asar 的偏移表——这也让它能稳稳地"还原成和原文件逐字节相同"。这一条我用 sha256 对比验证过。


四、原理三:让侧栏和内容区同色

DSH 的侧栏、内容区、标题栏条带各自有背景令牌,直接把它们都设成 transparent 会出现"侧栏比内容区亮/暗"的色差。

做法是只保留 _frame 一层底色,其余全部透明:

/* 侧栏和标题栏条带都不再单独上色 */
--dsw-specific-sidebar-fill: transparent;

/* 整窗只剩这一层:主题底色 × 不透明度 */
[class*="_frame"]:has([class*="_centerCol"]) {
  background: color-mix(in srgb, var(--dsh-win11-base) calc(var(--dsh-win11-tint) * 100%), transparent) !important;
}

--dsh-win11-base 不是写死的色值,而是插件在启动时(以及主题切换时)从 --dsw-alias-bg-base 读出来的实际颜色。所以任何第三方主题、浅色/深色模式都能跟着走。

顺带把输入框和状态栏做成同一套表面:

[data-composer-card],
[data-composer-dock] {
  background: color-mix(in srgb, var(--dsw-specific-input-major) 76%, transparent);
  box-shadow: var(--dsw-elevation-soft), inset 0 1px 0 rgba(255,255,255,.06);
}

/* 状态栏收成贴合内容的胶囊 */
[data-composer-dock] {
  width: auto; align-self: center;
  margin: 8px 0 1px; padding: 4px 12px;
  border-radius: 999px;
}

五、失败记录:backdrop-filter 为什么永远不生效

一开始我想让状态栏变成"真·毛玻璃":背后有内容就糊一下。

[data-composer-dock] {
  backdrop-filter: blur(18px) saturate(1.6);
}

结果:一点变化都没有。于是开始排查,前后试了四个落点:

落点结果原因
状态栏自身 / 座位层后代完全不糊座位层是 position: sticky; z-index: 7,构成 stacking context,backdrop 里根本没有会话正文
座位层自身糊了,但糊满整条 composer它的 backdrop 在外面,能看到正文;但没法用 mask 收窄(会连输入框一起裁掉)
页面层 position: fixed(z-index 正确)仍然不糊合成器把滚动内容放在独立的 scrolling contents layer,页面层取不到
滚动容器内部(与正文同层,每帧跟随)仍然不糊理论上唯一可行,实际也不出图

同时确认了不是环境问题:GPU 进程正常启动,lib/main.js 里没有任何 disable-gpu / disableHardwareAcceleration。

结论:这个透明窗口的合成路径下,backdrop-filter 不产生任何输出。既然做不出真模糊,就把状态栏做成半透明"牛奶玻璃"——把薄膜浓度调高,让背后文字被盖住,视觉上靠近磨砂:

background: rgba(150, 156, 168, var(--dsh-win11-glass-tint, .5));
box-shadow: 0 2px 10px rgba(0, 0, 0, .18);

这段经历我留在 README 里了,免得以后又有人重走一遍。


六、稳定性:两个踩过的坑

1. 热重载会炸 koffi。 koffi 的 struct / proto 名字是进程级注册的,插件热重载时再定义一次 ACCENTPOLICY 就会抛 Duplicate type name,然后整个原生层"静默失效"。

解法是给每次加载的结构体名字加唯一后缀,并且用带 ?v=时间戳 的动态 import() 加载原生模块,让热重载拿到新代码:

const native = await import(`${new URL('./native.js', import.meta.url).href}?v=${Date.now()}`)

2. 部分状态更新不能带 undefined。 客户端改不透明度时会 POST { tint },如果顺手把 material: undefined 也带上,服务端的 normalize 就会把材质重置成默认值——表现出来就是"调一下透明度,材质自己跳回 Acrylic"。现在会在写状态前先丢掉 undefined 的键,并且有回归测试盯着。

两套 smoke 测试(Host 的状态机 + 浏览器半部的渲染)都能一条命令跑:

node tests/host.smoke.mjs
node tests/client.smoke.mjs

七、怎么装

git clone https://github.com/CoraxEldridge/dsh-win11-material.git
cd dsh-win11-material

node tools/install.mjs              # 链接进 profile
node tools/window-bridge.mjs apply  # 打窗口补丁(先退出 DSH)

重启 DSH 后,设置 → 窗口材质 里就能选材质和调不透明度。

卸载:

node tools/uninstall.mjs
node tools/window-bridge.mjs restore   # 把 app.asar 还原成原始字节

八、已知限制

  • 失焦会变实:窗口不活跃时,Windows 会把所有 DWM 材质(Mica / Acrylic / Tabbed)替换成实色。这是系统行为,插件不做对抗。
  • 改了原生层需要重启:lib/native.js 虽然有缓存绕过,但 koffi 的注册表是进程级的,稳妥起见改完还是重启一次。
  • 只支持 Windows 11 22H2(build 22621)及以上。
  • 打窗口补丁要退出 DSH,因为 asar 被占用时无法写入。

九、小结

这个插件本身不复杂,核心就三件事:

  1. 用 FFI 调 DwmSetWindowAttribute,把系统材质接上
  2. 用一次长度不变的 asar 补丁,让窗口真的能透
  3. 用一层 color-mix 把页面半透明、侧栏同色这两件事对齐

比较有价值的可能是那两个"不顺利"的部分:backdrop-filter 在这个合成路径下无效,以及 koffi 在热重载下的类型注册冲突。它们都不在文档里,只能自己撞一遍。

代码和踩坑记录都在仓库里:

https://github.com/CoraxEldridge/dsh-win11-material