mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
839c438a6d
* fix(cover): sanitize server_index_key on disk so Windows accepts ":port" URLs `serverIndexKeyFromUrl` (frontend) strips the URL scheme and leaves the rest of the host as the index key — for a Navidrome instance running on the default `:4533` port that is `host:4533/...`. On Linux/macOS the `:` is fine as a path segment; on Windows `CreateDirectory` rejects the whole path with `ERROR_INVALID_NAME` (os error 123). Result: every `cover_cache_ensure` and `cover_cache_peek_batch` rejected its promise and the album / now-playing / mainstage / lightbox surfaces stayed blank. Empirically verified — switching the active server to a colon-free reverse-proxy URL made the covers load again without any other change. Centralize the fix in a new `cover_server_dir(root, key)` helper next to the existing `cover_entity_relative_dir`: it runs `sanitize_path_segment` on the server key the same way kind/entity ids are already cleaned, so `host:4533` becomes `host_4533` and embedded URL paths collapse into one flat bucket instead of nested directories. Every call site that wants the server bucket — `cover_dir`, `count_cached_cover_ids`, `dir_usage_for_server`, the clear-server command, and `clear_cover_fetch_failures` in the backfill worker — now goes through it. The on-disk layout changes (no more colons, no more nested URL paths), so bump `LAYOUT_STAMP` to `canonical-segment-v4`. The existing stamp-mismatch sweep at startup wipes the legacy buckets — users with a previously-working (colon-free) layout rebuild the cache lazily as they browse. Library, offline, and hot-cache data are not touched. Adds two unit tests covering the sanitization on `cover_server_dir` and the `cover_dir` passthrough. Follow-up to #878 (which introduced `cover_cache_layout.rs` with `sanitize_path_segment` applied only to kind/entity_id). * chore(windows): silence dead_code warnings in debug taskbar_win build `lib.rs` gates `taskbar_win::init` on `cfg(not(debug_assertions))` (PR #866 — debug runs alongside an installed release instance and must not fight it for the taskbar subclass). The `update_taskbar_icon` command still ships in debug and early-returns until `init` populates the COM/HWND atomics, so the init-only helpers (icon HICONs, button IDs, subclass plumbing, `make_buttons`, `subclass_proc`, `init` itself) all look unused — 14 dead_code warnings on every Windows debug `cargo build`. File-level `#![cfg_attr(debug_assertions, allow(dead_code))]` suppresses those warnings only in the debug profile. Release builds keep the strict dead-code check, so a real removal would still surface there. * docs(release): CHANGELOG for windows cover-cache server-key fix (PR #889)
298 lines
11 KiB
Rust
298 lines
11 KiB
Rust
//! Windows Taskbar Thumbnail Toolbar (ITaskbarList3::ThumbBarAddButtons).
|
|
//!
|
|
//! Adds Prev / Play-Pause / Next buttons to the taskbar thumbnail preview.
|
|
//! Button clicks are intercepted via SetWindowSubclass and routed to the same
|
|
//! `media:prev`, `media:play-pause`, `media:next` events as souvlaki / tray.
|
|
//!
|
|
//! `init` is only called in release builds (lib.rs gates it on
|
|
//! `not(debug_assertions)` so a `tauri dev` run does not fight an installed
|
|
//! release instance for the taskbar subclass — see PR #866). In debug builds
|
|
//! the init-only helpers therefore look unused; the file-level cfg_attr
|
|
//! suppresses dead-code warnings just for that profile and keeps release
|
|
//! strict.
|
|
#![cfg_attr(debug_assertions, allow(dead_code))]
|
|
|
|
use std::sync::atomic::{AtomicIsize, Ordering};
|
|
|
|
use tauri::{AppHandle, Emitter};
|
|
use windows::{
|
|
Win32::{
|
|
Foundation::{HWND, LPARAM, LRESULT, WPARAM},
|
|
System::Com::{
|
|
CoCreateInstance, CoInitializeEx, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED,
|
|
},
|
|
UI::{
|
|
Shell::{
|
|
DefSubclassProc, ITaskbarList3, RemoveWindowSubclass, SetWindowSubclass,
|
|
TaskbarList, THUMBBUTTON, THUMBBUTTONFLAGS, THUMBBUTTONMASK, THBN_CLICKED,
|
|
THB_FLAGS, THB_ICON, THB_TOOLTIP,
|
|
},
|
|
WindowsAndMessaging::{
|
|
CreateIconFromResourceEx, DestroyIcon, HICON, LR_DEFAULTCOLOR,
|
|
WM_COMMAND, WM_NCDESTROY,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
// ── Embedded ICO assets ──────────────────────────────────────────────────────
|
|
|
|
static PREV_ICO: &[u8] = include_bytes!("../icons/windows/prev.ico");
|
|
static PLAY_ICO: &[u8] = include_bytes!("../icons/windows/play.ico");
|
|
static PAUSE_ICO: &[u8] = include_bytes!("../icons/windows/pause.ico");
|
|
static NEXT_ICO: &[u8] = include_bytes!("../icons/windows/next.ico");
|
|
|
|
// Button IDs — arbitrary u32 values, must fit in WPARAM low-word.
|
|
const BTN_PREV: u32 = 0xE001;
|
|
const BTN_PLAY: u32 = 0xE002;
|
|
const BTN_NEXT: u32 = 0xE003;
|
|
|
|
// Unique subclass ID.
|
|
const SUBCLASS_ID: usize = 0xC0DE_7A8B;
|
|
|
|
// Raw pointers kept as atomics so `update_taskbar_icon` can reach the
|
|
// COM object and icons without managed state.
|
|
static TASKBAR_PTR: AtomicIsize = AtomicIsize::new(0);
|
|
static HWND_VAL: AtomicIsize = AtomicIsize::new(0);
|
|
|
|
// All four HICONs stored for WM_NCDESTROY cleanup and play/pause swapping.
|
|
static HICON_PREV: AtomicIsize = AtomicIsize::new(0);
|
|
static HICON_PLAY: AtomicIsize = AtomicIsize::new(0);
|
|
static HICON_PAUSE: AtomicIsize = AtomicIsize::new(0);
|
|
static HICON_NEXT: AtomicIsize = AtomicIsize::new(0);
|
|
|
|
// ── ICO resource loader ──────────────────────────────────────────────────────
|
|
|
|
/// Load the best-match image from a raw `.ico` file in memory and return an HICON.
|
|
///
|
|
/// Parses the ICO directory to pick the entry with the highest bit depth
|
|
/// (32 bpp = true-colour + alpha), then passes the image bits directly to
|
|
/// `CreateIconFromResourceEx`.
|
|
///
|
|
/// Note: `LookupIconIdFromDirectoryEx` operates on Win32 *resource* group-icon
|
|
/// format (GRPICONDIR), not raw `.ico` files, so we parse the ICO header ourselves.
|
|
unsafe fn load_icon_from_memory(bytes: &[u8]) -> HICON {
|
|
// ICO file layout:
|
|
// ICONDIR : reserved(2) + type(2) + count(2)
|
|
// ICONDIRENTRY[] : width(1) height(1) color_count(1) reserved(1)
|
|
// planes(2) bit_count(2) bytes_in_res(4) image_offset(4)
|
|
if bytes.len() < 6 {
|
|
return HICON::default();
|
|
}
|
|
let count = u16::from_le_bytes([bytes[4], bytes[5]]) as usize;
|
|
if count == 0 || bytes.len() < 6 + count * 16 {
|
|
return HICON::default();
|
|
}
|
|
|
|
// Pick the entry with the highest bit depth; 32 bpp carries alpha.
|
|
let mut best_idx = 0usize;
|
|
let mut best_bpp = 0u16;
|
|
for i in 0..count {
|
|
let base = 6 + i * 16;
|
|
let bpp = u16::from_le_bytes([bytes[base + 6], bytes[base + 7]]);
|
|
if bpp >= best_bpp {
|
|
best_bpp = bpp;
|
|
best_idx = i;
|
|
}
|
|
}
|
|
|
|
let entry = &bytes[6 + best_idx * 16..];
|
|
let img_size = u32::from_le_bytes(entry[8..12].try_into().unwrap_or([0; 4]));
|
|
let img_offset = u32::from_le_bytes(entry[12..16].try_into().unwrap_or([0; 4])) as usize;
|
|
|
|
if img_size == 0 || img_offset + img_size as usize > bytes.len() {
|
|
return HICON::default();
|
|
}
|
|
|
|
CreateIconFromResourceEx(
|
|
&bytes[img_offset..img_offset + img_size as usize],
|
|
true, // fIcon = TRUE
|
|
0x0003_0000, // dwVer = 3.0 (required by the API)
|
|
0, 0, // cxDesired / cyDesired — 0 lets the system choose
|
|
LR_DEFAULTCOLOR,
|
|
)
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
// ── Button descriptors ───────────────────────────────────────────────────────
|
|
|
|
fn copy_tip(dest: &mut [u16], src: &str) {
|
|
let wide: Vec<u16> = src.encode_utf16().chain(std::iter::once(0)).collect();
|
|
let len = wide.len().min(dest.len());
|
|
dest[..len].copy_from_slice(&wide[..len]);
|
|
}
|
|
|
|
unsafe fn make_buttons(
|
|
h_prev: HICON,
|
|
h_play: HICON,
|
|
h_next: HICON,
|
|
) -> [THUMBBUTTON; 3] {
|
|
let mask = THUMBBUTTONMASK(THB_ICON.0 | THB_TOOLTIP.0 | THB_FLAGS.0);
|
|
let flags = THUMBBUTTONFLAGS(0); // THBF_ENABLED
|
|
|
|
let mut prev = THUMBBUTTON {
|
|
dwMask: mask,
|
|
iId: BTN_PREV,
|
|
hIcon: h_prev,
|
|
dwFlags: flags,
|
|
..Default::default()
|
|
};
|
|
copy_tip(&mut prev.szTip, "Previous");
|
|
|
|
let mut play = THUMBBUTTON {
|
|
dwMask: mask,
|
|
iId: BTN_PLAY,
|
|
hIcon: h_play,
|
|
dwFlags: flags,
|
|
..Default::default()
|
|
};
|
|
copy_tip(&mut play.szTip, "Play");
|
|
|
|
let mut next = THUMBBUTTON {
|
|
dwMask: mask,
|
|
iId: BTN_NEXT,
|
|
hIcon: h_next,
|
|
dwFlags: flags,
|
|
..Default::default()
|
|
};
|
|
copy_tip(&mut next.szTip, "Next");
|
|
|
|
[prev, play, next]
|
|
}
|
|
|
|
// ── WndProc subclass ─────────────────────────────────────────────────────────
|
|
|
|
struct SubclassData {
|
|
app: AppHandle,
|
|
}
|
|
|
|
unsafe extern "system" fn subclass_proc(
|
|
hwnd: HWND,
|
|
msg: u32,
|
|
wparam: WPARAM,
|
|
lparam: LPARAM,
|
|
_uid: usize,
|
|
data: usize,
|
|
) -> LRESULT {
|
|
if msg == WM_COMMAND {
|
|
let hi = (wparam.0 >> 16) as u32;
|
|
let lo = (wparam.0 & 0xFFFF) as u32;
|
|
if hi == THBN_CLICKED {
|
|
if data != 0 {
|
|
let state = &*(data as *const SubclassData);
|
|
let _ = match lo {
|
|
x if x == BTN_PREV => state.app.emit("media:prev", ()),
|
|
x if x == BTN_PLAY => state.app.emit("media:play-pause", ()),
|
|
x if x == BTN_NEXT => state.app.emit("media:next", ()),
|
|
_ => Ok(()),
|
|
};
|
|
}
|
|
return LRESULT(0);
|
|
}
|
|
}
|
|
|
|
if msg == WM_NCDESTROY {
|
|
let _ = RemoveWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID);
|
|
if data != 0 {
|
|
drop(Box::from_raw(data as *mut SubclassData));
|
|
}
|
|
let raw = TASKBAR_PTR.swap(0, Ordering::SeqCst);
|
|
if raw != 0 {
|
|
drop(Box::from_raw(raw as *mut ITaskbarList3));
|
|
}
|
|
HWND_VAL.store(0, Ordering::SeqCst);
|
|
// Destroy all stored HICONs.
|
|
for cell in [&HICON_PREV, &HICON_PLAY, &HICON_PAUSE, &HICON_NEXT] {
|
|
let h = cell.swap(0, Ordering::SeqCst);
|
|
if h != 0 { let _ = DestroyIcon(HICON(h as *mut _)); }
|
|
}
|
|
}
|
|
|
|
DefSubclassProc(hwnd, msg, wparam, lparam)
|
|
}
|
|
|
|
// ── Public init ──────────────────────────────────────────────────────────────
|
|
|
|
pub fn init(app: &AppHandle, hwnd_raw: isize) {
|
|
unsafe {
|
|
let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED);
|
|
|
|
let hwnd = HWND(hwnd_raw as *mut _);
|
|
|
|
let taskbar: ITaskbarList3 = match CoCreateInstance(
|
|
&TaskbarList, None, CLSCTX_INPROC_SERVER,
|
|
) {
|
|
Ok(t) => t,
|
|
Err(e) => { crate::app_eprintln!("[psysonic] taskbar: CoCreateInstance failed: {e}"); return; }
|
|
};
|
|
|
|
if let Err(e) = taskbar.HrInit() {
|
|
crate::app_eprintln!("[psysonic] taskbar: HrInit failed: {e}");
|
|
return;
|
|
}
|
|
|
|
let h_prev = load_icon_from_memory(PREV_ICO);
|
|
let h_play = load_icon_from_memory(PLAY_ICO);
|
|
let h_pause = load_icon_from_memory(PAUSE_ICO);
|
|
let h_next = load_icon_from_memory(NEXT_ICO);
|
|
|
|
// Store all HICONs for cleanup and play/pause swapping.
|
|
HICON_PREV .store(h_prev .0 as isize, Ordering::SeqCst);
|
|
HICON_PLAY .store(h_play .0 as isize, Ordering::SeqCst);
|
|
HICON_PAUSE.store(h_pause.0 as isize, Ordering::SeqCst);
|
|
HICON_NEXT .store(h_next .0 as isize, Ordering::SeqCst);
|
|
|
|
let buttons = make_buttons(h_prev, h_play, h_next);
|
|
if let Err(e) = taskbar.ThumbBarAddButtons(hwnd, &buttons) {
|
|
crate::app_eprintln!("[psysonic] taskbar: ThumbBarAddButtons failed: {e}");
|
|
return;
|
|
}
|
|
|
|
let raw = Box::into_raw(Box::new(taskbar));
|
|
TASKBAR_PTR.store(raw as isize, Ordering::SeqCst);
|
|
HWND_VAL .store(hwnd_raw, Ordering::SeqCst);
|
|
|
|
let data = Box::into_raw(Box::new(SubclassData { app: app.clone() }));
|
|
if !SetWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID, data as usize).as_bool() {
|
|
crate::app_eprintln!("[psysonic] taskbar: SetWindowSubclass failed");
|
|
drop(Box::from_raw(data));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Tauri command ────────────────────────────────────────────────────────────
|
|
|
|
#[tauri::command]
|
|
pub fn update_taskbar_icon(is_playing: bool) {
|
|
let taskbar_raw = TASKBAR_PTR.load(Ordering::SeqCst);
|
|
let hwnd_raw = HWND_VAL .load(Ordering::SeqCst);
|
|
if taskbar_raw == 0 || hwnd_raw == 0 { return; }
|
|
|
|
let icon_raw = if is_playing {
|
|
HICON_PAUSE.load(Ordering::SeqCst)
|
|
} else {
|
|
HICON_PLAY.load(Ordering::SeqCst)
|
|
};
|
|
if icon_raw == 0 { return; }
|
|
|
|
unsafe {
|
|
let taskbar = &*(taskbar_raw as *const ITaskbarList3);
|
|
let hwnd = HWND(hwnd_raw as *mut _);
|
|
|
|
let mut btn = THUMBBUTTON {
|
|
dwMask: THUMBBUTTONMASK(THB_ICON.0 | THB_TOOLTIP.0 | THB_FLAGS.0),
|
|
iId: BTN_PLAY,
|
|
hIcon: HICON(icon_raw as *mut _),
|
|
dwFlags: THUMBBUTTONFLAGS(0),
|
|
..Default::default()
|
|
};
|
|
copy_tip(&mut btn.szTip, if is_playing { "Pause" } else { "Play" });
|
|
|
|
let btns = [btn];
|
|
if let Err(e) = taskbar.ThumbBarUpdateButtons(hwnd, &btns) {
|
|
crate::app_deprintln!("[psysonic] taskbar: ThumbBarUpdateButtons failed: {e}");
|
|
let _ = e;
|
|
}
|
|
}
|
|
}
|