mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +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
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
+108
-13
@@ -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<std::sync::mpsc::Sender<(String, String)>>,
|
||||
}
|
||||
|
||||
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<String>, body: impl Into<String>) {
|
||||
#[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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user