From 4d44db0949cad1d2b0f4396dcb9a072da9639203 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Fri, 2 Oct 2026 23:27:25 +0200 Subject: [PATCH] feat(app): desktop notifications on Windows Toasts through WinRT, under the app id io.narl.proton-stream. The id is registered for the current user under HKCU\Software\Classes\AppUserModelId with a display name and icon on the first notification of a session, which is what lets an app with no package or installer-made shortcut show one. If that write fails, the toast goes out under PowerShell's id rather than not at all. --- Cargo.lock | 74 ++++++++++++++++++++- Cargo.toml | 2 + crates/pstr-app/Cargo.toml | 3 +- crates/pstr-app/src/app.rs | 3 +- crates/pstr-app/src/notify.rs | 121 ++++++++++++++++++++++++++++++---- docs/ARCHITECTURE.md | 1 + 6 files changed, 188 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32a9491..c7b982b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4422,6 +4422,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "souvlaki", + "tauri-winrt-notification", "tokio", "tracing", "tracing-subscriber", @@ -5544,7 +5545,7 @@ dependencies = [ "dispatch", "objc", "thiserror 1.0.69", - "windows", + "windows 0.44.0", ] [[package]] @@ -5635,6 +5636,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tauri-winrt-notification" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" +dependencies = [ + "thiserror 2.0.19", + "windows 0.62.2", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -6695,6 +6707,27 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -6708,6 +6741,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6736,6 +6780,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -6871,6 +6925,24 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" diff --git a/Cargo.toml b/Cargo.toml index 89089db..6534521 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,6 +98,8 @@ souvlaki = { version = "0.8", default-features = false, features = ["use_dbus"] # hold it for. dbus = "0.9" windows-sys = "0.61" +# Toasts on Windows. Linux notifications go over the `dbus` above. +tauri-winrt-notification = "0.8" rpassword = "7" libloading = "0.8" lru = "0.12" diff --git a/crates/pstr-app/Cargo.toml b/crates/pstr-app/Cargo.toml index 689d6d9..06dd9b6 100644 --- a/crates/pstr-app/Cargo.toml +++ b/crates/pstr-app/Cargo.toml @@ -40,5 +40,6 @@ dbus.workspace = true [target.'cfg(windows)'.dependencies] # Named pipes, for handing a second launch's request to the open window. tokio = { workspace = true, features = ["net"] } -windows-sys = { workspace = true, features = ["Win32_System_Power"] } +windows-sys = { workspace = true, features = ["Win32_System_Power", "Win32_System_Registry"] } +tauri-winrt-notification.workspace = true raw-window-handle = "0.6" diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 7a2e1e2..ac62f3d 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -508,6 +508,7 @@ impl App { // theme, and a window that paints one frame in the default palette // before switching is a window that flashes on every launch. let window = crate::window::WindowMemory::new(dirs.window_file()); + let notifier = crate::notify::Notifier::new(&dirs.cache); let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?; theme::install_fonts(&cc.egui_ctx); theme::apply(&cc.egui_ctx, engine.appearance()); @@ -558,7 +559,7 @@ impl App { media_cover: None, inhibitor: crate::inhibit::Inhibitor::new(), shown_page: (None, 0.0), - notifier: crate::notify::Notifier::new(), + notifier, downloads: Vec::new(), offline_files: std::collections::HashSet::new(), confirm_partial_delete: None, diff --git a/crates/pstr-app/src/notify.rs b/crates/pstr-app/src/notify.rs index 7a7f7b0..7dd9068 100644 --- a/crates/pstr-app/src/notify.rs +++ b/crates/pstr-app/src/notify.rs @@ -4,41 +4,48 @@ //! app already has the toast, and a second copy of it from the panel is noise. //! That rule is the caller's — see `App::notify`. //! -//! Linux only, over `org.freedesktop.Notifications`. Windows wants an -//! application id registered by an installer before a toast will show, and a -//! notification that silently never appears is worse than none. +//! Linux over `org.freedesktop.Notifications`; Windows as a toast. Nothing on +//! macOS yet. +//! +//! A Windows toast is shown under an application id, and Windows drops a toast +//! whose id it does not know without a word. The id is registered for the +//! current user under `HKCU\Software\Classes\AppUserModelId` — the same +//! registration the Windows App SDK makes for an app with no package — which +//! works for the MSI and the portable zip alike. If that write fails, the +//! toast goes out under PowerShell's id instead, which Windows always knows: a +//! notification that names the wrong program beats one that never appears. + +use std::path::Path; /// Sends notifications from a thread of its own, so a notification daemon /// that is slow to answer never costs a frame. pub struct Notifier { - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", windows))] orders: Option>, } impl Notifier { - pub fn new() -> Self { + /// `cache` is where Windows is pointed for the toast's icon. + pub fn new(cache: &Path) -> Self { + let _ = cache; Self { #[cfg(target_os = "linux")] orders: linux::spawn(), + #[cfg(windows)] + orders: windows::spawn(cache.join("notification-icon.png")), } } pub fn send(&self, summary: impl Into, body: impl Into) { - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", windows))] if let Some(orders) = &self.orders { let _ = orders.send((summary.into(), body.into())); } - #[cfg(not(target_os = "linux"))] + #[cfg(not(any(target_os = "linux", windows)))] let _ = (summary.into(), body.into()); } } -impl Default for Notifier { - fn default() -> Self { - Self::new() - } -} - #[cfg(target_os = "linux")] mod linux { use std::sync::mpsc::{Sender, channel}; @@ -93,3 +100,91 @@ mod linux { Some(orders) } } + +#[cfg(windows)] +mod windows { + use std::path::{Path, PathBuf}; + use std::sync::mpsc::{Sender, channel}; + + use tauri_winrt_notification::Toast; + + /// The id toasts are shown under, and the key it is registered at. + const APP_ID: &str = "io.narl.proton-stream"; + const ICON: &[u8] = include_bytes!("../assets/icon-256.png"); + + pub fn spawn(icon: PathBuf) -> Option> { + let (orders, receiver) = channel::<(String, String)>(); + std::thread::Builder::new() + .name("notify".into()) + .spawn(move || { + // Registered on first use rather than at startup: most + // sessions never send one. + let mut app_id: Option<&str> = None; + for (summary, body) in receiver { + let id = *app_id.get_or_insert_with(|| { + let _ = std::fs::write(&icon, ICON); + if register(&icon) { + APP_ID + } else { + Toast::POWERSHELL_APP_ID + } + }); + let shown = Toast::new(id).title(&summary).text1(&body).show(); + if let Err(error) = shown { + tracing::debug!("notification: {error}"); + } + } + }) + .inspect_err(|error| tracing::debug!("notify thread: {error}")) + .ok()?; + Some(orders) + } + + /// Tell Windows the app id's display name and icon. Per user, no rights + /// needed, and written again every session so a moved install points at + /// a live icon. + fn register(icon: &Path) -> bool { + use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_CURRENT_USER, KEY_WRITE, REG_OPTION_NON_VOLATILE, REG_SZ, RegCloseKey, + RegCreateKeyExW, RegSetValueExW, + }; + + fn wide(text: &str) -> Vec { + text.encode_utf16().chain(std::iter::once(0)).collect() + } + + let path = wide(&format!(r"Software\Classes\AppUserModelId\{APP_ID}")); + let mut key: HKEY = std::ptr::null_mut(); + // SAFETY: every pointer is to a live, NUL-terminated buffer or an out + // parameter on this stack frame, and the key is closed before return. + unsafe { + let created = RegCreateKeyExW( + HKEY_CURRENT_USER, + path.as_ptr(), + 0, + std::ptr::null(), + REG_OPTION_NON_VOLATILE, + KEY_WRITE, + std::ptr::null(), + &mut key, + std::ptr::null_mut(), + ); + if created != 0 { + tracing::debug!("register the notification id: error {created}"); + return false; + } + let icon = icon.to_string_lossy(); + let mut written = true; + for (name, value) in [("DisplayName", "Proton Stream"), ("IconUri", icon.as_ref())] { + let name = wide(name); + let value = wide(value); + let bytes = u32::try_from(value.len() * 2).unwrap_or(u32::MAX); + let set = + RegSetValueExW(key, name.as_ptr(), 0, REG_SZ, value.as_ptr().cast(), bytes); + written &= set == 0; + } + RegCloseKey(key); + written + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 56beb21..4637c0a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -125,3 +125,4 @@ an edge case. | Desktop-only settings | config dir, `desktop.json` | Defaults if missing — hardware decoding, cache budget; never overwritten when unparseable | | Window size | config dir, `window.json` | Defaults if missing or implausible | | Single-instance pipe key (Windows) | config dir, `instance.key` | Yes — regenerated if missing | +| Notification app id (Windows) | `HKCU\Software\Classes\AppUserModelId\io.narl.proton-stream`, icon in cache dir | Yes — written again the first time a session notifies |