Skip to content

Commit d1c8d32

Browse files
fix(desktop): report Safari's TCC denial as needing Full Disk Access
A Safari import with no Full Disk Access failed with the generic "cookie database could not be read" instead of telling the user to grant access — and no prompt appears, because macOS never prompts for Full Disk Access; the app is added by hand. The denial arrives as EPERM, which Effect tags `Unknown`, not `PermissionDenied` (that is EACCES), so checking the tag alone never matched. The underlying errno is checked too. Verified against the real jar: the reason is now `needsFullDiskAccess`, which the renderer maps to the System Settings instruction. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e9c3a37 commit d1c8d32

2 files changed

Lines changed: 50 additions & 5 deletions

File tree

apps/desktop/src/preview/BrowserImport/SafariCookies.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,14 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
44
import { assert, describe, expect, it } from "@effect/vitest";
55
import * as Effect from "effect/Effect";
66
import * as FileSystem from "effect/FileSystem";
7+
import * as PlatformError from "effect/PlatformError";
78

8-
import { parseBinaryCookies, readSafariCookies, SafariCookieReadError } from "./SafariCookies.ts";
9+
import {
10+
isPermissionDenied,
11+
parseBinaryCookies,
12+
readSafariCookies,
13+
SafariCookieReadError,
14+
} from "./SafariCookies.ts";
915

1016
const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
1117

@@ -221,3 +227,25 @@ describe("readSafariCookies", () => {
221227
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
222228
);
223229
});
230+
231+
describe("isPermissionDenied", () => {
232+
// Shapes taken from a real `FileSystem.readFile` failure on macOS — verified
233+
// against Safari's TCC-protected jar, whose denial is EPERM, tagged
234+
// `Unknown` rather than `PermissionDenied`.
235+
const platformError = (reasonTag: string, code: string): PlatformError.PlatformError =>
236+
({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never;
237+
238+
it("treats a TCC EPERM denial as permission denied", () => {
239+
// The regression: EPERM is tagged `Unknown`, so checking the tag alone
240+
// reported Safari's Full Disk Access refusal as a generic read failure.
241+
expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true);
242+
});
243+
244+
it("treats an EACCES denial as permission denied", () => {
245+
expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(true);
246+
});
247+
248+
it("does not treat an unrelated failure as permission denied", () => {
249+
expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false);
250+
});
251+
});

apps/desktop/src/preview/BrowserImport/SafariCookies.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
*/
2222
import * as Effect from "effect/Effect";
2323
import * as FileSystem from "effect/FileSystem";
24+
import * as PlatformError from "effect/PlatformError";
2425
import * as Schema from "effect/Schema";
2526

2627
import type { ImportedCookie } from "./CookieDatabase.ts";
@@ -159,17 +160,33 @@ export function parseBinaryCookies(buffer: Buffer): ReadonlyArray<ImportedCookie
159160
return cookies;
160161
}
161162

163+
/**
164+
* Whether a filesystem error is the OS refusing access.
165+
*
166+
* A TCC denial arrives as EPERM, which Effect tags `Unknown` rather than
167+
* `PermissionDenied` (reserved for EACCES), so the underlying errno is checked
168+
* too — otherwise a Full Disk Access refusal is reported as a generic read
169+
* failure and the user is never told what to grant.
170+
*/
171+
export const isPermissionDenied = (error: PlatformError.PlatformError): boolean => {
172+
if (error.reason._tag === "PermissionDenied") return true;
173+
const code = (error.reason as { cause?: { code?: unknown } }).cause?.code;
174+
return code === "EPERM" || code === "EACCES";
175+
};
176+
162177
export const readSafariCookies = Effect.fn("SafariCookies.readSafariCookies")(function* (
163178
cookiePath: string,
164179
) {
165180
const fileSystem = yield* FileSystem.FileSystem;
166181
const contents = yield* fileSystem.readFile(cookiePath).pipe(
167182
Effect.mapError((cause) => {
168-
// TCC denies the read even though the file exists, which is a permission
169-
// the user can grant rather than a missing browser.
170-
const denied = cause.reason._tag === "PermissionDenied";
183+
// TCC denies the read even though the file exists — a permission the user
184+
// grants in System Settings rather than a missing browser. macOS never
185+
// prompts for Full Disk Access, so there is no dialog to wait on; the
186+
// read just fails, and it fails with EPERM, which Effect surfaces as an
187+
// `Unknown` system error rather than `PermissionDenied` (that is EACCES).
171188
return new SafariCookieReadError({
172-
reason: denied ? "needsFullDiskAccess" : "readFailed",
189+
reason: isPermissionDenied(cause) ? "needsFullDiskAccess" : "readFailed",
173190
cookieDatabasePath: cookiePath,
174191
cause,
175192
});

0 commit comments

Comments
 (0)