Skip to content

Commit 675447a

Browse files
feat(web): guide the user to Full Disk Access when Safari import is blocked
Safari's cookies sit behind Full Disk Access, and macOS never prompts for it — the app is added by hand — so a toast naming the setting left the user to hunt for it. An import that fails with `needsFullDiskAccess` now opens a dialog that explains why and links straight into System Settings → Privacy & Security → Full Disk Access. Every other failure keeps its toast. `importFailureReason` is exported and tested, since the whole path depends on the reason token surviving the trip through IPC. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 180da0b commit 675447a

2 files changed

Lines changed: 75 additions & 2 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
3+
import { importFailureReason } from "./IntegrationsSettings";
4+
5+
// Mirrors `BrowserImportFailedError.message`, which IPC flattens to a string
6+
// before the renderer sees it.
7+
const failure = (reason: string) => ({
8+
message: `Importing cookies from safari failed: ${reason}.`,
9+
});
10+
11+
describe("importFailureReason", () => {
12+
it("recovers the reason token from the flattened message", () => {
13+
// The whole import error path — including the Full Disk Access dialog —
14+
// depends on this token surviving the trip through IPC.
15+
expect(importFailureReason(failure("needsFullDiskAccess"))).toBe("needsFullDiskAccess");
16+
expect(importFailureReason(failure("browserRunning"))).toBe("browserRunning");
17+
expect(importFailureReason(failure("readFailed"))).toBe("readFailed");
18+
});
19+
20+
it("falls back to readFailed for anything it cannot classify", () => {
21+
expect(importFailureReason(new Error("something else entirely"))).toBe("readFailed");
22+
expect(importFailureReason(undefined)).toBe("readFailed");
23+
});
24+
});

apps/web/src/components/settings/IntegrationsSettings.tsx

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import {
5454
MenuSubTrigger,
5555
MenuTrigger,
5656
} from "../ui/menu";
57+
import { readLocalApi } from "~/localApi";
58+
5759
import { toastManager } from "../ui/toast";
5860
import {
5961
AlertDialog,
@@ -117,7 +119,7 @@ const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`;
117119
* it. Anything unrecognised reads as a plain read failure rather than leaking
118120
* the raw message into a toast.
119121
*/
120-
const importFailureReason = (cause: unknown): BrowserImportFailureReason => {
122+
export const importFailureReason = (cause: unknown): BrowserImportFailureReason => {
121123
const message = String((cause as { message?: unknown } | undefined)?.message ?? "");
122124
return (
123125
BrowserImportFailureReason.literals.find((reason) => message.includes(`failed: ${reason}.`)) ??
@@ -520,13 +522,25 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
520522
* files, and the answer changes while the app is running (quitting the browser
521523
* clears `browserRunning`), so a value cached at mount would go stale.
522524
*/
525+
/**
526+
* Opens System Settings → Privacy & Security → Full Disk Access. The scheme is
527+
* unchanged from the old System Preferences and still resolves on Ventura and
528+
* later.
529+
*/
530+
const FULL_DISK_ACCESS_SETTINGS_URL =
531+
"x-apple.systempreferences:com.apple.preference.security?Privacy_AllFilesAccess";
532+
523533
function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
524534
const userProfiles = useClientSettings((settings) => settings.browserProfiles);
525535
const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
526536
const updateSettings = useUpdatePrimarySettings();
527537
const environmentId = usePrimaryEnvironment()?.environmentId;
528538
const [sources, setSources] = useState<ReadonlyArray<BrowserImportSource> | null>(null);
529539
const [busy, setBusy] = useState(false);
540+
// Safari's cookies sit behind Full Disk Access, which macOS never prompts
541+
// for — the app is added by hand. A dialog with a link into the right pane
542+
// is more use than a toast that names a setting the user then has to find.
543+
const [fullDiskAccessSource, setFullDiskAccessSource] = useState<string | null>(null);
530544
const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
531545

532546
const profiles = resolveBrowserProfiles(userProfiles);
@@ -653,10 +667,15 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
653667
});
654668
})
655669
.catch((cause: unknown) => {
670+
const reason = importFailureReason(cause);
671+
if (reason === "needsFullDiskAccess") {
672+
setFullDiskAccessSource(source.name);
673+
return;
674+
}
656675
toastManager.add({
657676
type: "error",
658677
title: `Could not import from ${source.name}`,
659-
description: BROWSER_IMPORT_FAILURE_COPY[importFailureReason(cause)],
678+
description: BROWSER_IMPORT_FAILURE_COPY[reason],
660679
});
661680
})
662681
.finally(() => setBusy(false));
@@ -882,6 +901,36 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
882901
</AlertDialogFooter>
883902
</AlertDialogPopup>
884903
</AlertDialog>
904+
<AlertDialog
905+
open={fullDiskAccessSource !== null}
906+
onOpenChange={(open) => {
907+
if (!open) setFullDiskAccessSource(null);
908+
}}
909+
>
910+
<AlertDialogPopup>
911+
<AlertDialogHeader>
912+
<AlertDialogTitle>T3 Code needs Full Disk Access</AlertDialogTitle>
913+
<AlertDialogDescription>
914+
{fullDiskAccessSource ?? "Safari"} keeps its cookies in a protected folder. Turn on
915+
Full Disk Access for T3 Code in System Settings, then run the import again. macOS
916+
doesn’t ask for this permission — you add the app yourself.
917+
</AlertDialogDescription>
918+
</AlertDialogHeader>
919+
<AlertDialogFooter>
920+
<AlertDialogClose render={<Button variant="outline" />}>Not now</AlertDialogClose>
921+
<AlertDialogClose
922+
render={<Button />}
923+
onClick={() => {
924+
void readLocalApi()
925+
?.shell.openExternal(FULL_DISK_ACCESS_SETTINGS_URL)
926+
.catch(() => undefined);
927+
}}
928+
>
929+
Open System Settings
930+
</AlertDialogClose>
931+
</AlertDialogFooter>
932+
</AlertDialogPopup>
933+
</AlertDialog>
885934
</SettingsRow>
886935
);
887936
}

0 commit comments

Comments
 (0)