| author | |
| committer | |
| log | 36668dbe4fe2f8c881cc4f93bc675413eef1406f |
| tree | 92a2c1f6d1657b142533e43b2d2e99e29f248851 |
| parent | a92161a05040c3fcaaa479f1b60d2d45cd368dd4 |
| signature | Signed by PGP key B5690EEEBB952194 |
30 files changed, 1368 insertions(+), 162 deletions(-)
apps/desktop/src/electron/ElectronShell.ts+2-15| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts"; | |
| 1 | 2 | import { |
| 2 | 3 | REMOTE_CAPABLE_EDITOR_IDS, |
| 3 | 4 | remoteSchemeForEditor, |
| ... | ... | @@ -10,20 +11,6 @@ import * as Option from "effect/Option"; |
| 10 | 11 | |
| 11 | 12 | import * as Electron from "electron"; |
| 12 | 13 | |
| 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 | */ | |
| 22 | const SYSTEM_SETTINGS_URLS: Record<SystemSettingsPane, string> = { | |
| 23 | "full-disk-access": | |
| 24 | "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles", | |
| 25 | }; | |
| 26 | ||
| 27 | 14 | // Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`, |
| 28 | 15 | // `zed://ssh/<host>/<path>`) must reach the OS handler; every other non-web |
| 29 | 16 | // scheme stays blocked. |
| ... | ... | @@ -88,7 +75,7 @@ export const make = ElectronShell.of({ |
| 88 | 75 | }), |
| 89 | 76 | openSystemSettings: (pane) => |
| 90 | 77 | Effect.promise(() => |
| 91 | Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then( | |
| 78 | Electron.shell.openExternal(MAC_PERMISSION_SETTINGS_URLS[pane]).then( | |
| 92 | 79 | () => true, |
| 93 | 80 | () => false, |
| 94 | 81 | ), |
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | ... | @@ -39,6 +39,7 @@ import { |
| 39 | 39 | getWindowFullscreenState, |
| 40 | 40 | openExternal, |
| 41 | 41 | openSystemSettings, |
| 42 | checkSystemPermission, | |
| 42 | 43 | probeRemoteEditors, |
| 43 | 44 | pickFolder, |
| 44 | 45 | pickProjectFavicon, |
| ... | ... | @@ -122,6 +123,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" |
| 122 | 123 | yield* ipc.handle(showContextMenu); |
| 123 | 124 | yield* ipc.handle(openExternal); |
| 124 | 125 | yield* ipc.handle(openSystemSettings); |
| 126 | yield* ipc.handle(checkSystemPermission); | |
| 125 | 127 | yield* ipc.handle(probeRemoteEditors); |
| 126 | 128 | yield* ipc.handle(getUpdateState); |
| 127 | 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 | 105 | export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; |
| 106 | 106 | export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; |
| 107 | 107 | export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; |
| 108 | ||
| 109 | export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; | |
| 110 | ||
| 111 | export 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 | 34 | import * as ElectronShell from "../../electron/ElectronShell.ts"; |
| 35 | 35 | import * as ElectronTheme from "../../electron/ElectronTheme.ts"; |
| 36 | 36 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; |
| 37 | import * as Electron from "electron"; | |
| 38 | import * as MacPermissions from "../../permissions/MacPermissions.ts"; | |
| 39 | import { safariPermissionCheck } from "../../preview/BrowserImport/SafariPermission.ts"; | |
| 37 | 40 | import * as IpcChannels from "../channels.ts"; |
| 38 | 41 | import * as DesktopIpc from "../DesktopIpc.ts"; |
| 39 | 42 | import { |
| ... | ... | @@ -305,7 +308,16 @@ export const openSystemSettings = DesktopIpc.makeIpcMethod({ |
| 305 | 308 | result: Schema.Boolean, |
| 306 | 309 | handler: Effect.fn("desktop.ipc.window.openSystemSettings")(function* (pane) { |
| 307 | 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 | }); |
| 311 | 323 | |
| ... | ... | @@ -379,3 +391,15 @@ export const pickThemeFiles = DesktopIpc.makeIpcMethod({ |
| 379 | 391 | }); |
| 380 | 392 | }), |
| 381 | 393 | }); |
| 394 | ||
| 395 | export 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 @@ |
| 1 | import { ipcRenderer } from "electron"; | |
| 2 | import { 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. | |
| 5 | window.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 | import * as MacPermissions from "./permissions/MacPermissions.ts"; | |
| 1 | 2 | for (const stream of [process.stdout, process.stderr]) { |
| 2 | 3 | stream.on("error", (err: NodeJS.ErrnoException) => { |
| 3 | 4 | if (err.code !== "EPIPE") throw err; |
| ... | ... | @@ -133,6 +134,7 @@ const electronLayer = Layer.mergeAll( |
| 133 | 134 | ); |
| 134 | 135 | |
| 135 | 136 | const desktopFoundationLayer = Layer.mergeAll( |
| 137 | MacPermissions.layer, | |
| 136 | 138 | DesktopState.layer, |
| 137 | 139 | DesktopShutdown.layer, |
| 138 | 140 | DesktopAppSettings.layer, |
apps/desktop/src/permissions/MacPermission.ts created+15| ... | ... | @@ -0,0 +1,15 @@ |
| 1 | export 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 | ||
| 9 | export type MacPermission = keyof typeof MAC_PERMISSION_SETTINGS_URLS; | |
| 10 | ||
| 11 | export 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 @@ |
| 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | import * as Electron from "electron"; | |
| 3 | import { MacPermissionHelper, macAppBundlePath } from "./MacPermissionHelper.ts"; | |
| 4 | import type { SettingsWindow } from "./MacSettingsWindow.ts"; | |
| 5 | import { MAC_PERMISSION_HELPER_CHANNEL } from "../ipc/channels.ts"; | |
| 6 | ||
| 7 | const 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 | })); | |
| 18 | const 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 | ); | |
| 28 | vi.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 | }); | |
| 81 | vi.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 | }); | |
| 95 | let helper: MacPermissionHelper; | |
| 96 | beforeEach(() => { | |
| 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 | }); | |
| 106 | afterEach(() => { | |
| 107 | helper.close(); | |
| 108 | vi.useRealTimers(); | |
| 109 | }); | |
| 110 | const iconPaths = ["/bundle/prod-resources/icon.png"]; | |
| 111 | const open = () => | |
| 112 | helper.show("accessibility", "/bundle/mac-permission-preload.cjs", null, iconPaths); | |
| 113 | function 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 | ||
| 125 | describe("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 | }); | |
| 134 | it("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 | }); | |
| 147 | it("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 | }); | |
| 155 | it("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 | }); | |
| 164 | it("does not open for a permission already granted", async () => { | |
| 165 | mocks.granted = true; | |
| 166 | await open(); | |
| 167 | expect(windows).toHaveLength(0); | |
| 168 | }); | |
| 169 | it("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 | }); | |
| 174 | it("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 | ||
| 181 | it("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 | ||
| 191 | it("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 | }); | |
| 201 | it("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 | ||
| 210 | it("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 | ||
| 218 | it("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 | ||
| 241 | it("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 | }); | |
| 250 | it("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 | ||
| 261 | it("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 | ||
| 283 | it("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 | ||
| 300 | it("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. | |
| 2 | import * as Electron from "electron"; | |
| 3 | import { MAC_PERMISSION_HELPER_CHANNEL } from "../ipc/channels.ts"; | |
| 4 | ||
| 5 | import { | |
| 6 | settingsHelperBounds, | |
| 7 | watchMacSettingsWindow, | |
| 8 | type SettingsWindow, | |
| 9 | } from "./MacSettingsWindow.ts"; | |
| 10 | ||
| 11 | import { MAC_PERMISSION_TITLES, type MacPermission } from "./MacPermission.ts"; | |
| 12 | ||
| 13 | const 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. */ | |
| 22 | export function macAppBundlePath(executable: string): string | undefined { | |
| 23 | return /^(.+\.app)\/Contents\/MacOS\/[^/]+$/.exec(executable)?.[1]; | |
| 24 | } | |
| 25 | ||
| 26 | const 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 | ||
| 42 | function 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; } | |
| 50 | html { background: transparent; } | |
| 51 | body { 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); } | |
| 53 | header { font-weight: 600; white-space: nowrap; } | |
| 54 | button { font: inherit; color: inherit; } | |
| 55 | button: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; } | |
| 60 | img { 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. */ | |
| 69 | export 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 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Path from "effect/Path"; | |
| 5 | import * as Electron from "electron"; | |
| 6 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 7 | import { MacPermissionHelper } from "./MacPermissionHelper.ts"; | |
| 8 | import type { MacPermission } from "./MacPermission.ts"; | |
| 9 | ||
| 10 | export 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 | ||
| 21 | export 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 @@ |
| 1 | import * as NodeEvents from "node:events"; | |
| 2 | import { beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 3 | import { settingsHelperBounds, watchMacSettingsWindow } from "./MacSettingsWindow.ts"; | |
| 4 | ||
| 5 | const mocks = vi.hoisted(() => ({ spawn: vi.fn() })); | |
| 6 | vi.mock("node:child_process", () => ({ spawn: mocks.spawn })); | |
| 7 | let child: NodeEvents.EventEmitter & { | |
| 8 | stdout: NodeEvents.EventEmitter & { setEncoding: ReturnType<typeof vi.fn> }; | |
| 9 | kill: ReturnType<typeof vi.fn>; | |
| 10 | }; | |
| 11 | beforeEach(() => { | |
| 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 | }); | |
| 18 | it("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 | }); | |
| 29 | it("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 | }); | |
| 44 | it("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. | |
| 2 | import * as NodeChildProcess from "node:child_process"; | |
| 3 | import * as Schema from "effect/Schema"; | |
| 4 | import type * as Electron from "electron"; | |
| 5 | ||
| 6 | const 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 | ); | |
| 15 | export type SettingsWindow = typeof SettingsWindow.Type; | |
| 16 | const 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. | |
| 21 | const SETTINGS_WINDOW_SCRIPT = ` | |
| 22 | ObjC.import("CoreGraphics"); | |
| 23 | ObjC.import("AppKit"); | |
| 24 | function 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. */ | |
| 60 | export 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. */ | |
| 73 | export 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 | 152 | ...(position === undefined ? {} : { position }), |
| 153 | 153 | }), |
| 154 | 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 | 157 | openSystemSettings: (pane: string) => |
| 156 | 158 | ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane), |
| 157 | 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 | 11 | parseBinaryCookies, |
| 12 | 12 | readSafariCookies, |
| 13 | 13 | safariAccessDenied, |
| 14 | safariAccessGranted, | |
| 14 | 15 | SafariCookieReadError, |
| 15 | 16 | } from "./SafariCookies.ts"; |
| 16 | 17 | |
| ... | ... | @@ -441,3 +442,36 @@ describe("isPermissionDenied", () => { |
| 441 | 442 | expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false); |
| 442 | 443 | }); |
| 443 | 444 | }); |
| 445 | ||
| 446 | describe("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 | 225 | ); |
| 226 | 226 | }); |
| 227 | 227 | |
| 228 | /** A missing or unreadable jar is never evidence that access was granted. */ | |
| 229 | export 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 | ||
| 228 | 238 | export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* ( |
| 229 | 239 | cookiePath: string, |
| 230 | 240 | ) { |
apps/desktop/src/preview/BrowserImport/SafariPermission.test.ts created+59| ... | ... | @@ -0,0 +1,59 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as PlatformError from "effect/PlatformError"; | |
| 7 | import { safariPermissionCheck } from "./SafariPermission.ts"; | |
| 8 | ||
| 9 | it.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 | ||
| 45 | it.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 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import * as FileSystem from "effect/FileSystem"; | |
| 3 | import { safariAccessGranted } from "./SafariCookies.ts"; | |
| 4 | import { | |
| 5 | BROWSER_IMPORT_SOURCES, | |
| 6 | resolveCookieDatabase, | |
| 7 | listSourceProfiles, | |
| 8 | sourcePathContext, | |
| 9 | } from "./Sources.ts"; | |
| 10 | ||
| 11 | export 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 | import * as MacPermissions from "../permissions/MacPermissions.ts"; | |
| 1 | 2 | import { assert, it } from "@effect/vitest"; |
| 2 | 3 | import { |
| 3 | 4 | DEFAULT_CLIENT_SETTINGS, |
| ... | ... | @@ -488,41 +489,45 @@ const testLayer = ( |
| 488 | 489 | DesktopClientSettings.DesktopClientSettingsReadError |
| 489 | 490 | > = Effect.succeed(initialSettings), |
| 490 | 491 | ) => |
| 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 | ), | |
| 526 | 531 | ), |
| 527 | 532 | ); |
| 528 | 533 |
apps/desktop/src/snapShot/DesktopSnapShot.ts+39-13| ... | ... | @@ -76,6 +76,8 @@ import { |
| 76 | 76 | type AccessibilityProcessPool, |
| 77 | 77 | makeSnapShotAccessibilityProcessPool, |
| 78 | 78 | } from "./SnapShotAccessibilityProcess.ts"; |
| 79 | import * as MacPermissions from "../permissions/MacPermissions.ts"; | |
| 80 | import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts"; | |
| 79 | 81 | import { showWindowsCaptureOverlay } from "./WindowsCaptureFeedback.ts"; |
| 80 | 82 | |
| 81 | 83 | import { |
| ... | ... | @@ -96,8 +98,7 @@ const FLASH_ANIMATION_DURATION_MS = 180; |
| 96 | 98 | const FLASH_STATIC_DURATION_MS = 60; |
| 97 | 99 | const FLASH_FRAME_INTERVAL_MS = 16; |
| 98 | 100 | const FLASH_PEAK_OPACITY = 0.08; |
| 99 | const MAC_SCREEN_CAPTURE_SETTINGS_URL = | |
| 100 | "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"; | |
| 101 | const MAC_SCREEN_CAPTURE_SETTINGS_URL = MAC_PERMISSION_SETTINGS_URLS["screen-recording"]; | |
| 101 | 102 | const MAC_SCREEN_CAPTURE_PERMISSION_MESSAGE = |
| 102 | 103 | "Allow Screen Recording in System Settings, then restart T3 Code."; |
| 103 | 104 | const MAC_ACCESSIBILITY_PERMISSION_MESSAGE = |
| ... | ... | @@ -704,6 +705,7 @@ function probeGlobalShortcut(accelerator: string): DesktopSnapShotShortcutAvaila |
| 704 | 705 | |
| 705 | 706 | export const make = Effect.gen(function* () { |
| 706 | 707 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 708 | const permissions = yield* MacPermissions.MacPermissions; | |
| 707 | 709 | const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; |
| 708 | 710 | const desktopWindow = yield* DesktopWindow.DesktopWindow; |
| 709 | 711 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -1300,14 +1302,26 @@ export const make = Effect.gen(function* () { |
| 1300 | 1302 | yield* configurationMutex.withPermits(1)(applySettings(settings, null)); |
| 1301 | 1303 | }); |
| 1302 | 1304 | |
| 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)); | |
| 1311 | 1325 | |
| 1312 | 1326 | const setup = Effect.fn("desktop.snapShot.setup")(function* (action: DesktopSnapShotSetupAction) { |
| 1313 | 1327 | if (action === "test-mac-capture") { |
| ... | ... | @@ -1372,9 +1386,21 @@ export const make = Effect.gen(function* () { |
| 1372 | 1386 | action, |
| 1373 | 1387 | reason: "unsupported-session", |
| 1374 | 1388 | }); |
| 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 | ); | |
| 1378 | 1404 | } else if (action !== "retry-shortcut") { |
| 1379 | 1405 | if (!hasGnomeSetup()) |
| 1380 | 1406 | return yield* new DesktopSnapShotSetupError({ |
apps/desktop/vite.config.ts+9| ... | ... | @@ -93,6 +93,15 @@ export default defineConfig({ |
| 93 | 93 | outExtensions: () => ({ js: ".cjs" }), |
| 94 | 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 | 106 | test: { |
| 98 | 107 | // The Windows lane runs workspace suites concurrently; filesystem-heavy |
apps/web/src/components/permissions/PermissionChecklist.tsx created+57| ... | ... | @@ -0,0 +1,57 @@ |
| 1 | import { CircleCheckIcon } from "lucide-react"; | |
| 2 | import type { ComponentProps, ReactNode } from "react"; | |
| 3 | import { Button } from "../ui/button"; | |
| 4 | ||
| 5 | export interface PermissionItem { | |
| 6 | id: string; | |
| 7 | icon: ReactNode; | |
| 8 | title: string; | |
| 9 | description: string; | |
| 10 | granted: boolean; | |
| 11 | onAllow: () => void; | |
| 12 | } | |
| 13 | ||
| 14 | export 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 | ||
| 46 | export 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 @@ |
| 1 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 2 | import { reactHookHarness as hooks } from "../../test/reactHookHarness"; | |
| 3 | import { usePermissionStatus } from "./usePermissionStatus"; | |
| 4 | ||
| 5 | const effects = vi.hoisted(() => [] as Array<() => (() => void) | undefined>); | |
| 6 | vi.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 | }); | |
| 16 | vi.mock("react/compiler-runtime", async () => { | |
| 17 | const { reactHookHarness } = await import("../../test/reactHookHarness"); | |
| 18 | return { c: reactHookHarness.useMemoCache }; | |
| 19 | }); | |
| 20 | const check = vi.fn<() => Promise<{ screen: boolean; accessibility: boolean }>>(); | |
| 21 | let cleanup: (() => void) | undefined; | |
| 22 | let page: EventTarget & { visibilityState: string }; | |
| 23 | const render = () => { | |
| 24 | hooks.beginRender(); | |
| 25 | return usePermissionStatus(check, { screen: false, accessibility: false }); | |
| 26 | }; | |
| 27 | beforeEach(() => { | |
| 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 | }); | |
| 36 | afterEach(() => { | |
| 37 | cleanup?.(); | |
| 38 | vi.useRealTimers(); | |
| 39 | vi.unstubAllGlobals(); | |
| 40 | }); | |
| 41 | const start = async () => { | |
| 42 | render(); | |
| 43 | cleanup = effects[0]!(); | |
| 44 | await Promise.resolve(); | |
| 45 | }; | |
| 46 | ||
| 47 | it("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 | ||
| 63 | it("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 | ||
| 81 | it("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 @@ |
| 1 | import { useEffect, useEffectEvent, useState } from "react"; | |
| 2 | ||
| 3 | export 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 | import { PermissionChecklist, PermissionContinueButton } from "../permissions/PermissionChecklist"; | |
| 2 | import { usePermissionStatus } from "../permissions/usePermissionStatus"; | |
| 1 | 3 | import type { BrowserImportSource } from "@t3tools/contracts"; |
| 2 | 4 | import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts"; |
| 3 | import { ArrowDownIcon, ArrowRightIcon, CheckIcon } from "lucide-react"; | |
| 5 | import { ArrowDownIcon, ArrowRightIcon, CheckIcon, HardDriveIcon } from "lucide-react"; | |
| 4 | 6 | import { useRef, useState } from "react"; |
| 5 | 7 | |
| 6 | 8 | import { cn, randomUUID } from "~/lib/utils"; |
| ... | ... | @@ -57,7 +59,8 @@ interface BrowserImportWizardProps { |
| 57 | 59 | /** Re-checks the source's availability after the user quits the browser. */ |
| 58 | 60 | readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>; |
| 59 | 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 | 64 | readonly onClose: () => void; |
| 62 | 65 | } |
| 63 | 66 | |
| ... | ... | @@ -76,6 +79,7 @@ export function BrowserImportWizard({ |
| 76 | 79 | onImport, |
| 77 | 80 | onRefreshSource, |
| 78 | 81 | onOpenFullDiskAccessSettings, |
| 82 | onCheckFullDiskAccess, | |
| 79 | 83 | onClose, |
| 80 | 84 | }: BrowserImportWizardProps) { |
| 81 | 85 | const [source, setSource] = useState(initialSource); |
| ... | ... | @@ -147,6 +151,13 @@ export function BrowserImportWizard({ |
| 147 | 151 | source={source} |
| 148 | 152 | onCancel={onClose} |
| 149 | 153 | onOpenSettings={onOpenFullDiskAccessSettings} |
| 154 | onCheck={ | |
| 155 | onCheckFullDiskAccess ?? | |
| 156 | (async () => { | |
| 157 | const refreshed = await onRefreshSource(); | |
| 158 | return refreshed !== undefined && refreshed.unavailable === undefined; | |
| 159 | }) | |
| 160 | } | |
| 150 | 161 | onGranted={step.resume === "import" ? runImport : recheckFullDiskAccess} |
| 151 | 162 | stillRequired={step.checked === true} |
| 152 | 163 | /> |
| ... | ... | @@ -248,13 +259,29 @@ function FullDiskAccessStep({ |
| 248 | 259 | onOpenSettings, |
| 249 | 260 | onGranted, |
| 250 | 261 | stillRequired, |
| 262 | onCheck, | |
| 251 | 263 | }: { |
| 252 | 264 | readonly source: BrowserImportSource; |
| 253 | 265 | readonly onCancel: () => void; |
| 254 | readonly onOpenSettings: () => void; | |
| 266 | readonly onOpenSettings: () => void | Promise<void>; | |
| 267 | readonly onCheck: () => Promise<boolean>; | |
| 255 | 268 | readonly onGranted: () => void; |
| 256 | 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 | 285 | return ( |
| 259 | 286 | <> |
| 260 | 287 | <DialogHeader> |
| ... | ... | @@ -265,22 +292,49 @@ function FullDiskAccessStep({ |
| 265 | 292 | done. |
| 266 | 293 | </DialogDescription> |
| 267 | 294 | </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} | |
| 273 | 317 | </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> | |
| 276 | 327 | <DialogFooter> |
| 277 | 328 | <Button variant="outline" onClick={onCancel}> |
| 278 | 329 | Cancel |
| 279 | 330 | </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> | |
| 284 | 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 | 1260 | runWizardImport(importSession.source, importSession.environmentId, input) |
| 1261 | 1261 | } |
| 1262 | 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 | 1269 | // Rejects outside the desktop shell (and on shells that predate the |
| 1265 | 1270 | // method), so the one toast covers every way the link can fail. |
| 1266 | void readLocalApi() | |
| 1271 | await readLocalApi() | |
| 1267 | 1272 | ?.shell.openSystemSettings("full-disk-access") |
| 1268 | 1273 | .catch(() => { |
| 1269 | 1274 | toastManager.add({ |
apps/web/src/components/settings/SnapShotSettings.tsx+1-6| ... | ... | @@ -338,11 +338,6 @@ export function SnapShotSettings() { |
| 338 | 338 | } |
| 339 | 339 | }; |
| 340 | 340 | |
| 341 | const refreshSetup = useCallback(() => { | |
| 342 | setSetupError(null); | |
| 343 | return refreshState(); | |
| 344 | }, [refreshState]); | |
| 345 | ||
| 346 | 341 | const closeSetup = async (completed: boolean) => { |
| 347 | 342 | if (!wizard || setupBusy) return; |
| 348 | 343 | setSetupBusy(true); |
| ... | ... | @@ -612,7 +607,7 @@ export function SnapShotSettings() { |
| 612 | 607 | onSaveShortcut={saveShortcut} |
| 613 | 608 | onEnable={enableForSetup} |
| 614 | 609 | onAction={setup} |
| 615 | onRefresh={refreshSetup} | |
| 610 | onRefresh={refreshState} | |
| 616 | 611 | onClose={closeSetup} |
| 617 | 612 | onLeaveStep={stopRecording} |
| 618 | 613 | /> |
apps/web/src/components/settings/SnapShotSetupDialog.tsx+49-71| ... | ... | @@ -1,10 +1,11 @@ |
| 1 | import { PermissionChecklist, PermissionContinueButton } from "../permissions/PermissionChecklist"; | |
| 2 | import { usePermissionStatus } from "../permissions/usePermissionStatus"; | |
| 1 | 3 | import { |
| 2 | 4 | isModifierPairShortcut, |
| 3 | 5 | type DesktopSnapShotSetupAction, |
| 4 | 6 | type DesktopSnapShotState, |
| 5 | 7 | } from "@t3tools/contracts"; |
| 6 | import { CircleCheckIcon } from "lucide-react"; | |
| 7 | import { useEffect, useId, useState, type ReactNode } from "react"; | |
| 8 | import { useId, useState, type ReactNode } from "react"; | |
| 8 | 9 | import { CaptureShortcutConfig } from "./CaptureShortcutConfig"; |
| 9 | 10 | import { Button } from "../ui/button"; |
| 10 | 11 | import { Dialog, DialogDescription } from "../ui/dialog"; |
| ... | ... | @@ -15,7 +16,6 @@ import { |
| 15 | 16 | captureSetupCheckMessage, |
| 16 | 17 | captureSetupDesktopName, |
| 17 | 18 | captureSetupInitialStep, |
| 18 | captureSetupMacPermissionsReady, | |
| 19 | 19 | captureSetupShortcutReady, |
| 20 | 20 | type CaptureSetupStep, |
| 21 | 21 | } from "./SnapShotSetupDialog.logic"; |
| ... | ... | @@ -127,42 +127,6 @@ function AccessibilityPermissionIcon() { |
| 127 | 127 | ); |
| 128 | 128 | } |
| 129 | 129 | |
| 130 | function 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 | ||
| 166 | 130 | export function SnapShotSetupDialog({ |
| 167 | 131 | state, |
| 168 | 132 | initialStep, |
| ... | ... | @@ -212,17 +176,24 @@ export function SnapShotSetupDialog({ |
| 212 | 176 | const installHelper = backend === "hyprland" ? "install-hyprland-helper" : "install-kde-helper"; |
| 213 | 177 | const removeHelper = backend === "hyprland" ? "remove-hyprland-helper" : "remove-kde-helper"; |
| 214 | 178 | 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 | ); | |
| 217 | 194 | const shortcutReady = captureSetupShortcutReady(state, shortcutChanged); |
| 218 | 195 | const install = extension?.status === "not-installed" || extension?.status === "update-required"; |
| 219 | 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 | 197 | const changeStep = (next: CaptureSetupStep) => { |
| 227 | 198 | onLeaveStep(); |
| 228 | 199 | setChecked(false); |
| ... | ... | @@ -357,28 +328,34 @@ export function SnapShotSetupDialog({ |
| 357 | 328 | {checked && !busy && !error ? captureSetupCheckMessage(state) : null} |
| 358 | 329 | </p> |
| 359 | 330 | {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 | |
| 374 | 347 | ? "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> | |
| 382 | 359 | ) : null} |
| 383 | 360 | {helperBackend && helper?.status === "error" ? ( |
| 384 | 361 | <Button |
| ... | ... | @@ -527,8 +504,9 @@ export function SnapShotSetupDialog({ |
| 527 | 504 | : "Check again"} |
| 528 | 505 | </Button> |
| 529 | 506 | ) : ( |
| 530 | <Button | |
| 531 | disabled={busy || !macPermissionsReady} | |
| 507 | <PermissionContinueButton | |
| 508 | ready={macPermissionsReady} | |
| 509 | busy={busy} | |
| 532 | 510 | onClick={async () => { |
| 533 | 511 | if (await onEnable()) changeStep("shortcut"); |
| 534 | 512 | }} |
| ... | ... | @@ -542,7 +520,7 @@ export function SnapShotSetupDialog({ |
| 542 | 520 | : !accessReady && !macPermissions |
| 543 | 521 | ? "Try again" |
| 544 | 522 | : "Continue"} |
| 545 | </Button> | |
| 523 | </PermissionContinueButton> | |
| 546 | 524 | ) |
| 547 | 525 | ) : !configShortcut ? ( |
| 548 | 526 | <Button |
docs/user/browser-import.md+4-5| ... | ... | @@ -10,11 +10,10 @@ unlock prompt if one appears. |
| 10 | 10 | This is a one-time copy. Later login changes stay separate between the two browsers, and some |
| 11 | 11 | sites may still require you to sign in again. |
| 12 | 12 | |
| 13 | On macOS, Safari is also available. Safari protects its cookies with Full Disk Access rather than | |
| 14 | a keychain, so the import wizard asks you to grant it: **Open System Settings** takes you to the | |
| 15 | right pane, and macOS may ask you to quit and reopen T3 Code before the grant applies. You can | |
| 16 | revoke Full Disk Access after the import is done. Only Safari's primary profile is imported; cookies | |
| 17 | kept by additional Safari profiles are not. | |
| 13 | On macOS, Safari imports need Full Disk Access. Choose **Allow**, drag T3 Code into the | |
| 14 | System Settings permission list, and turn access on. **Continue** becomes available when access | |
| 15 | is detected. macOS may require you to quit and reopen T3 Code before the grant applies; reopen | |
| 16 | the import wizard afterward. You can revoke Full Disk Access once the import is done. | |
| 18 | 17 | |
| 19 | 18 | On Windows, import supports Firefox and Helium profiles that use standard profile encryption. |
| 20 | 19 | Other Chromium-based browsers use app-bound encryption and cannot be imported. Partitioned cookies |
knip.jsonc+1| ... | ... | @@ -40,6 +40,7 @@ |
| 40 | 40 | "src/preload.ts!", |
| 41 | 41 | "src/preview-pick-preload.ts!", |
| 42 | 42 | "src/preview-pip-preload.ts!", |
| 43 | "src/mac-permission-preload.ts!", | |
| 43 | 44 | "src/preview/Annotation.css!", |
| 44 | 45 | ], |
| 45 | 46 | // The injected runtime resolves Playwright by string; release tooling runs |
packages/contracts/src/ipc.ts+1| ... | ... | @@ -1301,6 +1301,7 @@ export interface DesktopBridge { |
| 1301 | 1301 | * lack it, and callers no-op when it is missing. |
| 1302 | 1302 | */ |
| 1303 | 1303 | openSystemSettings?: (pane: SystemSettingsPane) => Promise<boolean>; |
| 1304 | checkSystemPermission?: (pane: SystemSettingsPane) => Promise<boolean>; | |
| 1304 | 1305 | /** |
| 1305 | 1306 | * Probe this desktop machine for installed remote-capable editor CLIs |
| 1306 | 1307 | * (used for remote open-in-editor deep links). Optional: older desktop |