Skip to content

Commit c84cc4b

Browse files
committed
fix: buffer desktop project-open IPC
1 parent 4a4fa5a commit c84cc4b

2 files changed

Lines changed: 118 additions & 6 deletions

File tree

apps/desktop/src/preload.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { DesktopBridge } from "@t3tools/contracts";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const OPEN_PROJECT_PATH_CHANNEL = "desktop:open-project-path";
5+
6+
const { exposeInMainWorldMock, ipcRendererMock, ipcListeners } = vi.hoisted(() => {
7+
const listeners = new Map<string, Set<(...args: unknown[]) => void>>();
8+
return {
9+
exposeInMainWorldMock: vi.fn(),
10+
ipcRendererMock: {
11+
invoke: vi.fn(),
12+
on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => {
13+
const channelListeners = listeners.get(channel) ?? new Set<(...args: unknown[]) => void>();
14+
channelListeners.add(listener);
15+
listeners.set(channel, channelListeners);
16+
}),
17+
removeListener: vi.fn((channel: string, listener: (...args: unknown[]) => void) => {
18+
listeners.get(channel)?.delete(listener);
19+
}),
20+
sendSync: vi.fn(),
21+
},
22+
ipcListeners: listeners,
23+
};
24+
});
25+
26+
vi.mock("electron", () => ({
27+
contextBridge: {
28+
exposeInMainWorld: exposeInMainWorldMock,
29+
},
30+
ipcRenderer: ipcRendererMock,
31+
}));
32+
33+
function emitIpc(channel: string, ...args: unknown[]): void {
34+
for (const listener of ipcListeners.get(channel) ?? []) {
35+
listener({} as Electron.IpcRendererEvent, ...args);
36+
}
37+
}
38+
39+
async function loadDesktopBridge(): Promise<DesktopBridge> {
40+
vi.resetModules();
41+
await import("./preload.ts");
42+
return exposeInMainWorldMock.mock.calls[0]?.[1] as DesktopBridge;
43+
}
44+
45+
describe("desktop preload", () => {
46+
beforeEach(() => {
47+
vi.clearAllMocks();
48+
ipcListeners.clear();
49+
});
50+
51+
it("buffers open-project-path IPC until the renderer subscribes", async () => {
52+
const bridge = await loadDesktopBridge();
53+
emitIpc(OPEN_PROJECT_PATH_CHANNEL, "/tmp/project-sample");
54+
emitIpc(OPEN_PROJECT_PATH_CHANNEL, "/tmp/project-other");
55+
emitIpc(OPEN_PROJECT_PATH_CHANNEL, { path: "/tmp/not-a-string" });
56+
57+
const listener = vi.fn();
58+
bridge.onOpenProjectPath(listener);
59+
60+
expect(listener).toHaveBeenCalledTimes(2);
61+
expect(listener).toHaveBeenNthCalledWith(1, "/tmp/project-sample");
62+
expect(listener).toHaveBeenNthCalledWith(2, "/tmp/project-other");
63+
});
64+
65+
it("registers for open-project-path IPC as preload initializes", async () => {
66+
await loadDesktopBridge();
67+
68+
expect(ipcRendererMock.on).toHaveBeenCalledWith(
69+
OPEN_PROJECT_PATH_CHANNEL,
70+
expect.any(Function),
71+
);
72+
});
73+
74+
it("delivers open-project-path IPC directly after subscription", async () => {
75+
const bridge = await loadDesktopBridge();
76+
const listener = vi.fn();
77+
78+
bridge.onOpenProjectPath(listener);
79+
emitIpc(OPEN_PROJECT_PATH_CHANNEL, "/tmp/project-sample");
80+
81+
expect(listener).toHaveBeenCalledTimes(1);
82+
expect(listener).toHaveBeenCalledWith("/tmp/project-sample");
83+
});
84+
85+
it("does not notify unsubscribed open-project-path listeners", async () => {
86+
const bridge = await loadDesktopBridge();
87+
const listener = vi.fn();
88+
89+
const unsubscribe = bridge.onOpenProjectPath(listener);
90+
unsubscribe();
91+
emitIpc(OPEN_PROJECT_PATH_CHANNEL, "/tmp/project-sample");
92+
93+
expect(listener).not.toHaveBeenCalled();
94+
});
95+
});

apps/desktop/src/preload.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,25 @@ const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environmen
2626
const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
2727
const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
2828

29+
const openProjectPathListeners = new Set<(path: string) => void>();
30+
const pendingOpenProjectPaths: string[] = [];
31+
32+
function dispatchOpenProjectPath(path: string): void {
33+
if (!openProjectPathListeners.size) {
34+
pendingOpenProjectPaths.push(path);
35+
return;
36+
}
37+
38+
for (const listener of openProjectPathListeners) {
39+
listener(path);
40+
}
41+
}
42+
43+
ipcRenderer.on(OPEN_PROJECT_PATH_CHANNEL, (_event: Electron.IpcRendererEvent, path: unknown) => {
44+
if (typeof path !== "string") return;
45+
dispatchOpenProjectPath(path);
46+
});
47+
2948
contextBridge.exposeInMainWorld("desktopBridge", {
3049
getAppBranding: () => {
3150
const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL);
@@ -71,14 +90,12 @@ contextBridge.exposeInMainWorld("desktopBridge", {
7190
};
7291
},
7392
onOpenProjectPath: (listener) => {
74-
const wrappedListener = (_event: Electron.IpcRendererEvent, path: unknown) => {
75-
if (typeof path !== "string") return;
93+
openProjectPathListeners.add(listener);
94+
for (const path of pendingOpenProjectPaths.splice(0)) {
7695
listener(path);
77-
};
78-
79-
ipcRenderer.on(OPEN_PROJECT_PATH_CHANNEL, wrappedListener);
96+
}
8097
return () => {
81-
ipcRenderer.removeListener(OPEN_PROJECT_PATH_CHANNEL, wrappedListener);
98+
openProjectPathListeners.delete(listener);
8299
};
83100
},
84101
getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),

0 commit comments

Comments
 (0)