Skip to content

Commit c5bd2fa

Browse files
feat(desktop): add macOS completion badges
1 parent 4ad15b6 commit c5bd2fa

4 files changed

Lines changed: 65 additions & 41 deletions

File tree

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
import { assert, beforeEach, describe, it } from "@effect/vitest";
22
import { vi } from "vite-plus/test";
33

4-
const { createFromBuffer, overlayImage } = vi.hoisted(() => ({
4+
const { createFromBuffer, overlayImage, setBadgeCount } = vi.hoisted(() => ({
55
createFromBuffer: vi.fn(),
66
overlayImage: { isEmpty: vi.fn(() => false) },
7+
setBadgeCount: vi.fn(() => true),
78
}));
89

910
vi.mock("electron", () => ({
11+
app: { setBadgeCount },
1012
nativeImage: { createFromBuffer },
1113
}));
1214

13-
import { setWindowsTaskbarUnreadIndicator } from "./WindowsTaskbarBadge.ts";
15+
import { setDesktopUnreadBadge } from "./DesktopUnreadBadge.ts";
1416

1517
function makeWindow(destroyed = false) {
1618
return {
@@ -19,21 +21,21 @@ function makeWindow(destroyed = false) {
1921
};
2022
}
2123

22-
describe("setWindowsTaskbarUnreadIndicator", () => {
24+
describe("setDesktopUnreadBadge", () => {
2325
beforeEach(() => {
2426
createFromBuffer.mockReset();
2527
createFromBuffer.mockReturnValue(overlayImage);
2628
overlayImage.isEmpty.mockReset();
2729
overlayImage.isEmpty.mockReturnValue(false);
30+
setBadgeCount.mockReset();
31+
setBadgeCount.mockReturnValue(true);
2832
});
2933

3034
it("sets and clears the Windows taskbar overlay", () => {
3135
const window = makeWindow();
3236
const badgeDataUrl = "data:image/png;base64,one";
3337

34-
assert.isTrue(
35-
setWindowsTaskbarUnreadIndicator({ platform: "win32", window, count: 1, badgeDataUrl }),
36-
);
38+
assert.isTrue(setDesktopUnreadBadge({ platform: "win32", window, count: 1, badgeDataUrl }));
3739
assert.deepEqual(createFromBuffer.mock.calls, [
3840
[Buffer.from("one", "base64"), { width: 64, height: 64, scaleFactor: 4 }],
3941
]);
@@ -43,7 +45,7 @@ describe("setWindowsTaskbarUnreadIndicator", () => {
4345
]);
4446

4547
assert.isTrue(
46-
setWindowsTaskbarUnreadIndicator({
48+
setDesktopUnreadBadge({
4749
platform: "win32",
4850
window,
4951
count: 0,
@@ -53,33 +55,49 @@ describe("setWindowsTaskbarUnreadIndicator", () => {
5355
assert.deepEqual(window.setOverlayIcon.mock.calls[1], [null, ""]);
5456
});
5557

56-
it("caches decoded overlays", () => {
57-
const window = makeWindow();
58-
const badgeDataUrl = "data:image/png;base64,two";
59-
58+
it("uses the native badge count on macOS", () => {
6059
assert.isTrue(
61-
setWindowsTaskbarUnreadIndicator({ platform: "win32", window, count: 2, badgeDataUrl }),
60+
setDesktopUnreadBadge({ platform: "darwin", window: null, count: 2, badgeDataUrl: null }),
6261
);
6362
assert.isTrue(
64-
setWindowsTaskbarUnreadIndicator({ platform: "win32", window, count: 2, badgeDataUrl }),
63+
setDesktopUnreadBadge({ platform: "darwin", window: null, count: 0, badgeDataUrl: null }),
6564
);
6665

66+
assert.deepEqual(setBadgeCount.mock.calls, [[2], [0]]);
67+
assert.lengthOf(createFromBuffer.mock.calls, 0);
68+
});
69+
70+
it("returns the native result when macOS cannot show badge counts", () => {
71+
setBadgeCount.mockReturnValueOnce(false);
72+
73+
assert.isFalse(
74+
setDesktopUnreadBadge({ platform: "darwin", window: null, count: 1, badgeDataUrl: null }),
75+
);
76+
});
77+
78+
it("caches decoded Windows overlays", () => {
79+
const window = makeWindow();
80+
const badgeDataUrl = "data:image/png;base64,two";
81+
82+
assert.isTrue(setDesktopUnreadBadge({ platform: "win32", window, count: 2, badgeDataUrl }));
83+
assert.isTrue(setDesktopUnreadBadge({ platform: "win32", window, count: 2, badgeDataUrl }));
84+
6785
assert.equal(createFromBuffer.mock.calls.length, 1);
6886
});
6987

70-
it("rejects missing, malformed, and empty badge images", () => {
88+
it("rejects missing, malformed, and empty Windows badge images", () => {
7189
const window = makeWindow();
7290

7391
assert.isFalse(
74-
setWindowsTaskbarUnreadIndicator({
92+
setDesktopUnreadBadge({
7593
platform: "win32",
7694
window,
7795
count: 1,
7896
badgeDataUrl: null,
7997
}),
8098
);
8199
assert.isFalse(
82-
setWindowsTaskbarUnreadIndicator({
100+
setDesktopUnreadBadge({
83101
platform: "win32",
84102
window,
85103
count: 1,
@@ -89,7 +107,7 @@ describe("setWindowsTaskbarUnreadIndicator", () => {
89107

90108
overlayImage.isEmpty.mockReturnValueOnce(true);
91109
assert.isFalse(
92-
setWindowsTaskbarUnreadIndicator({
110+
setDesktopUnreadBadge({
93111
platform: "win32",
94112
window,
95113
count: 1,
@@ -99,37 +117,29 @@ describe("setWindowsTaskbarUnreadIndicator", () => {
99117
assert.lengthOf(window.setOverlayIcon.mock.calls, 0);
100118
});
101119

102-
it("does nothing outside Windows or without a live window", () => {
120+
it("does nothing on unsupported platforms or without a live Windows window", () => {
103121
const window = makeWindow();
104122
const destroyedWindow = makeWindow(true);
105123
const badgeDataUrl = "data:image/png;base64,platform";
106124

107125
assert.isFalse(
108-
setWindowsTaskbarUnreadIndicator({
109-
platform: "darwin",
110-
window,
111-
count: 1,
112-
badgeDataUrl,
113-
}),
114-
);
115-
assert.isFalse(
116-
setWindowsTaskbarUnreadIndicator({
126+
setDesktopUnreadBadge({
117127
platform: "linux",
118128
window,
119129
count: 1,
120130
badgeDataUrl,
121131
}),
122132
);
123133
assert.isFalse(
124-
setWindowsTaskbarUnreadIndicator({
134+
setDesktopUnreadBadge({
125135
platform: "win32",
126136
window: null,
127137
count: 1,
128138
badgeDataUrl,
129139
}),
130140
);
131141
assert.isFalse(
132-
setWindowsTaskbarUnreadIndicator({
142+
setDesktopUnreadBadge({
133143
platform: "win32",
134144
window: destroyedWindow,
135145
count: 1,
@@ -138,21 +148,29 @@ describe("setWindowsTaskbarUnreadIndicator", () => {
138148
);
139149
assert.lengthOf(window.setOverlayIcon.mock.calls, 0);
140150
assert.lengthOf(destroyedWindow.setOverlayIcon.mock.calls, 0);
151+
assert.lengthOf(setBadgeCount.mock.calls, 0);
141152
});
142153

143-
it("fails soft when Electron rejects the overlay", () => {
154+
it("fails soft when Electron rejects a badge update", () => {
144155
const window = makeWindow();
145156
window.setOverlayIcon.mockImplementation(() => {
146157
throw new Error("overlay failed");
147158
});
148159

149160
assert.isFalse(
150-
setWindowsTaskbarUnreadIndicator({
161+
setDesktopUnreadBadge({
151162
platform: "win32",
152163
window,
153164
count: 1,
154165
badgeDataUrl: "data:image/png;base64,failure",
155166
}),
156167
);
168+
169+
setBadgeCount.mockImplementationOnce(() => {
170+
throw new Error("badge failed");
171+
});
172+
assert.isFalse(
173+
setDesktopUnreadBadge({ platform: "darwin", window: null, count: 1, badgeDataUrl: null }),
174+
);
157175
});
158176
});

apps/desktop/src/electron/WindowsTaskbarBadge.ts renamed to apps/desktop/src/electron/DesktopUnreadBadge.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,21 @@ function getUnreadCompletionOverlay(dataUrl: string): Electron.NativeImage | nul
3232
return overlay;
3333
}
3434

35-
export function setWindowsTaskbarUnreadIndicator(input: {
35+
export function setDesktopUnreadBadge(input: {
3636
readonly platform: NodeJS.Platform;
3737
readonly window: Pick<Electron.BrowserWindow, "isDestroyed" | "setOverlayIcon"> | null;
3838
readonly count: number;
3939
readonly badgeDataUrl: string | null;
4040
}): boolean {
41-
if (input.platform !== "win32" || input.window === null || input.window.isDestroyed()) {
42-
return false;
43-
}
44-
4541
try {
42+
if (input.platform === "darwin") {
43+
return Electron.app.setBadgeCount(input.count);
44+
}
45+
46+
if (input.platform !== "win32" || input.window === null || input.window.isDestroyed()) {
47+
return false;
48+
}
49+
4650
if (input.count === 0) {
4751
input.window.setOverlayIcon(null, "");
4852
return true;

‎apps/desktop/src/ipc/methods/window.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import * as ElectronMenu from "../../electron/ElectronMenu.ts";
3434
import * as ElectronShell from "../../electron/ElectronShell.ts";
3535
import * as ElectronTheme from "../../electron/ElectronTheme.ts";
3636
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
37-
import { setWindowsTaskbarUnreadIndicator } from "../../electron/WindowsTaskbarBadge.ts";
37+
import { setDesktopUnreadBadge } from "../../electron/DesktopUnreadBadge.ts";
3838
import * as IpcChannels from "../channels.ts";
3939
import * as DesktopIpc from "../DesktopIpc.ts";
4040
import {
@@ -280,7 +280,7 @@ export const setTaskbarUnreadIndicator = DesktopIpc.makeIpcMethod({
280280
const environment = yield* DesktopEnvironment.DesktopEnvironment;
281281
const electronWindow = yield* ElectronWindow.ElectronWindow;
282282
const window = yield* electronWindow.main;
283-
return setWindowsTaskbarUnreadIndicator({
283+
return setDesktopUnreadBadge({
284284
platform: environment.platform,
285285
window: Option.getOrNull(window),
286286
count: input.count,

‎apps/web/src/components/desktop/DesktopTaskbarUnreadCoordinator.tsx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,17 +81,19 @@ export function DesktopTaskbarUnreadCoordinator() {
8181
() => countUnseenTaskbarCompletions(threads, lastVisitedAtByThreadKey),
8282
[lastVisitedAtByThreadKey, threads],
8383
);
84-
const setIndicator = window.desktopBridge?.setTaskbarUnreadIndicator;
84+
const desktopBridge = window.desktopBridge;
85+
const setIndicator = desktopBridge?.setTaskbarUnreadIndicator;
86+
const needsOverlayImage = desktopBridge?.getClientPlatform?.() === "win32";
8587

8688
useEffect(() => {
8789
if (setIndicator === undefined) {
8890
return;
8991
}
9092
void setIndicator({
9193
count,
92-
badgeDataUrl: count > 0 ? createTaskbarBadgeDataUrl(count) : null,
94+
badgeDataUrl: count > 0 && needsOverlayImage ? createTaskbarBadgeDataUrl(count) : null,
9395
}).catch(() => {});
94-
}, [count, setIndicator]);
96+
}, [count, needsOverlayImage, setIndicator]);
9597

9698
useEffect(
9799
() => () => {

0 commit comments

Comments
 (0)