Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2b608c0
feat(desktop): import cookies from Safari
juliusmarminge Aug 16, 2026
39ef011
fix(desktop): reject a malformed Safari jar instead of importing part…
juliusmarminge Aug 16, 2026
23d6c1a
refactor(desktop): name the jar a Safari read failed on
juliusmarminge Aug 16, 2026
2b7849a
fix(desktop): report Safari's TCC denial as needing Full Disk Access
juliusmarminge Aug 17, 2026
e3ab027
feat(web): add the Full Disk Access step to the import wizard
juliusmarminge Aug 17, 2026
273b764
fix(desktop): open System Settings from the Full Disk Access step
juliusmarminge Aug 28, 2026
cda6152
fix(desktop): validate Safari cookie record headers
juliusmarminge Aug 29, 2026
289c96b
fix(desktop): retain Safari cookie path on parse errors
juliusmarminge Aug 29, 2026
0dcadc3
fix(desktop): open the Full Disk Access settings pane
juliusmarminge Aug 29, 2026
dc3c406
fix(web): recover initial Safari access
juliusmarminge Aug 29, 2026
94f66f4
test(web): prioritize Safari access recovery
juliusmarminge Aug 29, 2026
46f6a53
fix(web): report Safari access rechecks
juliusmarminge Aug 29, 2026
3833440
fix(web): reject unavailable System Settings links
juliusmarminge Aug 29, 2026
0945140
fix(web): explain denied Safari import retries
juliusmarminge Aug 29, 2026
658ff9e
fix(desktop): bracket Safari IPv6 hosts and skip directory candidates
juliusmarminge Sep 2, 2026
f979d6f
fix(desktop): refuse a Safari jar its page table doesn't describe, an…
juliusmarminge Sep 2, 2026
3a8fcf7
fix(desktop): reject Safari records that overlap the page table or ea…
juliusmarminge Sep 2, 2026
35f4dcd
fix(desktop): tell Safari's Full Disk Access apart at listing time
juliusmarminge Sep 2, 2026
9611ffb
fix(desktop): keep host-only Safari cookies host-only
juliusmarminge Sep 4, 2026
423564c
fix(web): tell the user to relaunch when Full Disk Access still reads…
juliusmarminge Sep 4, 2026
c6cb164
feat(desktop): discover Safari profile cookie stores
juliusmarminge Sep 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/desktop/src/electron/ElectronShell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ describe("ElectronShell", () => {
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens the Full Disk Access settings anchor", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);

const electronShell = yield* ElectronShell.ElectronShell;
const result = yield* electronShell.openSystemSettings("full-disk-access");

assert.equal(result, true);
assert.deepEqual(openExternalMock.mock.calls, [
["x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles"],
]);
}).pipe(Effect.provide(ElectronShell.layer)),
);

it.effect("opens remote SSH editor URLs", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
Expand Down
29 changes: 28 additions & 1 deletion apps/desktop/src/electron/ElectronShell.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import { REMOTE_CAPABLE_EDITOR_IDS, remoteSchemeForEditor } from "@t3tools/contracts";
import {
REMOTE_CAPABLE_EDITOR_IDS,
remoteSchemeForEditor,
type SystemSettingsPane,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import * as Electron from "electron";

/**
* Deep links to individual System Settings panes. These are app-fixed, not
* renderer-supplied, so they skip `parseSafeExternalUrl` — which exists to keep
* arbitrary link schemes from reaching the OS handler — and open through their
* own path below. The pane rather than the URL crosses the IPC boundary, so a
* renderer can only ask for one of these known destinations.
*
* Full Disk Access uses the post-Ventura `PrivacySecurity.extension` anchor.
*/
const SYSTEM_SETTINGS_URLS: Record<SystemSettingsPane, string> = {
"full-disk-access":
"x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_AllFiles",
};
Comment thread
juliusmarminge marked this conversation as resolved.

// Remote open-in-editor deep links (`vscode://vscode-remote/ssh-remote+…`)
// must reach the OS handler; every other non-web scheme stays blocked.
const SAFE_WEB_PROTOCOLS = new Set(["http:", "https:"]);
Expand Down Expand Up @@ -43,6 +61,8 @@ export class ElectronShell extends Context.Service<
ElectronShell,
{
readonly openExternal: (rawUrl: unknown) => Effect.Effect<boolean>;
/** Opens a known System Settings pane by identifier, not by URL. */
readonly openSystemSettings: (pane: SystemSettingsPane) => Effect.Effect<boolean>;
readonly copyText: (text: string) => Effect.Effect<void>;
}
>()("@t3tools/desktop/electron/ElectronShell") {}
Expand All @@ -59,6 +79,13 @@ export const make = ElectronShell.of({
),
),
}),
openSystemSettings: (pane) =>
Effect.promise(() =>
Electron.shell.openExternal(SYSTEM_SETTINGS_URLS[pane]).then(
() => true,
() => false,
),
),
copyText: (text) =>
Effect.sync(() => {
Electron.clipboard.writeText(text);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
getSystemLocale,
getWindowFullscreenState,
openExternal,
openSystemSettings,
probeRemoteEditors,
pickFolder,
pickProjectFavicon,
Expand Down Expand Up @@ -94,6 +95,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(setTheme);
yield* ipc.handle(showContextMenu);
yield* ipc.handle(openExternal);
yield* ipc.handle(openSystemSettings);
yield* ipc.handle(probeRemoteEditors);
yield* ipc.handle(getUpdateState);
yield* ipc.handle(setUpdateChannel);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
export const SET_THEME_CHANNEL = "desktop:set-theme";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
PickFolderOptionsSchema,
PRIMARY_LOCAL_ENVIRONMENT_ID,
REMOTE_CAPABLE_EDITOR_IDS,
SystemSettingsPaneSchema,
type DesktopEnvironmentBootstrap,
type PickedThemeFile,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -298,6 +299,16 @@ export const openExternal = DesktopIpc.makeIpcMethod({
}),
});

