Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ plist = "1"
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] }
keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true }
user-idle = { version = "0.6", default-features = false }
# Native Windows toast notifications so the app registers with Windows Settings
# > System > Notifications and click actions work through WinRT.
tauri-winrt-notification = "0.7"

[dependencies]
atomic-write-file = "0.3"
Expand Down
58 changes: 56 additions & 2 deletions desktop/src-tauri/src/commands/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,16 @@ pub async fn show_native_notification(
crate::macos_notifications::show(title, body, target).await
}

#[cfg(not(any(target_os = "linux", target_os = "macos")))]
#[cfg(target_os = "windows")]
{
windows::show(app, title, body, target);
Ok(())
}

#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
let _ = (&app, &title, &body, &target);
Err("show_native_notification is only supported on Linux and macOS".to_string())
Err("show_native_notification is not supported on this platform".to_string())
}
}

Expand Down Expand Up @@ -106,3 +112,51 @@ mod linux {
});
}
}

// ── Windows ────────────────────────────────────────────────────────────────
//
// Uses `tauri-winrt-notification` to post Windows toast notifications. This
// registers the app with Windows Settings > System > Notifications (so the
// user can control per-app notification preferences) and surfaces click
// actions through the WinRT `Activated` handler, which we forward to the
// frontend via the same `native-notification-activated` event that Linux uses.

#[cfg(target_os = "windows")]
mod windows {
use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT;
use tauri::Emitter;
use tauri_winrt_notification::{Duration, Toast};

pub fn show(
app: tauri::AppHandle,
title: String,
body: Option<String>,
target: Option<serde_json::Value>,
) {
// The Tauri identifier (e.g. "xyz.block.buzz.app") is the
// AppUserModelID that Windows uses to group notifications and
// surface the app in Settings > Notifications.
let app_id = app.config().identifier.clone();

std::thread::spawn(move || {
let app_clone = app.clone();
let result = Toast::new(&app_id)
.title(&title)
.text1(body.as_deref().unwrap_or(""))
.sound(None)
.duration(Duration::Short)
.on_activated(move |_action| {
// _action is None for the default (body) click and
// Some(arg) for button clicks. We only use the default
// click, matching the Linux behaviour.
let _ = app_clone.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, &target);
Ok(())
})
.show();

if let Err(error) = result {
eprintln!("buzz-desktop: failed to post Windows notification: {error}");
}
});
}
}
26 changes: 22 additions & 4 deletions desktop/src/features/notifications/lib/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
onAction,
requestPermission,
} from "@tauri-apps/plugin-notification";
import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform";
import { isLinuxPlatform, isMacPlatform, isWindowsPlatform } from "@/shared/lib/platform";

// Backend event emitted when a native Linux notification is clicked or a
// queued macOS activation becomes available. See src-tauri notification code.
Expand Down Expand Up @@ -146,6 +146,17 @@ export async function getDesktopNotificationPermissionState(): Promise<DesktopNo
}
}

// On Windows, WebView2's Notification.permission can report "denied" even
// when the WinRT toast API is available. Skip the browser-level check and
// go straight to the Tauri plugin, which queries the native WinRT status.
if (isTauri() && isWindowsPlatform()) {
try {
return (await isPermissionGranted()) ? "granted" : "default";
} catch {
return "default";
}
}

