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 "<device>
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 <noreply@anthropic.com>
355 lines
14 KiB
Rust
355 lines
14 KiB
Rust
//! 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<FnSetPreferredAppMode>,
|
|
refresh_immersive_colors: Option<FnVoid>,
|
|
flush_menu_themes: Option<FnVoid>,
|
|
}
|
|
|
|
static UXTHEME: OnceLock<UxTheme> = 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::<u32>() 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<PCWSTR>,
|
|
bitmaps: Vec<HBITMAP>,
|
|
}
|
|
|
|
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::<MENUITEMINFOW>() 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<PCWSTR> {
|
|
[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<HBITMAP> {
|
|
// 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::<BITMAPINFOHEADER>() 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)
|
|
}
|
|
}
|