mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
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.
This commit is contained in:
1 parent
4a54af3245
commit
4d44db0949
6 files changed
+188
-16
No files matched your search
Generated
+73
-1
@@ -4422,6 +4422,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"souvlaki",
|
"souvlaki",
|
||||||
|
"tauri-winrt-notification",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
@@ -5544,7 +5545,7 @@ dependencies = [
|
|||||||
"dispatch",
|
"dispatch",
|
||||||
"objc",
|
"objc",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"windows",
|
"windows 0.44.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5635,6 +5636,17 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
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]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -6695,6 +6707,27 @@ dependencies = [
|
|||||||
"windows-targets 0.42.2",
|
"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]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -6708,6 +6741,17 @@ dependencies = [
|
|||||||
"windows-strings",
|
"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]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -6736,6 +6780,16 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
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]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -6871,6 +6925,24 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.53.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.42.2"
|
version = "0.42.2"
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ souvlaki = { version = "0.8", default-features = false, features = ["use_dbus"]
|
|||||||
# hold it for.
|
# hold it for.
|
||||||
dbus = "0.9"
|
dbus = "0.9"
|
||||||
windows-sys = "0.61"
|
windows-sys = "0.61"
|
||||||
|
# Toasts on Windows. Linux notifications go over the `dbus` above.
|
||||||
|
tauri-winrt-notification = "0.8"
|
||||||
rpassword = "7"
|
rpassword = "7"
|
||||||
libloading = "0.8"
|
libloading = "0.8"
|
||||||
lru = "0.12"
|
lru = "0.12"
|
||||||
|
|||||||
@@ -40,5 +40,6 @@ dbus.workspace = true
|
|||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
# Named pipes, for handing a second launch's request to the open window.
|
# Named pipes, for handing a second launch's request to the open window.
|
||||||
tokio = { workspace = true, features = ["net"] }
|
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"
|
raw-window-handle = "0.6"
|
||||||
@@ -508,6 +508,7 @@ impl App {
|
|||||||
// theme, and a window that paints one frame in the default palette
|
// theme, and a window that paints one frame in the default palette
|
||||||
// before switching is a window that flashes on every launch.
|
// before switching is a window that flashes on every launch.
|
||||||
let window = crate::window::WindowMemory::new(dirs.window_file());
|
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())?;
|
let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?;
|
||||||
theme::install_fonts(&cc.egui_ctx);
|
theme::install_fonts(&cc.egui_ctx);
|
||||||
theme::apply(&cc.egui_ctx, engine.appearance());
|
theme::apply(&cc.egui_ctx, engine.appearance());
|
||||||
@@ -558,7 +559,7 @@ impl App {
|
|||||||
media_cover: None,
|
media_cover: None,
|
||||||
inhibitor: crate::inhibit::Inhibitor::new(),
|
inhibitor: crate::inhibit::Inhibitor::new(),
|
||||||
shown_page: (None, 0.0),
|
shown_page: (None, 0.0),
|
||||||
notifier: crate::notify::Notifier::new(),
|
notifier,
|
||||||
downloads: Vec::new(),
|
downloads: Vec::new(),
|
||||||
offline_files: std::collections::HashSet::new(),
|
offline_files: std::collections::HashSet::new(),
|
||||||
confirm_partial_delete: None,
|
confirm_partial_delete: None,
|
||||||
|
|||||||
+108
-13
@@ -4,41 +4,48 @@
|
|||||||
//! app already has the toast, and a second copy of it from the panel is noise.
|
//! 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`.
|
//! That rule is the caller's — see `App::notify`.
|
||||||
//!
|
//!
|
||||||
//! Linux only, over `org.freedesktop.Notifications`. Windows wants an
|
//! Linux over `org.freedesktop.Notifications`; Windows as a toast. Nothing on
|
||||||
//! application id registered by an installer before a toast will show, and a
|
//! macOS yet.
|
||||||
//! notification that silently never appears is worse than none.
|
//!
|
||||||
|
//! 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
|
/// Sends notifications from a thread of its own, so a notification daemon
|
||||||
/// that is slow to answer never costs a frame.
|
/// that is slow to answer never costs a frame.
|
||||||
pub struct Notifier {
|
pub struct Notifier {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
orders: Option<std::sync::mpsc::Sender<(String, String)>>,
|
orders: Option<std::sync::mpsc::Sender<(String, String)>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Notifier {
|
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 {
|
Self {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
orders: linux::spawn(),
|
orders: linux::spawn(),
|
||||||
|
#[cfg(windows)]
|
||||||
|
orders: windows::spawn(cache.join("notification-icon.png")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn send(&self, summary: impl Into<String>, body: impl Into<String>) {
|
pub fn send(&self, summary: impl Into<String>, body: impl Into<String>) {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(any(target_os = "linux", windows))]
|
||||||
if let Some(orders) = &self.orders {
|
if let Some(orders) = &self.orders {
|
||||||
let _ = orders.send((summary.into(), body.into()));
|
let _ = orders.send((summary.into(), body.into()));
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(any(target_os = "linux", windows)))]
|
||||||
let _ = (summary.into(), body.into());
|
let _ = (summary.into(), body.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Notifier {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux {
|
mod linux {
|
||||||
use std::sync::mpsc::{Sender, channel};
|
use std::sync::mpsc::{Sender, channel};
|
||||||
@@ -93,3 +100,91 @@ mod linux {
|
|||||||
Some(orders)
|
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<Sender<(String, String)>> {
|
||||||
|
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<u16> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
|
| 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 |
|
| Window size | config dir, `window.json` | Defaults if missing or implausible |
|
||||||
| Single-instance pipe key (Windows) | config dir, `instance.key` | Yes — regenerated if missing |
|
| 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 |
|
||||||
Reference in new issue
Block a user