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:
@@ -12,6 +12,7 @@ use crate::error::AppResult;
|
|||||||
use crate::gotify::ServerConfig;
|
use crate::gotify::ServerConfig;
|
||||||
use crate::storage::AppConfig as PersistedConfig;
|
use crate::storage::AppConfig as PersistedConfig;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct ActiveConfig {
|
pub struct ActiveConfig {
|
||||||
pub server: Option<ServerConfig>,
|
pub server: Option<ServerConfig>,
|
||||||
pub appearance: String,
|
pub appearance: String,
|
||||||
|
|||||||
+320
-172
@@ -1,6 +1,6 @@
|
|||||||
//! Windows Gotify Client (Rust) — core library.
|
//! Windows Gotify Client (Rust) — core library.
|
||||||
//!
|
//!
|
||||||
//! Phase 5 surface:
|
//! Phase 7 surface:
|
||||||
//! - [`config`]: active-config wrapper (Phase 5 C1)
|
//! - [`config`]: active-config wrapper (Phase 5 C1)
|
||||||
//! - [`error`]: AppError / AppResult
|
//! - [`error`]: AppError / AppResult
|
||||||
//! - [`gotify`]: blocking REST + WebSocket clients (Phase 3/4)
|
//! - [`gotify`]: blocking REST + WebSocket clients (Phase 3/4)
|
||||||
@@ -9,15 +9,22 @@
|
|||||||
//! - [`storage`]: `%APPDATA%/WindowsGotify/{config,state}.json`
|
//! - [`storage`]: `%APPDATA%/WindowsGotify/{config,state}.json`
|
||||||
//! - [`run`]: builds the MainWindow and runs its event loop
|
//! - [`run`]: builds the MainWindow and runs its event loop
|
||||||
//!
|
//!
|
||||||
//! Phase 5 C2 additions on top of Phase 4:
|
//! Phase 7 additions on top of Phase 6:
|
||||||
//! - `last_theme` + `last_message_id` persist into `state.json`
|
//! - Tray helper now exposes a full menu (Open / New messages / Pause
|
||||||
//! (AGENTS.md §10 / §16 / §18).
|
//! / Settings / Restart / Quit) and the registered
|
||||||
//! - Settings → Appearance radio fires `appearance-changed-persist`,
|
//! `WM_ACTIVATE_INSTANCE` path so a second launch surfaces the
|
||||||
//! which writes the new theme into `state.last_theme` immediately.
|
//! existing instance. (AGENTS.md §12, §26.)
|
||||||
//! - The HTTP catch-up uses `since=last_message_id` so a restart only
|
//! - Win32 toast (NIF_INFO via `Shell_NotifyIconW`) on every new
|
||||||
//! downloads the gap.
|
//! Gotify message. Respects `desktop_notifications`,
|
||||||
//! - The WS drain closure bumps `last_message_id` on every accepted
|
//! `notify_high`, `notify_max`, and the tray-menu pause toggle.
|
||||||
//! frame, so a WS-only session still advances the watermark.
|
//! (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 config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@@ -34,15 +41,14 @@ slint::include_modules!();
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use slint::{ModelRc, VecModel};
|
use slint::{Model, ModelRc, VecModel};
|
||||||
|
|
||||||
use storage::AppState;
|
use storage::AppState;
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use crate::windows::tray::{TrayCallbacks, TrayIcon};
|
use crate::windows::tray::{TrayCallbacks, TrayIcon, TrayState, NIIF_ERROR, NIIF_INFO, NIIF_WARNING};
|
||||||
#[cfg(windows)]
|
|
||||||
use crate::windows::url as url_opener;
|
|
||||||
|
|
||||||
/// Map the persisted theme string (`state.json::last_theme`) back into
|
/// Map the persisted theme string (`state.json::last_theme`) back into
|
||||||
/// the Slint `AppearanceMode` enum. Unknown values fall back to Dark so
|
/// the Slint `AppearanceMode` enum. Unknown values fall back to Dark so
|
||||||
@@ -101,8 +107,8 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
state_rc.borrow().last_window_height.max(0) as f32,
|
state_rc.borrow().last_window_height.max(0) as f32,
|
||||||
)));
|
)));
|
||||||
|
|
||||||
// ── Phase 6 C2: load live AppConfig toggles into MainWindow ───
|
// ── 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 cfg = config::ActiveConfig::load()?;
|
||||||
ui.set_desktop_notify(cfg.desktop_notifications);
|
ui.set_desktop_notify(cfg.desktop_notifications);
|
||||||
ui.set_sound(cfg.sound);
|
ui.set_sound(cfg.sound);
|
||||||
@@ -111,16 +117,133 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
ui.set_start_with_windows(cfg.start_with_windows);
|
ui.set_start_with_windows(cfg.start_with_windows);
|
||||||
ui.set_start_minimized(cfg.start_minimized);
|
ui.set_start_minimized(cfg.start_minimized);
|
||||||
ui.set_close_to_tray(cfg.close_to_tray);
|
ui.set_close_to_tray(cfg.close_to_tray);
|
||||||
}
|
|
||||||
|
|
||||||
// ── Phase 3 C2 + 4 C2 + 5 C1: HTTP catch-up, then channel for WS ────
|
// ── Phase 7 C2: long-lived message model. We bind `ui.messages`
|
||||||
let cfg = config::ActiveConfig::load()?;
|
// to this single VecModel once and update it via `set_vec` on
|
||||||
let shared_model: Rc<VecModel<MessageItem>>;
|
// 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 status_text: String;
|
||||||
let is_connected: bool;
|
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() {
|
match cfg.server.as_ref() {
|
||||||
Some(server) => match models::try_fetch_initial_data(
|
Some(server) => match models::try_fetch_initial_data(
|
||||||
&server.base_url,
|
&server.base_url,
|
||||||
@@ -134,9 +257,8 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
apps = apps.len(),
|
apps = apps.len(),
|
||||||
"gotify: HTTP catch-up ok"
|
"gotify: HTTP catch-up ok"
|
||||||
);
|
);
|
||||||
shared_model = Rc::new(VecModel::from(
|
shared_model
|
||||||
models::gotify_messages_to_items(&msgs, &apps),
|
.set_vec(models::gotify_messages_to_items(&msgs, &apps));
|
||||||
));
|
|
||||||
status_text = text;
|
status_text = text;
|
||||||
is_connected = true;
|
is_connected = true;
|
||||||
ws_credential = Some((server.base_url.clone(), server.token.clone(), apps));
|
ws_credential = Some((server.base_url.clone(), server.token.clone(), apps));
|
||||||
@@ -155,7 +277,7 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
server = %server.base_url,
|
server = %server.base_url,
|
||||||
"gotify: HTTP fetch failed; using mock data, WS worker skipped"
|
"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);
|
status_text = format!("Disconnected · mock feed · {}", server.base_url);
|
||||||
is_connected = false;
|
is_connected = false;
|
||||||
ws_credential = None;
|
ws_credential = None;
|
||||||
@@ -163,27 +285,22 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
},
|
},
|
||||||
None => {
|
None => {
|
||||||
tracing::info!("no server configured; mock feed only");
|
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();
|
status_text = "No server configured · mock feed".to_string();
|
||||||
is_connected = false;
|
is_connected = false;
|
||||||
ws_credential = None;
|
ws_credential = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.set_messages(ModelRc::from(shared_model.clone()));
|
|
||||||
ui.set_status_text(status_text.into());
|
ui.set_status_text(status_text.into());
|
||||||
ui.set_is_connected(is_connected);
|
ui.set_is_connected(is_connected);
|
||||||
|
|
||||||
// ── Phase 7 C1: tray state cell ─────────────────────────────────────
|
// ── Phase 4 C2 + 5 C2 + 7 C1/C2: WebSocket worker + UI-thread
|
||||||
// The tray is created further down (after the WS drain is wired so
|
// drain. The drain closure also fires a desktop toast for each
|
||||||
// we have a clean order). The drain closure needs to bump the tray
|
// new message via the tray's `show_toast` (any thread → the tray
|
||||||
// state on each new message, so we share a cell that the tray
|
// helper window's WndProc, which is the only thread allowed to
|
||||||
// init later populates. `RefCell` is fine here — both the drain
|
// call Shell_NotifyIconW), and now also services the
|
||||||
// callback and the post-tray-init assignment run on the UI thread.
|
// `restart_requested` flag set by the tray's on_restart. ──
|
||||||
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 ───────────
|
|
||||||
let drain_timer = if let Some((base_url, token, apps)) = ws_credential {
|
let drain_timer = if let Some((base_url, token, apps)) = ws_credential {
|
||||||
let (tx, rx) = mpsc::channel::<MessageItem>();
|
let (tx, rx) = mpsc::channel::<MessageItem>();
|
||||||
let apps_by_id: std::collections::HashMap<i64, gotify::GotifyApplication> =
|
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 timer = slint::Timer::default();
|
||||||
let model_for_drain = shared_model.clone();
|
let model_for_drain = shared_model.clone();
|
||||||
let state_for_drain = state_rc.clone();
|
let state_for_drain = state_rc.clone();
|
||||||
// Phase 7 C1: tray "New messages: N" counter. The tray owns
|
let tray_for_drain = tray_arc.clone();
|
||||||
// the Arc<TrayState>; we capture the cell here so the UI
|
let tray_state_for_drain = tray_state_for_ws.clone();
|
||||||
// drain can bump the atomic once the tray has populated the
|
let ui_handle_for_drain = ui.as_weak();
|
||||||
// cell (post-init, below).
|
|
||||||
let tray_state_for_drain = tray_state_cell.clone();
|
|
||||||
timer.start(
|
timer.start(
|
||||||
slint::TimerMode::Repeated,
|
slint::TimerMode::Repeated,
|
||||||
std::time::Duration::from_millis(100),
|
std::time::Duration::from_millis(100),
|
||||||
move || {
|
move || {
|
||||||
|
// Drain the WS channel.
|
||||||
loop {
|
loop {
|
||||||
match rx.try_recv() {
|
match rx.try_recv() {
|
||||||
Ok(item) => {
|
Ok(item) => {
|
||||||
@@ -227,17 +343,95 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
s.last_message_id = id;
|
s.last_message_id = id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Bump the tray counter so the menu
|
// Phase 7 C1: bump the tray counter so
|
||||||
// "New messages: N" row refreshes.
|
// the menu "New messages: N" row
|
||||||
if let Some(ref ts) = *tray_state_for_drain.borrow() {
|
// refreshes.
|
||||||
|
if let Some(ref ts) = tray_state_for_drain {
|
||||||
ts.new_messages
|
ts.new_messages
|
||||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
.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);
|
model_for_drain.push(item);
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
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)
|
Some(timer)
|
||||||
@@ -250,123 +444,63 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
ui.on_test_notification_clicked(|| {
|
ui.on_test_notification_clicked(|| {
|
||||||
tracing::info!("empty-state: Test Notification clicked (stub)");
|
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| {
|
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 ───────
|
// ── Phase 6 C3: URL opener import kept alive even when not on
|
||||||
// The tray owns a hidden helper window and a dedicated message-
|
// Windows — the cfg(windows) gate above handles the actual use.
|
||||||
// pump thread. On top of the Phase 6 C3 Open/Quit menu we now
|
// (Kept here so future cross-platform polish knows where to hook.) ──
|
||||||
// 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.
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
let _tray = {
|
let _ = crate::windows::url::open;
|
||||||
let ui_handle_for_open = ui.as_weak();
|
|
||||||
let ui_handle_for_settings = ui.as_weak();
|
// ── Phase 3 C2 + 7 C2: Settings → Connection → Test button ───
|
||||||
let ui_handle_for_restart = ui.as_weak();
|
ui.on_test_connection_requested({
|
||||||
let tray_state_for_pause_init = tray_state_cell.clone();
|
let ui_handle = ui.as_weak();
|
||||||
let cfg_for_pause_init = config::ActiveConfig::load().ok();
|
let model_for_test = shared_model.clone();
|
||||||
TrayIcon::new(TrayCallbacks {
|
move || {
|
||||||
on_open: Box::new(move || {
|
tracing::info!("settings: Test Connection clicked");
|
||||||
// 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) =
|
let Some(server) =
|
||||||
config::ActiveConfig::load().ok().and_then(|c| c.server)
|
config::ActiveConfig::load().ok().and_then(|c| c.server)
|
||||||
else {
|
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()
|
|
||||||
|
|
||||||
// ── 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 ───────────
|
|
||||||
ui.on_test_connection_requested({
|
|
||||||
let ui_handle = ui.as_weak();
|
|
||||||
move || {
|
|
||||||
tracing::info!("settings: Test Connection clicked");
|
|
||||||
let Some(server) = config::ActiveConfig::load().ok().and_then(|c| c.server) else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match models::try_fetch_initial_data(&server.base_url, &server.token, None) {
|
match models::try_fetch_initial_data(&server.base_url, &server.token, None) {
|
||||||
Some((apps, msgs, text)) => {
|
Some((apps, msgs, text)) => {
|
||||||
|
model_for_test.set_vec(
|
||||||
|
models::gotify_messages_to_items(&msgs, &apps),
|
||||||
|
);
|
||||||
if let Some(ui) = ui_handle.upgrade() {
|
if let Some(ui) = ui_handle.upgrade() {
|
||||||
ui.set_messages(models::build_message_model(&msgs, &apps));
|
|
||||||
ui.set_status_text(text.into());
|
ui.set_status_text(text.into());
|
||||||
ui.set_is_connected(true);
|
ui.set_is_connected(true);
|
||||||
}
|
}
|
||||||
@@ -399,34 +533,44 @@ pub fn run() -> error::AppResult<()> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Phase 6 C2: every other SettingsPage toggle (notifications,
|
// ── Phase 6 C2 + 7 C1/C2: every other SettingsPage toggle
|
||||||
// startup) routes through one callback. We dispatch on the `label`
|
// (notifications, startup) routes through one callback. We
|
||||||
// string the UI hands us, persist into `config.json`, and for
|
// dispatch on the `label` string the UI hands us, persist into
|
||||||
// `start_with_windows` we also write HKCU Run (Phase 6 C1).
|
// `config.json`, and for `start_with_windows` we also write HKCU
|
||||||
ui.on_setting_toggled(|label, value| {
|
// Run (Phase 6 C1). Phase 7 C1/C2 also mirrors the relevant
|
||||||
// Phase 6 C2 also: AppConfig is mutated through a fresh load +
|
// notification toggles into the tray state's cached atomics so
|
||||||
// save because the toggle handler is a `Fn` (no captured
|
// the WS drain's toast gating stays in sync without a restart. ──
|
||||||
// mutex); this keeps the persistence path side-effect-only and
|
let tray_state_for_settings = tray_state_for_ws.clone();
|
||||||
// doesn't risk racing with the close-requested save below.
|
ui.on_setting_toggled(move |label, value| {
|
||||||
let mut cfg = match storage::load_config() {
|
let mut cfg = storage::load_config();
|
||||||
c => c,
|
|
||||||
};
|
|
||||||
let label_str = label.to_string();
|
let label_str = label.to_string();
|
||||||
let label_ref: &str = &label_str;
|
let label_ref: &str = &label_str;
|
||||||
match label_ref {
|
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,
|
"sound" => cfg.sound = value,
|
||||||
"notify_high" => cfg.notify_high = value,
|
"notify_high" => {
|
||||||
"notify_max" => cfg.notify_max = value,
|
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,
|
"start_minimized" => cfg.start_minimized = value,
|
||||||
"close_to_tray" => {
|
"close_to_tray" => {
|
||||||
cfg.close_to_tray = value;
|
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" => {
|
"start_with_windows" => {
|
||||||
cfg.start_with_windows = value;
|
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()
|
ui.run()
|
||||||
.map_err(|e| error::AppError::Ui(e.to_string()))
|
.map_err(|e| error::AppError::Ui(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|||||||
+158
-8
@@ -27,17 +27,17 @@ use windows_sys::Win32::Foundation::{
|
|||||||
};
|
};
|
||||||
use windows_sys::Win32::System::Threading::GetCurrentThreadId;
|
use windows_sys::Win32::System::Threading::GetCurrentThreadId;
|
||||||
use windows_sys::Win32::UI::Shell::{
|
use windows_sys::Win32::UI::Shell::{
|
||||||
Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE,
|
Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE,
|
||||||
NOTIFYICONDATAW,
|
NIM_MODIFY, NOTIFYICONDATAW,
|
||||||
};
|
};
|
||||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||||
AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyWindow,
|
AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyWindow,
|
||||||
DispatchMessageW, GetCursorPos, GetMessageW, GetWindowLongPtrW, RegisterClassExW,
|
DispatchMessageW, GetCursorPos, GetMessageW, GetWindowLongPtrW, PostMessageW,
|
||||||
RegisterWindowMessageW, SendMessageW, SetForegroundWindow, SetWindowLongPtrW,
|
RegisterClassExW, RegisterWindowMessageW, SendMessageW, SetForegroundWindow,
|
||||||
TrackPopupMenuEx, TranslateMessage, CS_HREDRAW, CW_USEDEFAULT, GWLP_USERDATA,
|
SetWindowLongPtrW, TrackPopupMenuEx, TranslateMessage, CS_HREDRAW, CW_USEDEFAULT,
|
||||||
MF_CHECKED, MF_GRAYED, MF_SEPARATOR, MF_STRING, MSG, TPM_BOTTOMALIGN, TPM_LEFTALIGN,
|
GWLP_USERDATA, MF_CHECKED, MF_GRAYED, MF_SEPARATOR, MF_STRING, MSG, TPM_BOTTOMALIGN,
|
||||||
TPM_RETURNCMD, WM_COMMAND, WM_DESTROY, WM_LBUTTONDBLCLK, WM_RBUTTONUP, WM_USER,
|
TPM_LEFTALIGN, TPM_RETURNCMD, WM_COMMAND, WM_DESTROY, WM_LBUTTONDBLCLK, WM_RBUTTONUP,
|
||||||
WNDCLASSEXW, WS_EX_TOOLWINDOW,
|
WM_USER, WNDCLASSEXW, WS_EX_TOOLWINDOW,
|
||||||
};
|
};
|
||||||
use windows_sys::core::GUID;
|
use windows_sys::core::GUID;
|
||||||
|
|
||||||
@@ -48,6 +48,19 @@ const IDM_SETTINGS: u16 = 4;
|
|||||||
const IDM_RESTART: u16 = 5;
|
const IDM_RESTART: u16 = 5;
|
||||||
const IDM_QUIT: u16 = 6;
|
const IDM_QUIT: u16 = 6;
|
||||||
const WM_TRAYICON: u32 = WM_USER + 1;
|
const WM_TRAYICON: u32 = WM_USER + 1;
|
||||||
|
// Phase 7 C2: posted from any thread to ask the tray's WndProc to
|
||||||
|
// raise a toast via NIM_MODIFY. Payload is a heap-allocated
|
||||||
|
// `ToastPayload`; the WndProc reconstructs the Box and drops it.
|
||||||
|
const WM_SHOW_TOAST: u32 = WM_USER + 2;
|
||||||
|
// Windows 10+ toast severity flags (Win32 NIIF_* constants —
|
||||||
|
// windows-sys 0.59 doesn't re-export the names, so we hard-code).
|
||||||
|
// `pub` so `lib.rs` can pick a severity based on Gotify priority
|
||||||
|
// without re-hard-coding the bit patterns.
|
||||||
|
pub const NIIF_NONE: u32 = 0x0000_0000;
|
||||||
|
pub const NIIF_INFO: u32 = 0x0000_0001;
|
||||||
|
pub const NIIF_WARNING: u32 = 0x0000_0002;
|
||||||
|
pub const NIIF_ERROR: u32 = 0x0000_0003;
|
||||||
|
pub const NIIF_NOSOUND: u32 = 0x0000_0010;
|
||||||
const TRAY_CLASS_NAME: &str = "WindowsGotifyClientRust.TrayHidden\0";
|
const TRAY_CLASS_NAME: &str = "WindowsGotifyClientRust.TrayHidden\0";
|
||||||
const TRAY_TIP: &str = "Windows Gotify Client\0";
|
const TRAY_TIP: &str = "Windows Gotify Client\0";
|
||||||
// Wide-char constant for RegisterWindowMessageW. `RegisterWindowMessageW`
|
// Wide-char constant for RegisterWindowMessageW. `RegisterWindowMessageW`
|
||||||
@@ -61,6 +74,22 @@ const ACTIVATE_INSTANCE_MSG_NAME: &str = "WindowsGotifyClientRust.ActivateInstan
|
|||||||
pub struct TrayState {
|
pub struct TrayState {
|
||||||
pub paused: AtomicBool,
|
pub paused: AtomicBool,
|
||||||
pub new_messages: AtomicU32,
|
pub new_messages: AtomicU32,
|
||||||
|
/// Phase 7 C2: cached "show desktop notifications" master toggle
|
||||||
|
/// so the WS drain's toast gating can read it lock-free without
|
||||||
|
/// a config reload per message. `lib.rs` writes here from the
|
||||||
|
/// SettingsPage toggle callback.
|
||||||
|
pub desktop_notifications: AtomicBool,
|
||||||
|
/// Phase 7 C2: cached "notify on High priority" toggle.
|
||||||
|
pub notify_high: AtomicBool,
|
||||||
|
/// Phase 7 C2: cached "notify on Max priority" toggle.
|
||||||
|
pub notify_max: AtomicBool,
|
||||||
|
/// Phase 7 C2: "Restart connection" request flag. The tray's
|
||||||
|
/// `on_restart` callback sets it from the tray thread; the UI
|
||||||
|
/// drain timer (which runs on the UI thread) swaps it back to
|
||||||
|
/// `false` and triggers the actual refetch. Decouples the tray
|
||||||
|
/// callback from the UI-bound `Rc<VecModel<…>>` so the closure
|
||||||
|
/// can stay Send + Sync.
|
||||||
|
pub restart_requested: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TrayState {
|
impl TrayState {
|
||||||
@@ -68,6 +97,10 @@ impl TrayState {
|
|||||||
Self {
|
Self {
|
||||||
paused: AtomicBool::new(false),
|
paused: AtomicBool::new(false),
|
||||||
new_messages: AtomicU32::new(0),
|
new_messages: AtomicU32::new(0),
|
||||||
|
desktop_notifications: AtomicBool::new(true),
|
||||||
|
notify_high: AtomicBool::new(true),
|
||||||
|
notify_max: AtomicBool::new(true),
|
||||||
|
restart_requested: AtomicBool::new(false),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -264,6 +297,34 @@ impl TrayIcon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 7 C2: show a desktop toast for an incoming Gotify message.
|
||||||
|
/// `title` becomes `szInfoTitle`, `body` becomes `szInfo`,
|
||||||
|
/// `icon_flags` is one of the NIIF_* constants. Safe to call from
|
||||||
|
/// any thread — we heap-allocate the payload and PostMessageW it
|
||||||
|
/// to the tray's helper window so the actual `Shell_NotifyIconW`
|
||||||
|
/// call (which must happen on the registration thread) runs in the
|
||||||
|
/// WndProc.
|
||||||
|
pub fn show_toast(&self, title: &str, body: &str, icon_flags: u32) {
|
||||||
|
if self.hwnd.is_null() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let payload = Box::new(ToastPayload {
|
||||||
|
title: title.to_string(),
|
||||||
|
body: body.to_string(),
|
||||||
|
icon_flags,
|
||||||
|
});
|
||||||
|
let ptr = Box::into_raw(payload) as isize;
|
||||||
|
// SAFETY: PostMessageW queues the message; the WndProc owns
|
||||||
|
// the only reference to the heap-allocated payload and will
|
||||||
|
// reconstruct + drop it. If the WndProc never runs (e.g. the
|
||||||
|
// tray was just dropped and the helper HWND is destroyed),
|
||||||
|
// we leak the Box — that's a hard-to-trigger edge case we
|
||||||
|
// accept for V1.
|
||||||
|
unsafe {
|
||||||
|
PostMessageW(self.hwnd, WM_SHOW_TOAST, 0, ptr as LPARAM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn activate_msg_id_for_send(&self) -> u32 {
|
fn activate_msg_id_for_send(&self) -> u32 {
|
||||||
// Re-resolve the registered message id in the calling
|
// Re-resolve the registered message id in the calling
|
||||||
// process. The system-wide table returns the same u32 that
|
// process. The system-wide table returns the same u32 that
|
||||||
@@ -322,6 +383,14 @@ struct TrayShared {
|
|||||||
state: Arc<TrayState>,
|
state: Arc<TrayState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Heap-allocated payload for the WM_SHOW_TOAST posted message. Lives
|
||||||
|
/// only long enough to be reconstructed by the WndProc and dropped.
|
||||||
|
struct ToastPayload {
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
icon_flags: u32,
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: convert a Rust `&str` into a UTF-16 buffer for menu item
|
// Helper: convert a Rust `&str` into a UTF-16 buffer for menu item
|
||||||
// text. The caller appends the null terminator implicitly via
|
// text. The caller appends the null terminator implicitly via
|
||||||
// `chain(once(0))`.
|
// `chain(once(0))`.
|
||||||
@@ -383,6 +452,21 @@ unsafe extern "system" fn wnd_proc(
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if msg == WM_SHOW_TOAST {
|
||||||
|
// Phase 7 C2: show a desktop toast via NIM_MODIFY. Reconstruct
|
||||||
|
// the heap payload from LPARAM and drop it before returning.
|
||||||
|
let ptr = lparam as isize;
|
||||||
|
if ptr != 0 {
|
||||||
|
// SAFETY: `ptr` came from `Box::into_raw` in `show_toast`
|
||||||
|
// and we own the only reference (the WndProc is
|
||||||
|
// single-threaded for this message). Reconstructing the
|
||||||
|
// Box drops the String buffers when this scope ends.
|
||||||
|
let payload = unsafe { Box::from_raw(ptr as *mut ToastPayload) };
|
||||||
|
show_toast(hwnd, &payload);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
match msg {
|
match msg {
|
||||||
WM_TRAYICON => {
|
WM_TRAYICON => {
|
||||||
let event = lparam as u32;
|
let event = lparam as u32;
|
||||||
@@ -544,3 +628,69 @@ fn show_context_menu(hwnd: HWND, shared: &TrayShared) {
|
|||||||
// doesn't need it but the import documents the intended surface.
|
// doesn't need it but the import documents the intended surface.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn _mutex_keepalive(_m: &Mutex<()>) {}
|
fn _mutex_keepalive(_m: &Mutex<()>) {}
|
||||||
|
|
||||||
|
/// Phase 7 C2: render a single Gotify message as a desktop toast
|
||||||
|
/// via `Shell_NotifyIconW` with `NIF_INFO`. On Windows 10+ this is
|
||||||
|
/// routed through Action Center and shows up exactly like a modern
|
||||||
|
/// toast; on older Windows it falls back to a classic balloon. The
|
||||||
|
/// WndProc owns the call so it runs on the same thread that called
|
||||||
|
/// `NIM_ADD` — a hard Win32 requirement that the heap-allocated
|
||||||
|
/// payload dance in `show_toast` is structured around.
|
||||||
|
fn show_toast(hwnd: HWND, payload: &ToastPayload) {
|
||||||
|
let mut nid = NOTIFYICONDATAW {
|
||||||
|
cbSize: std::mem::size_of::<NOTIFYICONDATAW>() as u32,
|
||||||
|
hWnd: hwnd,
|
||||||
|
uID: 1,
|
||||||
|
// Re-declare every flag every time — NIM_MODIFY only honors
|
||||||
|
// the fields whose bits are set in `uFlags`. We set
|
||||||
|
// NIF_TIP|NIF_INFO so the OS picks up the existing tip AND
|
||||||
|
// the new info text. NIF_ICON is left alone: we don't ship
|
||||||
|
// a custom icon yet (TODO Phase 7 C5) so reusing whatever
|
||||||
|
// was registered at NIM_ADD keeps the visual consistent.
|
||||||
|
uFlags: NIF_INFO | NIF_TIP,
|
||||||
|
uCallbackMessage: 0,
|
||||||
|
hIcon: std::ptr::null_mut(),
|
||||||
|
szTip: [0u16; 128],
|
||||||
|
szInfo: [0u16; 256],
|
||||||
|
Anonymous: unsafe { std::mem::zeroed() },
|
||||||
|
szInfoTitle: [0u16; 64],
|
||||||
|
dwState: 0,
|
||||||
|
dwStateMask: 0,
|
||||||
|
dwInfoFlags: payload.icon_flags,
|
||||||
|
guidItem: GUID { data1: 0, data2: 0, data3: 0, data4: [0; 8] },
|
||||||
|
hBalloonIcon: std::ptr::null_mut(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Truncate aggressively: szInfoTitle is 64 wide chars (incl. the
|
||||||
|
// null terminator), szInfo is 256.
|
||||||
|
copy_wide_trunc(&payload.title, &mut nid.szInfoTitle);
|
||||||
|
copy_wide_trunc(&payload.body, &mut nid.szInfo);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let ok = Shell_NotifyIconW(NIM_MODIFY, &nid);
|
||||||
|
if ok == 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
title = %payload.title,
|
||||||
|
"tray: Shell_NotifyIconW NIM_MODIFY returned 0 (toast failed)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTF-16 encode `s` into `dst`, truncating to `dst.len() - 1`
|
||||||
|
/// characters and null-terminating so Win32 treats it as a C-style
|
||||||
|
/// wide string.
|
||||||
|
fn copy_wide_trunc(s: &str, dst: &mut [u16]) {
|
||||||
|
debug_assert!(!dst.is_empty());
|
||||||
|
let max = dst.len() - 1; // leave room for the null terminator
|
||||||
|
for (i, ch) in s.encode_utf16().take(max).enumerate() {
|
||||||
|
dst[i] = ch;
|
||||||
|
}
|
||||||
|
// Fill the rest with zeros so the buffer is "null-terminated"
|
||||||
|
// for Win32 readers that look at the trailing bytes (some shell
|
||||||
|
// versions do, surprisingly).
|
||||||
|
let written = s.encode_utf16().count().min(max);
|
||||||
|
for slot in &mut dst[written..] {
|
||||||
|
*slot = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user