if (window.Notification.permission !== "default") {
return window.Notification.permission;
}
Expand Down Expand Up @@ -183,7 +194,10 @@ export async function requestDesktopNotificationAccess(): Promise<DesktopNotific
throw error;
},
)
: requestPermission();
: // On Windows, always use the Tauri plugin's requestPermission() which
// triggers the WinRT notification permission prompt. The browser-level
// Notification.requestPermission() is unreliable in WebView2.
requestPermission();
pendingPermissionRequest = request.finally(() => {
pendingPermissionRequest = null;
});
Expand Down Expand Up @@ -214,7 +228,7 @@ export async function listenForDesktopNotificationActions(
if (isTauri()) {
const usesMacActivationQueue = isMacPlatform();

if (!isLinuxPlatform() && !usesMacActivationQueue) {
if (!isLinuxPlatform() && !isWindowsPlatform() && !usesMacActivationQueue) {
try {
pluginListener = await onAction((notification) => {
const target = parseNotificationTarget(
Expand Down Expand Up @@ -362,8 +376,12 @@ export async function sendDesktopNotification(

// Linux needs a retained D-Bus connection. macOS needs a native notification
// center delegate because the Tauri plugin does not deliver desktop clicks.
// Windows needs WinRT toast notifications so the app registers with
// Settings > System > Notifications and click actions work.
// Do NOT use the Tauri notification plugin's sendNotification() on Windows —
// the native WinRT path handles delivery and click actions exclusively.
// See src-tauri/src/commands/notifications.rs.
if (isTauri() && (isLinuxPlatform() || isMacPlatform())) {
if (isTauri() && (isLinuxPlatform() || isMacPlatform() || isWindowsPlatform())) {
try {
await invoke("show_native_notification", {
title: payload.title,
Expand Down
92 changes: 92 additions & 0 deletions desktop/src/shared/lib/platform.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import test from "node:test";

// Save the original navigator so we can restore it after each test.
const originalNavigator = globalThis.navigator;

function withNavigator(platform, userAgent) {
Object.defineProperty(globalThis, "navigator", {
value: { platform, userAgent },
configurable: true,
});
}

function restoreNavigator() {
Object.defineProperty(globalThis, "navigator", {
value: originalNavigator,
configurable: true,
});
}

const { isMacPlatform, isLinuxPlatform, isWindowsPlatform } = await import(
"./platform.ts"
);

// ── isWindowsPlatform ──────────────────────────────────────────────────────

test("isWindowsPlatform returns true for Win32", () => {
withNavigator("Win32", "Mozilla/5.0");
assert.equal(isWindowsPlatform(), true);
restoreNavigator();
});

test("isWindowsPlatform returns true for Win64", () => {
withNavigator("Win64", "Mozilla/5.0");
assert.equal(isWindowsPlatform(), true);
restoreNavigator();
});

test("isWindowsPlatform returns false for macOS", () => {
withNavigator("MacIntel", "Mozilla/5.0");
assert.equal(isWindowsPlatform(), false);
restoreNavigator();
});

test("isWindowsPlatform returns false for Linux", () => {
withNavigator("Linux x86_64", "Mozilla/5.0");
assert.equal(isWindowsPlatform(), false);
restoreNavigator();
});

test("isWindowsPlatform returns false when navigator is undefined", () => {
Object.defineProperty(globalThis, "navigator", {
value: undefined,
configurable: true,
});
assert.equal(isWindowsPlatform(), false);
restoreNavigator();
});

// ── isMacPlatform ──────────────────────────────────────────────────────────

test("isMacPlatform returns true for MacIntel", () => {
withNavigator("MacIntel", "Mozilla/5.0");
assert.equal(isMacPlatform(), true);
restoreNavigator();
});

test("isMacPlatform returns false for Win32", () => {
withNavigator("Win32", "Mozilla/5.0");
assert.equal(isMacPlatform(), false);
restoreNavigator();
});

// ── isLinuxPlatform ────────────────────────────────────────────────────────

test("isLinuxPlatform returns true for Linux", () => {
withNavigator("Linux x86_64", "Mozilla/5.0");
assert.equal(isLinuxPlatform(), true);
restoreNavigator();
});

test("isLinuxPlatform returns false for Android", () => {
withNavigator("Linux armv81", "Mozilla/5.0 (Linux; Android 14)");
assert.equal(isLinuxPlatform(), false);
restoreNavigator();
});

test("isLinuxPlatform returns false for Win32", () => {
withNavigator("Win32", "Mozilla/5.0");
assert.equal(isLinuxPlatform(), false);
restoreNavigator();
});
9 changes: 9 additions & 0 deletions desktop/src/shared/lib/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ export function isLinuxPlatform(): boolean {
);
}

/** Returns true on Windows desktops. */
export function isWindowsPlatform(): boolean {
if (typeof navigator === "undefined") {
return false;
}

return /win/i.test(navigator.platform);
}

/**
* The platform's normal application-shortcut modifier:
* - macOS: Command (Meta)
Expand Down