collective

Browse / github.com/storytold/craft-launcher / patch

[BUG FIX] Prevent invalid window geometry on relaunch (Issue #567)

[Bug Report] Launcher reopens in a tiny window after exiting from the system tray while minimized <h3>Description</h3> <p>ArtCraft Launcher consistently reopens in an unexpectedly tiny window after being minimized and then exited via the Windows system tray. The issue is reproducible every time using the steps below.</p> <h3>Steps to reproduce</h3> <ol> <li>Launch ArtCraft Launcher with a normal-sized window.</li> <li>Minimize the launcher.</li> <li>Exit the launcher using its system tray icon.</li> <li>Launch ArtCraft Launcher again.</li> </ol> <h3>Actual behavior</h3> <p>The launcher always reopens in a tiny window instead of restoring its previous normal size when following th

Status
open
Score
0.00
Adopters
0 other operators
Author
josefsuma (imported from pull request #570, open)
Base
c99e375a67cba194d386b3e87316d173fbc76343
Size
+232 −5 in 5 files
Review
pending
Shared
2026-10-28
Id
eb821ce6ae0ae2c5ed6c59e8

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/artcraft-launcher/Cargo.toml apps/artcraft-launcher/src/main.rs apps/artcraft-launcher/src/settings_file.rs apps/artcraft-launcher/src/window_state.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -283,6 +283,7 @@ dependencies = [  "artcraft-launcher-ui-egui",  "eframe",  "egui",+ "egui-winit",  "env_logger",  "image",  "log",diff --git a/apps/artcraft-launcher/Cargo.toml b/apps/artcraft-launcher/Cargo.toml--- a/apps/artcraft-launcher/Cargo.toml+++ b/apps/artcraft-launcher/Cargo.toml@@ -19,6 +19,8 @@ local-mirror = ["artcraft-launcher-native/local-mirror"] [dependencies] artcraft-launcher-core = { workspace = true } artcraft-launcher-ui-egui = { workspace = true }+# Persist only valid native window geometry; eframe already uses this crate.+egui-winit = { version = "0.36", default-features = false, features = ["serde"] } artcraft-launcher-native = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence", "links"] } egui = { workspace = true }diff --git a/apps/artcraft-launcher/src/main.rs b/apps/artcraft-launcher/src/main.rs--- a/apps/artcraft-launcher/src/main.rs+++ b/apps/artcraft-launcher/src/main.rs@@ -13,6 +13,7 @@ mod spaces; #[cfg(target_os = "linux")] mod system_theme; mod tray;+mod window_state;  use std::path::PathBuf; use std::process::ExitCode;@@ -30,6 +31,7 @@ use eframe::wgpu::rwh::{HasDisplayHandle, RawDisplayHandle};  const APP_ID: &str = "ai.storyteller.artcraft-launcher"; const SETTINGS_KEY: &str = "settings";+const MIN_WINDOW_SIZE: [f32; 2] = [720.0, 500.0];  type Wake = Arc<OnceLock<Box<dyn Fn() + Send + Sync>>>; @@ -83,6 +85,13 @@ fn main() -> ExitCode {      let backend_waker = waker.clone();     let settings_path = settings_file::path(APP_ID);+    if let Some(path) = settings_path.as_deref() {+        match settings_file::discard_invalid_window(path) {+            Ok(true) => log::warn!("discarded invalid persisted window geometry; starting with the default window size"),+            Ok(false) => {}+            Err(e) => log::warn!("couldn't repair persisted window geometry: {e}"),+        }+    }     let mut launcher = Launcher::new(Box::new(NativeBackend::new(move || backend_waker.wake())), starts, settings_path.clone());     #[cfg(target_os = "linux")]     {@@ -136,7 +145,7 @@ fn native_options(hidden: bool) -> eframe::NativeOptions {         .with_title("ArtCraft Launcher")         .with_app_id(APP_ID)         .with_inner_size([1120.0, 760.0])-        .with_min_inner_size([720.0, 500.0])+        .with_min_inner_size(MIN_WINDOW_SIZE)         .with_visible(!hidden);     // The header is the title bar (see `DesktopHost::title_bar`).     viewport = if cfg!(target_os = "macos") {@@ -622,6 +631,7 @@ struct Shell<'a> {     window_hides: bool,     visible: bool,     quitting: bool,+    window_state: window_state::WindowState,     /// The "show on the current desktop" setting last applied to the window (macOS).     #[cfg_attr(not(target_os = "macos"), allow(dead_code))]     applied_follow: Option<bool>,@@ -632,7 +642,14 @@ impl<'a> Shell<'a> {         let window_hides = window_can_hide(cc.display_handle().ok().map(|d| d.as_raw()));         // Started hidden at login but there's no tray to come back from: show the window.         let visible = !hidden || launcher.tray.is_none();-        let mut shell = Self { launcher, window_hides, visible: true, quitting: false, applied_follow: None };+        let mut shell = Self {+            launcher,+            window_hides,+            visible: true,+            quitting: false,+            window_state: window_state::WindowState::new(cc.storage),+            applied_follow: None,+        };         if !visible {             shell.put_away(&cc.egui_ctx);         } else if hidden {@@ -680,7 +697,10 @@ impl<'a> Shell<'a> { }  impl eframe::App for Shell<'_> {-    fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {+    fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {+        // eframe saves the native window (even if minimized) before calling App::save.+        // Remember only sane, visible geometry so save() can replace that snapshot.+        self.window_state.record(frame, ctx.zoom_factor(), self.visible);         let requests = self.launcher.tick(ctx);         #[cfg(target_os = "macos")]         if self.applied_follow != Some(self.launcher.ui.settings.show_on_current_desktop) {@@ -722,6 +742,7 @@ impl eframe::App for Shell<'_> {      fn save(&mut self, storage: &mut dyn eframe::Storage) {         eframe::set_value(storage, SETTINGS_KEY, &self.launcher.ui.settings);+        self.window_state.save(storage);     }      fn clear_color(&self, visuals: &egui::Visuals) -> [f32; 4] {@@ -965,7 +986,14 @@ mod tests {     fn quitting_overrides_a_close_to_the_tray() {         let (mut launcher, _show, _) = launcher();         let ctx = launcher.windowless.clone();-        let mut shell = Shell { launcher: &mut launcher, window_hides: false, visible: true, quitting: false, applied_follow: None };+        let mut shell = Shell {+            launcher: &mut launcher,+            window_hides: false,+            visible: true,+            quitting: false,+            window_state: window_state::WindowState::default(),+            applied_follow: None,+        };         shell.put_away(&ctx);         assert!(shell.launcher.closed_to_tray);         shell.quit(&ctx);@@ -976,7 +1004,14 @@ mod tests {     fn a_window_that_hides_is_not_closed_to_the_tray() {         let (mut launcher, _show, _) = launcher();         let ctx = launcher.windowless.clone();-        let mut shell = Shell { launcher: &mut launcher, window_hides: true, visible: true, quitting: false, applied_follow: None };+        let mut shell = Shell {+            launcher: &mut launcher,+            window_hides: true,+            visible: true,+            quitting: false,+            window_state: window_state::WindowState::default(),+            applied_follow: None,+        };         shell.put_away(&ctx);         assert!(!shell.visible);         assert!(!shell.launcher.closed_to_tray);diff --git a/apps/artcraft-launcher/src/settings_file.rs b/apps/artcraft-launcher/src/settings_file.rs--- a/apps/artcraft-launcher/src/settings_file.rs+++ b/apps/artcraft-launcher/src/settings_file.rs@@ -8,6 +8,8 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +use eframe::Storage;+ /// Where eframe keeps its state for `app_id`, as it picks it for a window with that app id. pub fn path(app_id: &str) -> Option<PathBuf> {     eframe::storage_dir(app_id).map(|dir| dir.join("app.ron"))@@ -18,6 +20,23 @@ pub fn load<T: serde::de::DeserializeOwned>(path: &Path, key: &str) -> Option<T>     eframe::get_value(&RonFile::read(path), key) } +/// Remove window geometry that cannot safely be restored by eframe, without touching user+/// settings or egui memory. Called before eframe opens the window on each launch.+pub fn discard_invalid_window(path: &Path) -> Result<bool, String> {+    const WINDOW_KEY: &str = "window";+    let mut file = RonFile::read(path);+    if file.get_string(WINDOW_KEY).is_none() {+        return Ok(false);+    }+    let valid = eframe::get_value::<egui_winit::WindowSettings>(&file, WINDOW_KEY).is_some_and(|settings| crate::window_state::is_valid(&settings));+    if valid {+        return Ok(false);+    }+    file.remove_string(WINDOW_KEY);+    file.write(path)?;+    Ok(true)+}+ /// Stores `value` under `key`, keeping what else the file holds. pub fn save<T: serde::Serialize>(path: &Path, key: &str, value: &T) -> Result<(), String> {     let mut file = RonFile::read(path);@@ -127,6 +146,40 @@ mod tests {         assert_eq!(std::fs::read(&tmp).unwrap(), b"another writer's data");     } +    #[test]+    fn discards_minimized_window_but_keeps_other_settings() {+        let dir = Scratch::new("invalid-window");+        let window = "(inner_size_points:Some((x:0.0,y:0.0)),outer_position_pixels:Some((x:-32000.0,y:-32000.0)))";+        let mut file = RonFile::default();+        file.set_string("window", window.to_owned());+        file.set_string("settings", "(notify:true)".to_owned());+        file.set_string("egui", "remember my layout".to_owned());+        assert_eq!(file.write(&dir.file()), Ok(()));+        assert_eq!(discard_invalid_window(&dir.file()), Ok(true));+        let restored = RonFile::read(&dir.file());+        assert_eq!(restored.get_string("window"), None);+        assert_eq!(restored.get_string("settings").as_deref(), Some("(notify:true)"));+        assert_eq!(restored.get_string("egui").as_deref(), Some("remember my layout"));+    }++    #[test]+    fn keeps_valid_window_geometry_unchanged() {+        let dir = Scratch::new("valid-window");+        let window = "(inner_size_points:Some((x:1120.0,y:760.0)),outer_position_pixels:Some((x:150.0,y:80.0)))";+        let mut file = RonFile::default();+        file.set_string("window", window.to_owned());+        assert_eq!(file.write(&dir.file()), Ok(()));+        assert_eq!(discard_invalid_window(&dir.file()), Ok(false));+        assert_eq!(RonFile::read(&dir.file()).get_string("window").as_deref(), Some(window));+    }++    #[test]+    fn no_window_state_does_not_create_storage_file() {+        let dir = Scratch::new("no-window");+        assert_eq!(discard_invalid_window(&dir.file()), Ok(false));+        assert!(!dir.0.exists());+    }+     #[test]     fn a_missing_or_broken_file_has_no_settings() {         let dir = Scratch::new("settings-broken");diff --git a/apps/artcraft-launcher/src/window_state.rs b/apps/artcraft-launcher/src/window_state.rsnew file mode 100644--- /dev/null+++ b/apps/artcraft-launcher/src/window_state.rs@@ -0,0 +1,136 @@+//! Keep the last usable native window geometry instead of eframe's minimized snapshot.+//!+//! eframe 0.36.2 writes its own `window` entry before calling `App::save`. On Windows,+//! a minimized window may report a zero inner size or off-screen coordinates. Preserve+//! the last valid geometry and replace that entry when the launcher is saved.++use eframe::Storage;+use egui_winit::WindowSettings;++const WINDOW_KEY: &str = "window";++/// A missing or tiny inner size must not override our initial 1120 x 760 viewport.+pub fn is_valid(settings: &WindowSettings) -> bool {+    let Some(size) = settings.inner_size_points() else { return false };+    let [min_width, min_height] = crate::MIN_WINDOW_SIZE;+    size.x.is_finite() && size.y.is_finite() && size.x >= min_width && size.y >= min_height+}++#[derive(Default)]+pub struct WindowState {+    last_valid: Option<WindowSettings>,+}++impl WindowState {+    pub fn new(storage: Option<&dyn Storage>) -> Self {+        let last_valid = storage.and_then(|storage| eframe::get_value(storage, WINDOW_KEY)).filter(is_valid);+        Self { last_valid }+    }++    /// Remember actual native geometry only while the window can be safely measured.+    pub fn record(&mut self, frame: &eframe::Frame, zoom_factor: f32, visible: bool) {+        let Some(window) = frame.winit_window() else { return };+        if !visible || window.is_minimized() == Some(true) || window.is_visible() == Some(false) {+            return;+        }+        let geometry = WindowSettings::from_window(zoom_factor, window);+        if is_valid(&geometry) {+            self.last_valid = Some(geometry);+        }+    }++    /// eframe wrote its possibly minimized snapshot *before* `App::save`.+    pub fn save(&self, storage: &mut dyn Storage) {+        if let Some(geometry) = &self.last_valid {+            eframe::set_value(storage, WINDOW_KEY, geometry);+        } else {+            // On an initial minimized start with no valid frame, let the next launch+            // use the default 1120 x 760 size rather than a zero-sized snapshot.+            storage.remove_string(WINDOW_KEY);+        }+    }+}++#[cfg(test)]+mod tests {+    use std::collections::HashMap;++    use super::*;++    #[derive(Default)]+    struct TestStorage(HashMap<String, String>);++    impl Storage for TestStorage {+        fn get_string(&self, key: &str) -> Option<String> {+            self.0.get(key).cloned()+        }++        fn set_string(&mut self, key: &str, value: String) {+            self.0.insert(key.to_owned(), value);+        }++        fn remove_string(&mut self, key: &str) {+            self.0.remove(key);+        }++        fn flush(&mut self) {}+    }++    #[test]+    fn only_accepts_usable_size() {+        for (width, height, expected) in [+            (1120.0, 760.0, true),+            (720.0, 500.0, true),+            (0.0, 0.0, false),+            (64.0, 64.0, false),+            (719.0, 500.0, false),+            (720.0, 499.0, false),+            (-1.0, 760.0, false),+        ] {+            let serialized = format!("(inner_size_points:Some((x:{width:?},y:{height:?})))");+            let candidate = ron::from_str::<WindowSettings>(&serialized);+            assert_eq!(candidate.as_ref().ok().map(is_valid), Some(expected), "{serialized}");+        }+        assert!(!is_valid(&WindowSettings::default()));+    }++    #[test]+    fn a_valid_size_without_saved_coordinates_is_accepted() {+        let window = "(inner_size_points:Some((x:1120.0,y:760.0)))";+        let mut storage = TestStorage::default();+        storage.set_string(WINDOW_KEY, window.to_owned());+        let state = WindowState::new(Some(&storage));+        assert!(state.last_valid.is_some());+    }++    #[test]+    fn ignores_invalid_geometry_already_in_storage() {+        let mut storage = TestStorage::default();+        storage.set_string(WINDOW_KEY, "(inner_size_points:Some((x:0.0,y:0.0)))".to_owned());+        let state = WindowState::new(Some(&storage));+        state.save(&mut storage);+        assert_eq!(storage.get_string(WINDOW_KEY), None);+    }++    #[test]+    fn saved_good_geometry_replaces_a_minimized_snapshot() {+        let mut storage = TestStorage::default();+        let last_valid = "(inner_size_points:Some((x:1120.0,y:760.0)),outer_position_pixels:Some((x:150.0,y:80.0)))";+        storage.set_string(WINDOW_KEY, last_valid.to_owned());+        let state = WindowState::new(Some(&storage));+        // Simulate what eframe did just before calling App::save.+        storage.set_string(WINDOW_KEY, "(inner_size_points:Some((x:0.0,y:0.0)))".to_owned());+        state.save(&mut storage);+        let saved = storage.get_string(WINDOW_KEY).and_then(|s| ron::from_str::<WindowSettings>(&s).ok());+        assert_eq!(saved.and_then(|s| s.inner_size_points()), Some(egui::vec2(1120.0, 760.0)));+    }++    #[test]+    fn no_valid_geometry_removes_minimized_snapshot() {+        let mut storage = TestStorage::default();+        let state = WindowState::new(None);+        storage.set_string(WINDOW_KEY, "(inner_size_points:Some((x:0.0,y:0.0)))".to_owned());+        state.save(&mut storage);+        assert_eq!(storage.get_string(WINDOW_KEY), None);+    }+}