Skip to content
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as Effect from "effect/Effect";

import * as DesktopIpc from "./DesktopIpc.ts";
import { installNotificationBadge } from "./methods/notificationBadge.ts";
import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
import {
clearConnectionCatalog,
Expand Down Expand Up @@ -68,6 +69,7 @@ import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./m

export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {
const ipc = yield* DesktopIpc.DesktopIpc;
yield* installNotificationBadge();
yield* PreviewIpc.installPreviewEventForwarding();

yield* ipc.handle(AppActivationIpc.setReady);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge";
export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon";
export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
export const SET_THEME_CHANNEL = "desktop:set-theme";
Expand Down
144 changes: 144 additions & 0 deletions apps/desktop/src/ipc/methods/notificationBadge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import * as Effect from "effect/Effect";
import { beforeEach, expect, vi } from "vite-plus/test";
import { it } from "@effect/vitest";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

const native = vi.hoisted(() => ({
setBadgeCount: vi.fn(),
setOverlayIcon: vi.fn(),
isDestroyed: vi.fn(() => false),
getFocusedWindow: vi.fn(() => null as object | null),
image: { isEmpty: vi.fn(() => false) },
createFromDataURL: vi.fn(),
webContents: { send: vi.fn() },
listeners: new Map<string, () => void>(),
}));
vi.mock("electron", () => ({
app: {
setBadgeCount: native.setBadgeCount,
on: (event: string, listener: () => void) => native.listeners.set(event, listener),
removeListener: (event: string) => native.listeners.delete(event),
},
BrowserWindow: {
getFocusedWindow: native.getFocusedWindow,
getAllWindows: () => [native],
},
nativeImage: { createFromDataURL: native.createFromDataURL },
}));

import * as ElectronApp from "../../electron/ElectronApp.ts";
import * as DesktopIpc from "../DesktopIpc.ts";
import { applyNotificationBadge, installNotificationBadge } from "./notificationBadge.ts";

const badge = { count: 2, image: "data:image/png;base64,aGVsbG8=" };

beforeEach(() => {
vi.clearAllMocks();
native.getFocusedWindow.mockReturnValue(null);
native.isDestroyed.mockReturnValue(false);
native.image.isEmpty.mockReturnValue(false);
native.createFromDataURL.mockReturnValue(native.image);
native.setBadgeCount.mockImplementation(() => true);
native.listeners.clear();
});

it.each(["darwin", "linux"] as const)("sets and clears the native %s count", (platform) => {
applyNotificationBadge(platform, badge);
applyNotificationBadge(platform, { count: 0, image: null });
expect(native.setBadgeCount.mock.calls).toEqual([[2], [0]]);
expect(native.createFromDataURL).not.toHaveBeenCalled();
});

it("sets and clears the Windows taskbar overlay", () => {
applyNotificationBadge("win32", badge);
expect(native.setOverlayIcon).toHaveBeenLastCalledWith(
native.image,
"2 threads with new notifications",
);
applyNotificationBadge("win32", { count: 0, image: null });
expect(native.setOverlayIcon).toHaveBeenLastCalledWith(null, "");
});

it.each(["win32", "darwin", "linux"] as const)(
"rejects a late positive count while %s is focused",
(platform) => {
native.getFocusedWindow.mockReturnValue({});
applyNotificationBadge(platform, badge);
if (platform === "win32") expect(native.setOverlayIcon).toHaveBeenCalledWith(null, "");
else expect(native.setBadgeCount).toHaveBeenCalledWith(0);
expect(native.createFromDataURL).not.toHaveBeenCalled();
},
);

it("ignores destroyed windows and clears invalid images", () => {
native.isDestroyed.mockReturnValue(true);
applyNotificationBadge("win32", badge);
expect(native.setOverlayIcon).not.toHaveBeenCalled();
native.isDestroyed.mockReturnValue(false);
native.image.isEmpty.mockReturnValue(true);
applyNotificationBadge("win32", badge);
expect(native.setOverlayIcon.mock.calls[0]?.[0]).toBeNull();
});

it("keeps notifications working when the native badge API fails", () => {
native.setBadgeCount.mockImplementation(() => {
throw new Error("Unavailable");
});
expect(() => applyNotificationBadge("linux", badge)).not.toThrow();
});

it.effect("validates IPC and clears on native focus, quit, and disposal", () =>
Effect.gen(function* () {
const handlers = new Map<string, DesktopIpc.DesktopIpcHandleListener>();
yield* Effect.scoped(
Effect.gen(function* () {
yield* installNotificationBadge();
const handler = handlers.get("desktop:set-notification-badge")!;
const event = { sender: { id: 1 } };
for (const invalid of [
{ ...badge, count: -1 },
{ ...badge, count: 0.5 },
{ ...badge, count: Infinity },
{ ...badge, image: "https://example.com/icon.png" },
{ ...badge, image: `data:image/png;base64,${"a".repeat(16_384)}` },
]) {
yield* Effect.promise(() => expect(handler(event, invalid)).rejects.toBeDefined());
}
expect(native.setBadgeCount).not.toHaveBeenCalled();
yield* Effect.promise(() => Promise.resolve(handler(event, badge)));
expect(native.setBadgeCount).toHaveBeenLastCalledWith(2);
native.listeners.get("browser-window-focus")!();
expect(native.setBadgeCount).toHaveBeenLastCalledWith(0);
expect(native.webContents.send).toHaveBeenCalledWith("desktop:set-notification-badge");
native.getFocusedWindow.mockReturnValue({});
yield* Effect.promise(() => Promise.resolve(handler(event, badge)));
expect(native.setBadgeCount).toHaveBeenLastCalledWith(0);
expect(native.webContents.send).toHaveBeenCalledTimes(2);
yield* Effect.promise(() => Promise.resolve(handler(event, { count: 0, image: null })));
expect(native.webContents.send).toHaveBeenCalledTimes(2);
native.getFocusedWindow.mockReturnValue(null);
yield* Effect.promise(() => Promise.resolve(handler(event, badge)));
native.listeners.get("before-quit")!();
expect(native.setBadgeCount).toHaveBeenLastCalledWith(0);
}),
).pipe(
Effect.provideService(HostProcessPlatform, "linux"),
Effect.provide([
ElectronApp.layer,
DesktopIpc.layer({
handle: (channel, handler) => {
handlers.set(channel, handler);
},
removeHandler: (channel) => {
handlers.delete(channel);
},
on: vi.fn(),
removeAllListeners: vi.fn(),
}),
]),
);
expect(native.setBadgeCount).toHaveBeenLastCalledWith(0);
expect(native.listeners.size).toBe(0);
expect(handlers.size).toBe(0);
}),
);
71 changes: 71 additions & 0 deletions apps/desktop/src/ipc/methods/notificationBadge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as Electron from "electron";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";

import * as ElectronApp from "../../electron/ElectronApp.ts";
import * as DesktopIpc from "../DesktopIpc.ts";
import { SET_NOTIFICATION_BADGE_CHANNEL } from "../channels.ts";

const NotificationBadge = Schema.Struct({
count: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 2_147_483_647 })),
image: Schema.NullOr(
Schema.String.check(
Schema.isMaxLength(16_384),
Schema.isPattern(/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i),
),
),
});