export const openSystemSettings = DesktopIpc.makeIpcMethod({
channel: IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL,
payload: SystemSettingsPaneSchema,
result: Schema.Boolean,
handler: Effect.fn("desktop.ipc.window.openSystemSettings")(function* (pane) {
const shell = yield* ElectronShell.ElectronShell;
return yield* shell.openSystemSettings(pane);
}),
});

export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL,
payload: Schema.Undefined,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
...(position === undefined ? {} : { position }),
}),
openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
openSystemSettings: (pane: string) =>
ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane),
probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
onMenuAction: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
Expand Down
50 changes: 35 additions & 15 deletions apps/desktop/src/preview/BrowserImport/BrowserImport.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import * as BrowserSession from "../BrowserSession.ts";
import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts";
import type { CookieReadResult } from "./CookieDatabase.ts";
import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts";
import { readSafariCookies, safariAccessDenied, SafariCookieReadError } from "./SafariCookies.ts";
import {
BROWSER_IMPORT_SOURCES,
resolveCookieDatabase,
Expand Down Expand Up @@ -92,6 +93,15 @@ const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function*
if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform";
if (!(yield* isSourceInstalled(definition, context))) return "notInstalled";
if (yield* isSourceRunning(definition, context)) return "browserRunning";
// Safari's jar is found by `stat`, which TCC permits without Full Disk
// Access — so a Safari that lists as ready may still refuse the read. Probe
// the grant here, so the wizard can open on the permission step and a
// post-grant recheck can tell granted from still-denied, rather than only
// discovering it by attempting the import.
if (definition.engine === "safari") {
const jar = yield* resolveCookieDatabase(definition, context, ".");
if (jar !== undefined && (yield* safariAccessDenied(jar))) return "needsFullDiskAccess";
}
return undefined;
});

Expand Down Expand Up @@ -254,25 +264,29 @@ export const make = Effect.gen(function* BrowserImportMake() {
const userDataDirectory = definition.userDataDirectory(pathContext);
const read: Effect.Effect<
CookieReadResult,
ChromiumCookieReadError | FirefoxCookieReadError,
ChromiumCookieReadError | FirefoxCookieReadError | SafariCookieReadError,
FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
> =
definition.engine === "firefox"
? readFirefoxCookies(databasePath).pipe(
definition.engine === "safari"
? readSafariCookies(databasePath).pipe(
Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })),
)
: readChromiumCookies({
cookieDatabasePath: databasePath,
keychainService: definition.keychainService,
keychainAccount: definition.keychainAccount,
linuxSecretApplication: definition.linuxSecretApplication,
...(platform === "win32" && userDataDirectory !== undefined
? {
windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"),
}
: {}),
platform,
});
: definition.engine === "firefox"
? readFirefoxCookies(databasePath).pipe(
Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })),
)
: readChromiumCookies({
cookieDatabasePath: databasePath,
keychainService: definition.keychainService,
keychainAccount: definition.keychainAccount,
linuxSecretApplication: definition.linuxSecretApplication,
...(platform === "win32" && userDataDirectory !== undefined
? {
windowsLocalStatePath: pathContext.path.join(userDataDirectory, "Local State"),
}
: {}),
platform,
});

const result = yield* read.pipe(
Effect.scoped,
Expand All @@ -289,6 +303,12 @@ export const make = Effect.gen(function* BrowserImportMake() {
Effect.fail(
new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }),
),
// Safari's reasons are already user-facing: a TCC refusal is the Full
// Disk Access prompt, anything else is a read failure.
SafariCookieReadError: (cause) =>
Effect.fail(
new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
),
}),
);

Expand Down
Loading
Loading