From a420855b5a48cab51313d4f0355f489d3c048cad Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 21:32:42 +0200 Subject: [PATCH] feat(tray): the Windows tray dresses for Windows 11 and announces connects The menu was a stock Win32 popup: light-mode on a dark taskbar, DPI-virtualized (blurry on every scaled laptop), no icons. Now the process opts into the system dark mode via the uxtheme ordinals (135/136 - the same undocumented calls Explorer/PowerToys/Notepad++ make; menus never got a documented opt-in, and there is no WinUI tray API to move to), a PerMonitorV2 manifest makes menu and icon crisp, the multi-size .ico serves the DPI-correct frame, and items carry Segoe Fluent glyph bitmaps - with the UAC shield on the elevated service actions, per Explorer's convention. Rounded corners come free on Windows 11. Everything degrades gracefully: missing ordinals mean the classic light menu, a missing icon font means no glyphs. New: a connect toast on the idle-to-streaming edge - title " connected" (from the summary's new client_name), body the mode ("Streaming 2560x1440 @ 120 fps") - via NIF_INFO, which Windows 11 renders as a native toast. The tray tags itself with the AUMID unom.punktfunk.tray and the installer registers it under Classes\AppUserModelId (DisplayName "Punktfunk" + the brand icon), so the toast is attributed to Punktfunk with the logo. Unregistered dev runs degrade to generic attribution, older hosts to a nameless title; a tray started mid-session never fires a stale toast. Co-Authored-By: Claude Fable 5 --- crates/punktfunk-tray/Cargo.toml | 2 + crates/punktfunk-tray/build.rs | 18 ++ crates/punktfunk-tray/src/main.rs | 2 + crates/punktfunk-tray/src/status.rs | 5 + crates/punktfunk-tray/src/win.rs | 217 +++++++++++++-- crates/punktfunk-tray/src/win_theme.rs | 354 +++++++++++++++++++++++++ packaging/windows/punktfunk-host.iss | 10 + 7 files changed, 584 insertions(+), 24 deletions(-) create mode 100644 crates/punktfunk-tray/src/win_theme.rs diff --git a/crates/punktfunk-tray/Cargo.toml b/crates/punktfunk-tray/Cargo.toml index 9bdd98c2..db0644ca 100644 --- a/crates/punktfunk-tray/Cargo.toml +++ b/crates/punktfunk-tray/Cargo.toml @@ -42,7 +42,9 @@ windows = { version = "0.62", features = [ "Win32_Graphics_Gdi", "Win32_Security", # CreateMutexW's SECURITY_ATTRIBUTES parameter type "Win32_System_LibraryLoader", + "Win32_System_Registry", # AppsUseLightTheme — glyph color must match the themed menu "Win32_System_Threading", + "Win32_UI_HiDpi", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", ] } diff --git a/crates/punktfunk-tray/build.rs b/crates/punktfunk-tray/build.rs index a18b39e0..87390e87 100644 --- a/crates/punktfunk-tray/build.rs +++ b/crates/punktfunk-tray/build.rs @@ -25,6 +25,24 @@ fn main() { // Task Manager / Explorer identity (matches the host's "Punktfunk Host"). res.set("FileDescription", "Punktfunk Tray"); res.set("ProductName", "Punktfunk"); + // PerMonitorV2: without a DPI manifest the process is virtualized and its menu + // GDI-stretched — visibly blurry on any scaled display (most Windows 11 laptops). + res.set_manifest( + r#" + + + + + + + + + + PerMonitorV2 + + +"#, + ); res.compile().expect("embed windows icon resources"); } } diff --git a/crates/punktfunk-tray/src/main.rs b/crates/punktfunk-tray/src/main.rs index 125a536f..387e2a7e 100644 --- a/crates/punktfunk-tray/src/main.rs +++ b/crates/punktfunk-tray/src/main.rs @@ -20,6 +20,8 @@ mod linux; mod status; #[cfg(windows)] mod win; +#[cfg(windows)] +mod win_theme; /// CLI configuration (hand-rolled parse, house style). The mgmt address/port default to the /// host's defaults; they are flags because the tray cannot read `host.env` on Windows (it is diff --git a/crates/punktfunk-tray/src/status.rs b/crates/punktfunk-tray/src/status.rs index 74aa02a3..a08b48c3 100644 --- a/crates/punktfunk-tray/src/status.rs +++ b/crates/punktfunk-tray/src/status.rs @@ -29,6 +29,10 @@ pub struct Summary { pub video_streaming: bool, pub audio_streaming: bool, pub session: Option, + /// Display name of the streaming client (trust-store name, else the device's own), for the + /// connect toast. `#[serde(default)]`: absent when idle, nameless, or from an older host. + #[serde(default)] + pub client_name: Option, pub paired_clients: u32, pub native_paired_clients: u32, pub pin_pending: bool, @@ -404,6 +408,7 @@ mod tests { height: 1440, fps: 120, }), + client_name: streaming.then(|| "studio-deck".into()), paired_clients: 1, native_paired_clients: 2, pin_pending: false, diff --git a/crates/punktfunk-tray/src/win.rs b/crates/punktfunk-tray/src/win.rs index 8c50cd31..f8c9e485 100644 --- a/crates/punktfunk-tray/src/win.rs +++ b/crates/punktfunk-tray/src/win.rs @@ -8,7 +8,7 @@ //! left admin-gated rather than DACL-opened to every local user. use std::os::windows::ffi::OsStrExt; -use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU8, Ordering}; use std::sync::{Mutex, OnceLock}; use windows::core::{w, PCWSTR}; @@ -17,21 +17,25 @@ use windows::Win32::Foundation::{ }; use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::System::Threading::CreateMutexW; +use windows::Win32::UI::HiDpi::GetSystemMetricsForDpi; use windows::Win32::UI::Shell::{ - ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIM_ADD, - NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, NIN_SELECT, NOTIFYICONDATAW, NOTIFYICON_VERSION_4, + SetCurrentProcessExplicitAppUserModelID, ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_INFO, + NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIIF_LARGE_ICON, NIIF_RESPECT_QUIET_TIME, NIIF_USER, + NIM_ADD, NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, NIN_SELECT, NOTIFYICONDATAW, + NOTIFYICON_VERSION_4, }; use windows::Win32::UI::WindowsAndMessaging::{ AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyMenu, DestroyWindow, - DispatchMessageW, FindWindowW, GetCursorPos, GetMessageW, LoadIconW, PostMessageW, + DispatchMessageW, FindWindowW, GetCursorPos, GetMessageW, LoadImageW, PostMessageW, PostQuitMessage, RegisterClassW, RegisterWindowMessageW, SetForegroundWindow, - SetMenuDefaultItem, TrackPopupMenuEx, TranslateMessage, HICON, MF_GRAYED, MF_SEPARATOR, - MF_STRING, MSG, SW_HIDE, SW_SHOWNORMAL, TPM_BOTTOMALIGN, TPM_RIGHTBUTTON, WINDOW_EX_STYLE, - WM_APP, WM_CLOSE, WM_COMMAND, WM_CONTEXTMENU, WM_DESTROY, WM_ENDSESSION, WM_NULL, WNDCLASSW, - WS_OVERLAPPED, + SetMenuDefaultItem, TrackPopupMenuEx, TranslateMessage, HICON, IMAGE_ICON, LR_SHARED, + MF_GRAYED, MF_SEPARATOR, MF_STRING, MSG, SM_CXICON, SM_CXSMICON, SW_HIDE, SW_SHOWNORMAL, + TPM_BOTTOMALIGN, TPM_RIGHTBUTTON, WINDOW_EX_STYLE, WM_APP, WM_CLOSE, WM_COMMAND, + WM_CONTEXTMENU, WM_DESTROY, WM_ENDSESSION, WM_NULL, WM_SETTINGCHANGE, WNDCLASSW, WS_OVERLAPPED, }; use crate::status::{Poller, TrayStatus}; +use crate::win_theme; /// Keyboard "select" on the icon (Enter/Space) — `NIN_SELECT | NINF_KEY`; the windows crate /// exports only NIN_SELECT. @@ -79,6 +83,10 @@ struct App { /// or falls back to showing the menu. web_console: AtomicBool, web_port: u16, + /// Streaming edge tracker for the connect toast: 0 = no status seen yet, 1 = not streaming, + /// 2 = streaming. The "no status yet" state keeps a tray started mid-session (sign-in while a + /// client already streams) from firing a stale toast. + streaming_seen: AtomicU8, } static APP: OnceLock = OnceLock::new(); @@ -128,6 +136,19 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> { return Ok(()); } + // Toast identity: the installer registers this AUMID under Classes\AppUserModelId with + // DisplayName "Punktfunk" + the brand IconUri (punktfunk-host.iss [Registry] — keep in sync), + // so the connect toast is attributed to "Punktfunk" with the logo instead of a generic entry. + // Must run before the notify icon exists. Unregistered (dev run) it degrades to the default + // attribution, never an error. + // SAFETY: static nul-terminated literal. + unsafe { + let _ = SetCurrentProcessExplicitAppUserModelID(w!("unom.punktfunk.tray")); + } + + // Before the first menu: opt this process's popup menus into the system dark mode. + win_theme::init_dark_mode(); + let host_exe = std::env::current_exe() .ok() .and_then(|p| p.parent().map(|d| d.join("punktfunk-host.exe"))) @@ -143,6 +164,7 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> { host_exe, web_console: AtomicBool::new(false), // live-probed by the poller within its first cycle web_port: args.web_port, + streaming_seen: AtomicU8::new(0), }) .ok() .expect("run() is called once"); @@ -247,14 +269,27 @@ fn update_icon(hwnd: HWND, add: bool) -> bool { uCallbackMessage: WMAPP_NOTIFYCALLBACK, ..Default::default() }; - // SAFETY: LoadIconW by ordinal from this exe's embedded resources (build.rs); the ordinal is - // one of the ids compiled in, and a failure falls back to a null icon rather than UB. + // Ask for the shell's small-icon size at this DPI, so LoadImageW serves the best frame of the + // multi-size .ico instead of the 32 px default the shell then downscales (soft at 125 %+). + // SAFETY: plain metric query; 0 (failure) falls back to the classic 16 px. + let sm = match unsafe { GetSystemMetricsForDpi(SM_CXSMICON, win_theme::window_dpi(hwnd)) } { + 0 => 16, + n => n, + }; + // SAFETY: LoadImageW by ordinal from this exe's embedded resources (build.rs); the ordinal is + // one of the ids compiled in, LR_SHARED handles are system-cached (never destroyed by us), + // and a failure falls back to a null icon rather than UB. nid.hIcon = unsafe { - LoadIconW( + LoadImageW( Some(GetModuleHandleW(None).unwrap_or_default().into()), PCWSTR(icon_ordinal(&status) as usize as *const u16), + IMAGE_ICON, + sm, + sm, + LR_SHARED, ) } + .map(|h| HICON(h.0)) .unwrap_or(HICON(std::ptr::null_mut())); // Tooltip: truncate to the szTip capacity (127 UTF-16 units + nul). let tip = to_wide(&status.headline()); @@ -281,6 +316,77 @@ fn update_icon(hwnd: HWND, add: bool) -> bool { } } +/// Toast when a client connects (the idle → streaming edge, as seen by the poller). Windows 11 +/// renders `NIF_INFO` balloons as native toasts under the app's name — no WinRT/AUMID +/// registration needed for a plain exe. Fired from the UI thread on WMAPP_STATUS. +fn notify_on_connect(hwnd: HWND) { + let status = app().status.lock().unwrap().clone(); + let now: u8 = if status.is_streaming() { 2 } else { 1 }; + // 0 = first status since launch: record only. A tray started mid-session (sign-in while a + // client already streams) must not fire a stale toast. + let was = app().streaming_seen.swap(now, Ordering::SeqCst); + if !(was == 1 && now == 2) { + return; + } + let (title, body) = match &status { + TrayStatus::Running(s) => ( + // The host resolves the name from its trust store, else the device's own Hello name; + // absent (older host / nameless client) the toast stays generic. + match &s.client_name { + Some(name) => format!("{name} connected"), + None => "Client connected".to_string(), + }, + match &s.session { + Some(sess) => format!( + "Streaming {}×{} @ {} fps", + sess.width, sess.height, sess.fps + ), + None => "A client is streaming from this host.".to_string(), + }, + ), + _ => return, // is_streaming() implies Running; stay defensive + }; + let mut nid = NOTIFYICONDATAW { + cbSize: std::mem::size_of::() as u32, + hWnd: hwnd, + uID: 1, + uFlags: NIF_INFO, // NIM_MODIFY touches only the balloon fields; icon/tip stay as-is + dwInfoFlags: NIIF_USER | NIIF_LARGE_ICON | NIIF_RESPECT_QUIET_TIME, + ..Default::default() + }; + let title = to_wide(&title); + let n = title.len().min(nid.szInfoTitle.len() - 1); + nid.szInfoTitle[..n].copy_from_slice(&title[..n]); + let body = to_wide(&body); + let n = body.len().min(nid.szInfo.len() - 1); + nid.szInfo[..n].copy_from_slice(&body[..n]); + // SAFETY: plain metric query; 0 (failure) falls back to the classic 32 px. + let sm = match unsafe { GetSystemMetricsForDpi(SM_CXICON, win_theme::window_dpi(hwnd)) } { + 0 => 32, + n => n, + }; + // The brand logo (ordinal 1, punktfunk.ico) at full toast size — the toast is Punktfunk + // speaking, not a status glyph. SAFETY: LoadImageW by ordinal from this exe's embedded + // resources; LR_SHARED handles are system-cached (never destroyed by us), and on failure the + // toast just shows no image. + nid.hBalloonIcon = unsafe { + LoadImageW( + Some(GetModuleHandleW(None).unwrap_or_default().into()), + PCWSTR(1usize as *const u16), + IMAGE_ICON, + sm, + sm, + LR_SHARED, + ) + } + .map(|h| HICON(h.0)) + .unwrap_or(HICON(std::ptr::null_mut())); + // SAFETY: nid fully initialized with a correct cbSize; NIM_MODIFY only reads it. + unsafe { + let _ = Shell_NotifyIconW(NIM_MODIFY, &nid); + } +} + /// The right-click menu, rebuilt from the live status each time. fn show_menu(hwnd: HWND) { let status = app().status.lock().unwrap().clone(); @@ -296,7 +402,10 @@ fn show_menu(hwnd: HWND) { // below (SetForegroundWindow before, WM_NULL after) per the Shell_NotifyIcon docs. unsafe { let Ok(menu) = CreatePopupMenu() else { return }; - let add = |id: usize, text: &str, grayed: bool| { + // Glyph bitmaps: the menu references but does not own them; the guard deletes them after + // DestroyMenu below. + let mut glyphs = win_theme::MenuGlyphs::new(hwnd); + let mut add = |id: usize, text: &str, grayed: bool, glyph: Option| { let wide = to_wide(text); let flags = if grayed { MF_STRING | MF_GRAYED @@ -304,41 +413,91 @@ fn show_menu(hwnd: HWND) { MF_STRING }; let _ = AppendMenuW(menu, flags, id, PCWSTR(wide.as_ptr())); + if let Some(g) = glyph { + glyphs.set(menu, id, g); + } }; - add(IDM_HEADER, &status.headline(), true); + add(IDM_HEADER, &status.headline(), true, None); let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); // The console entry is ALWAYS here — it is the reason most people open this menu, and // left-clicking the icon is not a discoverable substitute. When the loopback probe says // the console isn't answering the label says so, rather than the entry vanishing. if app().web_console.load(Ordering::SeqCst) { - add(IDM_OPEN_WEB, "Open web console", false); + add( + IDM_OPEN_WEB, + "Open web console", + false, + Some(win_theme::GLYPH_GLOBE), + ); } else { - add(IDM_OPEN_WEB, "Open web console (not responding)", false); + add( + IDM_OPEN_WEB, + "Open web console (not responding)", + false, + Some(win_theme::GLYPH_GLOBE), + ); } let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0); if status.pairing_attention() { - add(IDM_PAIRING, "Approve pairing request…", false); + add( + IDM_PAIRING, + "Approve pairing request…", + false, + Some(win_theme::GLYPH_APPROVE), + ); } match status.kept_displays() { 0 => {} - 1 => add(IDM_DISPLAYS, "Release kept display…", false), - n => add(IDM_DISPLAYS, &format!("Release {n} kept displays…"), false), + 1 => add( + IDM_DISPLAYS, + "Release kept display…", + false, + Some(win_theme::GLYPH_DISPLAY), + ), + n => add( + IDM_DISPLAYS, + &format!("Release {n} kept displays…"), + false, + Some(win_theme::GLYPH_DISPLAY), + ), } let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); + // The service actions all carry the shield: Explorer's convention for "selecting this + // opens a UAC prompt" (each runs `punktfunk-host.exe service …` elevated). if can_control { if startable { - add(IDM_START, "Start host", false); + add( + IDM_START, + "Start host", + false, + Some(win_theme::GLYPH_SHIELD), + ); } if running { - add(IDM_STOP, "Stop host", false); - add(IDM_RESTART, "Restart host", false); + add(IDM_STOP, "Stop host", false, Some(win_theme::GLYPH_SHIELD)); + add( + IDM_RESTART, + "Restart host", + false, + Some(win_theme::GLYPH_SHIELD), + ); } else if matches!(status, TrayStatus::Error(_)) { - add(IDM_RESTART, "Restart host", false); + add( + IDM_RESTART, + "Restart host", + false, + Some(win_theme::GLYPH_SHIELD), + ); } } - add(IDM_LOGS, "Open logs folder", false); + add( + IDM_LOGS, + "Open logs folder", + false, + Some(win_theme::GLYPH_FOLDER), + ); let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); - add(IDM_EXIT, "Exit tray", false); + add(IDM_EXIT, "Exit tray", false, Some(win_theme::GLYPH_POWER)); let mut pt = Default::default(); let _ = GetCursorPos(&mut pt); @@ -425,8 +584,18 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) match msg { WMAPP_STATUS => { update_icon(hwnd, false); + notify_on_connect(hwnd); LRESULT(0) } + WM_SETTINGCHANGE => { + // Light/dark flipped while running: drop the cached menu theme so the next popup + // renders in the new mode. + if win_theme::is_color_scheme_change(lparam) { + win_theme::on_color_scheme_changed(); + } + // SAFETY: setting broadcasts still get default processing. + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } + } WMAPP_NOTIFYCALLBACK => { // NOTIFYICON_VERSION_4: LOWORD(lParam) is the event. match (lparam.0 as u32) & 0xffff { diff --git a/crates/punktfunk-tray/src/win_theme.rs b/crates/punktfunk-tray/src/win_theme.rs new file mode 100644 index 00000000..08a96b5d --- /dev/null +++ b/crates/punktfunk-tray/src/win_theme.rs @@ -0,0 +1,354 @@ +//! Windows 11 fit-and-finish for the tray's Win32 UI. +//! +//! Three gaps make a stock Win32 tray read as "Windows 10 app" on Windows 11, and this module +//! closes them without pulling a UI framework into a ~2 MB always-resident helper (there is no +//! WinUI/App-SDK tray API — even fully modern apps register the icon via `Shell_NotifyIconW`; +//! only the popup differs): +//! +//! * **Dark mode.** Popup menus never got a documented dark-mode opt-in; every app that ships +//! one (Explorer, PowerToys, Notepad++) calls the same undocumented uxtheme ordinals — +//! `SetPreferredAppMode` (135) and `FlushMenuThemes` (136), stable since Windows 10 1809. +//! Every call here degrades to the classic light menu when an ordinal is missing. +//! * **Glyphs.** Menu items get "Segoe Fluent Icons" glyphs ("Segoe MDL2 Assets" on Windows 10 — +//! the codepoints are shared), rendered into premultiplied ARGB bitmaps that the themed menu +//! composites correctly in both light and dark. Rounded corners come free — Windows 11 rounds +//! every popup menu. +//! * **DPI.** Sizing helpers for the PerMonitorV2 manifest build.rs embeds — an unmanifested exe +//! is DPI-virtualized and its menu GDI-stretched (visibly blurry on any scaled display). + +use std::sync::OnceLock; + +use windows::core::{w, PCSTR, PCWSTR}; +use windows::Win32::Foundation::{COLORREF, HWND, LPARAM, RECT}; +use windows::Win32::Graphics::Gdi::{ + CreateCompatibleDC, CreateDIBSection, CreateFontIndirectW, DeleteDC, DeleteObject, DrawTextW, + GdiFlush, GetTextFaceW, SelectObject, SetBkMode, SetTextColor, ANTIALIASED_QUALITY, BITMAPINFO, + BITMAPINFOHEADER, BI_RGB, DEFAULT_CHARSET, DIB_RGB_COLORS, DT_CENTER, DT_NOCLIP, DT_SINGLELINE, + DT_VCENTER, HBITMAP, HFONT, LOGFONTW, TRANSPARENT, +}; +use windows::Win32::System::LibraryLoader::{ + GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32, +}; +use windows::Win32::System::Registry::{RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_DWORD}; +use windows::Win32::UI::HiDpi::GetDpiForWindow; +use windows::Win32::UI::WindowsAndMessaging::{ + SetMenuItemInfoW, HMENU, MENUITEMINFOW, MIIM_BITMAP, +}; + +// Glyph codepoints, identical in Segoe Fluent Icons and Segoe MDL2 Assets. +pub const GLYPH_GLOBE: u16 = 0xE774; // Globe — open web console +pub const GLYPH_APPROVE: u16 = 0xE73E; // CheckMark — approve pairing +pub const GLYPH_DISPLAY: u16 = 0xE7F4; // TVMonitor — kept displays +pub const GLYPH_SHIELD: u16 = 0xE7EF; // Admin — marks the UAC-elevated service actions +pub const GLYPH_FOLDER: u16 = 0xE8B7; // Folder — open logs +pub const GLYPH_POWER: u16 = 0xE7E8; // PowerButton — exit tray + +type FnSetPreferredAppMode = unsafe extern "system" fn(mode: i32) -> i32; +type FnVoid = unsafe extern "system" fn(); + +/// The undocumented uxtheme dark-mode entry points, resolved once by ordinal. Any of them may be +/// absent (pre-1809, or a future Windows that removes them) — each caller checks. +struct UxTheme { + set_preferred_app_mode: Option, + refresh_immersive_colors: Option, + flush_menu_themes: Option, +} + +static UXTHEME: OnceLock = OnceLock::new(); + +fn uxtheme() -> &'static UxTheme { + UXTHEME.get_or_init(|| { + let none = UxTheme { + set_preferred_app_mode: None, + refresh_immersive_colors: None, + flush_menu_themes: None, + }; + // SAFETY: system32-only load of a Windows-supplied DLL, then ordinal lookups that return + // None when absent. The transmutes assert the known signatures of ordinals 135/104/136 + // (unchanged since 1809; on 1809 ordinal 135 is `AllowDarkModeForApp(bool)`, for which + // the later `SetPreferredAppMode(1)` call means the same "allow dark"). + unsafe { + let Ok(lib) = LoadLibraryExW(w!("uxtheme.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32) + else { + return none; + }; + let ord = |n: u16| GetProcAddress(lib, PCSTR(n as usize as *const u8)); + UxTheme { + set_preferred_app_mode: ord(135).map(|f| std::mem::transmute(f)), + refresh_immersive_colors: ord(104).map(|f| std::mem::transmute(f)), + flush_menu_themes: ord(136).map(|f| std::mem::transmute(f)), + } + } + }) +} + +/// Opt this process's menus into the system app theme ("allow dark", not "force dark" — the user +/// setting decides). Call once before the first menu. +pub fn init_dark_mode() { + let ux = uxtheme(); + if let Some(set) = ux.set_preferred_app_mode { + // SAFETY: resolved above with this signature; 1 = AllowDark. + unsafe { set(1) }; + } + if let Some(refresh) = ux.refresh_immersive_colors { + // SAFETY: resolved above; takes no arguments. + unsafe { refresh() }; + } +} + +/// The system theme flipped while we're running — re-read the immersive colors and drop the +/// cached menu theme so the next popup renders in the new mode. +pub fn on_color_scheme_changed() { + let ux = uxtheme(); + if let Some(refresh) = ux.refresh_immersive_colors { + // SAFETY: resolved in uxtheme() with this signature. + unsafe { refresh() }; + } + if let Some(flush) = ux.flush_menu_themes { + // SAFETY: resolved in uxtheme() with this signature. + unsafe { flush() }; + } +} + +/// Is this WM_SETTINGCHANGE the "ImmersiveColorSet" broadcast (light/dark toggled)? +pub fn is_color_scheme_change(lparam: LPARAM) -> bool { + const NAME: &[u16] = &[ + b'I' as u16, + b'm' as u16, + b'm' as u16, + b'e' as u16, + b'r' as u16, + b's' as u16, + b'i' as u16, + b'v' as u16, + b'e' as u16, + b'C' as u16, + b'o' as u16, + b'l' as u16, + b'o' as u16, + b'r' as u16, + b'S' as u16, + b'e' as u16, + b't' as u16, + ]; + let p = lparam.0 as *const u16; + if p.is_null() { + return false; + } + // SAFETY: WM_SETTINGCHANGE documents lParam as a nul-terminated string (or null, handled + // above); the read is bounded to the compared length + terminator. + unsafe { + for (i, &want) in NAME.iter().enumerate() { + if *p.add(i) != want { + return false; + } + } + *p.add(NAME.len()) == 0 + } +} + +/// Apps dark theme active? (`AppsUseLightTheme` = 0). Menus under "allow dark" follow this same +/// value, so glyph colors chosen from it always match the menu background. +pub fn apps_use_dark() -> bool { + let mut data: u32 = 1; + let mut size = std::mem::size_of::() as u32; + // SAFETY: RegGetValueW writes at most `size` bytes into `data`; missing value is an error we + // treat as light (the OS default). + let r = unsafe { + RegGetValueW( + HKEY_CURRENT_USER, + w!(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"), + w!("AppsUseLightTheme"), + RRF_RT_REG_DWORD, + None, + Some(&mut data as *mut u32 as *mut _), + Some(&mut size), + ) + }; + r.is_ok() && data == 0 +} + +/// The window's monitor DPI (96 fallback for an invalid handle). +pub fn window_dpi(hwnd: HWND) -> u32 { + // SAFETY: valid on any window handle; returns 0 only for an invalid one. + match unsafe { GetDpiForWindow(hwnd) } { + 0 => 96, + d => d, + } +} + +/// Per-popup glyph bitmaps: rendered at the menu's DPI in the current theme's text color, +/// attached via `hbmpItem`, and deleted on drop — `DestroyMenu` does not free item bitmaps. +pub struct MenuGlyphs { + size: i32, + color: u32, // 0x00RRGGBB + face: Option, + bitmaps: Vec, +} + +impl MenuGlyphs { + pub fn new(hwnd: HWND) -> Self { + let dpi = window_dpi(hwnd) as i32; + MenuGlyphs { + size: (16 * dpi + 48) / 96, + // Match the themed menu's text: near-white on dark, near-black on light. + color: if apps_use_dark() { + 0x00EBEBEB + } else { + 0x00202020 + }, + face: resolve_icon_font(), + bitmaps: Vec::new(), + } + } + + /// Render `glyph` and attach it to menu item `id`. Silently a no-op when the icon font is + /// missing or GDI fails — the menu is fully usable without bitmaps. + pub fn set(&mut self, menu: HMENU, id: usize, glyph: u16) { + let Some(face) = self.face else { return }; + let Some(bmp) = glyph_bitmap(face, glyph, self.size, self.color) else { + return; + }; + let mii = MENUITEMINFOW { + cbSize: std::mem::size_of::() as u32, + fMask: MIIM_BITMAP, + hbmpItem: bmp, + ..Default::default() + }; + // SAFETY: mii is fully initialized with a correct cbSize; menu/id belong to the caller. + if unsafe { SetMenuItemInfoW(menu, id as u32, false, &mii) }.is_ok() { + self.bitmaps.push(bmp); + } else { + // SAFETY: created above, attached nowhere. + unsafe { + let _ = DeleteObject(bmp.into()); + } + } + } +} + +impl Drop for MenuGlyphs { + fn drop(&mut self) { + for bmp in self.bitmaps.drain(..) { + // SAFETY: bitmaps created by glyph_bitmap; the menu that referenced them is destroyed + // before the guard goes out of scope in show_menu. + unsafe { + let _ = DeleteObject(bmp.into()); + } + } + } +} + +/// The installed system icon font: Fluent (Windows 11) → MDL2 (Windows 10) → None (skip glyphs). +fn resolve_icon_font() -> Option { + [w!("Segoe Fluent Icons"), w!("Segoe MDL2 Assets")] + .into_iter() + .find(|f| font_exists(*f)) +} + +/// GDI never fails font creation — it substitutes. Detect a missing face by asking the DC what +/// it actually selected. +fn font_exists(face: PCWSTR) -> bool { + // SAFETY: local DC/font pair created and freed here; GetTextFaceW writes a bounded, + // nul-terminated name into buf. + unsafe { + let dc = CreateCompatibleDC(None); + if dc.is_invalid() { + return false; + } + let font = create_font(face, 16); + let old = SelectObject(dc, font.into()); + let mut buf = [0u16; 64]; + let n = GetTextFaceW(dc, Some(&mut buf)) as usize; + SelectObject(dc, old); + let _ = DeleteObject(font.into()); + let _ = DeleteDC(dc); + let got = &buf[..n.min(buf.len())]; + let got = got.strip_suffix(&[0]).unwrap_or(got); + n > 0 && got == face.as_wide() + } +} + +fn create_font(face: PCWSTR, height: i32) -> HFONT { + let mut lf = LOGFONTW { + lfHeight: -height, // negative = character height; MDL2/Fluent glyphs fill the em square + lfWeight: 400, + lfCharSet: DEFAULT_CHARSET, + // Grayscale AA, not ClearType — the alpha pass below reads coverage from one channel and + // subpixel rendering would leave color fringes. + lfQuality: ANTIALIASED_QUALITY, + ..Default::default() + }; + // SAFETY: both candidate face literals fit LF_FACESIZE (32) incl. nul; CreateFontIndirectW + // copies the struct. + unsafe { + let name = face.as_wide(); + lf.lfFaceName[..name.len()].copy_from_slice(name); + CreateFontIndirectW(&lf) + } +} + +/// Render one glyph, white-on-black, into a 32-bit top-down DIB, then convert coverage to a +/// premultiplied-alpha bitmap in `color` — the format themed menus composite without artifacts. +fn glyph_bitmap(face: PCWSTR, glyph: u16, size: i32, color: u32) -> Option { + // SAFETY: standard GDI render-to-memory-DIB. Every handle created here is released here (the + // returned bitmap by MenuGlyphs::drop), GdiFlush completes pending GDI writes before the bits + // are read, and the pixel loop stays inside the size*size allocation CreateDIBSection made. + unsafe { + let dc = CreateCompatibleDC(None); + if dc.is_invalid() { + return None; + } + let bi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: size, + biHeight: -size, // top-down + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + ..Default::default() + }, + ..Default::default() + }; + let mut bits: *mut core::ffi::c_void = std::ptr::null_mut(); + let Ok(bmp) = CreateDIBSection(Some(dc), &bi, DIB_RGB_COLORS, &mut bits, None, 0) else { + let _ = DeleteDC(dc); + return None; + }; + let font = create_font(face, size); + let old_bmp = SelectObject(dc, bmp.into()); + let old_font = SelectObject(dc, font.into()); + + std::ptr::write_bytes(bits as *mut u8, 0, (size * size * 4) as usize); + SetTextColor(dc, COLORREF(0x00FF_FFFF)); + SetBkMode(dc, TRANSPARENT); + let mut text = [glyph]; + let mut rect = RECT { + left: 0, + top: 0, + right: size, + bottom: size, + }; + DrawTextW( + dc, + &mut text, + &mut rect, + DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_NOCLIP, + ); + let _ = GdiFlush(); + + let px = bits as *mut u32; + let (r, g, b) = ((color >> 16) & 0xFF, (color >> 8) & 0xFF, color & 0xFF); + for i in 0..(size * size) as usize { + let a = *px.add(i) & 0xFF; // white-on-black: any channel is the coverage + *px.add(i) = (a << 24) | ((r * a / 255) << 16) | ((g * a / 255) << 8) | (b * a / 255); + } + + SelectObject(dc, old_font); + SelectObject(dc, old_bmp); + let _ = DeleteObject(font.into()); + let _ = DeleteDC(dc); + Some(bmp) + } +} diff --git a/packaging/windows/punktfunk-host.iss b/packaging/windows/punktfunk-host.iss index cdd72fc7..b021e3bc 100644 --- a/packaging/windows/punktfunk-host.iss +++ b/packaging/windows/punktfunk-host.iss @@ -252,6 +252,16 @@ Source: "{#VkLayerDir}\pf_vkhdr_layer.json"; DestDir: "{app}\vklayer"; Flags: ig ; with the app). Operators who moved --mgmt-bind can append --mgmt-addr/--mgmt-port here. Root: HKLM64; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \ ValueName: "PunktfunkTray"; ValueData: """{app}\punktfunk-tray.exe"""; Flags: uninsdeletevalue; Tasks: trayicon +; Toast identity for the tray's notifications ("client connected"). The tray process tags itself +; with this AppUserModelID (win.rs TRAY_AUMID — keep in sync), and this registration is what makes +; Windows 11 attribute its toasts as "Punktfunk" with the brand icon instead of a generic entry — +; the same Classes\AppUserModelId mechanism the Windows App SDK uses for unpackaged apps. No Start +; menu shortcut needed. Installed unconditionally (like the tray exe itself): the keys are inert +; without the tray running. +Root: HKLM64; Subkey: "SOFTWARE\Classes\AppUserModelId\unom.punktfunk.tray"; ValueType: string; \ + ValueName: "DisplayName"; ValueData: "Punktfunk"; Flags: uninsdeletekey +Root: HKLM64; Subkey: "SOFTWARE\Classes\AppUserModelId\unom.punktfunk.tray"; ValueType: string; \ + ValueName: "IconUri"; ValueData: "{app}\punktfunk.ico" ; Put {app} on the MACHINE PATH so `punktfunk-host plugins add …` / `punktfunk-host service …` are ; runnable by name. Appended to the existing value ({olddata}) and guarded by PathNeedsAdd so a ; repair/upgrade never appends a duplicate. Deliberately NOT `uninsdeletevalue` — that would delete