1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import * as Electron from "electron";
3import { MacPermissionHelper, macAppBundlePath } from "./MacPermissionHelper.ts";
4import type { SettingsWindow } from "./MacSettingsWindow.ts";
5import { MAC_PERMISSION_HELPER_CHANNEL } from "../ipc/channels.ts";
6
7const mocks = vi.hoisted(() => ({
8 granted: false,
9 createFromPath: vi.fn(),
10 startDrag: vi.fn(),
11 showItemInFolder: vi.fn(),
12 send: vi.fn(),
13 loadURL: vi.fn(),
14 stopTracking: vi.fn(),
15 trackingFailed: undefined as (() => void) | undefined,
16 settingsChanged: undefined as ((state: SettingsWindow) => void) | undefined,
17}));
18const windows = vi.hoisted(
19 () =>
20 [] as Array<{
21 destroyed: boolean;
22 webContents: { mainFrame: object };
23 setBounds: ReturnType<typeof vi.fn>;
24 hide: ReturnType<typeof vi.fn>;
25 showInactive: ReturnType<typeof vi.fn>;
26 }>,
27);
28vi.mock("electron", async () => {
29 const { EventEmitter } = await import("node:events");
30 class MockWindow extends EventEmitter {
31 destroyed = false;
32 webContents = Object.assign(new EventEmitter(), {
33 mainFrame: {},
34 startDrag: mocks.startDrag,
35 send: mocks.send,
36 setWindowOpenHandler: vi.fn(),
37 });
38 constructor(_options: unknown) {
39 super();
40 windows.push(this);
41 }
42 isDestroyed() {
43 return this.destroyed;
44 }
45 destroy() {
46 this.destroyed = true;
47 this.emit("closed");
48 }
49 close() {
50 this.destroy();
51 }
52 loadURL = mocks.loadURL;
53 showInactive = vi.fn();
54 hide = vi.fn();
55 setBounds = vi.fn();
56 isVisible = () => false;
57 isFocused = () => false;
58 show = vi.fn();
59 focus = vi.fn();
60 getBounds = () => ({ x: 0, y: 0, width: 800, height: 600 });
61 }
62 return {
63 app: {
64 getPath: () => "/Applications/The Knife (Nightly).app/Contents/MacOS/The Knife",
65 },
66 nativeImage: { createFromPath: mocks.createFromPath },
67 BrowserWindow: class extends MockWindow {},
68 ipcMain: new EventEmitter(),
69 screen: {
70 getDisplayMatching: () => ({ workArea: { x: 0, y: 0, width: 1200, height: 900 } }),
71 getCursorScreenPoint: () => ({ x: 10, y: 10 }),
72 getDisplayNearestPoint: () => ({ workArea: { x: -1200, y: 0, width: 1200, height: 900 } }),
73 },
74 systemPreferences: {
75 getMediaAccessStatus: () => (mocks.granted ? "granted" : "denied"),
76 isTrustedAccessibilityClient: () => mocks.granted,
77 },
78 shell: { showItemInFolder: mocks.showItemInFolder },
79 };
80});
81vi.mock("./MacSettingsWindow.ts", async (importOriginal) => {
82 const actual = await importOriginal<typeof import("./MacSettingsWindow.ts")>();
83 return {
84 ...actual,
85 watchMacSettingsWindow: (
86 onChange: (state: SettingsWindow) => void,
87 onUnavailable: () => void,
88 ) => {
89 mocks.settingsChanged = onChange;
90 mocks.trackingFailed = onUnavailable;
91 return mocks.stopTracking;
92 },
93 };
94});
95let helper: MacPermissionHelper;
96beforeEach(() => {
97 vi.useFakeTimers();
98 vi.clearAllMocks();
99 mocks.granted = false;
100 const icon = { toDataURL: () => "data:image/png;base64,abc" };
101 mocks.createFromPath.mockReturnValue({ isEmpty: () => false, resize: () => icon });
102 mocks.loadURL.mockResolvedValue(undefined);
103 windows.length = 0;
104 helper = new MacPermissionHelper();
105});
106afterEach(() => {
107 helper.close();
108 vi.useRealTimers();
109});
110const iconPaths = ["/bundle/prod-resources/icon.png"];
111const open = () =>
112 helper.show("accessibility", "/bundle/mac-permission-preload.cjs", null, iconPaths);
113function send(action: string, trusted = true) {
114 const window = windows.at(-1)!;
115 Electron.ipcMain.emit(
116 MAC_PERMISSION_HELPER_CHANNEL,
117 {
118 sender: trusted ? window.webContents : {},
119 senderFrame: window.webContents.mainFrame,
120 },
121 action,
122 );
123}
124
125describe("macAppBundlePath", () => {
126 it("resolves bundles with spaces and refuses non-bundle executables", () => {
127 expect(macAppBundlePath("/Applications/The Knife.app/Contents/MacOS/The Knife")).toBe(
128 "/Applications/The Knife.app",
129 );
130 expect(macAppBundlePath("/usr/local/bin/electron")).toBeUndefined();
131 expect(macAppBundlePath("/Applications/The Knife.app/other/MacOS/The Knife")).toBeUndefined();
132 });
133});
134it("drags the running app bundle only for the helper's own renderer", async () => {
135 await open();
136 send("drag", false);
137 expect(mocks.startDrag).not.toHaveBeenCalled();
138 send("drag");
139 expect(mocks.createFromPath).toHaveBeenCalledWith("/bundle/prod-resources/icon.png");
140 expect(mocks.startDrag).toHaveBeenCalledWith({
141 file: "/Applications/The Knife (Nightly).app",
142 icon: mocks.createFromPath.mock.results[0]!.value.resize(),
143 });
144 send("finder");
145 expect(mocks.showItemInFolder).toHaveBeenCalledWith("/Applications/The Knife (Nightly).app");
146});
147it("rechecks permissions and releases resources when granted", async () => {
148 await open();
149 mocks.granted = true;
150 await vi.advanceTimersByTimeAsync(1000);
151 expect(windows[0]!.destroyed).toBe(true);
152 expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(0);
153 expect(vi.getTimerCount()).toBe(0);
154});
155it("keeps only one helper and cleans up on dismissal", async () => {
156 await open();
157 await helper.show("screen-recording", "/preload.cjs", null, iconPaths);
158 expect(windows[0]!.destroyed).toBe(true);
159 expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(1);
160 send("close");
161 expect(windows[1]!.destroyed).toBe(true);
162 expect(vi.getTimerCount()).toBe(0);
163});
164it("does not open for a permission already granted", async () => {
165 mocks.granted = true;
166 await open();
167 expect(windows).toHaveLength(0);
168});
169it("does not show a helper with a missing packaged icon", async () => {
170 mocks.createFromPath.mockReturnValueOnce({ isEmpty: () => true });
171 await expect(open()).rejects.toThrow("packaged The Knife icon is missing");
172 expect(windows).toHaveLength(0);
173});
174it("cleans up when the helper page fails to load", async () => {
175 mocks.loadURL.mockRejectedValueOnce(new Error("load failed"));
176 await expect(open()).rejects.toThrow("load failed");
177 expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(0);
178 expect(vi.getTimerCount()).toBe(0);
179});
180
181it("offers the Finder fallback when native dragging fails", async () => {
182 await open();
183 mocks.startDrag.mockImplementationOnce(() => {
184 throw new Error("drag failed");
185 });
186 send("drag");
187 expect(mocks.showItemInFolder).toHaveBeenCalledWith("/Applications/The Knife (Nightly).app");
188 expect(windows[0]!.destroyed).toBe(false);
189});
190
191it("returns focus to onboarding when the permission is granted", async () => {
192 const owner = new Electron.BrowserWindow({});
193 await helper.show("screen-recording", "/preload.cjs", owner, iconPaths);
194 mocks.granted = true;
195 await vi.advanceTimersByTimeAsync(1000);
196 expect(owner.show).toHaveBeenCalledOnce();
197 expect(owner.focus).toHaveBeenCalledOnce();
198 expect(windows[1]!.destroyed).toBe(true);
199 expect(owner.listenerCount("closed")).toBe(0);
200});
201it("closes the helper and stops checking when onboarding's window closes", async () => {
202 const owner = new Electron.BrowserWindow({});
203 await helper.show("accessibility", "/preload.cjs", owner, iconPaths);
204 owner.destroy();
205 expect(windows[1]!.destroyed).toBe(true);
206 expect(Electron.ipcMain.listenerCount(MAC_PERMISSION_HELPER_CHANNEL)).toBe(0);
207 expect(vi.getTimerCount()).toBe(0);
208});
209
210it("uses the packaged PNG when an earlier resource candidate is absent", async () => {
211 mocks.createFromPath.mockReturnValueOnce({ isEmpty: () => true });
212 await helper.show("accessibility", "/preload.cjs", null, ["/missing/icon.png", ...iconPaths]);
213 send("drag");
214 expect(mocks.startDrag).toHaveBeenCalled();
215 expect(mocks.createFromPath).toHaveBeenLastCalledWith(iconPaths[0]);
216});
217
218it("docks inside Settings and hides when it is covered or closed", async () => {
219 await open();
220 const window = windows[0]!;
221 const settings = { x: 367, y: 100, width: 723, height: 719, frontmost: true };
222 mocks.settingsChanged!(settings);
223 expect(window.setBounds).toHaveBeenLastCalledWith(
224 { x: 599, y: 663, width: 475, height: 140 },
225 false,
226 );
227 expect(window.showInactive).toHaveBeenCalledOnce();
228 mocks.settingsChanged!({ ...settings, x: -800, y: 200 });
229 expect(window.setBounds).toHaveBeenLastCalledWith(
230 { x: -568, y: 763, width: 475, height: 140 },
231 false,
232 );
233 mocks.settingsChanged!({ ...settings, frontmost: false });
234 expect(window.hide).toHaveBeenCalledOnce();
235 mocks.settingsChanged!(null);
236 expect(window.destroyed).toBe(true);
237 helper.close();
238 expect(mocks.stopTracking).toHaveBeenCalledOnce();
239});
240
241it("returns to onboarding when the Settings window disappears", async () => {
242 const owner = new Electron.BrowserWindow({});
243 owner.hide();
244 await helper.show("accessibility", "/preload.cjs", owner, iconPaths);
245 mocks.settingsChanged!({ x: 100, y: 100, width: 723, height: 719, frontmost: true });
246 mocks.settingsChanged!(null);
247 expect(owner.show).toHaveBeenCalledOnce();
248 expect(owner.focus).toHaveBeenCalledOnce();
249});
250it("hides on tracking failure and resumes on a valid update", async () => {
251 await open();
252 const state = { x: 100, y: 100, width: 723, height: 719, frontmost: true };
253 mocks.settingsChanged!(state);
254 mocks.trackingFailed!();
255 expect(windows[0]!.destroyed).toBe(false);
256 expect(windows[0]!.hide).toHaveBeenCalledOnce();
257 mocks.settingsChanged!(state);
258 expect(windows[0]!.showInactive).toHaveBeenCalledTimes(2);
259});
260
261it("waits for an asynchronous Full Disk Access check and returns to the owner", async () => {
262 const owner = new Electron.BrowserWindow();
263 const probe = vi.fn<() => Promise<boolean>>().mockResolvedValue(false);
264 await helper.show(
265 "full-disk-access",
266 "/bundle/mac-permission-preload.cjs",
267 owner,
268 iconPaths,
269 probe,
270 );
271 const pending = Promise.withResolvers<boolean>();
272 probe.mockReturnValue(pending.promise);
273 await vi.advanceTimersByTimeAsync(3000);
274 expect(probe).toHaveBeenCalledTimes(2);
275 expect(windows[1]!.destroyed).toBe(false);
276 pending.resolve(true);
277 await pending.promise;
278 expect(windows[1]!.destroyed).toBe(true);
279 expect(owner.focus).toHaveBeenCalledOnce();
280 expect(vi.getTimerCount()).toBe(0);
281});
282
283it("keeps the helper open after a failed access check and retries", async () => {
284 const probe = vi.fn<() => Promise<boolean>>().mockResolvedValue(false);
285 await helper.show(
286 "full-disk-access",
287 "/bundle/mac-permission-preload.cjs",
288 null,
289 iconPaths,
290 probe,
291 );
292 probe.mockRejectedValueOnce(new Error("temporarily unavailable"));
293 await vi.advanceTimersByTimeAsync(1000);
294 expect(windows[0]!.destroyed).toBe(false);
295 probe.mockResolvedValue(true);
296 await vi.advanceTimersByTimeAsync(1000);
297 expect(windows[0]!.destroyed).toBe(true);
298});
299
300it("does not reopen a superseded helper when its initial probe completes", async () => {
301 const pending = Promise.withResolvers<boolean>();
302 const first = helper.show(
303 "full-disk-access",
304 "/bundle/mac-permission-preload.cjs",
305 null,
306 iconPaths,
307 () => pending.promise,
308 );
309 await open();
310 pending.resolve(false);
311 await first;
312 expect(windows).toHaveLength(1);
313 expect(windows[0]!.destroyed).toBe(false);
314});