| 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,3 +1,4 @@ |
| 1 | import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts"; | ||
| 1 | import { | 2 | import { |
| 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"; |
| 10 | 11 | ||
| 11 | import * as Electron from "electron"; | 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 | // 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-web | 15 | // `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"; |
| 105 | export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; | 105 | export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; |
| 106 | export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; | 106 | export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; |
| 107 | export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; | 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,6 +34,9 @@ import * as ElectronMenu from "../../electron/ElectronMenu.ts"; |
| 34 | import * as ElectronShell from "../../electron/ElectronShell.ts"; | 34 | import * as ElectronShell from "../../electron/ElectronShell.ts"; |
| 35 | import * as ElectronTheme from "../../electron/ElectronTheme.ts"; | 35 | import * as ElectronTheme from "../../electron/ElectronTheme.ts"; |
| 36 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | 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 | import * as IpcChannels from "../channels.ts"; | 40 | import * as IpcChannels from "../channels.ts"; |
| 38 | import * as DesktopIpc from "../DesktopIpc.ts"; | 41 | import * as DesktopIpc from "../DesktopIpc.ts"; |
| 39 | import { | 42 | import { |
| ... | @@ -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 | }); |
| 311 | 323 | ||
| ... | @@ -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 | |||
| 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,3 +1,4 @@ |
| 1 | import * as MacPermissions from "./permissions/MacPermissions.ts"; | ||
| 1 | for (const stream of [process.stdout, process.stderr]) { | 2 | for (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 | ); |
| 134 | 135 | ||
| 135 | const desktopFoundationLayer = Layer.mergeAll( | 136 | const 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 @@ | ||
| 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,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"; |
| 16 | 17 | ||
| ... | @@ -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 | |||
| 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,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 | export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* ( | 238 | export 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 @@ | ||
| 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,3 +1,4 @@ |
| 1 | import * as MacPermissions from "../permissions/MacPermissions.ts"; | ||
| 1 | import { assert, it } from "@effect/vitest"; | 2 | import { assert, it } from "@effect/vitest"; |
| 2 | import { | 3 | import { |
| 3 | DEFAULT_CLIENT_SETTINGS, | 4 | DEFAULT_CLIENT_SETTINGS, |
| ... | @@ -488,41 +489,45 @@ const testLayer = ( | ... | @@ -488,41 +489,45 @@ const testLayer = ( |
| 488 | DesktopClientSettings.DesktopClientSettingsReadError | 489 | 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 | ); |
| 528 | 533 |
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"; |
| 79 | import * as MacPermissions from "../permissions/MacPermissions.ts"; | ||
| 80 | import { MAC_PERMISSION_SETTINGS_URLS } from "../permissions/MacPermission.ts"; | ||
| 79 | import { showWindowsCaptureOverlay } from "./WindowsCaptureFeedback.ts"; | 81 | import { showWindowsCaptureOverlay } from "./WindowsCaptureFeedback.ts"; |
| 80 | 82 | ||
| 81 | import { | 83 | import { |
| ... | @@ -96,8 +98,7 @@ const FLASH_ANIMATION_DURATION_MS = 180; | ... | @@ -96,8 +98,7 @@ const FLASH_ANIMATION_DURATION_MS = 180; |
| 96 | const FLASH_STATIC_DURATION_MS = 60; | 98 | const FLASH_STATIC_DURATION_MS = 60; |
| 97 | const FLASH_FRAME_INTERVAL_MS = 16; | 99 | const FLASH_FRAME_INTERVAL_MS = 16; |
| 98 | const FLASH_PEAK_OPACITY = 0.08; | 100 | const FLASH_PEAK_OPACITY = 0.08; |
| 99 | const MAC_SCREEN_CAPTURE_SETTINGS_URL = | 101 | const MAC_SCREEN_CAPTURE_SETTINGS_URL = MAC_PERMISSION_SETTINGS_URLS["screen-recording"]; |
| 100 | "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"; | ||
| 101 | const MAC_SCREEN_CAPTURE_PERMISSION_MESSAGE = | 102 | const 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."; |
| 103 | const MAC_ACCESSIBILITY_PERMISSION_MESSAGE = | 104 | const MAC_ACCESSIBILITY_PERMISSION_MESSAGE = |
| ... | @@ -704,6 +705,7 @@ function probeGlobalShortcut(accelerator: string): DesktopSnapShotShortcutAvaila | ... | @@ -704,6 +705,7 @@ function probeGlobalShortcut(accelerator: string): DesktopSnapShotShortcutAvaila |
| 704 | 705 | ||
| 705 | export const make = Effect.gen(function* () { | 706 | export 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 | }); |
| 1302 | 1304 | ||
| 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)); | ||
| 1311 | 1325 | ||
| 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-heavy | 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,6 +1,8 @@ |
| 1 | import { PermissionChecklist, PermissionContinueButton } from "../permissions/PermissionChecklist"; | ||
| 2 | import { usePermissionStatus } from "../permissions/usePermissionStatus"; | ||
| 1 | import type { BrowserImportSource } from "@t3tools/contracts"; | 3 | import type { BrowserImportSource } from "@t3tools/contracts"; |
| 2 | import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts"; | 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 | import { useRef, useState } from "react"; | 6 | import { useRef, useState } from "react"; |
| 5 | 7 | ||
| 6 | import { cn, randomUUID } from "~/lib/utils"; | 8 | import { 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 | } |
| 63 | 66 | ||
| ... | @@ -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 | Cancel | 329 | Cancel |
| 279 | </Button> | 330 | </Button> |
| 280 | <Button variant="outline" onClick={onOpenSettings}> | 331 | <PermissionContinueButton |
| 281 | Open System Settings | 332 | 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 the | 1269 | // 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 | }; |
| 340 | 340 | ||
| 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 @@ |
| 1 | import { PermissionChecklist, PermissionContinueButton } from "../permissions/PermissionChecklist"; | ||
| 2 | import { usePermissionStatus } from "../permissions/usePermissionStatus"; | ||
| 1 | import { | 3 | import { |
| 2 | isModifierPairShortcut, | 4 | isModifierPairShortcut, |
| 3 | type DesktopSnapShotSetupAction, | 5 | type DesktopSnapShotSetupAction, |
| 4 | type DesktopSnapShotState, | 6 | type DesktopSnapShotState, |
| 5 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 6 | import { CircleCheckIcon } from "lucide-react"; | 8 | import { useId, useState, type ReactNode } from "react"; |
| 7 | import { useEffect, useId, useState, type ReactNode } from "react"; | ||
| 8 | import { CaptureShortcutConfig } from "./CaptureShortcutConfig"; | 9 | import { CaptureShortcutConfig } from "./CaptureShortcutConfig"; |
| 9 | import { Button } from "../ui/button"; | 10 | import { Button } from "../ui/button"; |
| 10 | import { Dialog, DialogDescription } from "../ui/dialog"; | 11 | import { 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 | } |
| 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 | export function SnapShotSetupDialog({ | 130 | export 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 | <MacPermissionRow | 332 | 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 | <MacPermissionRow | 340 | onAllow: () => void onAction("allow-screen-recording"), |
| 370 | icon={<AccessibilityPermissionIcon />} | 341 | }, |
| 371 | title="Accessibility" | 342 | { |
| 372 | description={ | 343 | id: "accessibility", |
| 373 | includeAccessibility | 344 | 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 | <Button | 361 | <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 | <Button | 507 | <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 && !macPermissions | 520 | : !accessReady && !macPermissions |
| 543 | ? "Try again" | 521 | ? "Try again" |
| 544 | : "Continue"} | 522 | : "Continue"} |
| 545 | </Button> | 523 | </PermissionContinueButton> |
| 546 | ) | 524 | ) |
| 547 | ) : !configShortcut ? ( | 525 | ) : !configShortcut ? ( |
| 548 | <Button | 526 | <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. |
| 10 | This is a one-time copy. Later login changes stay separate between the two browsers, and some | 10 | This is a one-time copy. Later login changes stay separate between the two browsers, and some |
| 11 | sites may still require you to sign in again. | 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 | 13 | On macOS, Safari imports need Full Disk Access. Choose **Allow**, drag T3 Code into the |
| 14 | a keychain, so the import wizard asks you to grant it: **Open System Settings** takes you to the | 14 | System Settings permission list, and turn access on. **Continue** becomes available when access |
| 15 | right pane, and macOS may ask you to quit and reopen T3 Code before the grant applies. You can | 15 | is detected. macOS may require you to quit and reopen T3 Code before the grant applies; reopen |
| 16 | revoke Full Disk Access after the import is done. Only Safari's primary profile is imported; cookies | 16 | the import wizard afterward. You can revoke Full Disk Access once the import is done. |
| 17 | kept by additional Safari profiles are not. | ||
| 18 | 17 | ||
| 19 | On Windows, import supports Firefox and Helium profiles that use standard profile encryption. | 18 | On Windows, import supports Firefox and Helium profiles that use standard profile encryption. |
| 20 | Other Chromium-based browsers use app-bound encryption and cannot be imported. Partitioned cookies | 19 | Other 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 runs | 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,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 CLIs | 1306 | * Probe this desktop machine for installed remote-capable editor CLIs |
| 1306 | * (used for remote open-in-editor deep links). Optional: older desktop | 1307 | * (used for remote open-in-editor deep links). Optional: older desktop |