- tray.rs: 增加 show_toast 方法(PostMessageW + 堆分配 payload + WndProc 内 Shell_NotifyIconW NIM_MODIFY),遵守 NIM_MODIFY 必须 NIM_ADD 同线程的硬性要求 - WS drain 每条新消息根据 priority + cached desktop_notifications/notify_high/notify_max/paused atomics 决定是否弹 toast,icon_flags 按 High/Max 分别用 NIIF_WARNING/NIIF_ERROR - on_message_card_clicked:点击含 has_click_url 的卡片时调 windows::url::open(已有 http/https scheme 严格校验) - TrayState 增加 desktop_notifications/notify_high/notify_max/restart_requested 原子,Settings 切换实时同步,tray on_restart 只翻 restart_requested flag - 修复 Phase 6 C3 隐藏 bug:Test Connection / tray Restart 用 set_messages(new_model) 会让 WS drain 推入旧 model。新增单例 Rc<VecModel>,统一改用 set_vec - on_restart 改用 Arc<OnceLock<Arc<TrayState>>> 占位 + 设置,避免 tray 线程闭包捕获 Rc<VecModel>(!Send) - ActiveConfig 派生 Clone(托盘初始化快照用)
664 lines
28 KiB
Rust
664 lines
28 KiB
Rust
//! 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<VecModel<MessageItem>>` 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",
|
|
}
|
|
}
|
|
|
|
/// 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<RefCell<AppState>> =
|
|
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: long-lived message model. We bind `ui.messages`
|
|
// to this single VecModel once and update it via `set_vec` on
|
|
// every fetch so the WS drain keeps pushing into the same model
|
|
// even after a refetch. ──
|
|
let shared_model: Rc<VecModel<MessageItem>> = Rc::new(VecModel::default());
|
|
ui.set_messages(ModelRc::from(shared_model.clone()));
|
|
|
|
// ── Phase 7 C1+C2: tray icon (Windows only). Created up-front so
|
|
// the WS drain closure can capture an `Arc<TrayIcon>` 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<Arc<TrayIcon>> = {
|
|
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<TrayState>` once the tray is constructed.
|
|
// Using `OnceCell<Arc<TrayState>>` keeps the closure Send
|
|
// + Sync without ever capturing the UI-bound
|
|
// `Rc<VecModel<…>>`.
|
|
let tray_state_for_restart_holder: Arc<std::sync::OnceLock<Arc<TrayState>>> =
|
|
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<TrayState> 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<Arc<TrayIcon>> = None;
|
|
#[cfg(windows)]
|
|
let tray_state_for_ws: Option<Arc<TrayState>> =
|
|
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<gotify::GotifyApplication>)>;
|
|
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!("Disconnected · mock feed · {}", 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 = "No server configured · mock feed".to_string();
|
|
is_connected = false;
|
|
ws_credential = None;
|
|
}
|
|
}
|
|
|
|
ui.set_status_text(status_text.into());
|
|
ui.set_is_connected(is_connected);
|
|
|
|
// ── Phase 4 C2 + 5 C2 + 7 C1/C2: WebSocket worker + UI-thread
|
|
// drain. The drain closure also fires a desktop toast for each
|
|
// new message via the tray's `show_toast` (any thread → the tray
|
|
// helper window's WndProc, which is the only thread allowed to
|
|
// call Shell_NotifyIconW), and now also services the
|
|
// `restart_requested` flag set by the tray's on_restart. ──
|
|
let drain_timer = if let Some((base_url, token, apps)) = ws_credential {
|
|
let (tx, rx) = mpsc::channel::<MessageItem>();
|
|
let apps_by_id: std::collections::HashMap<i64, gotify::GotifyApplication> =
|
|
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();
|
|
let model_for_drain = shared_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();
|
|
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);
|
|
}
|
|
}
|
|
model_for_drain.push(item);
|
|
}
|
|
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)) => {
|
|
model_for_drain.set_vec(
|
|
models::gotify_messages_to_items(&msgs, &apps),
|
|
);
|
|
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 model_for_test = shared_model.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)) => {
|
|
model_for_test.set_vec(
|
|
models::gotify_messages_to_items(&msgs, &apps),
|
|
);
|
|
if let Some(ui) = ui_handle.upgrade() {
|
|
ui.set_status_text(text.into());
|
|
ui.set_is_connected(true);
|
|
}
|
|
tracing::info!(msgs = msgs.len(), "Test Connection: ok");
|
|
}
|
|
None => {
|
|
if let Some(ui) = ui_handle.upgrade() {
|
|
ui.set_status_text(
|
|
format!("Disconnected · mock feed · {}", server.base_url).into(),
|
|
);
|
|
ui.set_is_connected(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 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}; |