Rust winit Iced wayland 应用最小化

起因：Wayland 上没有 set_visible

在开发 Rustle（自己的一个音乐软件）时，我需要实现 "最小化到系统托盘" 功能：

点击关闭按钮时，窗口隐藏而不是退出程序

点击托盘图标时，窗口重新显示

程序在后台继续运行（daemon 模式）

虽然使用 daemon 模式可以做到后台运行，但是 Iced 和 winit 的默认策略是关掉整个窗口，再需要时再唤出，但是由于我打算使用 GPU 渲染而不是软渲染，所以创建一个新的 GPU 上下文再重新初始化 vulkan 生命周期再调用 wgpu 绘制软件界面，这个冷启动的过程足足有 500ms！所以必须保存 vulkan/opengl 生命周期，则不能销毁窗口，而是令其不可见。

在 X11 上，这很简单 —— 调用 window.set_visible(false/true) 即可。但在 Wayland 上：

Copy
// winit 的 Wayland 实现
pub fn set_visible(&self, _visible: bool) {
// Not possible on Wayland.
}

winit 直接放弃了这个功能，注释写着 "Wayland 上不可能实现"。（？怎么可能）

查阅 Wayland 协议文档后发现，Wayland 的设计哲学与 X11 截然不同：

没有全局窗口管理器 API：客户端不能直接操作窗口的显示状态

Compositor 主导一切：窗口的显示、隐藏、位置都由 compositor 决定

只有 set_minimized：但这个操作是单向的—— 程序无法通过代码恢复最小化的窗口

翻遍全网也没找到一样的问题。但真的没有办法吗？

探索：GTK、Chromium 是怎么做的？

GTK 的实现

查看 GTK 源码发现了关键线索：

Copy
// gdk/wayland/gdkwindow-wayland.c
static void gdk_wayland_window_hide(GdkWindow *window) {
GdkWindowImplWayland *impl = GDK_WINDOW_IMPL_WAYLAND(window->impl);

wl_surface_attach(impl->display_server.wl_surface, NULL, 0, 0);
wl_surface_commit(impl->display_server.wl_surface);
_gdk_window_clear_update_area(window);
}

关键发现：GTK 通过 wl_surface_attach(NULL) 来隐藏窗口！

XDG Shell 协议规范

查阅 XDG Shell 协议文档，找到了官方说明：

Attaching a null buffer to a toplevel unmaps the surface.

The client can re-map the toplevel by performing a commit without any buffer attached, waiting for a configure event and handling it as usual.

这意味着：

隐藏：attach(NULL) + commit() → surface 被 unmap

显示：commit() → 触发 configure event → 重新渲染

Chromium 的实现

进一步研究 Chromium 的 Wayland 实现：

Copy
// WaylandToplevelWindow::Hide()
void WaylandToplevelWindow::Hide() {
shell_toplevel_.reset(); // 销毁 xdg_toplevel
connection()->buffer_manager_host()->ResetSurfaceContents(root_surface());
}

// WaylandToplevelWindow::Show()
void WaylandToplevelWindow::Show(bool inactive) {
if (!CreateShellToplevel()) { ... } // 重新创建 xdg_toplevel
}

Chromium 采用了更激进的方案 —— 销毁并重建 xdg_toplevel。但我后来发现这种方式在 Hyprland 上会导致 compositor 崩溃（这对吗？）。

实现：修改 winit

架构概览

Copy
┌────────────────────────────┐
│ Rustle (应用层) │
├────────────────────────────┤
│ iced (GUI 框架) │
├────────────────────────────┤
│ iced_winit (窗口管理) │
├────────────────────────────┤
│ winit (窗口抽象) │
├────────────────────────────┤
│ smithay-client-toolkit (Wayland 封装) │
├────────────────────────────┤
│ wayland-client (协议绑定) │
├────────────────────────────┤
│ Wayland Compositor │
└────────────────────────────┘

需要修改的层：

iced: 添加 set_visible API

winit: 实现 Wayland 上的 set_visible

winit 的修改

核心实现（src/platform_impl/linux/wayland/window/mod.rs）：

Copy
pub fn set_visible(&self, visible: bool) {
// 根据 XDG Shell 协议：
// - "Attaching a null buffer to a toplevel unmaps the surface."
// - "The client can re-map the toplevel by performing a commit without any
// buffer attached, waiting for a configure event and handling it as usual."

let surface = self.window.wl_surface();

if visible {
{
let mut state = self.window_state.lock().unwrap();
state.set_visible(true);
// 重置 frame callback 状态，打破死锁
state.frame_callback_reset();
}

surface.commit();
self.request_redraw();
} else {
self.window_state.lock().unwrap().set_visible(false);

// 清空待处理的 redraw 请求
self.window_requests.redraw_requested.store(false, Ordering::Relaxed);

// 按协议 unmap：attach(NULL) + commit
surface.attach(None, 0, 0);
surface.commit();
}
}

iced 的修改

添加 Action（runtime/src/window.rs）：

Copy
pub enum Action {
// ...existing actions...

/// Set the visibility of the window.
SetVisible(Id, bool),
}

/// Sets the visibility of the window.
pub fn set_visible<T>(id: Id, visible: bool) -> Task<T> {
task::effect(crate::Action::Window(Action::SetVisible(id, visible)))
}

处理 Action（winit/src/lib.rs）：

Copy
window::Action::SetVisible(id, visible) => {
if let Some(window) = window_manager.get_mut(id) {
window.raw.set_visible(visible);
}
}

