entracte_lib/window.rs
1//! Shared helpers for showing the long-lived `main` window (the
2//! Preferences UI), with a Linux/Wayland-specific workaround for #139.
3//!
4//! The `main` window is created `visible: false` (tauri.conf.json) and
5//! shown on demand from the tray. On GNOME/Wayland a window shown after
6//! being created hidden never receives an initial `configure` event from
7//! the compositor until the user manually resizes it, so its client-side
8//! decoration input region stays stale and the close/minimise controls
9//! swallow clicks until the first double-click-to-maximise toggle
10//! (upstream tauri-apps/tauri#13440, still open).
11//!
12//! The 0.0.6 fix nudged the size +1px and back synchronously after
13//! `show()`. That never cleared it on Steffi's Ubuntu 24.04 / GNOME /
14//! Wayland setup: Wayland batches surface state until commit, so two
15//! `set_size` calls in the same event-loop turn coalesce to the final
16//! (unchanged) size and no `configure` is emitted — the synchronous nudge
17//! was a no-op there. Given a hidden window shown later needs a *committed*
18//! state change, the strategies here defer their second half onto a later
19//! event-loop tick, and `maximize` is the default because Steffi confirmed
20//! it clears the controls on her hardware (it mirrors the manual
21//! double-click-titlebar that she found worked).
22//!
23//! `ENTRACTE_WL_FIX` selects the strategy so a different compositor can be
24//! handled empirically without a rebuild:
25//!
26//! - `maximize` (default): `maximize()` then `unmaximize()` on a later
27//! tick — the confirmed fix.
28//! - `nudge`: resize +1px, restore on a later tick (the 0.0.6 idea, fixed
29//! to actually commit). Kept as an alternative for compositors maximize
30//! perturbs.
31//! - `off`: do nothing (baseline / opt-out).
32//!
33//! Applied only on a real **Wayland** session: X11 gives a proper
34//! `configure` on `show()` and must not get a spurious maximise flash.
35
36use tauri::{Manager, Runtime};
37
38/// Env var selecting the Wayland configure workaround strategy. Honoured
39/// only on a Linux Wayland session; ignored elsewhere.
40const WL_FIX_ENV: &str = "ENTRACTE_WL_FIX";
41
42/// Delay before the deferred half of a nudge/maximize round-trip, long
43/// enough for the compositor to process and commit the intermediate
44/// surface state before we restore it.
45#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
46const DEFER_MS: u64 = 60;
47
48/// Which #139 Wayland workaround to apply when showing the `main` window.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum WaylandFix {
51 Off,
52 Nudge,
53 Maximize,
54}
55
56impl WaylandFix {
57 /// Parse the `ENTRACTE_WL_FIX` value. Defaults to
58 /// [`WaylandFix::Maximize`] when unset, empty, or unrecognised —
59 /// Steffi confirmed `maximize` clears #139 on Ubuntu 24.04 / GNOME /
60 /// Wayland, so a stock build applies the known-good fix. Matching is
61 /// case-insensitive and whitespace-trimmed. Pure so strategy selection
62 /// is unit-testable without touching the environment or a windowing
63 /// system.
64 pub fn from_env_value(value: Option<&str>) -> Self {
65 match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
66 Some("off") => Self::Off,
67 Some("nudge") => Self::Nudge,
68 _ => Self::Maximize,
69 }
70 }
71
72 /// Short stable token for logs and the diagnostics banner, so a bug
73 /// report shows which strategy was live without the user recalling the
74 /// env var they set.
75 pub fn as_str(self) -> &'static str {
76 match self {
77 Self::Off => "off",
78 Self::Nudge => "nudge",
79 Self::Maximize => "maximize",
80 }
81 }
82}
83
84/// Resolve the active strategy from the process environment.
85pub fn wayland_fix_strategy() -> WaylandFix {
86 WaylandFix::from_env_value(std::env::var(WL_FIX_ENV).ok().as_deref())
87}
88
89/// Pure Wayland-session test over the two relevant env signals, split out
90/// so the decision is unit-testable without mutating process env. Mirrors
91/// the probes in `scheduler::overlay` and `video`; kept local so the
92/// window path stays self-contained.
93#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
94fn wayland_session_from_env(session_type: Option<&str>, wayland_display: bool) -> bool {
95 session_type.is_some_and(|s| s.eq_ignore_ascii_case("wayland")) || wayland_display
96}
97
98/// Whether this is a Wayland session, from the process environment.
99#[cfg(target_os = "linux")]
100fn is_wayland_session() -> bool {
101 wayland_session_from_env(
102 std::env::var("XDG_SESSION_TYPE").ok().as_deref(),
103 std::env::var("WAYLAND_DISPLAY").is_ok(),
104 )
105}
106
107/// The one transient intermediate size the `nudge` strategy resizes to
108/// before restoring the real size, to provoke a fresh compositor configure
109/// event. Grow by 1px so the size genuinely changes (a no-op resize is
110/// coalesced away); if the window is already at the `u32` ceiling, shrink
111/// instead so the value still differs.
112///
113/// Pure so the "which size forces a configure" decision is unit-testable
114/// without a windowing system; the actual `set_size` FFI stays in
115/// `apply_wayland_fix` (Linux-only, so not linked here).
116#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
117fn nudged_dimension(value: u32) -> u32 {
118 value.checked_add(1).unwrap_or_else(|| value - 1)
119}
120
121/// Apply the selected #139 workaround to a freshly-shown `main` window.
122/// Reached only on a Linux Wayland session (see [`show_main_window`]); the
123/// nudge/maximize strategies defer their second half onto a later
124/// event-loop tick because Wayland coalesces state set within a single
125/// turn — the flaw that made the 0.0.6 synchronous nudge a no-op.
126#[cfg(target_os = "linux")]
127fn apply_wayland_fix<R: Runtime>(window: &tauri::WebviewWindow<R>, fix: WaylandFix) {
128 match fix {
129 WaylandFix::Off => {}
130 WaylandFix::Nudge => {
131 if let Ok(size) = window.inner_size() {
132 let nudged = tauri::PhysicalSize::new(nudged_dimension(size.width), size.height);
133 let _ = window.set_size(nudged);
134 let window = window.clone();
135 tauri::async_runtime::spawn(async move {
136 tokio::time::sleep(std::time::Duration::from_millis(DEFER_MS)).await;
137 let _ = window.set_size(size);
138 });
139 }
140 }
141 WaylandFix::Maximize => {
142 let _ = window.maximize();
143 let window = window.clone();
144 tauri::async_runtime::spawn(async move {
145 tokio::time::sleep(std::time::Duration::from_millis(DEFER_MS)).await;
146 let _ = window.unmaximize();
147 });
148 }
149 }
150}
151
152/// Show and focus the `main` window, applying the Wayland configure
153/// workaround on a Linux Wayland session. Single entry point so every
154/// "open Preferences" call site (tray menu, CLI re-invocation) gets
155/// identical behaviour.
156pub fn show_main_window<R: Runtime>(app: &tauri::AppHandle<R>) {
157 if let Some(window) = app.get_webview_window("main") {
158 let _ = window.show();
159 let _ = window.set_focus();
160 #[cfg(target_os = "linux")]
161 if is_wayland_session() {
162 apply_wayland_fix(&window, wayland_fix_strategy());
163 }
164 }
165}
166
167/// Show the small "Pause until…" picker, creating it on first use. Launched
168/// from the tray; mirrors the overlay's on-demand window creation. The
169/// renderer closes the window after pausing or cancelling, so the next
170/// launch builds a fresh one.
171pub fn show_pause_window<R: Runtime>(app: &tauri::AppHandle<R>) {
172 if let Some(window) = app.get_webview_window("pause") {
173 let _ = window.show();
174 let _ = window.set_focus();
175 return;
176 }
177 match tauri::WebviewWindowBuilder::new(
178 app,
179 "pause",
180 tauri::WebviewUrl::App("index.html?window=pause".into()),
181 )
182 .title("Pause Entracte")
183 .inner_size(360.0, 220.0)
184 .resizable(false)
185 .maximizable(false)
186 .minimizable(false)
187 .always_on_top(true)
188 .center()
189 .focused(true)
190 .build()
191 {
192 Ok(_) => log::debug!("pause: created picker window"),
193 Err(e) => log::error!("pause: failed to create picker window: {e}"),
194 }
195}
196
197/// Close the "Pause until…" picker. Invoked by the picker itself after it
198/// pauses or the user cancels. A backend command (rather than the JS window
199/// API) keeps `@tauri-apps/api/window` out of the renderer bundle.
200#[tauri::command]
201pub fn close_pause_window<R: Runtime>(app: tauri::AppHandle<R>) {
202 if let Some(window) = app.get_webview_window("pause") {
203 let _ = window.close();
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::{nudged_dimension, wayland_fix_strategy, wayland_session_from_env, WaylandFix};
210
211 #[test]
212 fn grows_normal_dimension_by_one() {
213 assert_eq!(nudged_dimension(800), 801);
214 assert_eq!(nudged_dimension(0), 1);
215 }
216
217 #[test]
218 fn shrinks_when_at_ceiling_so_value_still_changes() {
219 assert_eq!(nudged_dimension(u32::MAX), u32::MAX - 1);
220 }
221
222 #[test]
223 fn nudged_value_always_differs_from_input() {
224 for v in [0u32, 1, 600, 800, u32::MAX - 1, u32::MAX] {
225 assert_ne!(nudged_dimension(v), v);
226 }
227 }
228
229 #[test]
230 fn unset_blank_or_unknown_defaults_to_maximize() {
231 assert_eq!(WaylandFix::from_env_value(None), WaylandFix::Maximize);
232 assert_eq!(WaylandFix::from_env_value(Some("")), WaylandFix::Maximize);
233 assert_eq!(
234 WaylandFix::from_env_value(Some(" ")),
235 WaylandFix::Maximize
236 );
237 assert_eq!(
238 WaylandFix::from_env_value(Some("wobble")),
239 WaylandFix::Maximize
240 );
241 }
242
243 #[test]
244 fn parses_each_strategy_case_insensitively_and_trimmed() {
245 assert_eq!(WaylandFix::from_env_value(Some("off")), WaylandFix::Off);
246 assert_eq!(WaylandFix::from_env_value(Some(" OFF ")), WaylandFix::Off);
247 assert_eq!(WaylandFix::from_env_value(Some("nudge")), WaylandFix::Nudge);
248 assert_eq!(WaylandFix::from_env_value(Some("Nudge")), WaylandFix::Nudge);
249 assert_eq!(
250 WaylandFix::from_env_value(Some("maximize")),
251 WaylandFix::Maximize
252 );
253 assert_eq!(
254 WaylandFix::from_env_value(Some("MAXIMIZE")),
255 WaylandFix::Maximize
256 );
257 }
258
259 #[test]
260 fn as_str_round_trips_through_from_env_value() {
261 for fix in [WaylandFix::Off, WaylandFix::Nudge, WaylandFix::Maximize] {
262 assert_eq!(WaylandFix::from_env_value(Some(fix.as_str())), fix);
263 }
264 }
265
266 #[test]
267 fn strategy_from_process_env_is_a_valid_variant() {
268 // Exercises the env-reading wrapper without mutating process-global
269 // state: whatever the ambient env, the result must round-trip.
270 let s = wayland_fix_strategy();
271 assert_eq!(WaylandFix::from_env_value(Some(s.as_str())), s);
272 }
273
274 #[test]
275 fn wayland_session_detected_from_either_signal() {
276 assert!(wayland_session_from_env(Some("wayland"), false));
277 assert!(wayland_session_from_env(Some("WAYLAND"), false));
278 assert!(wayland_session_from_env(None, true));
279 assert!(wayland_session_from_env(Some("x11"), true));
280 }
281
282 #[test]
283 fn x11_or_absent_session_is_not_wayland() {
284 assert!(!wayland_session_from_env(Some("x11"), false));
285 assert!(!wayland_session_from_env(Some("tty"), false));
286 assert!(!wayland_session_from_env(None, false));
287 }
288}