export function applyNotificationBadge(
platform: NodeJS.Platform,
{ count, image }: typeof NotificationBadge.Type,
): void {
try {
if (Electron.BrowserWindow.getFocusedWindow()) count = 0;
if (platform === "win32") {
const overlay = count > 0 && image ? Electron.nativeImage.createFromDataURL(image) : null;
for (const window of Electron.BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) {
window.setOverlayIcon(
overlay?.isEmpty() ? null : overlay,
count > 0 ? `${count} threads with new notifications` : "",
);
}
}
} else if (platform === "darwin" || platform === "linux") {
Electron.app.setBadgeCount(count);
}
} catch (error) {
Effect.runSync(Effect.logWarning("Could not update notification badge", error));
}
}

export const installNotificationBadge = Effect.fn("desktop.ipc.installNotificationBadge")(
function* () {
const ipc = yield* DesktopIpc.DesktopIpc;
const app = yield* ElectronApp.ElectronApp;
const platform = yield* HostProcessPlatform;
const clear = () => {
applyNotificationBadge(platform, { count: 0, image: null });
for (const window of Electron.BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send(SET_NOTIFICATION_BADGE_CHANNEL);
}
};
yield* ipc.handle(
DesktopIpc.makeIpcMethod({
channel: SET_NOTIFICATION_BADGE_CHANNEL,
payload: NotificationBadge,
result: Schema.Void,
handler: (badge) =>
Effect.sync(() => {
if (badge.count > 0 && Electron.BrowserWindow.getFocusedWindow()) clear();
else applyNotificationBadge(platform, badge);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}),
}),
);
yield* app.on("browser-window-focus", clear);
yield* app.on("before-quit", clear);
yield* Effect.addFinalizer(() => Effect.sync(clear));
},
);
7 changes: 7 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ contextBridge.exposeInMainWorld("desktopBridge", {
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
getClientPlatform: () => clientPlatform,
setNotificationBadge: (badge) =>
ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge),
onNotificationBadgeClear: (listener) => {
const handler = () => listener();
ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
},
getSystemLocale: () => {
const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
return typeof result === "string" ? result : null;
Expand Down
Loading
Loading