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 "<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>
This commit is contained in:
@@ -42,7 +42,9 @@ windows = { version = "0.62", features = [
|
|||||||
"Win32_Graphics_Gdi",
|
"Win32_Graphics_Gdi",
|
||||||
"Win32_Security", # CreateMutexW's SECURITY_ATTRIBUTES parameter type
|
"Win32_Security", # CreateMutexW's SECURITY_ATTRIBUTES parameter type
|
||||||
"Win32_System_LibraryLoader",
|
"Win32_System_LibraryLoader",
|
||||||
|
"Win32_System_Registry", # AppsUseLightTheme — glyph color must match the themed menu
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
|
"Win32_UI_HiDpi",
|
||||||
"Win32_UI_Shell",
|
"Win32_UI_Shell",
|
||||||
"Win32_UI_WindowsAndMessaging",
|
"Win32_UI_WindowsAndMessaging",
|
||||||
] }
|
] }
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ fn main() {
|
|||||||
// Task Manager / Explorer identity (matches the host's "Punktfunk Host").
|
// Task Manager / Explorer identity (matches the host's "Punktfunk Host").
|
||||||
res.set("FileDescription", "Punktfunk Tray");
|
res.set("FileDescription", "Punktfunk Tray");
|
||||||
res.set("ProductName", "Punktfunk");
|
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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- Windows 10/11 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>"#,
|
||||||
|
);
|
||||||
res.compile().expect("embed windows icon resources");
|
res.compile().expect("embed windows icon resources");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ mod linux;
|
|||||||
mod status;
|
mod status;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
mod win;
|
mod win;
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod win_theme;
|
||||||
|
|
||||||
/// CLI configuration (hand-rolled parse, house style). The mgmt address/port default to the
|
/// 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
|
/// host's defaults; they are flags because the tray cannot read `host.env` on Windows (it is
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ pub struct Summary {
|
|||||||
pub video_streaming: bool,
|
pub video_streaming: bool,
|
||||||
pub audio_streaming: bool,
|
pub audio_streaming: bool,
|
||||||
pub session: Option<SessionInfo>,
|
pub session: Option<SessionInfo>,
|
||||||
|
/// 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<String>,
|
||||||
pub paired_clients: u32,
|
pub paired_clients: u32,
|
||||||
pub native_paired_clients: u32,
|
pub native_paired_clients: u32,
|
||||||
pub pin_pending: bool,
|
pub pin_pending: bool,
|
||||||
@@ -404,6 +408,7 @@ mod tests {
|
|||||||
height: 1440,
|
height: 1440,
|
||||||
fps: 120,
|
fps: 120,
|
||||||
}),
|
}),
|
||||||
|
client_name: streaming.then(|| "studio-deck".into()),
|
||||||
paired_clients: 1,
|
paired_clients: 1,
|
||||||
native_paired_clients: 2,
|
native_paired_clients: 2,
|
||||||
pin_pending: false,
|
pin_pending: false,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//! left admin-gated rather than DACL-opened to every local user.
|
//! left admin-gated rather than DACL-opened to every local user.
|
||||||
|
|
||||||
use std::os::windows::ffi::OsStrExt;
|
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 std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
use windows::core::{w, PCWSTR};
|
use windows::core::{w, PCWSTR};
|
||||||
@@ -17,21 +17,25 @@ use windows::Win32::Foundation::{
|
|||||||
};
|
};
|
||||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||||
use windows::Win32::System::Threading::CreateMutexW;
|
use windows::Win32::System::Threading::CreateMutexW;
|
||||||
|
use windows::Win32::UI::HiDpi::GetSystemMetricsForDpi;
|
||||||
use windows::Win32::UI::Shell::{
|
use windows::Win32::UI::Shell::{
|
||||||
ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_MESSAGE, NIF_SHOWTIP, NIF_TIP, NIM_ADD,
|
SetCurrentProcessExplicitAppUserModelID, ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_INFO,
|
||||||
NIM_DELETE, NIM_MODIFY, NIM_SETVERSION, NIN_SELECT, NOTIFYICONDATAW, NOTIFYICON_VERSION_4,
|
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::{
|
use windows::Win32::UI::WindowsAndMessaging::{
|
||||||
AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyMenu, DestroyWindow,
|
AppendMenuW, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyMenu, DestroyWindow,
|
||||||
DispatchMessageW, FindWindowW, GetCursorPos, GetMessageW, LoadIconW, PostMessageW,
|
DispatchMessageW, FindWindowW, GetCursorPos, GetMessageW, LoadImageW, PostMessageW,
|
||||||
PostQuitMessage, RegisterClassW, RegisterWindowMessageW, SetForegroundWindow,
|
PostQuitMessage, RegisterClassW, RegisterWindowMessageW, SetForegroundWindow,
|
||||||
SetMenuDefaultItem, TrackPopupMenuEx, TranslateMessage, HICON, MF_GRAYED, MF_SEPARATOR,
|
SetMenuDefaultItem, TrackPopupMenuEx, TranslateMessage, HICON, IMAGE_ICON, LR_SHARED,
|
||||||
MF_STRING, MSG, SW_HIDE, SW_SHOWNORMAL, TPM_BOTTOMALIGN, TPM_RIGHTBUTTON, WINDOW_EX_STYLE,
|
MF_GRAYED, MF_SEPARATOR, MF_STRING, MSG, SM_CXICON, SM_CXSMICON, SW_HIDE, SW_SHOWNORMAL,
|
||||||
WM_APP, WM_CLOSE, WM_COMMAND, WM_CONTEXTMENU, WM_DESTROY, WM_ENDSESSION, WM_NULL, WNDCLASSW,
|
TPM_BOTTOMALIGN, TPM_RIGHTBUTTON, WINDOW_EX_STYLE, WM_APP, WM_CLOSE, WM_COMMAND,
|
||||||
WS_OVERLAPPED,
|
WM_CONTEXTMENU, WM_DESTROY, WM_ENDSESSION, WM_NULL, WM_SETTINGCHANGE, WNDCLASSW, WS_OVERLAPPED,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::status::{Poller, TrayStatus};
|
use crate::status::{Poller, TrayStatus};
|
||||||
|
use crate::win_theme;
|
||||||
|
|
||||||
/// Keyboard "select" on the icon (Enter/Space) — `NIN_SELECT | NINF_KEY`; the windows crate
|
/// Keyboard "select" on the icon (Enter/Space) — `NIN_SELECT | NINF_KEY`; the windows crate
|
||||||
/// exports only NIN_SELECT.
|
/// exports only NIN_SELECT.
|
||||||
@@ -79,6 +83,10 @@ struct App {
|
|||||||
/// or falls back to showing the menu.
|
/// or falls back to showing the menu.
|
||||||
web_console: AtomicBool,
|
web_console: AtomicBool,
|
||||||
web_port: u16,
|
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<App> = OnceLock::new();
|
static APP: OnceLock<App> = OnceLock::new();
|
||||||
@@ -128,6 +136,19 @@ pub fn run(args: crate::Args) -> anyhow::Result<()> {
|
|||||||
return Ok(());
|
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()
|
let host_exe = std::env::current_exe()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|p| p.parent().map(|d| d.join("punktfunk-host.exe")))
|
.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,
|
host_exe,
|
||||||
web_console: AtomicBool::new(false), // live-probed by the poller within its first cycle
|
web_console: AtomicBool::new(false), // live-probed by the poller within its first cycle
|
||||||
web_port: args.web_port,
|
web_port: args.web_port,
|
||||||
|
streaming_seen: AtomicU8::new(0),
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
.expect("run() is called once");
|
.expect("run() is called once");
|
||||||
@@ -247,14 +269,27 @@ fn update_icon(hwnd: HWND, add: bool) -> bool {
|
|||||||
uCallbackMessage: WMAPP_NOTIFYCALLBACK,
|
uCallbackMessage: WMAPP_NOTIFYCALLBACK,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
// SAFETY: LoadIconW by ordinal from this exe's embedded resources (build.rs); the ordinal is
|
// Ask for the shell's small-icon size at this DPI, so LoadImageW serves the best frame of the
|
||||||
// one of the ids compiled in, and a failure falls back to a null icon rather than UB.
|
// 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 {
|
nid.hIcon = unsafe {
|
||||||
LoadIconW(
|
LoadImageW(
|
||||||
Some(GetModuleHandleW(None).unwrap_or_default().into()),
|
Some(GetModuleHandleW(None).unwrap_or_default().into()),
|
||||||
PCWSTR(icon_ordinal(&status) as usize as *const u16),
|
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()));
|
.unwrap_or(HICON(std::ptr::null_mut()));
|
||||||
// Tooltip: truncate to the szTip capacity (127 UTF-16 units + nul).
|
// Tooltip: truncate to the szTip capacity (127 UTF-16 units + nul).
|
||||||
let tip = to_wide(&status.headline());
|
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::<NOTIFYICONDATAW>() 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.
|
/// The right-click menu, rebuilt from the live status each time.
|
||||||
fn show_menu(hwnd: HWND) {
|
fn show_menu(hwnd: HWND) {
|
||||||
let status = app().status.lock().unwrap().clone();
|
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.
|
// below (SetForegroundWindow before, WM_NULL after) per the Shell_NotifyIcon docs.
|
||||||
unsafe {
|
unsafe {
|
||||||
let Ok(menu) = CreatePopupMenu() else { return };
|
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<u16>| {
|
||||||
let wide = to_wide(text);
|
let wide = to_wide(text);
|
||||||
let flags = if grayed {
|
let flags = if grayed {
|
||||||
MF_STRING | MF_GRAYED
|
MF_STRING | MF_GRAYED
|
||||||
@@ -304,41 +413,91 @@ fn show_menu(hwnd: HWND) {
|
|||||||
MF_STRING
|
MF_STRING
|
||||||
};
|
};
|
||||||
let _ = AppendMenuW(menu, flags, id, PCWSTR(wide.as_ptr()));
|
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());
|
let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null());
|
||||||
// The console entry is ALWAYS here — it is the reason most people open this menu, and
|
// 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
|
// 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.
|
// the console isn't answering the label says so, rather than the entry vanishing.
|
||||||
if app().web_console.load(Ordering::SeqCst) {
|
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 {
|
} 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);
|
let _ = SetMenuDefaultItem(menu, IDM_OPEN_WEB as u32, 0);
|
||||||
if status.pairing_attention() {
|
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() {
|
match status.kept_displays() {
|
||||||
0 => {}
|
0 => {}
|
||||||
1 => add(IDM_DISPLAYS, "Release kept display…", false),
|
1 => add(
|
||||||
n => add(IDM_DISPLAYS, &format!("Release {n} kept displays…"), false),
|
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());
|
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 can_control {
|
||||||
if startable {
|
if startable {
|
||||||
add(IDM_START, "Start host", false);
|
add(
|
||||||
|
IDM_START,
|
||||||
|
"Start host",
|
||||||
|
false,
|
||||||
|
Some(win_theme::GLYPH_SHIELD),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if running {
|
if running {
|
||||||
add(IDM_STOP, "Stop host", false);
|
add(IDM_STOP, "Stop host", false, Some(win_theme::GLYPH_SHIELD));
|
||||||
add(IDM_RESTART, "Restart host", false);
|
add(
|
||||||
|
IDM_RESTART,
|
||||||
|
"Restart host",
|
||||||
|
false,
|
||||||
|
Some(win_theme::GLYPH_SHIELD),
|
||||||
|
);
|
||||||
} else if matches!(status, TrayStatus::Error(_)) {
|
} 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());
|
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 mut pt = Default::default();
|
||||||
let _ = GetCursorPos(&mut pt);
|
let _ = GetCursorPos(&mut pt);
|
||||||
@@ -425,8 +584,18 @@ extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM)
|
|||||||
match msg {
|
match msg {
|
||||||
WMAPP_STATUS => {
|
WMAPP_STATUS => {
|
||||||
update_icon(hwnd, false);
|
update_icon(hwnd, false);
|
||||||
|
notify_on_connect(hwnd);
|
||||||
LRESULT(0)
|
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 => {
|
WMAPP_NOTIFYCALLBACK => {
|
||||||
// NOTIFYICON_VERSION_4: LOWORD(lParam) is the event.
|
// NOTIFYICON_VERSION_4: LOWORD(lParam) is the event.
|
||||||
match (lparam.0 as u32) & 0xffff {
|
match (lparam.0 as u32) & 0xffff {
|
||||||
|
|||||||
@@ -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<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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
; 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; \
|
Root: HKLM64; Subkey: "SOFTWARE\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; \
|
||||||
ValueName: "PunktfunkTray"; ValueData: """{app}\punktfunk-tray.exe"""; Flags: uninsdeletevalue; Tasks: trayicon
|
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
|
; 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
|
; 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
|
; repair/upgrade never appends a duplicate. Deliberately NOT `uninsdeletevalue` — that would delete
|
||||||
|
|||||||
Reference in New Issue
Block a user