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
119 changes: 118 additions & 1 deletion desktop/src/features/notifications/lib/desktop.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,55 @@ class ThrowingNotification {

globalThis.window = { Notification: ThrowingNotification };

const { sendDesktopNotification } = await import("./desktop.ts");
const { sendDesktopNotification, getDesktopNotificationPermissionState } =
await import("./desktop.ts");

// Stands in for the plugin's injected shim: `permission` is a cached value and
// only `requestPermission()` reaches the backend and rewrites it.
function shimNotification(initialPermission, grantedPermission = "granted") {
class ShimNotification {
static permission = initialPermission;
static requestCount = 0;

static async requestPermission() {
ShimNotification.requestCount += 1;
ShimNotification.permission = grantedPermission;
return grantedPermission;
}

close() {}
}

return ShimNotification;
}

async function withEnvironment({ platform, isTauri, notification }, callback) {
const originalNavigator = Object.getOwnPropertyDescriptor(
globalThis,
"navigator",
);
const originalNotification = window.Notification;
const originalIsTauri = globalThis.isTauri;

Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: { platform, userAgent: "" },
});
window.Notification = notification;
globalThis.isTauri = isTauri;

try {
return await callback();
} finally {
if (originalNavigator) {
Object.defineProperty(globalThis, "navigator", originalNavigator);
} else {
delete globalThis.navigator;
}
window.Notification = originalNotification;
globalThis.isTauri = originalIsTauri;
}
}

test("constructor failure is a delivery miss and does not prevent a later notification", async (t) => {
const warnings = [];
Expand All @@ -50,3 +98,72 @@ test("constructor failure is a delivery miss and does not prevent a later notifi
},
]);
});

test("Windows Tauri repairs the shim's false denied state on read", async () => {
const Notification = shimNotification("denied");

const permission = await withEnvironment(
{ platform: "Win32", isTauri: true, notification: Notification },
() => getDesktopNotificationPermissionState(),
);

assert.equal(permission, "granted");
assert.equal(Notification.requestCount, 1);
});

test("the repaired state is cached, so later reads do not request again", async () => {
const Notification = shimNotification("denied");

await withEnvironment(
{ platform: "Win32", isTauri: true, notification: Notification },
async () => {
await getDesktopNotificationPermissionState();
const second = await getDesktopNotificationPermissionState();
assert.equal(second, "granted");
},
);

assert.equal(Notification.requestCount, 1);
});

test("a denial that survives the request is reported as denied", async () => {
const Notification = shimNotification("denied", "denied");

const permission = await withEnvironment(
{ platform: "Win32", isTauri: true, notification: Notification },
() => getDesktopNotificationPermissionState(),
);

assert.equal(permission, "denied");
assert.equal(Notification.requestCount, 1);
});

test("denied stays terminal outside the Windows Tauri app", async () => {
for (const environment of [
{ label: "Windows web", platform: "Win32", isTauri: false },
{ label: "Linux Tauri", platform: "Linux x86_64", isTauri: true },
{ label: "Linux web", platform: "Linux x86_64", isTauri: false },
]) {
const Notification = shimNotification("denied");

const permission = await withEnvironment(
{ ...environment, notification: Notification },
() => getDesktopNotificationPermissionState(),
);

assert.equal(permission, "denied", environment.label);
assert.equal(Notification.requestCount, 0, environment.label);
}
});

test("granted is returned untouched and never triggers a request", async () => {
const Notification = shimNotification("granted");

const permission = await withEnvironment(
{ platform: "Win32", isTauri: true, notification: Notification },
() => getDesktopNotificationPermissionState(),
);

assert.equal(permission, "granted");
assert.equal(Notification.requestCount, 0);
});
25 changes: 24 additions & 1 deletion desktop/src/features/notifications/lib/desktop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ 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 @@ -147,6 +151,25 @@ export async function getDesktopNotificationPermissionState(): Promise<DesktopNo
}

if (window.Notification.permission !== "default") {
// On Windows the notification plugin's injected shim stamps `denied` at
// startup without ever consulting the backend — `permission_state()`
// returns `Granted` unconditionally on desktop — so a `denied` reading
// inside the Windows Tauri app is never a real denial. Repair it by
// requesting once: on Windows `request_permission()` raises no prompt,
// returns `Granted`, and rewrites the shim's cached value, so subsequent
// reads short-circuit here as `granted`.
if (
window.Notification.permission === "denied" &&
isTauri() &&
isWindowsPlatform()
) {
try {
return await requestDesktopNotificationAccess();
} catch {
return window.Notification.permission;
}
}

return window.Notification.permission;
}

Expand Down
38 changes: 38 additions & 0 deletions desktop/src/shared/lib/platform.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";

import { isWindowsPlatform } from "./platform.ts";

function withNavigator(navigator, callback) {
const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: navigator,
});

try {
callback();
} finally {
if (original) {
Object.defineProperty(globalThis, "navigator", original);
} else {
delete globalThis.navigator;
}
}
}

test("Windows detection accepts the platform strings WebView2 reports", () => {
for (const platform of ["Win32", "Win64", "Windows"]) {
withNavigator({ platform, userAgent: "" }, () => {
assert.equal(isWindowsPlatform(), true, platform);
});
}
});

test("Windows detection rejects the other desktop platforms", () => {
for (const platform of ["MacIntel", "Darwin", "Linux x86_64"]) {
withNavigator({ platform, userAgent: "" }, () => {
assert.equal(isWindowsPlatform(), false, platform);
});
}
});
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