diff --git a/crates/qbz-media-controls/src/notify.rs b/crates/qbz-media-controls/src/notify.rs index 99363e621..8412fba3c 100644 --- a/crates/qbz-media-controls/src/notify.rs +++ b/crates/qbz-media-controls/src/notify.rs @@ -87,9 +87,9 @@ fn build_body(meta: &NotificationMeta) -> String { lines.join("\n") } -// --- artwork cache (Linux + macOS) ------------------------------------------ +// --- artwork cache (Linux + macOS + Windows) ------------------------------------------ -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn artwork_cache_dir() -> Result { let dir = dirs::cache_dir() .ok_or_else(|| "Could not find cache directory".to_string())? @@ -99,7 +99,7 @@ fn artwork_cache_dir() -> Result { Ok(dir) } -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn resolve_local_artwork(url: &str) -> Option { if let Some(path) = url.strip_prefix("file://") { // file:// URLs built with url::Url::from_file_path (e.g. the shared @@ -109,6 +109,29 @@ fn resolve_local_artwork(url: &str) -> Option { let decoded = urlencoding::decode(path) .map(|c| c.into_owned()) .unwrap_or_else(|_| path.to_string()); + // `file:///C:/x` leaves `/C:/x` here: the empty authority's slash, in + // front of a drive letter. PathBuf keeps it and the file never + // resolves, so the toast silently shows no cover. Forward slashes + // after that are fine -- Windows accepts them. + // + // Additive: Linux and macOS never match this shape, and their full + // percent-decode above is left exactly as it was. It has to stay + // full: art_url reaches here from BOTH url::Url::from_file_path + // (which escapes spaces as %20) and fs_url::file_url (which escapes + // only % # ?), and only the wider decode reads both. + #[cfg(target_os = "windows")] + { + let b = decoded.as_bytes(); + if b.len() >= 3 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' { + return Some(PathBuf::from(&decoded[1..])); + } + // A non-empty authority is a UNC server: `file://nas/share/x` + // names `\\nas\share\x`. Without this it stays RELATIVE and + // resolves against the process working directory. + if !decoded.starts_with('/') && !decoded.is_empty() { + return Some(PathBuf::from(format!("//{decoded}"))); + } + } return Some(PathBuf::from(decoded)); } if let Some(path) = url.strip_prefix("asset://localhost/") { @@ -120,7 +143,7 @@ fn resolve_local_artwork(url: &str) -> Option { /// Shared blocking HTTP client (a fresh client per track leaks an fd → EMFILE /// over a long session — same reasoning as the Tauri image cache). -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn http_client() -> &'static reqwest::blocking::Client { static CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); CLIENT.get_or_init(|| { @@ -136,7 +159,7 @@ fn http_client() -> &'static reqwest::blocking::Client { /// `offline` = local paths + md5 cache hits only, never the HTTP download — /// the verdict is injected by the caller so this crate stays frontend-agnostic /// (no dependency on the app's offline-mode engine). -#[cfg(any(target_os = "linux", target_os = "macos"))] +#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] fn cache_artwork(url: &str, offline: bool) -> Result { use md5::{Digest, Md5}; use std::io::Write; @@ -307,7 +330,39 @@ pub async fn show_track_notification(meta: NotificationMeta, offline: bool) { .await; } - #[cfg(not(any(target_os = "linux", target_os = "macos")))] + #[cfg(target_os = "windows")] + { + let _ = tokio::task::spawn_blocking(move || { + let artwork_path = meta.art_url.as_deref().and_then(|url| { + match cache_artwork(url, offline) { + Ok(path) => Some(path), + Err(e) => { + log::debug!("[notify] could not cache artwork: {e}"); + None + } + } + }); + let mut notification = notify_rust::Notification::new(); + // app_id MUST match the AUMID set in main() and registered by the + // MSI, or Windows drops the toast without a word. notify-rust's + // Windows arm forwards summary/body/app_id/image and SILENTLY + // ignores `actions` (src/windows.rs), so no buttons are attempted + // here -- adding them would look implemented and do nothing. + notification + .summary(&meta.title) + .body(&body) + .app_id("com.blitzfc.qbz"); + if let Some(path) = artwork_path.as_ref().and_then(|p| p.to_str()) { + notification.image_path(path); + } + if let Err(e) = notification.show() { + log::warn!("[notify] Windows toast failed: {e}"); + } + }) + .await; + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] { let _ = body; let _ = offline; diff --git a/crates/qbz-qt/Cargo.toml b/crates/qbz-qt/Cargo.toml index c6020f16f..8d5924824 100644 --- a/crates/qbz-qt/Cargo.toml +++ b/crates/qbz-qt/Cargo.toml @@ -182,6 +182,12 @@ winresource = "0.1" # ⚠ NOT COMPILED OR TESTED HERE. This tree builds on Linux; the macOS arm is # behind cfg, so `cargo build` cannot see it. First real check is a macOS # build — the Mac mini (192.168.0.69) is where that happens. +[target.'cfg(target_os = "windows")'.dependencies] +# SetCurrentProcessExplicitAppUserModelID: the identity Windows groups the +# taskbar button under and matches a toast's app_id against. 0.61 is already +# in the workspace lock. +windows-sys = { version = "0.61", features = ["Win32_UI_Shell"] } + [target.'cfg(target_os = "macos")'.dependencies] # DIRECT objc2, added 2026-08-05 for the menu-bar tray: `declare_class!`, # `msg_send_id!`, `sel!`, `ClassType`, `DeclaredClass`, `mutability` and diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index fded8b454..73d3828a2 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -3409,6 +3409,30 @@ fn main() { } } + // The process identity Windows groups the taskbar button under, and the + // one a toast's `app_id` is matched against. Must be set BEFORE any window + // exists -- Windows caches the identity with the first one, and a later + // call is documented to fail. + // + // Without it the taskbar treats each launch as an anonymous window and the + // MSI's Start-menu shortcut (which carries System.AppUserModel.ID) does not + // group with it, and toasts are dropped with no error at all. + #[cfg(target_os = "windows")] + { + use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; + let id: Vec = "com.blitzfc.qbz\0".encode_utf16().collect(); + // SAFETY: `id` is NUL-terminated UTF-16 and outlives the call, which + // copies it. Called before any window is created, as documented. + let hr = unsafe { SetCurrentProcessExplicitAppUserModelID(id.as_ptr()) }; + if hr < 0 { + // Worth a line: when this fails, taskbar grouping and toast + // delivery both break, and both fail SILENTLY. Without this the + // only symptom is "notifications do not work" with nothing to + // point at. + log::warn!("[qbz-qt] SetCurrentProcessExplicitAppUserModelID failed: 0x{hr:08X}"); + } + } + // D7 (research/02 §4.4): with no explicit style Qt loads `Windows` on // Windows and `Fusion` on Linux — never Basic — and the popups without an // explicit `background:` inherit native chrome. Pin Basic so all three diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 35c257061..3bff479c7 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -2279,9 +2279,19 @@ pub async fn publish_snapshot() { .unwrap_or(false), is_macos: cfg!(target_os = "macos"), is_windows: cfg!(target_os = "windows"), - // When plan A-2 T3/T5 land, add target_os = "windows" to these - // two lists; those plans point at this line. + // Windows joins `tray_supported` when plan A-2 T5 lands; that + // plan points at this line. tray_supported: cfg!(any(target_os = "linux", target_os = "macos")), + // NOT Windows yet, even though the toast arm is implemented. + // An unpackaged desktop app only gets toasts when Windows can + // RESOLVE its AppUserModelID -- which needs a Start-menu shortcut + // carrying System.AppUserModel.ID, written by the MSI (plan A + // Task 14, not built yet). Calling + // SetCurrentProcessExplicitAppUserModelID is necessary and not + // sufficient. Until the MSI ships, a portable or dev build would + // show the row, the user would enable it, and nothing would ever + // appear -- the exact "renders and drives nothing" failure this + // flag exists to prevent. Flip it in the MSI task. system_notifications_supported: cfg!(any( target_os = "linux", target_os = "macos"