Skip to main content

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}