authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-11 16:42:11-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-11 16:42:11-07:00
log36668dbe4fe2f8c881cc4f93bc675413eef1406f
tree92a2c1f6d1657b142533e43b2d2e99e29f248851
parenta92161a05040c3fcaaa479f1b60d2d45cd368dd4
signature Signed by PGP key B5690EEEBB952194

feat(desktop): share macOS permission onboarding (#11289)


30 files changed, 1368 insertions(+), 162 deletions(-)

apps/desktop/src/electron/ElectronShell.ts+2-15
......@@ -1,3 +1,4 @@
1import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts";
12import {
23 REMOTE_CAPABLE_EDITOR_IDS,
34 remoteSchemeForEditor,
......@@ -10,20 +11,6 @@ import * as Option from "effect/Option";
1011
1112import * as Electron from "electron";
1213
13/**
14 * Deep links to individual System Settings panes. These are app-fixed, not
15 * renderer-supplied, so they skip `parseSafeExternalUrl` — which exists to keep
16 * arbitrary link schemes from reaching the OS handler — and open through their
17 * own path below. The pane rather than the URL crosses the IPC boundary, so a
18 * renderer can only ask for one of these known destinations.
19 *
20 * Full Disk Access uses the post-Ventura `PrivacySecurity.extension` anchor.
21 */
22const SYSTEM_SETTINGS_URLS: Record<SystemSettingsPane, string> = {
23 "full-disk-access":
24 "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles",
25};
26
2714// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`,
2815// `zed://ssh/<host>/<path>`) must reach the OS handler; every other non-web
2916// scheme stays blocked.
......@@ -88,7 +75,7 @@ export const make = ElectronShell.of({
8875 }),
8976 openSystemSettings: (pane) =>
9077 Effect.promise(() =>
91 Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then(
78 Electron.shell.openExternal(MAC_PERMISSION_SETTINGS_URLS[pane]).then(
9279 () => true,
9380 () => false,
9481 ),
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
......@@ -39,6 +39,7 @@ import {
3939 getWindowFullscreenState,
4040 openExternal,
4141 openSystemSettings,
42 checkSystemPermission,
4243 probeRemoteEditors,
4344 pickFolder,
4445 pickProjectFavicon,
......@@ -122,6 +123,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
122123 yield* ipc.handle(showContextMenu);
123124 yield* ipc.handle(openExternal);
124125 yield* ipc.handle(openSystemSettings);
126 yield* ipc.handle(checkSystemPermission);
125127 yield* ipc.handle(probeRemoteEditors);
126128 yield* ipc.handle(getUpdateState);
127129 yield* ipc.handle(setUpdateChannel);
apps/desktop/src/ipc/channels.ts+4
......@@ -105,3 +105,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
105105export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";
106106export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
107107export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event";
108
109export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper";
110
111export const CHECK_SYSTEM_PERMISSION_CHANNEL = "desktop:check-system-permission";
apps/desktop/src/ipc/methods/window.ts+25-1
......@@ -34,6 +34,9 @@ import * as ElectronMenu from "../../electron/ElectronMenu.ts";
3434import * as ElectronShell from "../../electron/ElectronShell.ts";
3535import * as ElectronTheme from "../../electron/ElectronTheme.ts";
3636import * as ElectronWindow from "../../electron/ElectronWindow.ts";
37import * as Electron from "electron";
38import * as MacPermissions from "../../permissions/MacPermissions.ts";
39import { safariPermissionCheck } from "../../preview/BrowserImport/SafariPermission.ts";
3740import * as IpcChannels from "../channels.ts";
3841import * as DesktopIpc from "../DesktopIpc.ts";
3942import {
......@@ -305,7 +308,16 @@ export const openSystemSettings = DesktopIpc.makeIpcMethod({
305308 result: Schema.Boolean,
306309 handler: Effect.fn("desktop.ipc.window.openSystemSettings")(function* (pane) {
307310 const shell = yield* ElectronShell.ElectronShell;
308 return yield* shell.openSystemSettings(pane);
311 const environment = yield* DesktopEnvironment.DesktopEnvironment;
312 if (environment.platform !== "darwin") return false;
313 const owner = Electron.BrowserWindow.getFocusedWindow();
314 const opened = yield* shell.openSystemSettings(pane);
315 if (opened && environment.isPackaged) {
316 const permissions = yield* MacPermissions.MacPermissions;
317 const isGranted = yield* safariPermissionCheck;
318 yield* permissions.showHelper(pane, owner, isGranted);
319 }
320 return opened;
309321 }),
310322});
311323
......@@ -379,3 +391,15 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({
379391 });
380392 }),
381393});
394
395export const checkSystemPermission = DesktopIpc.makeIpcMethod({
396 channel: IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL,
397 payload: SystemSettingsPaneSchema,
398 result: Schema.Boolean,
399 handler: Effect.fn("desktop.ipc.window.checkSystemPermission")(function* () {
400 const environment = yield* DesktopEnvironment.DesktopEnvironment;
401 if (environment.platform !== "darwin") return false;
402 const check = yield* safariPermissionCheck;
403 return yield* Effect.promise(check);
404 }),
405});
apps/desktop/src/mac-permission-preload.ts created+17
......@@ -0,0 +1,17 @@
1import { ipcRenderer } from "electron";
2import { MAC_PERMISSION_HELPER_CHANNEL } from "./ipc/channels.ts";
3
4// This preload belongs only to the static permission panel. No general desktop bridge is exposed.
5window.addEventListener("DOMContentLoaded", () => {
6 const send = (action: "drag" | "finder" | "close") =>
7 ipcRenderer.send(MAC_PERMISSION_HELPER_CHANNEL, action);
8 document.getElementById("app")?.addEventListener("dragstart", (event) => {
9 event.preventDefault();
10 send("drag");
11 });
12 document.getElementById("app")?.addEventListener("click", () => send("finder"));
13 document.getElementById("close")?.addEventListener("click", () => send("close"));
14 document.addEventListener("keydown", (event) => {
15 if (event.key === "Escape") send("close");
16 });
17});
apps/desktop/src/main.ts+2
......@@ -1,3 +1,4 @@
1import * as MacPermissions from "./permissions/MacPermissions.ts";
12for (const stream of [process.stdout, process.stderr]) {
23 stream.on("error", (err: NodeJS.ErrnoException) => {
34 if (err.code !== "EPIPE") throw err;
......@@ -133,6 +134,7 @@ const electronLayer = Layer.mergeAll(
133134);
134135
135136const desktopFoundationLayer = Layer.mergeAll(
137 MacPermissions.layer,
136138 DesktopState.layer,
137139 DesktopShutdown.layer,
138140 DesktopAppSettings.layer,
apps/desktop/src/permissions/MacPermission.ts created+15
......@@ -0,0 +1,15 @@
1export const MAC_PERMISSION_SETTINGS_URLS = {
2 "screen-recording":
3 "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
4 accessibility: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility",
5 "full-disk-access":
6 "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles",
7};
8
9export type MacPermission = keyof typeof MAC_PERMISSION_SETTINGS_URLS;
10
11export const MAC_PERMISSION_TITLES: Record<MacPermission, string> = {
12 "screen-recording": "Screen Recording",
13 accessibility: "Accessibility",
14 "full-disk-access": "Full Disk Access",
15};
apps/desktop/src/permissions/MacPermissionHelper.test.ts created+314
......@@ -0,0 +1,314 @@
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/T3 Code (Nightly).app/Contents/MacOS/T3 Code",
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/T3 Code.app/Contents/MacOS/T3 Code")).toBe(
128 "/Applications/T3 Code.app",
129 );
130 expect(macAppBundlePath("/usr/local/bin/electron")).toBeUndefined();
131 expect(macAppBundlePath("/Applications/T3 Code.app/other/MacOS/T3 Code")).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/T3 Code (Nightly).app",
142 icon: mocks.createFromPath.mock.results[0]!.value.resize(),
143 });
144 send("finder");
145 expect(mocks.showItemInFolder).toHaveBeenCalledWith("/Applications/T3 Code (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 T3 Code 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/T3 Code (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});
apps/desktop/src/permissions/MacPermissionHelper.ts created+220
......@@ -0,0 +1,220 @@
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: 3px; }
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 T3 Code into the list above</header>
64<button id="app" draggable="true" aria-label="Drag T3 Code to System Settings, or click to reveal in Finder"><img src="${escapeHtml(icon)}" alt="" draggable="false">T3 Code</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 T3 Code 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}
apps/desktop/src/permissions/MacPermissions.ts created+45
......@@ -0,0 +1,45 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Path from "effect/Path";
5import * as Electron from "electron";
6import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
7import { MacPermissionHelper } from "./MacPermissionHelper.ts";
8import type { MacPermission } from "./MacPermission.ts";
9
10export class MacPermissions extends Context.Service<
11 MacPermissions,
12 {
13 readonly showHelper: (
14 permission: MacPermission,
15 owner: Electron.BrowserWindow | null,
16 isGranted?: () => boolean | Promise<boolean>,
17 ) => Effect.Effect<void>;
18 }
19>()("@t3tools/desktop/permissions/MacPermissions") {}
20
21export const layer = Layer.effect(
22 MacPermissions,
23 Effect.gen(function* () {
24 const environment = yield* DesktopEnvironment.DesktopEnvironment;
25 const path = yield* Path.Path;
26 const helper = new MacPermissionHelper();
27 yield* Effect.addFinalizer(() => Effect.sync(() => helper.close()));
28 return MacPermissions.of({
29 showHelper: Effect.fn("MacPermissions.showHelper")(function* (permission, owner, isGranted) {
30 if (environment.platform !== "darwin" || !environment.isPackaged) return;
31 yield* Effect.tryPromise(() =>
32 helper.show(
33 permission,
34 path.join(environment.dirname, "mac-permission-preload.cjs"),
35 owner,
36 environment.resolveResourcePathCandidates("icon.png"),
37 isGranted,
38 ),
39 ).pipe(
40 Effect.catch((cause) => Effect.logWarning("Could not show permission helper", cause)),
41 );
42 }),
43 });
44 }),
45);
apps/desktop/src/permissions/MacSettingsWindow.test.ts created+55
......@@ -0,0 +1,55 @@
1import * as NodeEvents from "node:events";
2import { beforeEach, expect, it, vi } from "vite-plus/test";
3import { settingsHelperBounds, watchMacSettingsWindow } from "./MacSettingsWindow.ts";
4
5const mocks = vi.hoisted(() => ({ spawn: vi.fn() }));
6vi.mock("node:child_process", () => ({ spawn: mocks.spawn }));
7let child: NodeEvents.EventEmitter & {
8 stdout: NodeEvents.EventEmitter & { setEncoding: ReturnType<typeof vi.fn> };
9 kill: ReturnType<typeof vi.fn>;
10};
11beforeEach(() => {
12 child = Object.assign(new NodeEvents.EventEmitter(), {
13 stdout: Object.assign(new NodeEvents.EventEmitter(), { setEncoding: vi.fn() }),
14 kill: vi.fn(),
15 });
16 mocks.spawn.mockReturnValue(child);
17});
18it("places the helper inside small and large Settings windows across displays", () => {
19 for (const x of [-900, 20]) {
20 for (const width of [668, 1000]) {
21 const state = { x, y: 30, width, height: 700, frontmost: true };
22 const helper = settingsHelperBounds(state);
23 expect(helper.x).toBeGreaterThanOrEqual(x + 216);
24 expect(helper.x + helper.width).toBeLessThanOrEqual(x + width - 16);
25 expect(helper.y + helper.height).toBe(714);
26 }
27 }
28});
29it("decodes partial updates and stops the one watcher process on disposal", () => {
30 const changed = vi.fn();
31 const unavailable = vi.fn();
32 const stop = watchMacSettingsWindow(changed, unavailable);
33 const state = { x: 10, y: 20, width: 723, height: 719, frontmost: true };
34 const line = JSON.stringify(state);
35 child.stdout.emit("data", line.slice(0, 8));
36 expect(changed).not.toHaveBeenCalled();
37 child.stdout.emit("data", line.slice(8) + "\nnull\n");
38 expect(changed.mock.calls).toEqual([[state], [null]]);
39 stop();
40 expect(child.kill).toHaveBeenCalledOnce();
41 child.emit("exit", 0);
42 expect(changed).toHaveBeenCalledTimes(2);
43});
44it("distinguishes unavailable tracking from a valid absent window", () => {
45 const changed = vi.fn();
46 const unavailable = vi.fn();
47 const stop = watchMacSettingsWindow(changed, unavailable);
48 child.stdout.emit("data", '{"x":"bad"}\n');
49 expect(changed).not.toHaveBeenCalled();
50 expect(unavailable).toHaveBeenCalledOnce();
51 child.emit("error", new Error("spawn failed"));
52 expect(changed).not.toHaveBeenCalled();
53 expect(unavailable).toHaveBeenCalledTimes(2);
54 stop();
55});
apps/desktop/src/permissions/MacSettingsWindow.ts created+114
......@@ -0,0 +1,114 @@
1// @effect-diagnostics nodeBuiltinImport:off -- This native boundary keeps one JXA process open while the permission helper tracks System Settings.
2import * as NodeChildProcess from "node:child_process";
3import * as Schema from "effect/Schema";
4import type * as Electron from "electron";
5
6const SettingsWindow = Schema.NullOr(
7 Schema.Struct({
8 x: Schema.Finite,
9 y: Schema.Finite,
10 width: Schema.Finite,
11 height: Schema.Finite,
12 frontmost: Schema.Boolean,
13 }),
14);
15export type SettingsWindow = typeof SettingsWindow.Type;
16const decodeSettingsWindow = Schema.decodeUnknownSync(Schema.fromJsonString(SettingsWindow));
17
18// Window bounds and owner PIDs are available before Screen Recording is granted.
19// Use the bundle identifier rather than the localized app/window title. A single
20// process avoids launching osascript repeatedly while the user moves Settings.
21const SETTINGS_WINDOW_SCRIPT = `
22ObjC.import("CoreGraphics");
23ObjC.import("AppKit");
24function run() {
25 let previous = "";
26 while (true) {
27 const apps = $.NSRunningApplication.runningApplicationsWithBundleIdentifier("com.apple.systempreferences");
28 let result = null;
29 if (apps.count > 0) {
30 const pid = apps.objectAtIndex(0).processIdentifier;
31 const front = $.NSWorkspace.sharedWorkspace.frontmostApplication;
32 const list = $.CGWindowListCopyWindowInfo(
33 $.kCGWindowListOptionOnScreenOnly | $.kCGWindowListExcludeDesktopElements,
34 $.kCGNullWindowID
35 );
36 if (list) {
37 $.CFMakeCollectable(list);
38 const count = $.CFArrayGetCount(list);
39 for (let i = 0; i < count; i++) {
40 const w = ObjC.castRefToObject($.CFArrayGetValueAtIndex(list, i));
41 if (w.objectForKey("kCGWindowOwnerPID").js !== pid || w.objectForKey("kCGWindowLayer").js !== 0) continue;
42 const b = ObjC.deepUnwrap(w.objectForKey("kCGWindowBounds"));
43 if (b.Width < 500 || b.Height < 350) continue;
44 result = { x: b.X, y: b.Y, width: b.Width, height: b.Height, frontmost: !front.isNil() && front.processIdentifier === pid };
45 break;
46 }
47 }
48 }
49 const line = JSON.stringify(result);
50 if (line !== previous) {
51 const data = $(line + "\\n").dataUsingEncoding($.NSUTF8StringEncoding);
52 $.NSFileHandle.fileHandleWithStandardOutput.writeData(data);
53 previous = line;
54 }
55 $.NSThread.sleepForTimeInterval(result && result.frontmost ? 0.5 : 1);
56 }
57}`;
58
59/** Place the panel inside Settings' content column, above its bottom edge. */
60export function settingsHelperBounds(settings: NonNullable<SettingsWindow>): Electron.Rectangle {
61 const sidebarWidth = 216;
62 const inset = 16;
63 const width = Math.min(560, settings.width - sidebarWidth - inset * 2);
64 return {
65 x: Math.round(settings.x + sidebarWidth + (settings.width - sidebarWidth - width) / 2),
66 y: Math.round(settings.y + settings.height - 140 - inset),
67 width: Math.round(width),
68 height: 140,
69 };
70}
71
72/** Track only metadata; this does not request Accessibility or Screen Recording. */
73export function watchMacSettingsWindow(
74 onChange: (window: SettingsWindow) => void,
75 onUnavailable: () => void,
76): () => void {
77 const child = NodeChildProcess.spawn(
78 "/usr/bin/osascript",
79 ["-l", "JavaScript", "-e", SETTINGS_WINDOW_SCRIPT],
80 {
81 stdio: ["ignore", "pipe", "ignore"],
82 },
83 );
84 let pending = "";
85 let closed = false;
86 child.stdout.setEncoding("utf8");
87 child.stdout.on("data", (chunk: string) => {
88 pending += chunk;
89 let end: number;
90 while ((end = pending.indexOf("\n")) !== -1) {
91 const line = pending.slice(0, end);
92 pending = pending.slice(end + 1);
93 if (closed) return;
94 let settings: SettingsWindow;
95 try {
96 settings = decodeSettingsWindow(line);
97 } catch {
98 onUnavailable();
99 continue;
100 }
101 onChange(settings);
102 }
103 });
104 const onExit = () => {
105 if (!closed) onUnavailable();
106 };
107 child.on("error", onExit);
108 child.on("exit", onExit);
109 return () => {
110 closed = true;
111 child.stdout.removeAllListeners("data");
112 child.kill();
113 };
114}
apps/desktop/src/preload.ts+2
......@@ -152,6 +152,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
152152 ...(position === undefined ? {} : { position }),
153153 }),
154154 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
155 checkSystemPermission: (pane: string) =>
156 ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane),
155157 openSystemSettings: (pane: string) =>
156158 ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane),
157159 probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts+34
......@@ -11,6 +11,7 @@ import {
1111 parseBinaryCookies,
1212 readSafariCookies,
1313 safariAccessDenied,
14 safariAccessGranted,
1415 SafariCookieReadError,
1516} from "./SafariCookies.ts";
1617
......@@ -441,3 +442,36 @@ describe("isPermissionDenied", () => {
441442 expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false);
442443 });
443444});
445
446describe("safariAccessGranted", () => {
447 it.effect("only reports a successful read-only open as granted", () =>
448 Effect.gen(function* () {
449 const fileSystem = yield* FileSystem.FileSystem;
450 const directory = yield* fileSystem.makeTempDirectoryScoped({
451 prefix: "t3-safari-permission-",
452 });
453 const jar = `${directory}/Cookies.binarycookies`;
454 assert.isFalse(yield* safariAccessGranted(jar));
455 yield* fileSystem.writeFileString(jar, "no cookie parsing needed");
456 assert.isTrue(yield* safariAccessGranted(jar));
457 yield* fileSystem.remove(jar);
458 assert.isFalse(yield* safariAccessGranted(jar));
459 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
460 );
461
462 it.effect("does not mistake TCC denial for a grant", () =>
463 Effect.gen(function* () {
464 const denied = PlatformError.systemError({
465 _tag: "Unknown",
466 module: "FileSystem",
467 method: "open",
468 cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }),
469 });
470 assert.isFalse(
471 yield* safariAccessGranted("/protected/Cookies.binarycookies").pipe(
472 Effect.provide(FileSystem.layerNoop({ open: () => Effect.fail(denied) })),
473 ),
474 );
475 }),
476 );
477});
apps/desktop/src/preview/BrowserImport/SafariCookies.ts+10
......@@ -225,6 +225,16 @@ export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: strin
225225 );
226226});
227227
228/** A missing or unreadable jar is never evidence that access was granted. */
229export const safariAccessGranted = Effect.fnUntraced(function* (cookiePath: string) {
230 const fileSystem = yield* FileSystem.FileSystem;
231 return yield* fileSystem.open(cookiePath, { flag: "r" }).pipe(
232 Effect.as(true),
233 Effect.orElseSucceed(() => false),
234 Effect.scoped,
235 );
236});
237
228238export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* (
229239 cookiePath: string,
230240) {
apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts created+59
......@@ -0,0 +1,59 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as PlatformError from "effect/PlatformError";
7import { safariPermissionCheck } from "./SafariPermission.ts";
8
9it.effect("detects Safari access becoming available without reading or importing cookies", () =>
10 Effect.gen(function* () {
11 const fs = yield* FileSystem.FileSystem;
12 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-safari-access-" });
13 const directory = `${home}/Library/Containers/com.apple.Safari/Data/Library/Cookies`;
14 yield* fs.makeDirectory(directory, { recursive: true });
15 const jar = `${directory}/Cookies.binarycookies`;
16 yield* fs.writeFileString(jar, "not a valid cookie database");
17 let allowed = false;
18 const guardedFs = FileSystem.FileSystem.of({
19 ...fs,
20 open: (path, options) =>
21 allowed
22 ? fs.open(path, options)
23 : Effect.fail(
24 PlatformError.systemError({
25 _tag: "Unknown",
26 module: "FileSystem",
27 method: "open",
28 cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }),
29 }),
30 ),
31 });
32 const check = yield* safariPermissionCheck.pipe(
33 Effect.provideService(HostProcessEnvironment, { HOME: home }),
34 Effect.provideService(HostProcessPlatform, "darwin"),
35 Effect.provideService(FileSystem.FileSystem, guardedFs),
36 );
37 assert.isFalse(yield* Effect.promise(check));
38 allowed = true;
39 assert.isTrue(yield* Effect.promise(check));
40 yield* fs.remove(jar);
41 assert.isFalse(yield* Effect.promise(check));
42 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
43);
44
45it.effect("recognizes access when cookies exist only in a named Safari profile", () =>
46 Effect.gen(function* () {
47 const fs = yield* FileSystem.FileSystem;
48 const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-safari-named-access-" });
49 const check = yield* safariPermissionCheck.pipe(
50 Effect.provideService(HostProcessEnvironment, { HOME: home }),
51 Effect.provideService(HostProcessPlatform, "darwin"),
52 );
53 assert.isFalse(yield* Effect.promise(check));
54 const directory = `${home}/Library/Containers/com.apple.Safari/Data/Library/WebKit/WebsiteDataStore/12345678-1234-1234-1234-123456789abc/Cookies`;
55 yield* fs.makeDirectory(directory, { recursive: true });
56 yield* fs.writeFileString(`${directory}/Cookies.binarycookies`, "not parsed");
57 assert.isTrue(yield* Effect.promise(check));
58 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
59);
apps/desktop/src/preview/BrowserImport/SafariPermission.ts created+31
......@@ -0,0 +1,31 @@
1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import { safariAccessGranted } from "./SafariCookies.ts";
4import {
5 BROWSER_IMPORT_SOURCES,
6 resolveCookieDatabase,
7 listSourceProfiles,
8 sourcePathContext,
9} from "./Sources.ts";
10
11export const safariPermissionCheck = Effect.gen(function* () {
12 const context = yield* sourcePathContext;
13 const services = yield* Effect.context<FileSystem.FileSystem>();
14 const runPromise = Effect.runPromiseWith(services);
15 const safari = BROWSER_IMPORT_SOURCES.find((source) => source.engine === "safari");
16 const check = Effect.gen(function* () {
17 if (!safari || context.platform !== "darwin") return false;
18 const defaultJar = yield* resolveCookieDatabase(safari, context, ".");
19 if (defaultJar !== undefined) return yield* safariAccessGranted(defaultJar);
20 // A Safari installation can have cookies only in a named profile. Rediscover
21 // those stores after a grant, since TCC may have hidden their metadata before.
22 const profiles = yield* listSourceProfiles(safari, context);
23 for (const profile of profiles) {
24 const jar = yield* resolveCookieDatabase(safari, context, profile.directory);
25 if (jar !== undefined && (yield* safariAccessGranted(jar))) return true;
26 }
27 return false;
28 });
29 // Open and close the jar without reading cookies or attempting an import.
30 return () => runPromise(check);
31});
apps/desktop/src/snapShot/DesktopSnapShot.test.ts+40-35
......@@ -1,3 +1,4 @@
1import * as MacPermissions from "../permissions/MacPermissions.ts";
12import { assert, it } from "@effect/vitest";
23import {
34 DEFAULT_CLIENT_SETTINGS,
......@@ -488,41 +489,45 @@ const testLayer = (
488489 DesktopClientSettings.DesktopClientSettingsReadError
489490 > = Effect.succeed(initialSettings),
490491) =>
491 Layer.mergeAll(
492 Layer.succeed(
493 DesktopEnvironment.DesktopEnvironment,
494 DesktopEnvironment.DesktopEnvironment.of({
495 platform,
496 stateDir: "/state",
497 linuxDesktopEntryName: "com.t3tools.T3Code.desktop",
498 appRoot: "/repo",
499 linuxApplicationsDir: "/test-data/applications",
500 } as DesktopEnvironment.DesktopEnvironment["Service"]),
501 ),
502 Layer.succeed(
503 DesktopClientSettings.DesktopClientSettings,
504 DesktopClientSettings.DesktopClientSettings.of({
505 get: settingsGet,
506 set: () => Effect.void,
507 }),
508 ),
509 Layer.succeed(
510 DesktopWindow.DesktopWindow,
511 DesktopWindow.DesktopWindow.of({
512 activate: Effect.void,
513 prepareCaptureReveal: Effect.sync(prepareCaptureRevealMock),
514 dispatchMenuAction: () => Effect.void,
515 dispatchSnapShotEvent: () => Effect.void,
516 } as unknown as DesktopWindow.DesktopWindow["Service"]),
517 ),
518 FileSystem.layerNoop(fileSystemOverrides),
519 Path.layer,
520 Layer.succeed(
521 Crypto.Crypto,
522 Crypto.make({
523 randomBytes: (size) => new Uint8Array(size),
524 digest: (_algorithm, data) => Effect.succeed(data),
525 }),
492 MacPermissions.layer.pipe(
493 Layer.provideMerge(
494 Layer.mergeAll(
495 Layer.succeed(
496 DesktopEnvironment.DesktopEnvironment,
497 DesktopEnvironment.DesktopEnvironment.of({
498 platform,
499 stateDir: "/state",
500 linuxDesktopEntryName: "com.t3tools.T3Code.desktop",
501 appRoot: "/repo",
502 linuxApplicationsDir: "/test-data/applications",
503 } as DesktopEnvironment.DesktopEnvironment["Service"]),
504 ),
505 Layer.succeed(
506 DesktopClientSettings.DesktopClientSettings,
507 DesktopClientSettings.DesktopClientSettings.of({
508 get: settingsGet,
509 set: () => Effect.void,
510 }),
511 ),
512 Layer.succeed(
513 DesktopWindow.DesktopWindow,
514 DesktopWindow.DesktopWindow.of({
515 activate: Effect.void,
516 prepareCaptureReveal: Effect.sync(prepareCaptureRevealMock),
517 dispatchMenuAction: () => Effect.void,
518 dispatchSnapShotEvent: () => Effect.void,
519 } as unknown as DesktopWindow.DesktopWindow["Service"]),
520 ),
521 FileSystem.layerNoop(fileSystemOverrides),
522 Path.layer,
523 Layer.succeed(
524 Crypto.Crypto,
525 Crypto.make({
526 randomBytes: (size) => new Uint8Array(size),
527 digest: (_algorithm, data) => Effect.succeed(data),
528 }),
529 ),
530 ),
526531 ),
527532 );
528533
apps/desktop/src/snapShot/DesktopSnapShot.ts+39-13
......@@ -76,6 +76,8 @@ import {
7676 type AccessibilityProcessPool,
7777 makeSnapShotAccessibilityProcessPool,
7878} from "./SnapShotAccessibilityProcess.ts";
79import * as MacPermissions from "../permissions/MacPermissions.ts";
80import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts";
7981import { showWindowsCaptureOverlay } from "./WindowsCaptureFeedback.ts";
8082
8183import {
......@@ -96,8 +98,7 @@ const FLASH_ANIMATION_DURATION_MS = 180;
9698const FLASH_STATIC_DURATION_MS = 60;
9799const FLASH_FRAME_INTERVAL_MS = 16;
98100const FLASH_PEAK_OPACITY = 0.08;
99const MAC_SCREEN_CAPTURE_SETTINGS_URL =
100 "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture";
101const MAC_SCREEN_CAPTURE_SETTINGS_URL = MAC_PERMISSION_SETTINGS_URLS["screen-recording"];
101102const MAC_SCREEN_CAPTURE_PERMISSION_MESSAGE =
102103 "Allow Screen Recording in System Settings, then restart T3 Code.";
103104const MAC_ACCESSIBILITY_PERMISSION_MESSAGE =
......@@ -704,6 +705,7 @@ function probeGlobalShortcut(accelerator: string): DesktopSnapShotShortcutAvaila
704705
705706export const make = Effect.gen(function* () {
706707 const environment = yield* DesktopEnvironment.DesktopEnvironment;
708 const permissions = yield* MacPermissions.MacPermissions;
707709 const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
708710 const desktopWindow = yield* DesktopWindow.DesktopWindow;
709711 const fileSystem = yield* FileSystem.FileSystem;
......@@ -1300,14 +1302,26 @@ export const make = Effect.gen(function* () {
13001302 yield* configurationMutex.withPermits(1)(applySettings(settings, null));
13011303 });
13021304
1303 const requestPermissions = (includeAccessibility: boolean) =>
1304 configurationMutex.withPermits(1)(
1305 environment.platform === "darwin"
1306 ? Effect.promise(() => requestMacSnapShotPermissions(includeAccessibility)).pipe(
1307 Effect.asVoid,
1308 )
1309 : Effect.void,
1310 );
1305 const requestPermissions = Effect.fn("desktop.snapShot.requestPermissions")(function* (
1306 includeAccessibility: boolean,
1307 ) {
1308 if (environment.platform !== "darwin") return;
1309 const owner = Electron.BrowserWindow.getFocusedWindow();
1310 yield* Effect.promise(() => requestMacSnapShotPermissions(includeAccessibility));
1311 if (Electron.systemPreferences.getMediaAccessStatus("screen") !== "granted") {
1312 yield* permissions.showHelper("screen-recording", owner);
1313 } else if (
1314 includeAccessibility &&
1315 !Electron.systemPreferences.isTrustedAccessibilityClient(false)
1316 ) {
1317 yield* Effect.promise(() =>
1318 Electron.shell
1319 .openExternal(MAC_PERMISSION_SETTINGS_URLS.accessibility)
1320 .catch(() => undefined),
1321 );
1322 yield* permissions.showHelper("accessibility", owner);
1323 }
1324 }, configurationMutex.withPermits(1));
13111325
13121326 const setup = Effect.fn("desktop.snapShot.setup")(function* (action: DesktopSnapShotSetupAction) {
13131327 if (action === "test-mac-capture") {
......@@ -1372,9 +1386,21 @@ export const make = Effect.gen(function* () {
13721386 action,
13731387 reason: "unsupported-session",
13741388 });
1375 if (action === "allow-accessibility")
1376 Electron.systemPreferences.isTrustedAccessibilityClient(true);
1377 else yield* Effect.promise(requestMacScreenCapturePermission);
1389 const owner = Electron.BrowserWindow.getFocusedWindow();
1390 if (action === "allow-accessibility") {
1391 const granted = Electron.systemPreferences.isTrustedAccessibilityClient(true);
1392 if (!granted && environment.isPackaged) {
1393 yield* Effect.promise(() =>
1394 Electron.shell
1395 .openExternal(MAC_PERMISSION_SETTINGS_URLS.accessibility)
1396 .catch(() => undefined),
1397 );
1398 }
1399 } else yield* Effect.promise(requestMacScreenCapturePermission);
1400 yield* permissions.showHelper(
1401 action === "allow-accessibility" ? "accessibility" : "screen-recording",
1402 owner,
1403 );
13781404 } else if (action !== "retry-shortcut") {
13791405 if (!hasGnomeSetup())
13801406 return yield* new DesktopSnapShotSetupError({
apps/desktop/vite.config.ts+9
......@@ -93,6 +93,15 @@ export default defineConfig({
9393 outExtensions: () => ({ js: ".cjs" }),
9494 entry: ["src/preview-pip-preload.ts"],
9595 },
96 {
97 // Sandboxed preloads must be self-contained, without shared runtime chunks.
98 format: "cjs",
99 outDir: "dist-electron",
100 dts: false,
101 sourcemap: true,
102 outExtensions: () => ({ js: ".cjs" }),
103 entry: ["src/mac-permission-preload.ts"],
104 },
96105 ],
97106 test: {
98107 // The Windows lane runs workspace suites concurrently; filesystem-heavy
apps/web/src/components/permissions/PermissionChecklist.tsx created+57
......@@ -0,0 +1,57 @@
1import { CircleCheckIcon } from "lucide-react";
2import type { ComponentProps, ReactNode } from "react";
3import { Button } from "../ui/button";
4
5export interface PermissionItem {
6 id: string;
7 icon: ReactNode;
8 title: string;
9 description: string;
10 granted: boolean;
11 onAllow: () => void;
12}
13
14export function PermissionChecklist({
15 permissions,
16 busy = false,
17}: {
18 permissions: readonly PermissionItem[];
19 busy?: boolean;
20}) {
21 return (
22 <div className="space-y-2">
23 {permissions.map((permission) => (
24 <div key={permission.id} className="flex items-center gap-3 rounded-lg border px-3 py-2">
25 {permission.icon}
26 <div className="min-w-0 flex-1">
27 <p className="font-medium">{permission.title}</p>
28 <p className="text-xs text-muted-foreground">{permission.description}</p>
29 </div>
30 {permission.granted ? (
31 <span role="status" className="flex items-center gap-1 text-xs text-success">
32 <CircleCheckIcon className="size-4" aria-hidden="true" />
33 Allowed
34 </span>
35 ) : (
36 <Button size="xs" variant="outline" disabled={busy} onClick={permission.onAllow}>
37 Allow
38 </Button>
39 )}
40 </div>
41 ))}
42 </div>
43 );
44}
45
46export function PermissionContinueButton({
47 ready,
48 busy = false,
49 children = "Continue",
50 ...props
51}: Omit<ComponentProps<typeof Button>, "disabled"> & { ready: boolean; busy?: boolean }) {
52 return (
53 <Button {...props} disabled={!ready || busy}>
54 {children}
55 </Button>
56 );
57}
apps/web/src/components/permissions/usePermissionStatus.test.ts created+96
......@@ -0,0 +1,96 @@
1import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
2import { reactHookHarness as hooks } from "../../test/reactHookHarness";
3import { usePermissionStatus } from "./usePermissionStatus";
4
5const effects = vi.hoisted(() => [] as Array<() => (() => void) | undefined>);
6vi.mock("react", async (original) => {
7 const actual = await original<typeof import("react")>();
8 const { reactHookHarness } = await import("../../test/reactHookHarness");
9 return {
10 ...actual,
11 useState: reactHookHarness.useState,
12 useEffect: (effect: () => (() => void) | undefined) => effects.push(effect),
13 useEffectEvent: <T>(callback: T) => callback,
14 };
15});
16vi.mock("react/compiler-runtime", async () => {
17 const { reactHookHarness } = await import("../../test/reactHookHarness");
18 return { c: reactHookHarness.useMemoCache };
19});
20const check = vi.fn<() => Promise<{ screen: boolean; accessibility: boolean }>>();
21let cleanup: (() => void) | undefined;
22let page: EventTarget & { visibilityState: string };
23const render = () => {
24 hooks.beginRender();
25 return usePermissionStatus(check, { screen: false, accessibility: false });
26};
27beforeEach(() => {
28 hooks.reset();
29 effects.length = 0;
30 vi.useFakeTimers();
31 page = Object.assign(new EventTarget(), { visibilityState: "visible" });
32 vi.stubGlobal("document", page);
33 vi.stubGlobal("window", Object.assign(new EventTarget(), { setInterval, clearInterval }));
34 check.mockReset().mockResolvedValue({ screen: false, accessibility: false });
35});
36afterEach(() => {
37 cleanup?.();
38 vi.useRealTimers();
39 vi.unstubAllGlobals();
40});
41const start = async () => {
42 render();
43 cleanup = effects[0]!();
44 await Promise.resolve();
45};
46
47it("unlocks Continue only for required grants and relocks on revocation", async () => {
48 await start();
49 expect(render().isReady(["screen"])).toBe(false);
50 check.mockResolvedValue({ screen: true, accessibility: false });
51 await vi.advanceTimersByTimeAsync(1500);
52 expect(render().isReady(["screen"])).toBe(true);
53 expect(render().isReady(["screen", "accessibility"])).toBe(false);
54 check.mockResolvedValue({ screen: true, accessibility: true });
55 await vi.advanceTimersByTimeAsync(1500);
56 expect(render().isReady(["screen", "accessibility"])).toBe(true);
57 check.mockResolvedValue({ screen: false, accessibility: true });
58 window.dispatchEvent(new Event("focus"));
59 await Promise.resolve();
60 expect(render().isReady(["screen"])).toBe(false);
61});
62
63it("does not overlap checks and discards completion after closing", async () => {
64 let resolve!: (status: { screen: boolean; accessibility: boolean }) => void;
65 const promise = new Promise<{ screen: boolean; accessibility: boolean }>((done) => {
66 resolve = done;
67 });
68 const pending = { promise, resolve };
69 check.mockReturnValue(pending.promise);
70 await start();
71 await vi.advanceTimersByTimeAsync(4500);
72 window.dispatchEvent(new Event("focus"));
73 expect(check).toHaveBeenCalledTimes(1);
74 cleanup?.();
75 pending.resolve({ screen: true, accessibility: true });
76 await pending.promise;
77 expect(render().isReady(["screen"])).toBe(false);
78 expect(vi.getTimerCount()).toBe(0);
79});
80
81it("pauses in the background and blocks stale grants after a check failure", async () => {
82 check.mockResolvedValue({ screen: true, accessibility: true });
83 await start();
84 expect(render().isReady(["screen"])).toBe(true);
85 page.visibilityState = "hidden";
86 await vi.advanceTimersByTimeAsync(3000);
87 expect(check).toHaveBeenCalledTimes(1);
88 check.mockRejectedValue(new Error("IPC unavailable"));
89 page.visibilityState = "visible";
90 page.dispatchEvent(new Event("visibilitychange"));
91 await Promise.resolve();
92 expect(render().isReady(["screen"])).toBe(false);
93 check.mockResolvedValue({ screen: true, accessibility: true });
94 await vi.advanceTimersByTimeAsync(1500);
95 expect(render().isReady(["screen"])).toBe(true);
96});
apps/web/src/components/permissions/usePermissionStatus.ts created+45
......@@ -0,0 +1,45 @@
1import { useEffect, useEffectEvent, useState } from "react";
2
3export function usePermissionStatus<Id extends string>(
4 check: () => Promise<Record<Id, boolean>>,
5 initialStatus: Record<Id, boolean>,
6 enabled = true,
7) {
8 const [status, setStatus] = useState(initialStatus);
9 const [error, setError] = useState<string | null>(null);
10 const checkLatest = useEffectEvent(check);
11 useEffect(() => {
12 if (!enabled) return;
13 let disposed = false;
14 let checking = false;
15 const refresh = async () => {
16 if (disposed || checking || document.visibilityState === "hidden") return;
17 checking = true;
18 try {
19 const next = await checkLatest();
20 if (!disposed) {
21 setStatus(next);
22 setError(null);
23 }
24 } catch {
25 if (!disposed) setError("Could not check permissions. We'll try again automatically.");
26 }
27 checking = false;
28 };
29 void refresh();
30 const timer = window.setInterval(() => void refresh(), 1500);
31 window.addEventListener("focus", refresh);
32 document.addEventListener("visibilitychange", refresh);
33 return () => {
34 disposed = true;
35 window.clearInterval(timer);
36 window.removeEventListener("focus", refresh);
37 document.removeEventListener("visibilitychange", refresh);
38 };
39 }, [enabled]);
40 return {
41 status,
42 error,
43 isReady: (required: readonly Id[]) => error === null && required.every((id) => status[id]),
44 };
45}
apps/web/src/components/settings/BrowserImportWizard.tsx+68-14
......@@ -1,6 +1,8 @@
1import { PermissionChecklist, PermissionContinueButton } from "../permissions/PermissionChecklist";
2import { usePermissionStatus } from "../permissions/usePermissionStatus";
13import type { BrowserImportSource } from "@t3tools/contracts";
24import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts";
3import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
5import { ArrowDownIcon, ArrowRightIcon, CheckIcon, HardDriveIcon } from "lucide-react";
46import { useRef, useState } from "react";
57
68import { cn, randomUUID } from "~/lib/utils";
......@@ -57,7 +59,8 @@ interface BrowserImportWizardProps {
5759 /** Re-checks the source's availability after the user quits the browser. */
5860 readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;
5961 /** Opens the OS setting that grants access to a protected cookie store. */
60 readonly onOpenFullDiskAccessSettings: () => void;
62 readonly onOpenFullDiskAccessSettings: () => void | Promise<void>;
63 readonly onCheckFullDiskAccess?: (() => Promise<boolean>) | undefined;
6164 readonly onClose: () => void;
6265}
6366
......@@ -76,6 +79,7 @@ export function BrowserImportWizard({
7679 onImport,
7780 onRefreshSource,
7881 onOpenFullDiskAccessSettings,
82 onCheckFullDiskAccess,
7983 onClose,
8084}: BrowserImportWizardProps) {
8185 const [source, setSource] = useState(initialSource);
......@@ -147,6 +151,13 @@ export function BrowserImportWizard({
147151 source={source}
148152 onCancel={onClose}
149153 onOpenSettings={onOpenFullDiskAccessSettings}
154 onCheck={
155 onCheckFullDiskAccess ??
156 (async () => {
157 const refreshed = await onRefreshSource();
158 return refreshed !== undefined && refreshed.unavailable === undefined;
159 })
160 }
150161 onGranted={step.resume === "import" ? runImport : recheckFullDiskAccess}
151162 stillRequired={step.checked === true}
152163 />
......@@ -248,13 +259,29 @@ function FullDiskAccessStep({
248259 onOpenSettings,
249260 onGranted,
250261 stillRequired,
262 onCheck,
251263}: {
252264 readonly source: BrowserImportSource;
253265 readonly onCancel: () => void;
254 readonly onOpenSettings: () => void;
266 readonly onOpenSettings: () => void | Promise<void>;
267 readonly onCheck: () => Promise<boolean>;
255268 readonly onGranted: () => void;
256269 readonly stillRequired: boolean;
257270}) {
271 const [opening, setOpening] = useState(false);
272 const [openingError, setOpeningError] = useState<string | null>(null);
273 const permission = usePermissionStatus(async () => ({ fullDiskAccess: await onCheck() }), {
274 fullDiskAccess: false,
275 });
276 const allow = () => {
277 if (opening) return;
278 setOpening(true);
279 setOpeningError(null);
280 void Promise.resolve()
281 .then(onOpenSettings)
282 .catch(() => setOpeningError("Could not open System Settings. Try Allow again."))
283 .finally(() => setOpening(false));
284 };
258285 return (
259286 <>
260287 <DialogHeader>
......@@ -265,22 +292,49 @@ function FullDiskAccessStep({
265292 done.
266293 </DialogDescription>
267294 </DialogHeader>
268 {stillRequired ? (
269 <DialogPanel>
270 <p role="status" className="text-sm text-muted-foreground">
271 Full Disk Access is still required. If you just turned it on, quit and reopen T3 Code,
272 then try again.
295 <DialogPanel>
296 <PermissionChecklist
297 busy={opening}
298 permissions={[
299 {
300 id: "fullDiskAccess",
301 icon: (
302 <HardDriveIcon
303 className="size-8 shrink-0 text-muted-foreground"
304 aria-hidden="true"
305 />
306 ),
307 title: "Full Disk Access",
308 description: `Read ${source.name}'s cookies for this import.`,
309 granted: permission.status.fullDiskAccess,
310 onAllow: () => void allow(),
311 },
312 ]}
313 />
314 {openingError || permission.error ? (
315 <p role="status" className="mt-3 text-xs text-muted-foreground">
316 {openingError ?? permission.error}
273317 </p>
274 </DialogPanel>
275 ) : null}
318 ) : null}
319 {!permission.isReady(["fullDiskAccess"]) ? (
320 <p className="mt-3 text-xs text-muted-foreground">
321 {stillRequired
322 ? "Access is still required. Quit and reopen T3 Code if you just allowed it, then retry the import."
323 : "If access doesn't update after you allow it, quit and reopen T3 Code, then retry the import."}
324 </p>
325 ) : null}
326 </DialogPanel>
276327 <DialogFooter>
277328 <Button variant="outline" onClick={onCancel}>
278329 Cancel
279330 </Button>
280 <Button variant="outline" onClick={onOpenSettings}>
281 Open System Settings
282 </Button>
283 <Button onClick={onGranted}>I&rsquo;ve turned it on</Button>
331 <PermissionContinueButton
332 ready={permission.isReady(["fullDiskAccess"])}
333 busy={opening}
334 onClick={onGranted}
335 >
336 Continue
337 </PermissionContinueButton>
284338 </DialogFooter>
285339 </>
286340 );
apps/web/src/components/settings/IntegrationsSettings.tsx+7-2
......@@ -1260,10 +1260,15 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
12601260 runWizardImport(importSession.source, importSession.environmentId, input)
12611261 }
12621262 onRefreshSource={() => refreshImportSource(importSession.source.id)}
1263 onOpenFullDiskAccessSettings={() => {
1263 onCheckFullDiskAccess={
1264 window.desktopBridge?.checkSystemPermission
1265 ? () => window.desktopBridge!.checkSystemPermission!("full-disk-access")
1266 : undefined
1267 }
1268 onOpenFullDiskAccessSettings={async () => {
12641269 // Rejects outside the desktop shell (and on shells that predate the
12651270 // method), so the one toast covers every way the link can fail.
1266 void readLocalApi()
1271 await readLocalApi()
12671272 ?.shell.openSystemSettings("full-disk-access")
12681273 .catch(() => {
12691274 toastManager.add({
apps/web/src/components/settings/SnapShotSettings.tsx+1-6
......@@ -338,11 +338,6 @@ export function SnapShotSettings() {
338338 }
339339 };
340340
341 const refreshSetup = useCallback(() => {
342 setSetupError(null);
343 return refreshState();
344 }, [refreshState]);
345
346341 const closeSetup = async (completed: boolean) => {
347342 if (!wizard || setupBusy) return;
348343 setSetupBusy(true);
......@@ -612,7 +607,7 @@ export function SnapShotSettings() {
612607 onSaveShortcut={saveShortcut}
613608 onEnable={enableForSetup}
614609 onAction={setup}
615 onRefresh={refreshSetup}
610 onRefresh={refreshState}
616611 onClose={closeSetup}
617612 onLeaveStep={stopRecording}
618613 />
apps/web/src/components/settings/SnapShotSetupDialog.tsx+49-71
......@@ -1,10 +1,11 @@
1import { PermissionChecklist, PermissionContinueButton } from "../permissions/PermissionChecklist";
2import { usePermissionStatus } from "../permissions/usePermissionStatus";
13import {
24 isModifierPairShortcut,
35 type DesktopSnapShotSetupAction,
46 type DesktopSnapShotState,
57} from "@t3tools/contracts";
6import { CircleCheckIcon } from "lucide-react";
7import { useEffect, useId, useState, type ReactNode } from "react";
8import { useId, useState, type ReactNode } from "react";
89import { CaptureShortcutConfig } from "./CaptureShortcutConfig";
910import { Button } from "../ui/button";
1011import { Dialog, DialogDescription } from "../ui/dialog";
......@@ -15,7 +16,6 @@ import {
1516 captureSetupCheckMessage,
1617 captureSetupDesktopName,
1718 captureSetupInitialStep,
18 captureSetupMacPermissionsReady,
1919 captureSetupShortcutReady,
2020 type CaptureSetupStep,
2121} from "./SnapShotSetupDialog.logic";
......@@ -127,42 +127,6 @@ function AccessibilityPermissionIcon() {
127127 );
128128}
129129
130function MacPermissionRow({
131 icon,
132 title,
133 description,
134 granted,
135 busy,
136 onAllow,
137}: {
138 icon: ReactNode;
139 title: string;
140 description: string;
141 granted: boolean;
142 busy: boolean;
143 onAllow: () => void;
144}) {
145 return (
146 <div className="flex items-center gap-3 rounded-lg border px-3 py-2">
147 {icon}
148 <div className="min-w-0 flex-1">
149 <p className="font-medium">{title}</p>
150 <p className="text-xs text-muted-foreground">{description}</p>
151 </div>
152 {granted ? (
153 <span className="flex items-center gap-1 text-xs text-success">
154 <CircleCheckIcon className="size-4" aria-hidden="true" />
155 Allowed
156 </span>
157 ) : (
158 <Button size="xs" variant="outline" disabled={busy} onClick={onAllow}>
159 Allow
160 </Button>
161 )}
162 </div>
163 );
164}
165
166130export function SnapShotSetupDialog({
167131 state,
168132 initialStep,
......@@ -212,17 +176,24 @@ export function SnapShotSetupDialog({
212176 const installHelper = backend === "hyprland" ? "install-hyprland-helper" : "install-kde-helper";
213177 const removeHelper = backend === "hyprland" ? "remove-hyprland-helper" : "remove-kde-helper";
214178 const accessReady = captureSetupAccessReady(state);
215 const macPermissions = state.macPermissions;
216 const macPermissionsReady = captureSetupMacPermissionsReady(state, includeAccessibility);
179 const permissionStatus = usePermissionStatus(
180 async () => {
181 const refreshed = await onRefresh();
182 if (!refreshed?.macPermissions) throw new Error("Permission status unavailable");
183 return refreshed.macPermissions;
184 },
185 state.macPermissions ?? { screenRecording: false, accessibility: false },
186 Boolean(state.macPermissions) && step === "access" && !busy,
187 );
188 const macPermissions = state.macPermissions ? permissionStatus.status : undefined;
189 const macPermissionsReady =
190 !macPermissions ||
191 permissionStatus.isReady(
192 includeAccessibility ? ["screenRecording", "accessibility"] : ["screenRecording"],
193 );
217194 const shortcutReady = captureSetupShortcutReady(state, shortcutChanged);
218195 const install = extension?.status === "not-installed" || extension?.status === "update-required";
219196 const enable = extension?.status === "disabled";
220 useEffect(() => {
221 if (!macPermissions || step !== "access") return;
222 const refresh = () => void onRefresh();
223 window.addEventListener("focus", refresh);
224 return () => window.removeEventListener("focus", refresh);
225 }, [macPermissions, onRefresh, step]);
226197 const changeStep = (next: CaptureSetupStep) => {
227198 onLeaveStep();
228199 setChecked(false);
......@@ -357,28 +328,34 @@ export function SnapShotSetupDialog({
357328 {checked && !busy && !error ? captureSetupCheckMessage(state) : null}
358329 </p>
359330 {macPermissions ? (
360 <div className="space-y-2">
361 <MacPermissionRow
362 icon={<ScreenRecordingIcon />}
363 title="Screen Recording"
364 description="Capture the window you're using."
365 granted={macPermissions.screenRecording}
366 busy={busy}
367 onAllow={() => void onAction("allow-screen-recording")}
368 />
369 <MacPermissionRow
370 icon={<AccessibilityPermissionIcon />}
371 title="Accessibility"
372 description={
373 includeAccessibility
331 <PermissionChecklist
332 busy={busy}
333 permissions={[
334 {
335 id: "screenRecording",
336 icon: <ScreenRecordingIcon />,
337 title: "Screen Recording",
338 description: "Capture the window you're using.",
339 granted: macPermissions.screenRecording,
340 onAllow: () => void onAction("allow-screen-recording"),
341 },
342 {
343 id: "accessibility",
344 icon: <AccessibilityPermissionIcon />,
345 title: "Accessibility",
346 description: includeAccessibility
374347 ? "Include text and controls from the captured app."
375 : "Optional. Include text and controls from the captured app."
376 }
377 granted={macPermissions.accessibility}
378 busy={busy}
379 onAllow={() => void onAction("allow-accessibility")}
380 />
381 </div>
348 : "Optional. Include text and controls from the captured app.",
349 granted: macPermissions.accessibility,
350 onAllow: () => void onAction("allow-accessibility"),
351 },
352 ]}
353 />
354 ) : null}
355 {permissionStatus.error && macPermissions ? (
356 <p role="status" className="text-xs text-muted-foreground">
357 {permissionStatus.error}
358 </p>
382359 ) : null}
383360 {helperBackend && helper?.status === "error" ? (
384361 <Button
......@@ -527,8 +504,9 @@ export function SnapShotSetupDialog({
527504 : "Check again"}
528505 </Button>
529506 ) : (
530 <Button
531 disabled={busy || !macPermissionsReady}
507 <PermissionContinueButton
508 ready={macPermissionsReady}
509 busy={busy}
532510 onClick={async () => {
533511 if (await onEnable()) changeStep("shortcut");
534512 }}
......@@ -542,7 +520,7 @@ export function SnapShotSetupDialog({
542520 : !accessReady && !macPermissions
543521 ? "Try again"
544522 : "Continue"}
545 </Button>
523 </PermissionContinueButton>
546524 )
547525 ) : !configShortcut ? (
548526 <Button
docs/user/browser-import.md+4-5
......@@ -10,11 +10,10 @@ unlock prompt if one appears.
1010This is a one-time copy. Later login changes stay separate between the two browsers, and some
1111sites may still require you to sign in again.
1212
13On macOS, Safari is also available. Safari protects its cookies with Full Disk Access rather than
14a keychain, so the import wizard asks you to grant it: **Open System Settings** takes you to the
15right pane, and macOS may ask you to quit and reopen T3 Code before the grant applies. You can
16revoke Full Disk Access after the import is done. Only Safari's primary profile is imported; cookies
17kept by additional Safari profiles are not.
13On macOS, Safari imports need Full Disk Access. Choose **Allow**, drag T3 Code into the
14System Settings permission list, and turn access on. **Continue** becomes available when access
15is detected. macOS may require you to quit and reopen T3 Code before the grant applies; reopen
16the import wizard afterward. You can revoke Full Disk Access once the import is done.
1817
1918On Windows, import supports Firefox and Helium profiles that use standard profile encryption.
2019Other Chromium-based browsers use app-bound encryption and cannot be imported. Partitioned cookies
knip.jsonc+1
......@@ -40,6 +40,7 @@
4040 "src/preload.ts!",
4141 "src/preview-pick-preload.ts!",
4242 "src/preview-pip-preload.ts!",
43 "src/mac-permission-preload.ts!",
4344 "src/preview/Annotation.css!",
4445 ],
4546 // The injected runtime resolves Playwright by string; release tooling runs
packages/contracts/src/ipc.ts+1
......@@ -1301,6 +1301,7 @@ export interface DesktopBridge {
13011301 * lack it, and callers no-op when it is missing.
13021302 */
13031303 openSystemSettings?: (pane: SystemSettingsPane) => Promise<boolean>;
1304 checkSystemPermission?: (pane: SystemSettingsPane) => Promise<boolean>;
13041305 /**
13051306 * Probe this desktop machine for installed remote-capable editor CLIs
13061307 * (used for remote open-in-editor deep links). Optional: older desktop