//! Windows Gotify Client (Rust) — core library. //! //! Phase 7 surface: //! - [`config`]: active-config wrapper (Phase 5 C1) //! - [`error`]: AppError / AppResult //! - [`gotify`]: blocking REST + WebSocket clients (Phase 3/4) //! - [`logging`]: tracing-subscriber init //! - [`models`]: mock feed + Gotify→Slint adapter + model builders //! - [`storage`]: `%APPDATA%/WindowsGotify/{config,state}.json` //! - [`run`]: builds the MainWindow and runs its event loop //! //! Phase 7 additions on top of Phase 6: //! - Tray helper now exposes a full menu (Open / New messages / Pause //! / Settings / Restart / Quit) and the registered //! `WM_ACTIVATE_INSTANCE` path so a second launch surfaces the //! existing instance. (AGENTS.md §12, §26.) //! - Win32 toast (NIF_INFO via `Shell_NotifyIconW`) on every new //! Gotify message. Respects `desktop_notifications`, //! `notify_high`, `notify_max`, and the tray-menu pause toggle. //! (AGENTS.md §13.) //! - Message-card click opens the `click_url` via the strict //! http/https URL opener. (AGENTS.md §13 / §38.) //! - Single, long-lived `Rc>` so the WS //! drain, the catch-up path, the Test Connection button, and the //! tray Restart item all update the same model — fixing a Phase 6 //! C3 foot-gun where `set_messages(new_model)` detached the WS //! drain. pub mod config; pub mod error; pub mod gotify; pub mod logging; pub mod models; pub mod storage; #[cfg(windows)] pub mod windows; slint::include_modules!(); use std::cell::RefCell; use std::rc::Rc; use std::sync::mpsc; use std::sync::Arc; use slint::{Model, ModelRc, VecModel}; use storage::AppState; #[cfg(windows)] use crate::windows::tray::{TrayCallbacks, TrayIcon, TrayState, NIIF_ERROR, NIIF_INFO, NIIF_WARNING}; /// Map the persisted theme string (`state.json::last_theme`) back into /// the Slint `AppearanceMode` enum. Unknown values fall back to Dark so /// a corrupted config file can't strand the UI on Light with no way to /// switch. fn parse_theme(s: &str) -> AppearanceMode { match s { "system" => AppearanceMode::System, "light" => AppearanceMode::Light, _ => AppearanceMode::Dark, } } fn theme_str(m: AppearanceMode) -> &'static str { match m { AppearanceMode::System => "system", AppearanceMode::Light => "light", AppearanceMode::Dark => "dark", } } /// Phase 7 C4: rebuild `filtered_model` from `shared_model` based on /// the current priority filter. `priority_filter` matches the Slint /// encoding (0 = all, 1..4 = Low/Normal/High/Max). Used at startup, /// on each filter change, and on each refetch. Cheap enough at our /// scale (≤ a few hundred messages in V1) to rebuild fully instead /// of tracking diffs. fn rebuild_filtered_model( shared: &Rc>, filtered: &Rc>, priority_filter: i32, ) { let items = shared.iter().filter(|m| match priority_filter { 1 => m.priority == Priority::Low, 2 => m.priority == Priority::Normal, 3 => m.priority == Priority::High, 4 => m.priority == Priority::Max, _ => true, // 0 or unknown = show all }).collect::>(); filtered.set_vec(items); } /// Push a freshly-arrived WS message into both models in one go. /// Honors the active priority filter so the filtered_model doesn't /// drift out of sync with the canonical shared_model. fn push_message_to_models( shared: &Rc>, filtered: &Rc>, item: MessageItem, priority_filter: i32, ) { let matches = match priority_filter { 1 => item.priority == Priority::Low, 2 => item.priority == Priority::Normal, 3 => item.priority == Priority::High, 4 => item.priority == Priority::Max, _ => true, }; shared.push(item.clone()); if matches { filtered.push(item); } } /// Build the MainWindow and run its event loop until the window is closed. pub fn run() -> error::AppResult<()> { tracing::info!( "starting windows-gotify-client-rust v{}", env!("CARGO_PKG_VERSION") ); let ui = MainWindow::new().map_err(|e| error::AppError::Ui(e.to_string()))?; // ── Shared in-memory state. Lives for the whole process so the // WS drain closure, the close-requested callback, and the // appearance-changed-persist callback can all mutate it. ── let state_rc: Rc> = Rc::new(RefCell::new(storage::load_state())); { let s = state_rc.borrow(); tracing::info!( width = s.last_window_width, height = s.last_window_height, theme = %s.last_theme, last_message_id = s.last_message_id, "loaded state.json" ); } let initial_theme = parse_theme(&state_rc.borrow().last_theme); let initial_appearance_index = match initial_theme { AppearanceMode::System => 0, AppearanceMode::Light => 1, AppearanceMode::Dark => 2, }; ui.set_theme(initial_theme); ui.set_appearance_index(initial_appearance_index); ui.window().set_size(slint::WindowSize::Logical(slint::LogicalSize::new( state_rc.borrow().last_window_width.max(0) as f32, state_rc.borrow().last_window_height.max(0) as f32, ))); // ── Phase 6 C2 + 7 C2: load live AppConfig toggles into MainWindow // (SettingsPage is just a thin View onto these properties). ── let cfg = config::ActiveConfig::load()?; ui.set_desktop_notify(cfg.desktop_notifications); ui.set_sound(cfg.sound); ui.set_notify_high(cfg.notify_high); ui.set_notify_max(cfg.notify_max); ui.set_start_with_windows(cfg.start_with_windows); ui.set_start_minimized(cfg.start_minimized); ui.set_close_to_tray(cfg.close_to_tray); // ── Phase 7 C2 + C4: message models. `shared_model` is the // canonical full feed (every message we've seen). `filtered_model` // is what `ui.messages` actually binds to — it mirrors shared_model // but only includes items matching the current `priority-filter` // (0 = all). Rebuilding this on every filter change / message // arrival / refetch fixes the layout bug where Slint's `visible: // false` on for-loop items doesn't actually collapse layout slots // (see user screenshot: cards stretched to fill vertical space, // big empty gaps between filtered items). ── let shared_model: Rc> = Rc::new(VecModel::default()); let filtered_model: Rc> = Rc::new(VecModel::default()); ui.set_messages(ModelRc::from(filtered_model.clone())); // ── Phase 7 C1+C2: tray icon (Windows only). Created up-front so // the WS drain closure can capture an `Arc` and call // `show_toast` for every new message. We initialize the cached // notification preferences (desktop_notifications / notify_high / // notify_max / paused) from the loaded AppConfig so the toast // gating matches what the user picked in Settings. ── #[cfg(windows)] let tray_arc: Option> = { let ui_handle_for_open = ui.as_weak(); let ui_handle_for_settings = ui.as_weak(); let cfg_snapshot = cfg.clone(); // Phase 7 C2: indirection so the `on_restart` closure // below (which runs on the tray thread and must stay Send // + Sync) can capture an `Arc<…>` placeholder, then reach // the live `Arc` once the tray is constructed. // Using `OnceCell>` keeps the closure Send // + Sync without ever capturing the UI-bound // `Rc>`. let tray_state_for_restart_holder: Arc>> = Arc::new(std::sync::OnceLock::new()); match TrayIcon::new(TrayCallbacks { on_open: Box::new(move || { // Phase 7 C1: the tray's own WndProc already reset // the "New messages" counter when it decided to // dispatch this callback. We only surface the // window here. if let Some(ui) = ui_handle_for_open.upgrade() { let _ = ui.window().show(); tracing::info!("tray: Open → window shown"); } }), on_pause_toggle: Box::new(move |now: bool| { tracing::info!(paused = now, "tray: Pause notifications toggled"); let mut cfg = storage::load_config(); cfg.notifications_paused = now; if let Err(e) = storage::save_config(&cfg) { tracing::warn!(error = %e, "tray: save_config (pause) failed"); } }), on_settings: Box::new(move || { if let Some(ui) = ui_handle_for_settings.upgrade() { ui.set_selection(SidebarSelection::Settings); let _ = ui.window().show(); tracing::info!("tray: Settings → sidebar switched"); } }), // Phase 7 C2: Restart connection just sets the // `restart_requested` flag in TrayState. The UI drain // timer (which runs on the UI thread) swaps it back to // false and triggers the actual refetch — keeping the // tray callback Send+Sync (it only captures an // `Arc<…>`) and the HTTP fetch + VecModel mutation on // the UI thread where they belong. on_restart: { let holder = tray_state_for_restart_holder.clone(); Box::new(move || { tracing::info!("tray: Restart connection requested"); if let Some(ts) = holder.get() { ts.restart_requested .store(true, std::sync::atomic::Ordering::Relaxed); } }) }, on_quit: Box::new(|| { tracing::info!("tray: Quit → exit"); std::process::exit(0); }), }) { Ok(tray) => { // Seed the cached notification preferences from the // loaded config so the WS drain's toast gating // matches what the user picked in Settings. let ts = tray.state(); ts.paused.store( cfg_snapshot.notifications_paused, std::sync::atomic::Ordering::Relaxed, ); ts.desktop_notifications.store( cfg_snapshot.desktop_notifications, std::sync::atomic::Ordering::Relaxed, ); ts.notify_high.store( cfg_snapshot.notify_high, std::sync::atomic::Ordering::Relaxed, ); ts.notify_max.store( cfg_snapshot.notify_max, std::sync::atomic::Ordering::Relaxed, ); // Hand the live Arc to the on_restart // closure via the OnceCell. From here on, // restart_requested toggles will reach the drain // timer. let _ = tray_state_for_restart_holder.set(ts.clone()); tracing::info!("tray: installed (Phase 7 C1 menu + C2 toast)"); Some(Arc::new(tray)) } Err(e) => { tracing::warn!(error = %e, "tray: failed to install, continuing without it"); None } } }; #[cfg(not(windows))] let tray_arc: Option> = None; #[cfg(windows)] let tray_state_for_ws: Option> = tray_arc.as_ref().map(|t| t.state()); // ── Phase 3 C2 + 4 C2 + 5 C1 + 7 C2: HTTP catch-up. ── // Note: Phase 7 C2 mutates the *single* `shared_model` via // `set_vec` instead of replacing it with a new VecModel — that's // the change that lets the WS drain (which captured the Rc) // keep pushing into the same model after a refetch. let since = state_rc.borrow().last_message_id; let ws_credential: Option<(String, String, Vec)>; let status_text: String; let is_connected: bool; match cfg.server.as_ref() { Some(server) => match models::try_fetch_initial_data( &server.base_url, &server.token, Some(since), ) { Some((apps, msgs, text)) => { tracing::info!( server = %server.base_url, msgs = msgs.len(), apps = apps.len(), "gotify: HTTP catch-up ok" ); shared_model .set_vec(models::gotify_messages_to_items(&msgs, &apps)); status_text = text; is_connected = true; ws_credential = Some((server.base_url.clone(), server.token.clone(), apps)); // Phase 5 C2: advance the watermark from the catch-up // batch even before the WS stream starts. { let mut s = state_rc.borrow_mut(); let max_id = msgs.iter().map(|m| m.id).max().unwrap_or(s.last_message_id); if max_id > s.last_message_id { s.last_message_id = max_id; } } } None => { tracing::warn!( server = %server.base_url, "gotify: HTTP fetch failed; using mock data, WS worker skipped" ); shared_model.set_vec(models::mock_messages()); status_text = format!("未连接 · 模拟数据 · {}", server.base_url); is_connected = false; ws_credential = None; } }, None => { tracing::info!("no server configured; mock feed only"); shared_model.set_vec(models::mock_messages()); status_text = "未配置服务器 · 模拟数据".to_string(); is_connected = false; ws_credential = None; } } // Phase 7 C4: rebuild filtered model from the freshly-populated // shared_model. Filter state is 0 (all) right after startup; the // callback wired below keeps it consistent on user changes. rebuild_filtered_model(&shared_model, &filtered_model, 0); // Phase 7 C4: shared cell holding the current priority filter. // Used by the WS drain, the refetch path, and the priority-filter // callback. Lives in `Rc` so multiple closures (drain, // priority-filter-changed, test_connection) can all read+write // it on the UI thread. let priority_filter_cell: Rc> = Rc::new(RefCell::new(0)); ui.set_status_text(status_text.into()); ui.set_is_connected(is_connected); // Phase 7 C5: forward active-server summary to SettingsPage. // V1 only has the single active server (AGENTS.md §21), so we // surface exactly that. Empty values trigger the "未配置服务器" // empty-state in SettingsPage. Display name falls back to the // URL host (e.g. "gotify.example.com" or "192.168.50.103:8418") // since `ServerConfig` only carries base_url + token — the human- // friendly `name` lives on `ServerEntry`, which V1 doesn't expose // through `ActiveConfig`. That's fine for V1. match cfg.server.as_ref() { Some(server) => { let display_name = url::Url::parse(&server.base_url) .ok() .and_then(|u| u.host_str().map(|s| { if let Some(port) = u.port() { format!("{s}:{port}") } else { s.to_string() } })) .unwrap_or_else(|| server.base_url.clone()); ui.set_active_server_name(display_name.into()); ui.set_active_server_url(server.base_url.as_str().into()); ui.set_active_server_enabled(is_connected); } None => { ui.set_active_server_name("(未配置服务器)".into()); ui.set_active_server_url("".into()); ui.set_active_server_enabled(false); } } // ── Phase 4 C2 + 5 C2 + 7 C1/C2 + 7 C4: WebSocket worker + UI-thread // drain. The drain closure now writes to BOTH the canonical // shared_model AND the filtered_model (honoring the active // filter), so the UI sees exactly the right set of cards without // Slint having to do the filtering itself. ── let drain_timer = if let Some((base_url, token, apps)) = ws_credential { let (tx, rx) = mpsc::channel::(); let apps_by_id: std::collections::HashMap = apps.iter().map(|a| (a.id, a.clone())).collect(); std::thread::Builder::new() .name("gotify-ws".into()) .spawn({ move || { let server = gotify::ServerConfig::new(base_url, token); gotify::websocket::run_stream_loop(server, move |msg| { let apps = apps_by_id.clone(); let item = models::gotify_message_to_item(&msg, &apps); if tx.send(item).is_err() { // UI closed; nothing to do. } }); } }) .map_err(|e| error::AppError::Gotify(format!("ws thread spawn: {e}")))?; let timer = slint::Timer::default(); // Phase 7 C4: drain closure now writes to both shared_model // (canonical) and filtered_model (UI-bound, priority-aware). let shared_for_drain = shared_model.clone(); let filtered_for_drain = filtered_model.clone(); let state_for_drain = state_rc.clone(); let tray_for_drain = tray_arc.clone(); let tray_state_for_drain = tray_state_for_ws.clone(); let ui_handle_for_drain = ui.as_weak(); let filter_cell_for_drain = priority_filter_cell.clone(); timer.start( slint::TimerMode::Repeated, std::time::Duration::from_millis(100), move || { // Drain the WS channel. loop { match rx.try_recv() { Ok(item) => { { let mut s = state_for_drain.borrow_mut(); let id = item.id as i64; if id > s.last_message_id { s.last_message_id = id; } } // Phase 7 C1: bump the tray counter so // the menu "New messages: N" row // refreshes. if let Some(ref ts) = tray_state_for_drain { ts.new_messages .fetch_add(1, std::sync::atomic::Ordering::Relaxed); } // Phase 7 C2: surface a desktop toast // for the new message when the user's // preferences allow it. The check fires // *before* pushing into the model so a // suppressed toast still updates the // in-app list. if let (Some(ts), Some(tray)) = ( tray_state_for_drain.as_ref(), tray_for_drain.as_ref(), ) { let priority = item.priority; let desktop_ok = ts .desktop_notifications .load(std::sync::atomic::Ordering::Relaxed); let paused = ts .paused .load(std::sync::atomic::Ordering::Relaxed); let priority_ok = match priority { crate::Priority::High => ts .notify_high .load(std::sync::atomic::Ordering::Relaxed), crate::Priority::Max => ts .notify_max .load(std::sync::atomic::Ordering::Relaxed), _ => false, // Low / Normal don't toast. }; if desktop_ok && !paused && priority_ok { let title: String = if !item.title.is_empty() { item.title.to_string() } else { item.app_name.to_string() }; let body = item.body.to_string(); let icon_flags = match priority { crate::Priority::High => NIIF_WARNING, crate::Priority::Max => NIIF_ERROR, _ => NIIF_INFO, }; tray.show_toast(&title, &body, icon_flags); } } push_message_to_models( &shared_for_drain, &filtered_for_drain, item, *filter_cell_for_drain.borrow(), ); } Err(_) => break, } } // Phase 7 C2: handle "Restart connection" requests // from the tray menu. We swap the flag back to // false so a second click also works. if let Some(ref ts) = tray_state_for_drain { if ts .restart_requested .swap(false, std::sync::atomic::Ordering::Relaxed) { tracing::info!("drain: restart_requested set → refetching"); let Some(server) = config::ActiveConfig::load() .ok() .and_then(|c| c.server) else { tracing::warn!("drain: restart → no server configured"); return; }; match models::try_fetch_initial_data( &server.base_url, &server.token, None, ) { Some((apps, msgs, text)) => { shared_for_drain.set_vec( models::gotify_messages_to_items(&msgs, &apps), ); rebuild_filtered_model( &shared_for_drain, &filtered_for_drain, *filter_cell_for_drain.borrow(), ); if let Some(ui) = ui_handle_for_drain.upgrade() { ui.set_status_text(text.into()); ui.set_is_connected(true); } } None => { tracing::warn!("drain: restart → fetch failed"); } } } } }, ); Some(timer) } else { None }; let _drain_timer = drain_timer; // ── Phase 2 C2 callbacks ─────────────────────────────────────── ui.on_test_notification_clicked(|| { tracing::info!("empty-state: Test Notification clicked (stub)"); }); // Phase 7 C2: clicking a message card selects it AND opens the // Gotify `click_url` via the strict http/https opener if the card // carries one. We read the item from `shared_model` rather than // re-parse the index — keeps the wiring independent of the Slint // message-card internals. #[cfg(windows)] { let ui_handle_for_click = ui.as_weak(); let model_for_click = shared_model.clone(); ui.on_message_card_clicked(move |idx: i32| { let Some(item) = model_for_click.row_data(idx as usize) else { tracing::debug!(idx, "message card clicked but index out of range"); return; }; tracing::debug!(idx, title = %item.title, "message card clicked"); if item.has_click_url && !item.click_url.is_empty() { if !crate::windows::url::open(&item.click_url) { tracing::warn!( idx, url = %item.click_url, "message card click: url::open refused the URL" ); } } if let Some(ui) = ui_handle_for_click.upgrade() { ui.set_selected_message_index(idx); } }); } #[cfg(not(windows))] ui.on_message_card_clicked(|idx| { tracing::debug!(idx, "message card clicked (no-op on non-Windows)"); }); // ── Phase 6 C3: URL opener import kept alive even when not on // Windows — the cfg(windows) gate above handles the actual use. // (Kept here so future cross-platform polish knows where to hook.) ── #[cfg(windows)] let _ = crate::windows::url::open; // ── Phase 3 C2 + 7 C2: Settings → Connection → Test button ─── ui.on_test_connection_requested({ let ui_handle = ui.as_weak(); let shared_for_test = shared_model.clone(); let filtered_for_test = filtered_model.clone(); let filter_cell_for_test = priority_filter_cell.clone(); move || { tracing::info!("settings: Test Connection clicked"); let Some(server) = config::ActiveConfig::load().ok().and_then(|c| c.server) else { return; }; match models::try_fetch_initial_data(&server.base_url, &server.token, None) { Some((apps, msgs, text)) => { shared_for_test.set_vec( models::gotify_messages_to_items(&msgs, &apps), ); rebuild_filtered_model( &shared_for_test, &filtered_for_test, *filter_cell_for_test.borrow(), ); if let Some(ui) = ui_handle.upgrade() { ui.set_status_text(text.into()); ui.set_is_connected(true); // Phase 7 C5: refresh the active-server // summary so the Settings card lights up // green after a successful test. ui.set_active_server_enabled(true); } tracing::info!(msgs = msgs.len(), "Test Connection: ok"); } None => { if let Some(ui) = ui_handle.upgrade() { ui.set_status_text( format!("未连接 · 模拟数据 · {}", server.base_url).into(), ); ui.set_is_connected(false); // Phase 7 C5: failed test → server card // dot returns to "configured but not // connected" (gray). ui.set_active_server_enabled(false); } tracing::warn!("Test Connection: failed"); } } } }); // ── Phase 5 C2: Settings → Appearance radio persists theme ───── ui.on_appearance_changed_persist({ let state_for_theme = state_rc.clone(); move |idx: i32| { let s = theme_str(match idx { 0 => AppearanceMode::System, 1 => AppearanceMode::Light, _ => AppearanceMode::Dark, }); tracing::info!(theme = %s, "settings: theme changed → persisting"); state_for_theme.borrow_mut().last_theme = s.to_string(); } }); // ── Phase 6 C2 + 7 C1/C2: every other SettingsPage toggle // (notifications, startup) routes through one callback. We // dispatch on the `label` string the UI hands us, persist into // `config.json`, and for `start_with_windows` we also write HKCU // Run (Phase 6 C1). Phase 7 C1/C2 also mirrors the relevant // notification toggles into the tray state's cached atomics so // the WS drain's toast gating stays in sync without a restart. ── let tray_state_for_settings = tray_state_for_ws.clone(); ui.on_setting_toggled(move |label, value| { let mut cfg = storage::load_config(); let label_str = label.to_string(); let label_ref: &str = &label_str; match label_ref { "desktop_notifications" => { cfg.desktop_notifications = value; if let Some(ref ts) = tray_state_for_settings { ts.desktop_notifications .store(value, std::sync::atomic::Ordering::Relaxed); } } "sound" => cfg.sound = value, "notify_high" => { cfg.notify_high = value; if let Some(ref ts) = tray_state_for_settings { ts.notify_high .store(value, std::sync::atomic::Ordering::Relaxed); } } "notify_max" => { cfg.notify_max = value; if let Some(ref ts) = tray_state_for_settings { ts.notify_max .store(value, std::sync::atomic::Ordering::Relaxed); } } "start_minimized" => cfg.start_minimized = value, "close_to_tray" => { cfg.close_to_tray = value; } "start_with_windows" => { cfg.start_with_windows = value; #[cfg(windows)] { use crate::windows::startup; if !startup::set_enabled(value) { tracing::warn!("startup: HKCU Run write failed"); } } #[cfg(not(windows))] { tracing::debug!("startup: not on Windows, skipping HKCU"); } } _ => { tracing::warn!(label = %label_str, "settings: unknown toggle label"); return; } } if let Err(e) = storage::save_config(&cfg) { tracing::warn!(error = %e, label = %label_str, "settings: save_config failed"); } else { tracing::info!(label = %label_str, value, "settings: toggle persisted"); } }); // ── Phase 2 C3 callbacks ─────────────────────────────────────── ui.on_about_clicked({ let ui_handle = ui.as_weak(); move || { tracing::info!("about-clicked"); if let Some(ui) = ui_handle.upgrade() { ui.set_selection(SidebarSelection::About); } } }); // ── Phase 7 C4: priority-filter-changed callback. The Slint // sidebar's "全部 / 最高 / 高 / 普通 / 低" rows fire this when // tapped; we update the shared filter cell and rebuild the // filtered model so the visible cards immediately match the // user's selection. The Slint property `priority-filter` is // still set by the click handler (drives the sidebar highlight); // this callback runs alongside it. ── ui.on_priority_filter_changed({ let shared_for_filter = shared_model.clone(); let filtered_for_filter = filtered_model.clone(); let filter_cell_for_filter = priority_filter_cell.clone(); move |filter: i32| { tracing::info!(filter, "sidebar: priority filter changed"); *filter_cell_for_filter.borrow_mut() = filter; rebuild_filtered_model(&shared_for_filter, &filtered_for_filter, filter); } }); // ── Phase 2 C3 / 5 C2 / 6 C2: save state on OS-close ────────── // Phase 6 C2: when `close_to_tray` is true, hide the window and // pretend to honor the close (return `KeepWindowShown`); otherwise // let Slint proceed with its default behavior (which hides the // window on Windows so the process keeps running until Quit). ui.window().on_close_requested({ let state_for_save = state_rc.clone(); let ui_handle = ui.as_weak(); move || { let close_to_tray = ui_handle .upgrade() .map(|u| u.get_close_to_tray()) .unwrap_or(false); if let Some(ui) = ui_handle.upgrade() { let size = ui.window().size(); let mut s = state_for_save.borrow_mut(); s.last_window_width = size.width as i32; s.last_window_height = size.height as i32; } let snapshot = state_for_save.borrow().clone(); if let Err(e) = storage::save_state(&snapshot) { tracing::warn!(error = %e, "on close: failed to save state.json"); } else { tracing::info!( width = snapshot.last_window_width, height = snapshot.last_window_height, last_message_id = snapshot.last_message_id, theme = %snapshot.last_theme, "saved state.json on close" ); } if close_to_tray { if let Some(ui) = ui_handle.upgrade() { let _ = ui.window().hide(); tracing::info!("close: window hidden (close-to-tray)"); return slint::CloseRequestResponse::KeepWindowShown; } } slint::CloseRequestResponse::HideWindow } }); // Keep tray_arc alive for the lifetime of run(); the Drop impl // removes the icon and joins the helper thread. let _tray_arc = tray_arc; ui.run() .map_err(|e| error::AppError::Ui(e.to_string())) } pub use error::{AppError, AppResult};