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 @@...@@ -1,3 +1,4 @@
1import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts";
1import {2import {
2 REMOTE_CAPABLE_EDITOR_IDS,3 REMOTE_CAPABLE_EDITOR_IDS,
3 remoteSchemeForEditor,4 remoteSchemeForEditor,
...@@ -10,20 +11,6 @@ import * as Option from "effect/Option";...@@ -10,20 +11,6 @@ import * as Option from "effect/Option";
1011
11import * as Electron from "electron";12import * 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
27// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`,14// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`,
28// `zed://ssh/<host>/<path>`) must reach the OS handler; every other non-web15// `zed://ssh/<host>/<path>`) must reach the OS handler; every other non-web
29// scheme stays blocked.16// scheme stays blocked.
...@@ -88,7 +75,7 @@ export const make = ElectronShell.of({...@@ -88,7 +75,7 @@ export const make = ElectronShell.of({
88 }),75 }),
89 openSystemSettings: (pane) =>76 openSystemSettings: (pane) =>
90 Effect.promise(() =>77 Effect.promise(() =>
91 Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then(78 Electron.shell.openExternal(MAC_PERMISSION_SETTINGS_URLS[pane]).then(
92 () => true,79 () => true,
93 () => false,80 () => false,
94 ),81 ),
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
...@@ -39,6 +39,7 @@ import {...@@ -39,6 +39,7 @@ import {
39 getWindowFullscreenState,39 getWindowFullscreenState,
40 openExternal,40 openExternal,
41 openSystemSettings,41 openSystemSettings,
42 checkSystemPermission,
42 probeRemoteEditors,43 probeRemoteEditors,
43 pickFolder,44 pickFolder,
44 pickProjectFavicon,45 pickProjectFavicon,
...@@ -122,6 +123,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"...@@ -122,6 +123,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
122 yield* ipc.handle(showContextMenu);123 yield* ipc.handle(showContextMenu);
123 yield* ipc.handle(openExternal);124 yield* ipc.handle(openExternal);
124 yield* ipc.handle(openSystemSettings);125 yield* ipc.handle(openSystemSettings);
126 yield* ipc.handle(checkSystemPermission);
125 yield* ipc.handle(probeRemoteEditors);127 yield* ipc.handle(probeRemoteEditors);
126 yield* ipc.handle(getUpdateState);128 yield* ipc.handle(getUpdateState);
127 yield* ipc.handle(setUpdateChannel);129 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";...@@ -105,3 +105,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save";
105export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";105export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame";
106export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";106export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change";
107export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event";107export 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";...@@ -34,6 +34,9 @@ import * as ElectronMenu from "../../electron/ElectronMenu.ts";
34import * as ElectronShell from "../../electron/ElectronShell.ts";34import * as ElectronShell from "../../electron/ElectronShell.ts";
35import * as ElectronTheme from "../../electron/ElectronTheme.ts";35import * as ElectronTheme from "../../electron/ElectronTheme.ts";
36import * as ElectronWindow from "../../electron/ElectronWindow.ts";36import * 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";
37import * as IpcChannels from "../channels.ts";40import * as IpcChannels from "../channels.ts";
38import * as DesktopIpc from "../DesktopIpc.ts";41import * as DesktopIpc from "../DesktopIpc.ts";
39import {42import {
...@@ -305,7 +308,16 @@ export const openSystemSettings = DesktopIpc.makeIpcMethod({...@@ -305,7 +308,16 @@ export const openSystemSettings = DesktopIpc.makeIpcMethod({
305 result: Schema.Boolean,308 result: Schema.Boolean,
306 handler: Effect.fn("desktop.ipc.window.openSystemSettings")(function* (pane) {309 handler: Effect.fn("desktop.ipc.window.openSystemSettings")(function* (pane) {
307 const shell = yield* ElectronShell.ElectronShell;310 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;
309 }),321 }),
310});322});
311323
...@@ -379,3 +391,15 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({...@@ -379,3 +391,15 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({
379 });391 });
380 }),392 }),
381});393});
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 @@...@@ -1,3 +1,4 @@
1import * as MacPermissions from "./permissions/MacPermissions.ts";
1for (const stream of [process.stdout, process.stderr]) {2for (const stream of [process.stdout, process.stderr]) {
2 stream.on("error", (err: NodeJS.ErrnoException) => {3 stream.on("error", (err: NodeJS.ErrnoException) => {
3 if (err.code !== "EPIPE") throw err;4 if (err.code !== "EPIPE") throw err;
...@@ -133,6 +134,7 @@ const electronLayer = Layer.mergeAll(...@@ -133,6 +134,7 @@ const electronLayer = Layer.mergeAll(
133);134);
134135
135const desktopFoundationLayer = Layer.mergeAll(136const desktopFoundationLayer = Layer.mergeAll(
137 MacPermissions.layer,
136 DesktopState.layer,138 DesktopState.layer,
137 DesktopShutdown.layer,139 DesktopShutdown.layer,
138 DesktopAppSettings.layer,140 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", {...@@ -152,6 +152,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
152 ...(position === undefined ? {} : { position }),152 ...(position === undefined ? {} : { position }),
153 }),153 }),
154 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),154 openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
155 checkSystemPermission: (pane: string) =>
156 ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane),
155 openSystemSettings: (pane: string) =>157 openSystemSettings: (pane: string) =>
156 ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane),158 ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane),
157 probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),159 probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts+34
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 parseBinaryCookies,11 parseBinaryCookies,
12 readSafariCookies,12 readSafariCookies,
13 safariAccessDenied,13 safariAccessDenied,
14 safariAccessGranted,
14 SafariCookieReadError,15 SafariCookieReadError,
15} from "./SafariCookies.ts";16} from "./SafariCookies.ts";
1617
...@@ -441,3 +442,36 @@ describe("isPermissionDenied", () => {...@@ -441,3 +442,36 @@ describe("isPermissionDenied", () => {
441 expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false);442 expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false);
442 });443 });
443});444});
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...@@ -225,6 +225,16 @@ export const safariAccessDenied = Effect.fnUntraced(function* (cookiePath: strin
225 );225 );
226});226});
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
228export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* (238export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* (
229 cookiePath: string,239 cookiePath: string,
230) {240) {
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 @@...@@ -1,3 +1,4 @@
1import * as MacPermissions from "../permissions/MacPermissions.ts";
1import { assert, it } from "@effect/vitest";2import { assert, it } from "@effect/vitest";
2import {3import {
3 DEFAULT_CLIENT_SETTINGS,4 DEFAULT_CLIENT_SETTINGS,
...@@ -488,41 +489,45 @@ const testLayer = (...@@ -488,41 +489,45 @@ const testLayer = (
488 DesktopClientSettings.DesktopClientSettingsReadError489 DesktopClientSettings.DesktopClientSettingsReadError
489 > = Effect.succeed(initialSettings),490 > = Effect.succeed(initialSettings),
490) =>491) =>
491 Layer.mergeAll(492 MacPermissions.layer.pipe(
492 Layer.succeed(493 Layer.provideMerge(
493 DesktopEnvironment.DesktopEnvironment,494 Layer.mergeAll(
494 DesktopEnvironment.DesktopEnvironment.of({495 Layer.succeed(
495 platform,496 DesktopEnvironment.DesktopEnvironment,
496 stateDir: "/state",497 DesktopEnvironment.DesktopEnvironment.of({
497 linuxDesktopEntryName: "com.t3tools.T3Code.desktop",498 platform,
498 appRoot: "/repo",499 stateDir: "/state",
499 linuxApplicationsDir: "/test-data/applications",500 linuxDesktopEntryName: "com.t3tools.T3Code.desktop",
500 } as DesktopEnvironment.DesktopEnvironment["Service"]),501 appRoot: "/repo",
501 ),502 linuxApplicationsDir: "/test-data/applications",
502 Layer.succeed(503 } as DesktopEnvironment.DesktopEnvironment["Service"]),
503 DesktopClientSettings.DesktopClientSettings,504 ),
504 DesktopClientSettings.DesktopClientSettings.of({505 Layer.succeed(
505 get: settingsGet,506 DesktopClientSettings.DesktopClientSettings,
506 set: () => Effect.void,507 DesktopClientSettings.DesktopClientSettings.of({
507 }),508 get: settingsGet,
508 ),509 set: () => Effect.void,
509 Layer.succeed(510 }),
510 DesktopWindow.DesktopWindow,511 ),
511 DesktopWindow.DesktopWindow.of({512 Layer.succeed(
512 activate: Effect.void,513 DesktopWindow.DesktopWindow,
513 prepareCaptureReveal: Effect.sync(prepareCaptureRevealMock),514 DesktopWindow.DesktopWindow.of({
514 dispatchMenuAction: () => Effect.void,515 activate: Effect.void,
515 dispatchSnapShotEvent: () => Effect.void,516 prepareCaptureReveal: Effect.sync(prepareCaptureRevealMock),
516 } as unknown as DesktopWindow.DesktopWindow["Service"]),517 dispatchMenuAction: () => Effect.void,
517 ),518 dispatchSnapShotEvent: () => Effect.void,
518 FileSystem.layerNoop(fileSystemOverrides),519 } as unknown as DesktopWindow.DesktopWindow["Service"]),
519 Path.layer,520 ),
520 Layer.succeed(521 FileSystem.layerNoop(fileSystemOverrides),
521 Crypto.Crypto,522 Path.layer,
522 Crypto.make({523 Layer.succeed(
523 randomBytes: (size) => new Uint8Array(size),524 Crypto.Crypto,
524 digest: (_algorithm, data) => Effect.succeed(data),525 Crypto.make({
525 }),526 randomBytes: (size) => new Uint8Array(size),
527 digest: (_algorithm, data) => Effect.succeed(data),
528 }),
529 ),
530 ),
526 ),531 ),
527 );532 );
528533
apps/desktop/src/snapShot/DesktopSnapShot.ts+39-13
...@@ -76,6 +76,8 @@ import {...@@ -76,6 +76,8 @@ import {
76 type AccessibilityProcessPool,76 type AccessibilityProcessPool,
77 makeSnapShotAccessibilityProcessPool,77 makeSnapShotAccessibilityProcessPool,
78} from "./SnapShotAccessibilityProcess.ts";78} from "./SnapShotAccessibilityProcess.ts";
79import * as MacPermissions from "../permissions/MacPermissions.ts";
80import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts";
79import { showWindowsCaptureOverlay } from "./WindowsCaptureFeedback.ts";81import { showWindowsCaptureOverlay } from "./WindowsCaptureFeedback.ts";
8082
81import {83import {
...@@ -96,8 +98,7 @@ const FLASH_ANIMATION_DURATION_MS = 180;...@@ -96,8 +98,7 @@ const FLASH_ANIMATION_DURATION_MS = 180;
96const FLASH_STATIC_DURATION_MS = 60;98const FLASH_STATIC_DURATION_MS = 60;
97const FLASH_FRAME_INTERVAL_MS = 16;99const FLASH_FRAME_INTERVAL_MS = 16;
98const FLASH_PEAK_OPACITY = 0.08;100const FLASH_PEAK_OPACITY = 0.08;
99const MAC_SCREEN_CAPTURE_SETTINGS_URL =101const MAC_SCREEN_CAPTURE_SETTINGS_URL = MAC_PERMISSION_SETTINGS_URLS["screen-recording"];
100 "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture";
101const MAC_SCREEN_CAPTURE_PERMISSION_MESSAGE =102const MAC_SCREEN_CAPTURE_PERMISSION_MESSAGE =
102 "Allow Screen Recording in System Settings, then restart T3 Code.";103 "Allow Screen Recording in System Settings, then restart T3 Code.";
103const MAC_ACCESSIBILITY_PERMISSION_MESSAGE =104const MAC_ACCESSIBILITY_PERMISSION_MESSAGE =
...@@ -704,6 +705,7 @@ function probeGlobalShortcut(accelerator: string): DesktopSnapShotShortcutAvaila...@@ -704,6 +705,7 @@ function probeGlobalShortcut(accelerator: string): DesktopSnapShotShortcutAvaila
704705
705export const make = Effect.gen(function* () {706export const make = Effect.gen(function* () {
706 const environment = yield* DesktopEnvironment.DesktopEnvironment;707 const environment = yield* DesktopEnvironment.DesktopEnvironment;
708 const permissions = yield* MacPermissions.MacPermissions;
707 const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;709 const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
708 const desktopWindow = yield* DesktopWindow.DesktopWindow;710 const desktopWindow = yield* DesktopWindow.DesktopWindow;
709 const fileSystem = yield* FileSystem.FileSystem;711 const fileSystem = yield* FileSystem.FileSystem;
...@@ -1300,14 +1302,26 @@ export const make = Effect.gen(function* () {...@@ -1300,14 +1302,26 @@ export const make = Effect.gen(function* () {
1300 yield* configurationMutex.withPermits(1)(applySettings(settings, null));1302 yield* configurationMutex.withPermits(1)(applySettings(settings, null));
1301 });1303 });
13021304
1303 const requestPermissions = (includeAccessibility: boolean) =>1305 const requestPermissions = Effect.fn("desktop.snapShot.requestPermissions")(function* (
1304 configurationMutex.withPermits(1)(1306 includeAccessibility: boolean,
1305 environment.platform === "darwin"1307 ) {
1306 ? Effect.promise(() => requestMacSnapShotPermissions(includeAccessibility)).pipe(1308 if (environment.platform !== "darwin") return;
1307 Effect.asVoid,1309 const owner = Electron.BrowserWindow.getFocusedWindow();
1308 )1310 yield* Effect.promise(() => requestMacSnapShotPermissions(includeAccessibility));
1309 : Effect.void,1311 if (Electron.systemPreferences.getMediaAccessStatus("screen") !== "granted") {
1310 );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
1312 const setup = Effect.fn("desktop.snapShot.setup")(function* (action: DesktopSnapShotSetupAction) {1326 const setup = Effect.fn("desktop.snapShot.setup")(function* (action: DesktopSnapShotSetupAction) {
1313 if (action === "test-mac-capture") {1327 if (action === "test-mac-capture") {
...@@ -1372,9 +1386,21 @@ export const make = Effect.gen(function* () {...@@ -1372,9 +1386,21 @@ export const make = Effect.gen(function* () {
1372 action,1386 action,
1373 reason: "unsupported-session",1387 reason: "unsupported-session",
1374 });1388 });
1375 if (action === "allow-accessibility")1389 const owner = Electron.BrowserWindow.getFocusedWindow();
1376 Electron.systemPreferences.isTrustedAccessibilityClient(true);1390 if (action === "allow-accessibility") {
1377 else yield* Effect.promise(requestMacScreenCapturePermission);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 );
1378 } else if (action !== "retry-shortcut") {1404 } else if (action !== "retry-shortcut") {
1379 if (!hasGnomeSetup())1405 if (!hasGnomeSetup())
1380 return yield* new DesktopSnapShotSetupError({1406 return yield* new DesktopSnapShotSetupError({
apps/desktop/vite.config.ts+9
...@@ -93,6 +93,15 @@ export default defineConfig({...@@ -93,6 +93,15 @@ export default defineConfig({
93 outExtensions: () => ({ js: ".cjs" }),93 outExtensions: () => ({ js: ".cjs" }),
94 entry: ["src/preview-pip-preload.ts"],94 entry: ["src/preview-pip-preload.ts"],
95 },95 },
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 },
96 ],105 ],
97 test: {106 test: {
98 // The Windows lane runs workspace suites concurrently; filesystem-heavy107 // 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 @@...@@ -1,6 +1,8 @@
1import { PermissionChecklist, PermissionContinueButton } from "../permissions/PermissionChecklist";
2import { usePermissionStatus } from "../permissions/usePermissionStatus";
1import type { BrowserImportSource } from "@t3tools/contracts";3import type { BrowserImportSource } from "@t3tools/contracts";
2import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts";4import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts";
3import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react";5import { ArrowDownIcon, ArrowRightIcon, CheckIcon, HardDriveIcon } from "lucide-react";
4import { useRef, useState } from "react";6import { useRef, useState } from "react";
57
6import { cn, randomUUID } from "~/lib/utils";8import { cn, randomUUID } from "~/lib/utils";
...@@ -57,7 +59,8 @@ interface BrowserImportWizardProps {...@@ -57,7 +59,8 @@ interface BrowserImportWizardProps {
57 /** Re-checks the source's availability after the user quits the browser. */59 /** Re-checks the source's availability after the user quits the browser. */
58 readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;60 readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;
59 /** Opens the OS setting that grants access to a protected cookie store. */61 /** 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;
61 readonly onClose: () => void;64 readonly onClose: () => void;
62}65}
6366
...@@ -76,6 +79,7 @@ export function BrowserImportWizard({...@@ -76,6 +79,7 @@ export function BrowserImportWizard({
76 onImport,79 onImport,
77 onRefreshSource,80 onRefreshSource,
78 onOpenFullDiskAccessSettings,81 onOpenFullDiskAccessSettings,
82 onCheckFullDiskAccess,
79 onClose,83 onClose,
80}: BrowserImportWizardProps) {84}: BrowserImportWizardProps) {
81 const [source, setSource] = useState(initialSource);85 const [source, setSource] = useState(initialSource);
...@@ -147,6 +151,13 @@ export function BrowserImportWizard({...@@ -147,6 +151,13 @@ export function BrowserImportWizard({
147 source={source}151 source={source}
148 onCancel={onClose}152 onCancel={onClose}
149 onOpenSettings={onOpenFullDiskAccessSettings}153 onOpenSettings={onOpenFullDiskAccessSettings}
154 onCheck={
155 onCheckFullDiskAccess ??
156 (async () => {
157 const refreshed = await onRefreshSource();
158 return refreshed !== undefined && refreshed.unavailable === undefined;
159 })
160 }
150 onGranted={step.resume === "import" ? runImport : recheckFullDiskAccess}161 onGranted={step.resume === "import" ? runImport : recheckFullDiskAccess}
151 stillRequired={step.checked === true}162 stillRequired={step.checked === true}
152 />163 />
...@@ -248,13 +259,29 @@ function FullDiskAccessStep({...@@ -248,13 +259,29 @@ function FullDiskAccessStep({
248 onOpenSettings,259 onOpenSettings,
249 onGranted,260 onGranted,
250 stillRequired,261 stillRequired,
262 onCheck,
251}: {263}: {
252 readonly source: BrowserImportSource;264 readonly source: BrowserImportSource;
253 readonly onCancel: () => void;265 readonly onCancel: () => void;
254 readonly onOpenSettings: () => void;266 readonly onOpenSettings: () => void | Promise<void>;
267 readonly onCheck: () => Promise<boolean>;
255 readonly onGranted: () => void;268 readonly onGranted: () => void;
256 readonly stillRequired: boolean;269 readonly stillRequired: boolean;
257}) {270}) {
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 };
258 return (285 return (
259 <>286 <>
260 <DialogHeader>287 <DialogHeader>
...@@ -265,22 +292,49 @@ function FullDiskAccessStep({...@@ -265,22 +292,49 @@ function FullDiskAccessStep({
265 done.292 done.
266 </DialogDescription>293 </DialogDescription>
267 </DialogHeader>294 </DialogHeader>
268 {stillRequired ? (295 <DialogPanel>
269 <DialogPanel>296 <PermissionChecklist
270 <p role="status" className="text-sm text-muted-foreground">297 busy={opening}
271 Full Disk Access is still required. If you just turned it on, quit and reopen T3 Code,298 permissions={[
272 then try again.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}
273 </p>317 </p>
274 </DialogPanel>318 ) : null}
275 ) : 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>
276 <DialogFooter>327 <DialogFooter>
277 <Button variant="outline" onClick={onCancel}>328 <Button variant="outline" onClick={onCancel}>
278 Cancel329 Cancel
279 </Button>330 </Button>
280 <Button variant="outline" onClick={onOpenSettings}>331 <PermissionContinueButton
281 Open System Settings332 ready={permission.isReady(["fullDiskAccess"])}
282 </Button>333 busy={opening}
283 <Button onClick={onGranted}>I&rsquo;ve turned it on</Button>334 onClick={onGranted}
335 >
336 Continue
337 </PermissionContinueButton>
284 </DialogFooter>338 </DialogFooter>
285 </>339 </>
286 );340 );
apps/web/src/components/settings/IntegrationsSettings.tsx+7-2
...@@ -1260,10 +1260,15 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {...@@ -1260,10 +1260,15 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
1260 runWizardImport(importSession.source, importSession.environmentId, input)1260 runWizardImport(importSession.source, importSession.environmentId, input)
1261 }1261 }
1262 onRefreshSource={() => refreshImportSource(importSession.source.id)}1262 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 () => {
1264 // Rejects outside the desktop shell (and on shells that predate the1269 // Rejects outside the desktop shell (and on shells that predate the
1265 // method), so the one toast covers every way the link can fail.1270 // method), so the one toast covers every way the link can fail.
1266 void readLocalApi()1271 await readLocalApi()
1267 ?.shell.openSystemSettings("full-disk-access")1272 ?.shell.openSystemSettings("full-disk-access")
1268 .catch(() => {1273 .catch(() => {
1269 toastManager.add({1274 toastManager.add({
apps/web/src/components/settings/SnapShotSettings.tsx+1-6
...@@ -338,11 +338,6 @@ export function SnapShotSettings() {...@@ -338,11 +338,6 @@ export function SnapShotSettings() {
338 }338 }
339 };339 };
340340
341 const refreshSetup = useCallback(() => {
342 setSetupError(null);
343 return refreshState();
344 }, [refreshState]);
345
346 const closeSetup = async (completed: boolean) => {341 const closeSetup = async (completed: boolean) => {
347 if (!wizard || setupBusy) return;342 if (!wizard || setupBusy) return;
348 setSetupBusy(true);343 setSetupBusy(true);
...@@ -612,7 +607,7 @@ export function SnapShotSettings() {...@@ -612,7 +607,7 @@ export function SnapShotSettings() {
612 onSaveShortcut={saveShortcut}607 onSaveShortcut={saveShortcut}
613 onEnable={enableForSetup}608 onEnable={enableForSetup}
614 onAction={setup}609 onAction={setup}
615 onRefresh={refreshSetup}610 onRefresh={refreshState}
616 onClose={closeSetup}611 onClose={closeSetup}
617 onLeaveStep={stopRecording}612 onLeaveStep={stopRecording}
618 />613 />
apps/web/src/components/settings/SnapShotSetupDialog.tsx+49-71
...@@ -1,10 +1,11 @@...@@ -1,10 +1,11 @@
1import { PermissionChecklist, PermissionContinueButton } from "../permissions/PermissionChecklist";
2import { usePermissionStatus } from "../permissions/usePermissionStatus";
1import {3import {
2 isModifierPairShortcut,4 isModifierPairShortcut,
3 type DesktopSnapShotSetupAction,5 type DesktopSnapShotSetupAction,
4 type DesktopSnapShotState,6 type DesktopSnapShotState,
5} from "@t3tools/contracts";7} from "@t3tools/contracts";
6import { CircleCheckIcon } from "lucide-react";8import { useId, useState, type ReactNode } from "react";
7import { useEffect, useId, useState, type ReactNode } from "react";
8import { CaptureShortcutConfig } from "./CaptureShortcutConfig";9import { CaptureShortcutConfig } from "./CaptureShortcutConfig";
9import { Button } from "../ui/button";10import { Button } from "../ui/button";
10import { Dialog, DialogDescription } from "../ui/dialog";11import { Dialog, DialogDescription } from "../ui/dialog";
...@@ -15,7 +16,6 @@ import {...@@ -15,7 +16,6 @@ import {
15 captureSetupCheckMessage,16 captureSetupCheckMessage,
16 captureSetupDesktopName,17 captureSetupDesktopName,
17 captureSetupInitialStep,18 captureSetupInitialStep,
18 captureSetupMacPermissionsReady,
19 captureSetupShortcutReady,19 captureSetupShortcutReady,
20 type CaptureSetupStep,20 type CaptureSetupStep,
21} from "./SnapShotSetupDialog.logic";21} from "./SnapShotSetupDialog.logic";
...@@ -127,42 +127,6 @@ function AccessibilityPermissionIcon() {...@@ -127,42 +127,6 @@ function AccessibilityPermissionIcon() {
127 );127 );
128}128}
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
166export function SnapShotSetupDialog({130export function SnapShotSetupDialog({
167 state,131 state,
168 initialStep,132 initialStep,
...@@ -212,17 +176,24 @@ export function SnapShotSetupDialog({...@@ -212,17 +176,24 @@ export function SnapShotSetupDialog({
212 const installHelper = backend === "hyprland" ? "install-hyprland-helper" : "install-kde-helper";176 const installHelper = backend === "hyprland" ? "install-hyprland-helper" : "install-kde-helper";
213 const removeHelper = backend === "hyprland" ? "remove-hyprland-helper" : "remove-kde-helper";177 const removeHelper = backend === "hyprland" ? "remove-hyprland-helper" : "remove-kde-helper";
214 const accessReady = captureSetupAccessReady(state);178 const accessReady = captureSetupAccessReady(state);
215 const macPermissions = state.macPermissions;179 const permissionStatus = usePermissionStatus(
216 const macPermissionsReady = captureSetupMacPermissionsReady(state, includeAccessibility);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 );
217 const shortcutReady = captureSetupShortcutReady(state, shortcutChanged);194 const shortcutReady = captureSetupShortcutReady(state, shortcutChanged);
218 const install = extension?.status === "not-installed" || extension?.status === "update-required";195 const install = extension?.status === "not-installed" || extension?.status === "update-required";
219 const enable = extension?.status === "disabled";196 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]);
226 const changeStep = (next: CaptureSetupStep) => {197 const changeStep = (next: CaptureSetupStep) => {
227 onLeaveStep();198 onLeaveStep();
228 setChecked(false);199 setChecked(false);
...@@ -357,28 +328,34 @@ export function SnapShotSetupDialog({...@@ -357,28 +328,34 @@ export function SnapShotSetupDialog({
357 {checked && !busy && !error ? captureSetupCheckMessage(state) : null}328 {checked && !busy && !error ? captureSetupCheckMessage(state) : null}
358 </p>329 </p>
359 {macPermissions ? (330 {macPermissions ? (
360 <div className="space-y-2">331 <PermissionChecklist
361 <MacPermissionRow332 busy={busy}
362 icon={<ScreenRecordingIcon />}333 permissions={[
363 title="Screen Recording"334 {
364 description="Capture the window you're using."335 id: "screenRecording",
365 granted={macPermissions.screenRecording}336 icon: <ScreenRecordingIcon />,
366 busy={busy}337 title: "Screen Recording",
367 onAllow={() => void onAction("allow-screen-recording")}338 description: "Capture the window you're using.",
368 />339 granted: macPermissions.screenRecording,
369 <MacPermissionRow340 onAllow: () => void onAction("allow-screen-recording"),
370 icon={<AccessibilityPermissionIcon />}341 },
371 title="Accessibility"342 {
372 description={343 id: "accessibility",
373 includeAccessibility344 icon: <AccessibilityPermissionIcon />,
345 title: "Accessibility",
346 description: includeAccessibility
374 ? "Include text and controls from the captured app."347 ? "Include text and controls from the captured app."
375 : "Optional. Include text and controls from the captured app."348 : "Optional. Include text and controls from the captured app.",
376 }349 granted: macPermissions.accessibility,
377 granted={macPermissions.accessibility}350 onAllow: () => void onAction("allow-accessibility"),
378 busy={busy}351 },
379 onAllow={() => void onAction("allow-accessibility")}352 ]}
380 />353 />
381 </div>354 ) : null}
355 {permissionStatus.error && macPermissions ? (
356 <p role="status" className="text-xs text-muted-foreground">
357 {permissionStatus.error}
358 </p>
382 ) : null}359 ) : null}
383 {helperBackend && helper?.status === "error" ? (360 {helperBackend && helper?.status === "error" ? (
384 <Button361 <Button
...@@ -527,8 +504,9 @@ export function SnapShotSetupDialog({...@@ -527,8 +504,9 @@ export function SnapShotSetupDialog({
527 : "Check again"}504 : "Check again"}
528 </Button>505 </Button>
529 ) : (506 ) : (
530 <Button507 <PermissionContinueButton
531 disabled={busy || !macPermissionsReady}508 ready={macPermissionsReady}
509 busy={busy}
532 onClick={async () => {510 onClick={async () => {
533 if (await onEnable()) changeStep("shortcut");511 if (await onEnable()) changeStep("shortcut");
534 }}512 }}
...@@ -542,7 +520,7 @@ export function SnapShotSetupDialog({...@@ -542,7 +520,7 @@ export function SnapShotSetupDialog({
542 : !accessReady && !macPermissions520 : !accessReady && !macPermissions
543 ? "Try again"521 ? "Try again"
544 : "Continue"}522 : "Continue"}
545 </Button>523 </PermissionContinueButton>
546 )524 )
547 ) : !configShortcut ? (525 ) : !configShortcut ? (
548 <Button526 <Button
docs/user/browser-import.md+4-5
...@@ -10,11 +10,10 @@ unlock prompt if one appears....@@ -10,11 +10,10 @@ unlock prompt if one appears.
10This is a one-time copy. Later login changes stay separate between the two browsers, and some10This is a one-time copy. Later login changes stay separate between the two browsers, and some
11sites may still require you to sign in again.11sites may still require you to sign in again.
1212
13On macOS, Safari is also available. Safari protects its cookies with Full Disk Access rather than13On macOS, Safari imports need Full Disk Access. Choose **Allow**, drag T3 Code into the
14a keychain, so the import wizard asks you to grant it: **Open System Settings** takes you to the14System Settings permission list, and turn access on. **Continue** becomes available when access
15right pane, and macOS may ask you to quit and reopen T3 Code before the grant applies. You can15is detected. macOS may require you to quit and reopen T3 Code before the grant applies; reopen
16revoke Full Disk Access after the import is done. Only Safari's primary profile is imported; cookies16the import wizard afterward. You can revoke Full Disk Access once the import is done.
17kept by additional Safari profiles are not.
1817
19On Windows, import supports Firefox and Helium profiles that use standard profile encryption.18On Windows, import supports Firefox and Helium profiles that use standard profile encryption.
20Other Chromium-based browsers use app-bound encryption and cannot be imported. Partitioned cookies19Other Chromium-based browsers use app-bound encryption and cannot be imported. Partitioned cookies
knip.jsonc+1
...@@ -40,6 +40,7 @@...@@ -40,6 +40,7 @@
40 "src/preload.ts!",40 "src/preload.ts!",
41 "src/preview-pick-preload.ts!",41 "src/preview-pick-preload.ts!",
42 "src/preview-pip-preload.ts!",42 "src/preview-pip-preload.ts!",
43 "src/mac-permission-preload.ts!",
43 "src/preview/Annotation.css!",44 "src/preview/Annotation.css!",
44 ],45 ],
45 // The injected runtime resolves Playwright by string; release tooling runs46 // The injected runtime resolves Playwright by string; release tooling runs
packages/contracts/src/ipc.ts+1
...@@ -1301,6 +1301,7 @@ export interface DesktopBridge {...@@ -1301,6 +1301,7 @@ export interface DesktopBridge {
1301 * lack it, and callers no-op when it is missing.1301 * lack it, and callers no-op when it is missing.
1302 */1302 */
1303 openSystemSettings?: (pane: SystemSettingsPane) => Promise<boolean>;1303 openSystemSettings?: (pane: SystemSettingsPane) => Promise<boolean>;
1304 checkSystemPermission?: (pane: SystemSettingsPane) => Promise<boolean>;
1304 /**1305 /**
1305 * Probe this desktop machine for installed remote-capable editor CLIs1306 * Probe this desktop machine for installed remote-capable editor CLIs
1306 * (used for remote open-in-editor deep links). Optional: older desktop1307 * (used for remote open-in-editor deep links). Optional: older desktop