feat(gotify): Phase 7 C2 — Win32 Toast 通知 + URL 打开接线 + 单 VecModel
- 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(托盘初始化快照用)
This commit is contained in:
+333
-185
@@ -1,23 +1,30 @@
|
||||
//! Windows Gotify Client (Rust) — core library.
|
||||
//!
|
||||
//! Phase 5 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 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 5 C2 additions on top of Phase 4:
|
||||
//! - `last_theme` + `last_message_id` persist into `state.json`
|
||||
//! (AGENTS.md §10 / §16 / §18).
|
||||
//! - Settings → Appearance radio fires `appearance-changed-persist`,
|
||||
//! which writes the new theme into `state.last_theme` immediately.
|
||||
//! - The HTTP catch-up uses `since=last_message_id` so a restart only
|
||||
//! downloads the gap.
|
||||
//! - The WS drain closure bumps `last_message_id` on every accepted
|
||||
//! frame, so a WS-only session still advances the watermark.
|
||||
//! 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;
|
||||
@@ -34,15 +41,14 @@ slint::include_modules!();
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use slint::{ModelRc, VecModel};
|
||||
use slint::{Model, ModelRc, VecModel};
|
||||
|
||||
use storage::AppState;
|
||||
|
||||
#[cfg(windows)]
|
||||
use crate::windows::tray::{TrayCallbacks, TrayIcon};
|
||||
#[cfg(windows)]
|
||||
use crate::windows::url as url_opener;
|
||||
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
|
||||
@@ -101,26 +107,143 @@ pub fn run() -> error::AppResult<()> {
|
||||
state_rc.borrow().last_window_height.max(0) as f32,
|
||||
)));
|
||||
|
||||
// ── Phase 6 C2: load live AppConfig toggles into MainWindow ───
|
||||
{
|
||||
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 3 C2 + 4 C2 + 5 C1: HTTP catch-up, then channel for WS ────
|
||||
// ── 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()?;
|
||||
let shared_model: Rc<VecModel<MessageItem>>;
|
||||
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;
|
||||
let ws_credential: Option<(String, String, Vec<gotify::GotifyApplication>)>;
|
||||
|
||||
let since = state_rc.borrow().last_message_id;
|
||||
match cfg.server.as_ref() {
|
||||
Some(server) => match models::try_fetch_initial_data(
|
||||
&server.base_url,
|
||||
@@ -134,9 +257,8 @@ pub fn run() -> error::AppResult<()> {
|
||||
apps = apps.len(),
|
||||
"gotify: HTTP catch-up ok"
|
||||
);
|
||||
shared_model = Rc::new(VecModel::from(
|
||||
models::gotify_messages_to_items(&msgs, &apps),
|
||||
));
|
||||
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));
|
||||
@@ -155,7 +277,7 @@ pub fn run() -> error::AppResult<()> {
|
||||
server = %server.base_url,
|
||||
"gotify: HTTP fetch failed; using mock data, WS worker skipped"
|
||||
);
|
||||
shared_model = Rc::new(VecModel::from(models::mock_messages()));
|
||||
shared_model.set_vec(models::mock_messages());
|
||||
status_text = format!("Disconnected · mock feed · {}", server.base_url);
|
||||
is_connected = false;
|
||||
ws_credential = None;
|
||||
@@ -163,27 +285,22 @@ pub fn run() -> error::AppResult<()> {
|
||||
},
|
||||
None => {
|
||||
tracing::info!("no server configured; mock feed only");
|
||||
shared_model = Rc::new(VecModel::from(models::mock_messages()));
|
||||
shared_model.set_vec(models::mock_messages());
|
||||
status_text = "No server configured · mock feed".to_string();
|
||||
is_connected = false;
|
||||
ws_credential = None;
|
||||
}
|
||||
}
|
||||
|
||||
ui.set_messages(ModelRc::from(shared_model.clone()));
|
||||
ui.set_status_text(status_text.into());
|
||||
ui.set_is_connected(is_connected);
|
||||
|
||||
// ── Phase 7 C1: tray state cell ─────────────────────────────────────
|
||||
// The tray is created further down (after the WS drain is wired so
|
||||
// we have a clean order). The drain closure needs to bump the tray
|
||||
// state on each new message, so we share a cell that the tray
|
||||
// init later populates. `RefCell` is fine here — both the drain
|
||||
// callback and the post-tray-init assignment run on the UI thread.
|
||||
let tray_state_cell: Rc<RefCell<Option<std::sync::Arc<windows::tray::TrayState>>>> =
|
||||
Rc::new(RefCell::new(None));
|
||||
|
||||
// ── Phase 4 C2 + 5 C2: WebSocket worker + UI-thread drain ───────────
|
||||
// ── 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> =
|
||||
@@ -208,15 +325,14 @@ pub fn run() -> error::AppResult<()> {
|
||||
let timer = slint::Timer::default();
|
||||
let model_for_drain = shared_model.clone();
|
||||
let state_for_drain = state_rc.clone();
|
||||
// Phase 7 C1: tray "New messages: N" counter. The tray owns
|
||||
// the Arc<TrayState>; we capture the cell here so the UI
|
||||
// drain can bump the atomic once the tray has populated the
|
||||
// cell (post-init, below).
|
||||
let tray_state_for_drain = tray_state_cell.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) => {
|
||||
@@ -227,17 +343,95 @@ pub fn run() -> error::AppResult<()> {
|
||||
s.last_message_id = id;
|
||||
}
|
||||
}
|
||||
// Bump the tray counter so the menu
|
||||
// "New messages: N" row refreshes.
|
||||
if let Some(ref ts) = *tray_state_for_drain.borrow() {
|
||||
// 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)
|
||||
@@ -250,123 +444,63 @@ pub fn run() -> error::AppResult<()> {
|
||||
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");
|
||||
tracing::debug!(idx, "message card clicked (no-op on non-Windows)");
|
||||
});
|
||||
|
||||
// ── Phase 7 C1: tray icon (Windows only) with full menu ───────
|
||||
// The tray owns a hidden helper window and a dedicated message-
|
||||
// pump thread. On top of the Phase 6 C3 Open/Quit menu we now
|
||||
// also expose Pause notifications / Settings / Restart
|
||||
// connection, plus a grayed-out "New messages: N" info row.
|
||||
// Single-instance activation: when a second copy of the binary
|
||||
// launches, it sends a registered window message to this helper
|
||||
// window (see `single_instance::activate_existing_instance`);
|
||||
// our WndProc treats that as an extra `on_open` invocation.
|
||||
// ── 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 _tray = {
|
||||
let ui_handle_for_open = ui.as_weak();
|
||||
let ui_handle_for_settings = ui.as_weak();
|
||||
let ui_handle_for_restart = ui.as_weak();
|
||||
let tray_state_for_pause_init = tray_state_cell.clone();
|
||||
let cfg_for_pause_init = config::ActiveConfig::load().ok();
|
||||
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 (it does the same on the
|
||||
// registered activate-message path). This closure
|
||||
// only needs to surface the window.
|
||||
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");
|
||||
}
|
||||
}),
|
||||
on_restart: Box::new(move || {
|
||||
tracing::info!("tray: Restart connection requested");
|
||||
// Phase 7 C1: "restart" is just a refetch using the
|
||||
// existing Test Connection path; we don't (yet)
|
||||
// cycle the WS worker thread, which would require
|
||||
// an additional shutdown signal. The catch-up +
|
||||
// status refresh is enough for V1.
|
||||
let Some(server) =
|
||||
config::ActiveConfig::load().ok().and_then(|c| c.server)
|
||||
else {
|
||||
tracing::warn!("tray: Restart → no server configured");
|
||||
return;
|
||||
};
|
||||
let Some((apps, msgs, text)) =
|
||||
models::try_fetch_initial_data(&server.base_url, &server.token, None)
|
||||
else {
|
||||
tracing::warn!("tray: Restart → fetch failed");
|
||||
return;
|
||||
};
|
||||
if let Some(ui) = ui_handle_for_restart.upgrade() {
|
||||
ui.set_messages(models::build_message_model(&msgs, &apps));
|
||||
ui.set_status_text(text.into());
|
||||
ui.set_is_connected(true);
|
||||
}
|
||||
}),
|
||||
on_quit: Box::new(|| {
|
||||
tracing::info!("tray: Quit → exit");
|
||||
std::process::exit(0);
|
||||
}),
|
||||
})
|
||||
.map(|tray| {
|
||||
// Populate the shared state cell so the WS drain can
|
||||
// bump the "New messages" counter, and prime the pause
|
||||
// toggle from the persisted config so the menu's
|
||||
// checkmark reflects the saved value on first show.
|
||||
let ts = tray.state();
|
||||
if let Some(cfg) = cfg_for_pause_init.as_ref() {
|
||||
ts.paused.store(cfg.notifications_paused, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
*tray_state_for_pause_init.borrow_mut() = Some(ts);
|
||||
tracing::info!("tray: installed (Phase 7 C1 menu)");
|
||||
tray
|
||||
})
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = %e, "tray: failed to install, continuing without it");
|
||||
})
|
||||
.ok()
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let _tray = _tray; // hold for the rest of run()
|
||||
let _ = crate::windows::url::open;
|
||||
|
||||
// ── Phase 6 C3: URL opener (Windows only) — already wired; the
|
||||
// Gotify `click_url` field is reserved in `models.rs` (Phase 3)
|
||||
// and is opened via this helper when Phase 7 polish wires a
|
||||
// message-card click on `has_click_url`. ──
|
||||
#[cfg(windows)]
|
||||
let _ = url_opener::open; // keep the import alive for Phase 7
|
||||
|
||||
// ── Phase 3 C2: Settings → Connection → Test button ───────────
|
||||
// ── 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 {
|
||||
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_messages(models::build_message_model(&msgs, &apps));
|
||||
ui.set_status_text(text.into());
|
||||
ui.set_is_connected(true);
|
||||
}
|
||||
@@ -399,34 +533,44 @@ pub fn run() -> error::AppResult<()> {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Phase 6 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).
|
||||
ui.on_setting_toggled(|label, value| {
|
||||
// Phase 6 C2 also: AppConfig is mutated through a fresh load +
|
||||
// save because the toggle handler is a `Fn` (no captured
|
||||
// mutex); this keeps the persistence path side-effect-only and
|
||||
// doesn't risk racing with the close-requested save below.
|
||||
let mut cfg = match storage::load_config() {
|
||||
c => c,
|
||||
};
|
||||
// ── 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,
|
||||
"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,
|
||||
"notify_max" => cfg.notify_max = 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;
|
||||
// Runtime-only: also flip the MainWindow property so
|
||||
// the close-requested handler picks up the new value
|
||||
// immediately without a restart. The Slint callback
|
||||
// already set root.close-to-tray = value on the UI
|
||||
// side; we just mirror it in `cfg` so save_config
|
||||
// captures the same value.
|
||||
}
|
||||
"start_with_windows" => {
|
||||
cfg.start_with_windows = value;
|
||||
@@ -509,6 +653,10 @@ pub fn run() -> error::AppResult<()> {
|
||||
}
|
||||
});
|
||||
|
||||
// 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()))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user