Swordreforge's blog

让-Wayland-窗口_隐身_一次受制于合成器的实战记录niri

阅读 64评论 0

在 Wayland 上,你无法真正隐藏一个窗口——只能学会与合成器“商量”。

背景

我的开源项目 live2d-rs 里有一个“宠物模式”:一个始终置顶的小窗口,上面浮动着 Live2D 角色。在 X11 下,这活儿太简单了——调用 window.set_visible(false) 就能把顶层窗口藏起来,但窗口依然映射在 X Server 上,事件照常收发。可到了 Wayland 世界,这招直接翻车:set_visible(false) 悄无声息地掐断了事件投递,导致系统托盘图标的所有交互都失灵。原因在于 Wayland 的安全模型压根不允许客户端随意隐藏自己的表面(surface)。

我想要的其实很简单:用户把“始终置顶”的宠物最小化后,窗口应该消失(角色所在的 layer-shell 表面不再可见),但进程必须活着,托盘图标依然能响应点击。

本文记录了我如何在 niri(一个滚动平铺式 Wayland 合成器)上一步步摸索出可行方案,以及在这个过程中被迫认清的那些“硬约束”。


尝试一:强退 X11 后端(失败 —— API 已被移除)

直觉告诉我:既然 X11 下 set_visible(false) 管用,那就强制 winit 走 X11 后端呗。

std::env::set_var("WINIT_UNIX_BACKEND", "x11");

结果:这个环境变量在 winit 0.29.0 中被正式移除。官方更新日志写得清清楚楚。变量被静默忽略,而在纯 Wayland 合成器(比如 niri)上,窗口永远只能走原生 Wayland 后端。

教训:永远根据实际依赖版本去验证 API 是否存在,别凭经验想当然。我这段代码其实是“死码”,毫无作用。


尝试二:用 x11rb 直接怼 X11 协议(放弃 —— 侵入性太强)

既然 winit 不暴露 Wayland 的 set_visible,那我另起炉灶——在现有窗口旁边再开一个 X11 连接,用 x11rb 发送 XUnmapWindow。

use x11rb::connection::Connection;
use x11rb::protocol::xproto::*;

问题接踵而至:

  • 这要求创建 winit 窗口时启用 with_x11(),等于更换了整个窗口后端;
  • 为了一个操作引入 x11rb 依赖,太“重”;
  • 窗口本身活在 Wayland 上,X11 连接根本动不了 Wayland 表面;
  • 在纯 Wayland 合成器(niri)上,压根没有 X11 显示服务器可连。

教训:混合后端只会徒增复杂度,解决不了平台根本矛盾。在 Wayland 上,你的表面归你管,但你不能通过另一个协议去“命令”它隐藏——这是架构层面的限制。


尝试三:移出屏幕(失败 —— Wayland 直接无视)

X11 时代常用的花招:把窗口挪到屏幕外面去。

window.set_outer_position(Position::Logical(30000.0, 30000.0));

结果:set_outer_position 在 Wayland 后端是有文档说明的空操作。Wayland 明确规定客户端无权定位自己的表面——窗口位置由合成器全权掌控。这是设计使然:防止窗口把自己挪到可视区域外搞点击劫持之类的攻击。

教训:Wayland 的安全模型与 X11 有本质不同。凡是涉及窗口位置、层叠顺序、可见性的事情,都是合成器的“专属领地”。别硬刚,要学会顺着它的规则走。


尝试四:缩到 1×1 像素(被合成器否决)

这个方案在 KDE/GNOME 上可行:把窗口缩到 1×1,并设置最大尺寸也为 1×1。

window.set_max_inner_size(Some(LogicalSize::new(1.0, 1.0)));
let _ = window.request_inner_size(LogicalSize::new(1.0, 1.0));
// 强制 EGL 表面缩放
surface.resize(&gl_context, NonZeroU32::new(1).unwrap(), NonZeroU32::new(1).unwrap());

在 niri 上发生了什么:

[pet] request_minimize: 尝试缩放到 1x1
[pet/wayland] surface configured: 400x500

合成器直接拒绝了 1×1 的请求,反手给了一个 400×500。我查了下,niri 强制要求最小表面尺寸大约为 50×50 逻辑像素——很多 Wayland 合成器都不允许零维表面存在。surface configured 回调里返回的尺寸,是合成器“钦定”的大小。

教训:在 Wayland 上,request_inner_size 是**“请求”**,不是“命令”。合成器可以(而且经常)给你一个完全不同的尺寸。Resized 事件必须能妥善处理这种“被篡改”的情况。


意外收获:egui 字体崩溃(小尺寸下的隐藏雷区)

当 1×1 被否决、表面被强行拉到 400×500 后,下一帧渲染时直接 panic:

thread 'main' panicked at epaint-0.27.2/src/text/font.rs:92:9:
assertion failed: scale_in_pixels > 0.0

根本原因:此时 minimized_to_float = true,egui 的帧处理流程照常运行。字体光栅化时计算 scale_in_pixels = pixels_per_point * font_size。在某些状态转换瞬间(比如部分 resize 尚未完成),pixels_per_point 或有效字体大小可能变成 0。

egui 源码中确实有两个断言:

