Skip to content

Commit 47e56cd

Browse files
feat(web): guide the user through Full Disk Access to import from Safari
Reading Safari's cookies needs Full Disk Access, which no one has granted before their first import — so this is an expected step, not a failure. Instead of an error toast, the import opens a dialog that says what it's for, links straight to the right System Settings pane, and — once access is on — runs the import itself from a "I've turned it on" button, so the user never returns to the menu to start over. Every other failure keeps its toast. `importFailureReason` is exported and tested, since the whole path depends on the reason token surviving IPC. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 180da0b commit 47e56cd

2 files changed

Lines changed: 101 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: 77 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,32 @@ 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+
// Reading Safari's cookies needs Full Disk Access, which the user won't have
541+
// granted before their first import — so this is an expected step, not an
542+
// error. The whole request is held so the dialog's confirm button can run
543+
// the import itself once access is on, instead of sending the user back to
544+
// the menu.
545+
const [pendingFullDiskAccessImport, setPendingFullDiskAccessImport] = useState<{
546+
readonly source: BrowserImportSource;
547+
readonly sourceProfileDirectory: string;
548+
readonly targetProfileId: string;
549+
readonly targetName: string;
550+
} | null>(null);
530551
const [profilePendingRemoval, setProfilePendingRemoval] = useState<BrowserProfile | null>(null);
531552

532553
const profiles = resolveBrowserProfiles(userProfiles);
@@ -653,10 +674,20 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
653674
});
654675
})
655676
.catch((cause: unknown) => {
677+
const reason = importFailureReason(cause);
678+
if (reason === "needsFullDiskAccess") {
679+
setPendingFullDiskAccessImport({
680+
source,
681+
sourceProfileDirectory,
682+
targetProfileId,
683+
targetName,
684+
});
685+
return;
686+
}
656687
toastManager.add({
657688
type: "error",
658689
title: `Could not import from ${source.name}`,
659-
description: BROWSER_IMPORT_FAILURE_COPY[importFailureReason(cause)],
690+
description: BROWSER_IMPORT_FAILURE_COPY[reason],
660691
});
661692
})
662693
.finally(() => setBusy(false));
@@ -882,6 +913,50 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
882913
</AlertDialogFooter>
883914
</AlertDialogPopup>
884915
</AlertDialog>
916+
<AlertDialog
917+
open={pendingFullDiskAccessImport !== null}
918+
onOpenChange={(open) => {
919+
if (!open) setPendingFullDiskAccessImport(null);
920+
}}
921+
>
922+
<AlertDialogPopup>
923+
<AlertDialogHeader>
924+
<AlertDialogTitle>Let T3 Code read Safari's cookies</AlertDialogTitle>
925+
<AlertDialogDescription>
926+
Safari keeps your cookies somewhere only apps with Full Disk Access can reach. Turn
927+
that on for T3 Code in System Settings, then come back and finish the import.
928+
</AlertDialogDescription>
929+
</AlertDialogHeader>
930+
<AlertDialogFooter>
931+
<Button
932+
variant="outline"
933+
onClick={() =>
934+
void readLocalApi()
935+
?.shell.openExternal(FULL_DISK_ACCESS_SETTINGS_URL)
936+
.catch(() => undefined)
937+
}
938+
>
939+
Open System Settings
940+
</Button>
941+
<Button
942+
onClick={() => {
943+
const pending = pendingFullDiskAccessImport;
944+
setPendingFullDiskAccessImport(null);
945+
if (pending) {
946+
runImport(
947+
pending.source,
948+
pending.sourceProfileDirectory,
949+
pending.targetProfileId,
950+
pending.targetName,
951+
);
952+
}
953+
}}
954+
>
955+
I've turned it on
956+
</Button>
957+
</AlertDialogFooter>
958+
</AlertDialogPopup>
959+
</AlertDialog>
885960
</SettingsRow>
886961
);
887962
}

0 commit comments

Comments
 (0)