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:
+90
-8
@@ -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");
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user