Files
Windows-Gotify-Client-Rust/src/lib.rs
T
KennethCheng a091e6839a fix(ui): Phase 7 C5 — 移除假按钮 + 真实托盘图标
用户报告设置页很多按钮无反应 + 关闭后托盘看不到图标。两处问题:\n\n1. ServerCard 占位按钮无回调:\n- server-card.slint:去掉了 toggled/edit-clicked/remove-clicked 三个回调以及 enabled 切换、Edit / Remove 按钮;变成纯展示卡(状态点 + name + url + 当前标记)\n- settings-page.slint:去掉硬编码的 Home / NAS 占位,改用 active-server-name / active-server-url / active-server-enabled 三个属性(MainWindow → AppConfig 实时填充);空时显示 '(未配置服务器)' 空态提示\n- app.slint:MainWindow 加三个属性 + 转发给 SettingsPage\n- lib.rs:startup 阶段把活跃服务器信息塞进 UI;测试连接成功/失败时更新 enabled 状态\n\n2. 托盘图标看不见:\n- tray.rs:原本 hIcon 写 null,Win10+ 上 Shell_NotifyIconW(NIM_ADD, NULL hIcon) 实际渲染为空白或很弱的小方块。改用 LoadIconW(NULL, IDI_APPLICATION) 加载 Windows 自带的应用程序图标(无需嵌入 .ico 资源,主题感知)。show_toast / Drop 的 hIcon 维持 null(让 NIM_MODIFY/NIM_DELETE 不动原有图标)。AGENTS.md §53 标记真 .ico 资源为 V2 项。
2026-09-19 20:40:33 +08:00

802 lines
35 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",
}
}
/// 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<VecModel<MessageItem>>,
filtered: &Rc<VecModel<MessageItem>>,
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::<Vec<_>>();
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<VecModel<MessageItem>>,
filtered: &Rc<VecModel<MessageItem>>,
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<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 + 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<VecModel<MessageItem>> = Rc::new(VecModel::default());
let filtered_model: Rc<VecModel<MessageItem>> = 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<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!("未连接 · 模拟数据 · {}", 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<RefCell>` so multiple closures (drain,
// priority-filter-changed, test_connection) can all read+write
// it on the UI thread.
let priority_filter_cell: Rc<RefCell<i32>> = 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::<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();
// 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};