不是哥们：那些令人头疼的 Bug

Hyprland Compositor 崩溃（为什么不能销毁 xdg_toplevel？）

最初的方案：参考 Chromium 的实现，销毁 xdg_toplevel 来隐藏窗口，重建它来显示窗口。

问题：在 Hyprland 上，销毁并重建 xdg_toplevel 会导致 compositor 崩溃，回到 SDDM 界面

Copy
// Hyprland 崩溃堆栈
CWindow::create(CXDGSurfaceResource)
CWLSurface::assign
CWLSurface::init // 崩溃点

根本原因：Hyprland 不能正确处理在同一个 xdg_surface 上重新创建 xdg_toplevel 的情况。

最终方案：完全避免销毁 xdg_toplevel，只使用 XDG Shell 协议规定的 wl_surface.attach(NULL) 方法：

隐藏：attach(NULL) + commit() → surface 被 unmap

显示：commit() + request_redraw() → 重新渲染

这个方案：

完全符合 XDG Shell 协议

不破坏 xdg_toplevel 生命周期

兼容所有 compositor（包括 Hyprland）

代码更简洁，不需要复杂的生命周期管理

隐藏后无法恢复显示

现象：set_visible(false) 成功隐藏窗口，但 set_visible(true) 后窗口不出现。

原因：Frame callback 死锁。

Copy
┌───────────────────────────┐
│ wgpu 等待 frame callback 才能提交 buffer │
│ ↓ │
│ compositor 等待 buffer 才能发送 frame callback │
│ ↓ │
│ 死锁！ │
└───────────────────────────┘

当窗口隐藏（attach(NULL)）后，compositor 不再发送 frame callback。但 winit 的渲染循环依赖 frame callback 来知道何时渲染下一帧。

解决方案：在 set_visible(true) 时重置 frame callback 状态：

Copy
state.frame_callback_reset(); // 重置为 None，允许立即重绘

隐藏时窗口闪烁

现象：set_visible(false) 时窗口消失后又闪现一次，而且每次闪现的次数会累积。

原因：Client-Side Decorations (CSD) 的刷新逻辑。

winit 的事件循环会周期性调用 refresh_frame() 来更新窗口装饰。即使窗口已经隐藏，如果 CSD 框架认为自己是 "dirty" 的，它仍然会触发重绘 —— 这会重新 attach buffer，导致窗口又出现。

解决方案：多层防护：

Copy
// 1. refresh_frame() 中检查 visible
pub fn refresh_frame(&mut self) -> bool {
if !self.visible {
return false; // 隐藏时不刷新装饰
}
// ...
}

// 2. request_redraw() 中检查 visible
pub fn request_redraw(&self) {
if !self.window_state.lock().unwrap().visible() {
return; // 隐藏时不请求重绘
}
// ...
}

// 3. set_visible(false) 时清空 pending redraw
self.window_requests.redraw_requested.store(false, Ordering::Relaxed);

// 4. event loop 派发时检查 visible
if !window.visible() {
window_requests.get(window_id).unwrap().take_redraw_requested();
return None; // 不派发 RedrawRequested
}

最终方案总结

核心原理

Copy
隐藏窗口:
┌────────────────────────┐
│ 1. set_visible(false) │
│ 2. 设置 visible 状态为 false │
│ 3. 清空 pending redraw 请求 │
│ 4. wl_surface.attach(NULL, 0, 0) │
│ 5. wl_surface.commit() │
│ → Surface 被 unmap，compositor 不再显示它 │
└────────────────────────┘

显示窗口:
┌────────────────────────┐
│ 1. set_visible(true) │
│ 2. 设置 visible 状态为 true │
│ 3. 重置 frame_callback_state (打破死锁) │
│ 4. wl_surface.commit() │
│ 5. request_redraw() │
│ → 触发重绘，wgpu attach buffer，窗口重新出现 │
└────────────────────────┘

修改的文件

项目 | 文件 | 修改内容
winit | src/.../wayland/window/mod.rs | 实现 set_visible()；在 request_redraw() 中检查 visible
winit | src/.../wayland/window/state.rs | 添加 visible 字段；在 refresh_frame() 中检查 visible
winit | src/.../wayland/event_loop/mod.rs | 在 RedrawRequested 派发前检查 visible 并清空 pending redraw
iced | runtime/src/window.rs | 添加 SetVisible action 和 set_visible() 函数
iced | winit/src/lib.rs | 处理 SetVisible action，调用 window.raw.set_visible()

使用方式

Copy
// 在 iced 应用中
Message::ToggleWindow => {
self.window_hidden = !self.window_hidden;
let visible = !self.window_hidden;

return iced::window::latest().and_then(move |id| {
iced::window::set_visible(id, visible)
});
}

参考资料

协议文档

XDG Shell Protocol - Wayland Explorer

The Wayland Protocol Book - XDG Surfaces

源码参考

GTK Wayland Window Implementation

Chromium Wayland Toplevel Window

winit Wayland Implementation

相关 Issue

winit: Wayland support for set_visible?

iced: Window visibility support

作者注：本实现基于 winit 0.30.12、iced 0.14.0。不同版本可能需要调整。

代码仓库：

Rustle: https://github.com/ArcticFoxNetwork/Rustle

winit fork: https://github.com/ArcticFoxNetwork/winit (wayland-visibility 分支)

iced fork: https://github.com/ArcticFoxNetwork/iced (wayland-visibility 分支)