feat(windows): Phase 7 C1 — 托盘菜单增强 + 单实例激活现有窗口

- 托盘菜单加 4 项:New messages: N(灰)/ Pause notifications(勾选态)/ Settings.../ Restart connection
- 单实例:第二次启动通过 RegisterWindowMessageW 发激活请求,托盘 WndProc 收后调用 on_open(show+reset counter);main.rs AlreadyRunning 路径调用 activate_existing_instance
- AppConfig 加 notifications_paused 字段(与 desktop_notifications 区分,tray 暂停持久化)
- WS drain 通过 Rc<RefCell<Option<Arc<TrayState>>>> 与托盘共享 new_messages 计数器
- WndProc 在调用 on_open 前自己清零计数器,免去回调侧的 RefCell 捕获(保持 Send+Sync)
This commit is contained in:
2026-09-19 08:13:42 +08:00
parent 1df072356f
commit 2614909c4c
6 changed files with 420 additions and 63 deletions
+90 -8
View File
@@ -174,6 +174,15 @@ pub fn run() -> error::AppResult<()> {
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 ───────────
let drain_timer = if let Some((base_url, token, apps)) = ws_credential {
let (tx, rx) = mpsc::channel::<MessageItem>();
@@ -199,6 +208,11 @@ 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();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(100),
@@ -213,6 +227,12 @@ 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() {
ts.new_messages
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
model_for_drain.push(item);
}
Err(_) => break,
@@ -234,30 +254,92 @@ pub fn run() -> error::AppResult<()> {
tracing::debug!(idx, "message card clicked");
});
// ── Phase 6 C3: tray icon (Windows only) ──────────────────────
// ── Phase 7 C1: tray icon (Windows only) with full menu ───────
// The tray owns a hidden helper window and a dedicated message-
// pump thread. `Open` shows the window; `Quit` exits the
// process. State is not saved from the quit path on purpose: any
// `close-to-tray` close already wrote state.json, and the tray
// only runs after the main loop started, so a window-resize that
// happened *between* the last close and the quit click is the
// only data at risk (Phase 7 polish will route this through
// a proper invoke_from_event_loop if it matters).
// 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.
#[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");
})