// epaint/src/text/font.rs:92-93
assert!(scale_in_pixels > 0.0);
assert!(pixels_per_point > 0.0);

但这是调试期用的开发断言,不是生产环境的错误处理。在“最小化 + 始终置顶”这个特殊状态下,egui 上下文算出零缩放值,直接炸了。

修复方案:在窗口处于“最小化且始终置顶”状态时,跳过整个 egui 帧(begin/end/tessellate/paint 全部绕开):

if minimized_to_float && pet_mode == AlwaysOnTop {
    // 只占 1×1 表面,不跑 egui 帧 —— 避免字体断言
} else {
    // 正常 egui 渲染
}

教训:当你屏蔽一个渲染路径(比如播放按钮),一定要检查其他路径(egui 界面)是否对窗口几何尺寸有隐藏依赖。另外要注意:request_inner_size 在 Wayland 上是异步的——新旧尺寸交替的窗口期内,渲染状态和实际几何可能不匹配。


关于 0×0 的迷思

能不能直接用 0×0 呢?不行——原因有三层:

第一层 NonZeroU32:glutin 的 surface.resize() 要求尺寸为 NonZeroU32。NonZeroU32::new(0) 返回 None,我们的代码会直接跳过 resize,最小化意图变成空操作。

第二层 小数尺寸(< 1.0):(0.5 * scale_factor) as u32 截断为 0,同样掉进 NonZeroU32 的坑。必须 .max(1) 才能通过类型检查,但这已经违背了“缩到零”的初衷。

第三层 resize 防抖循环:如果 float_logical = 0.0,防抖逻辑计算 max_phys = 0,而 size.width > 0 对任何真实表面都永远成立,于是陷入死循环:

合成器:给你 400×500
客户端:请求 resize(0, 0)!
合成器:不行,给你 400×500
客户端:请求 resize(0, 0)!
... 无限循环

而 float_logical = 1.0 时,max_phys = 1,防抖逻辑只触发有限次,合成器最终会稳定下来。

教训:“小”不等于“零”。小数会四舍五入到 0,制造出各种边界问题。.max(1) 这个模式实际上是对所有这类情况的保护伞。


最终方案:原地“隐身”

真正可行的方案,其实并没有隐藏窗口,而是让它什么都不画:

┌─────────────────────────────────────────────┐
│  请求最小化 → 尝试缩到 1×1(niri 给 400×500)│
│  设置 minimized_to_float = true              │
│                                              │
│  清屏色 = 全透明 (rgba 0,0,0,0)              │
│  Live2D 模型 → 跳过 (!minimized_to_float)   │
│  egui 界面  → 跳过 (AlwaysOnTop 保护)       │
│  播放按钮   → 跳过 (AlwaysOnTop 保护)       │
│  swap_buffers → 表面空空如也                │
└─────────────────────────────────────────────┘

我隔离了三个渲染路径,并且三个都必须关闭才能达成“看不见”的效果:

渲染路径守卫条件理由
Live2D 模型!minimized_to_float这个守卫一直存在,早于本次改动
egui UIminimized_to_float && AlwaysOnTop新增 —— 避免字体崩溃
播放按钮三角形minimized_to_float && AlwaysOnTop新增 —— 没什么可显示的
清屏色minimized_to_float && AlwaysOnTop → 透明新增 —— 不留视觉残留

对于“窗口化”宠物模式(非始终置顶),仍然保留播放按钮和彩色背景,走的是 50×50 的“浮标圆”分支;而始终置顶模式则获得一片透明的虚空。两者共用同一套 minimized_to_float 基础设施,只是渲染内容不同。


Wayland 窗口管理的几点核心认知

  1. request_inner_size 是“建议”,不是“命令”。必须监听 WindowEvent::Resized 并接受合成器给的尺寸。

  2. Wayland 合成器强制最小表面尺寸。niri 大约是 50px,任何低于这个值的请求都会被否决。

  3. 窗口定位是合成器的特权。set_outer_position 在 Wayland 上是空操作。没有 X11 的 XMoveWindow 或 XUnmapWindow 的对应物——这源自设计哲学。

  4. NonZeroU32 是你的好伙伴。它在类型层面明确了“表面尺寸必须 ≥ 1”的不变式,比临时的 >0 检查更可靠。

  5. 渲染守卫必须面面俱到。只关掉 egui 绘制,而模型渲染还在写帧缓冲,就会留下视觉残影。所有输出路径都要审计。

  6. 依赖库里的调试断言可能在你意想不到的地方爆炸。egui 在 font.rs 里的 assert!(scale_in_pixels > 0.0) 在正常使用下很合理,但没预料到客户端会在 1×1 几何下调用它。解决方案不是去改库,而是在那个几何尺寸下不去调用它。

  7. 在纯 Wayland 环境里,“隐藏”窗口在语义上就是不可能的。你能做的最好的事,就是什么都不画,然后期待合成器配合。Wayland 的设计哲学是:表面一旦创建就持续可见,直到被销毁。没有“映射/取消映射”这种状态机。


参考资料


*以上就是我踩过的一连串坑。如果你也在 Wayland 上折腾“最小化置顶窗口”,希望这篇记录能让你少走几个月的弯路。欢迎在评论区交流你的骚操作。

评论

还没有评论,来抢沙发。