1// @effect-diagnostics globalTimers:off -- Poll TCC only while the native permission helper is open.
2import * as Electron from "electron";
3import { MAC_PERMISSION_HELPER_CHANNEL } from "../ipc/channels.ts";
4
5import {
6 settingsHelperBounds,
7 watchMacSettingsWindow,
8 type SettingsWindow,
9} from "./MacSettingsWindow.ts";
10
11import { MAC_PERMISSION_TITLES, type MacPermission } from "./MacPermission.ts";
12
13const permissionGranted = (permission: MacPermission) => {
14 if (permission === "screen-recording")
15 return Electron.systemPreferences.getMediaAccessStatus("screen") === "granted";
16 if (permission === "accessibility")
17 return Electron.systemPreferences.isTrustedAccessibilityClient(false);
18 return false;
19};
20
21/** Resolve the outer app bundle, never the executable or the ASAR inside it. */
22export function macAppBundlePath(executable: string): string | undefined {
23 return /^(.+\.app)\/Contents\/MacOS\/[^/]+$/.exec(executable)?.[1];
24}
25
26const escapeHtml = (value: string) =>
27 value.replace(/[&<>"']/g, (character) => {
28 switch (character) {
29 case "&":
30 return "&amp;";
31 case "<":
32 return "&lt;";
33 case ">":
34 return "&gt;";
35 case '"':
36 return "&quot;";
37 default:
38 return "&#39;";
39 }
40 });
41
42function helperHtml(permission: MacPermission, icon: string) {
43 const title = MAC_PERMISSION_TITLES[permission];
44 return `<!doctype html><html lang="en"><head><meta charset="utf-8">
45<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'">
46<title>Set up ${title}</title><style>
47:root { color-scheme: light dark; --base: #fff; --row: #e7e7e7; --text: #292929; --line: #e3e3e3; }
48@media (prefers-color-scheme: dark) { :root { --base: #242424; --row: #383838; --text: #f5f5f5; --line: #484848; } }
49* { box-sizing: border-box; }
50html { background: transparent; }
51body { margin: 0; background: transparent; color: var(--text); font: 15px/22px -apple-system, BlinkMacSystemFont, sans-serif; user-select: none; }
52#panel { position: relative; margin: 2px; padding: 20px; height: 136px; border: 1px solid var(--line); border-radius: 24px; background: var(--base); }
53header { font-weight: 600; white-space: nowrap; }
54button { font: inherit; color: inherit; }
55button:focus-visible { outline: 2px solid #007aff; outline-offset: -2px; }
56#close { position: absolute; right: 8px; top: 6px; width: 22px; height: 22px; padding: 0; border: 0; border-radius: 50%; background: var(--base); font-size: 18px; cursor: pointer; opacity: 0; }
57#panel:hover #close, #close:focus-visible { opacity: 1; }
58#app { display: flex; align-items: center; gap: 12px; width: 100%; height: 52px; margin-top: 20px; padding: 8px 12px; border: 0; border-radius: 10px; background: var(--row); cursor: grab; text-align: left; font-size: 16px; font-weight: 600; }
59#app:active { cursor: grabbing; }
60img { width: 32px; height: 32px; pointer-events: none; }
61</style></head><body><main id="panel">
62<button id="close" aria-label="Close permission helper">×</button>
63<header>↑ Drag The Knife into the list above</header>
64<button id="app" draggable="true" aria-label="Drag The Knife to System Settings, or click to reveal in Finder"><img src="${escapeHtml(icon)}" alt="" draggable="false">The Knife</button>
65</main></body></html>`;
66}
67
68/** Owns one temporary panel and its IPC listener. Closing it releases all resources. */
69export class MacPermissionHelper {
70 private generation = 0;
71 private window: Electron.BrowserWindow | undefined;
72
73 close() {
74 this.generation++;
75 this.window?.destroy();
76 this.window = undefined;
77 }
78
79 async show(
80 permission: MacPermission,
81 preload: string,
82 owner: Electron.BrowserWindow | null,
83 iconPaths: readonly string[],
84 isGranted: () => boolean | Promise<boolean> = () => permissionGranted(permission),
85 ) {
86 this.close();
87 const generation = this.generation;
88 if (await isGranted()) return;
89 if (generation !== this.generation) return;
90 const bundle = macAppBundlePath(Electron.app.getPath("exe"));
91 if (!bundle) return;
92 if (owner?.isDestroyed()) return;
93 // Finder's bundle-icon lookup can return the generic app icon for mounted artifacts.
94 // Use the same PNG that packaging uses to generate the app's macOS icon.
95 const appIcon = iconPaths
96 .map((iconPath) => Electron.nativeImage.createFromPath(iconPath))
97 .find((image) => !image.isEmpty());
98 if (!appIcon) throw new Error("The packaged The Knife icon is missing.");
99 const icon = appIcon.resize({ width: 64, height: 64 });
100 const window = new Electron.BrowserWindow({
101 width: 560,
102 height: 140,
103 show: false,
104 frame: false,
105 transparent: true,
106 roundedCorners: false,
107 backgroundColor: "#00000000",
108 hasShadow: false,
109 resizable: false,
110 minimizable: false,
111 maximizable: false,
112 fullscreenable: false,
113 alwaysOnTop: true,
114 skipTaskbar: true,
115 title: `Set up ${MAC_PERMISSION_TITLES[permission]}`,
116 webPreferences: { preload, sandbox: true, contextIsolation: true, nodeIntegration: false },
117 });
118 this.window = window;
119 const finish = () => {
120 window.close();
121 if (owner && !owner.isDestroyed()) {
122 owner.show();
123 owner.focus();
124 }
125 };
126 let checking = false;
127 const check = async () => {
128 if (checking || window.isDestroyed()) return;
129 checking = true;
130 try {
131 if ((await isGranted()) && !window.isDestroyed()) finish();
132 } catch {
133 // An unavailable probe is not evidence of a grant; the wizard can retry.
134 } finally {
135 checking = false;
136 }
137 };
138 const onMessage = (event: Electron.IpcMainEvent, action: unknown) => {
139 if (event.sender !== window.webContents || event.senderFrame !== window.webContents.mainFrame)
140 return;
141 if (action === "drag") {
142 try {
143 window.webContents.startDrag({ file: bundle, icon });
144 } catch {
145 Electron.shell.showItemInFolder(bundle);
146 }
147 } else if (action === "finder") {
148 Electron.shell.showItemInFolder(bundle);
149 } else if (action === "close") {
150 finish();
151 }
152 };
153 let settingsWindow: SettingsWindow = null;
154 let foundSettings = false;
155 let trackingAvailable = true;
156 const syncPosition = () => {
157 if (window.isDestroyed()) return;
158 if (!trackingAvailable) {
159 window.hide();
160 return;
161 }
162 if (!settingsWindow && foundSettings) {
163 finish();
164 return;
165 }
166 if (!settingsWindow || (!settingsWindow.frontmost && !window.isFocused())) {
167 window.hide();
168 return;
169 }
170 const bounds = settingsHelperBounds(settingsWindow);
171 const current = window.getBounds();
172 if (
173 current.x !== bounds.x ||
174 current.y !== bounds.y ||
175 current.width !== bounds.width ||
176 current.height !== bounds.height
177 ) {
178 window.setBounds(bounds, false);
179 }
180 if (!window.isVisible()) window.showInactive();
181 };
182 let stopTracking = () => {};
183 window.on("blur", syncPosition);
184 const onOwnerClosed = () => window.destroy();
185 Electron.ipcMain.on(MAC_PERMISSION_HELPER_CHANNEL, onMessage);
186 const timer = setInterval(check, 1_000);
187 owner?.once("closed", onOwnerClosed);
188 window.once("closed", () => {
189 clearInterval(timer);
190 stopTracking();
191 Electron.ipcMain.removeListener(MAC_PERMISSION_HELPER_CHANNEL, onMessage);
192 owner?.removeListener("closed", onOwnerClosed);
193 if (this.window === window) this.window = undefined;
194 });
195 window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
196 window.webContents.on("will-navigate", (event) => event.preventDefault());
197 try {
198 await window.loadURL(
199 `data:text/html;charset=utf-8,${encodeURIComponent(helperHtml(permission, icon.toDataURL()))}`,
200 );
201 if (!window.isDestroyed()) {
202 stopTracking = watchMacSettingsWindow(
203 (current) => {
204 trackingAvailable = true;
205 settingsWindow = current;
206 if (current) foundSettings = true;
207 syncPosition();
208 },
209 () => {
210 trackingAvailable = false;
211 syncPosition();
212 },
213 );
214 }
215 } catch (error) {
216 if (!window.isDestroyed()) window.destroy();
217 throw error;
218 }
219 }
220}