Skip to content

Commit dbaf712

Browse files
feat(web): add the Full Disk Access step to the import wizard
Safari's cookies sit behind Full Disk Access, which no one has granted before their first import — so it is a step in the flow, not a failure. When an import comes back needing it, the wizard shows a screen that says what it's for, links to the right System Settings pane, and — from an "I've turned it on" button — runs the import itself, so the user never restarts from the menu. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d1c8d32 commit dbaf712

5 files changed

Lines changed: 91 additions & 4 deletions

File tree

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

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ interface BrowserImportWizardProps {
5757
}) => Promise<ImportOutcome>;
5858
/** Re-checks the source's availability after the user quits the browser. */
5959
readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;
60+
/** Opens the OS setting that grants access to a protected cookie store. */
61+
readonly onOpenFullDiskAccessSettings: () => void;
6062
readonly onClose: () => void;
6163
}
6264

@@ -73,6 +75,7 @@ export function BrowserImportWizard({
7375
canCreateProfile,
7476
onImport,
7577
onRefreshSource,
78+
onOpenFullDiskAccessSettings,
7679
onClose,
7780
}: BrowserImportWizardProps) {
7881
const [source, setSource] = useState(initialSource);
@@ -121,6 +124,13 @@ export function BrowserImportWizard({
121124
<DialogPopup className="max-w-lg">
122125
{step.step === "quit" ? (
123126
<QuitStep source={source} onCancel={onClose} onRechecked={recheckAfterQuit} />
127+
) : step.step === "fullDiskAccess" ? (
128+
<FullDiskAccessStep
129+
source={source}
130+
onCancel={onClose}
131+
onOpenSettings={onOpenFullDiskAccessSettings}
132+
onGranted={runImport}
133+
/>
124134
) : step.step === "importing" ? (
125135
<ImportingStep />
126136
) : step.step === "done" ? (
@@ -196,9 +206,39 @@ type ConfigureStepProps = {
196206
readonly onImport: () => void;
197207
};
198208

199-
// TEMP: an in-dialog layout switcher for comparing directions live — the ui.sh
200-
// picker can't load under the app's CSP. Collapse to the chosen variant and
201-
// delete this switcher before merge.
209+
function FullDiskAccessStep({
210+
source,
211+
onCancel,
212+
onOpenSettings,
213+
onGranted,
214+
}: {
215+
readonly source: BrowserImportSource;
216+
readonly onCancel: () => void;
217+
readonly onOpenSettings: () => void;
218+
readonly onGranted: () => void;
219+
}) {
220+
return (
221+
<>
222+
<DialogHeader>
223+
<DialogTitle>Let T3 Code read {source.name}&rsquo;s cookies</DialogTitle>
224+
<DialogDescription>
225+
{source.name} keeps its cookies somewhere only apps with Full Disk Access can reach. Turn
226+
that on for T3 Code in System Settings, then come back and finish the import.
227+
</DialogDescription>
228+
</DialogHeader>
229+
<DialogFooter>
230+
<Button variant="outline" onClick={onCancel}>
231+
Cancel
232+
</Button>
233+
<Button variant="outline" onClick={onOpenSettings}>
234+
Open System Settings
235+
</Button>
236+
<Button onClick={onGranted}>I&rsquo;ve turned it on</Button>
237+
</DialogFooter>
238+
</>
239+
);
240+
}
241+
202242
function ConfigureStep({
203243
source,
204244
targetProfiles,
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: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ import {
4949
MenuSeparator,
5050
MenuTrigger,
5151
} from "../ui/menu";
52+
import { readLocalApi } from "~/localApi";
53+
5254
import { toastManager } from "../ui/toast";
5355
import {
5456
AlertDialog,
@@ -114,7 +116,7 @@ const zoomLabel = (zoomFactor: number) => `${Math.round(zoomFactor * 100)}%`;
114116
* it. Anything unrecognised reads as a plain read failure rather than leaking
115117
* the raw message into a toast.
116118
*/
117-
const importFailureReason = (cause: unknown): BrowserImportFailureReason => {
119+
export const importFailureReason = (cause: unknown): BrowserImportFailureReason => {
118120
const message = String((cause as { message?: unknown } | undefined)?.message ?? "");
119121
return (
120122
BrowserImportFailureReason.literals.find((reason) => message.includes(`failed: ${reason}.`)) ??
@@ -517,6 +519,14 @@ function DesktopOnlyBrowserDefaults({ children }: { readonly children: ReactNode
517519
* files, and the answer changes while the app is running (quitting the browser
518520
* clears `browserRunning`), so a value cached at mount would go stale.
519521
*/
522+
/**
523+
* Opens System Settings → Privacy & Security → Full Disk Access. The scheme is
524+
* unchanged from the old System Preferences and still resolves on Ventura and
525+
* later.
526+
*/
527+
const FULL_DISK_ACCESS_SETTINGS_URL =
528+
"x-apple.systempreferences:com.apple.preference.security?Privacy_AllFilesAccess";
529+
520530
function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
521531
const userProfiles = useClientSettings((settings) => settings.browserProfiles);
522532
const defaultProfileId = useClientSettings((settings) => settings.browserDefaultProfileId);
@@ -842,6 +852,11 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) {
842852
canCreateProfile={!atProfileLimit}
843853
onImport={(input) => runWizardImport(importSource, input)}
844854
onRefreshSource={() => refreshImportSource(importSource.id)}
855+
onOpenFullDiskAccessSettings={() =>
856+
void readLocalApi()
857+
?.shell.openExternal(FULL_DISK_ACCESS_SETTINGS_URL)
858+
.catch(() => undefined)
859+
}
845860
onClose={() => setImportSource(null)}
846861
/>
847862
) : null}

apps/web/src/components/settings/browserImportWizard.logic.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ describe("outcomeToStep", () => {
5656
expect(outcomeToStep({ kind: "blocked", reason: "browserRunning" })).toEqual({ step: "quit" });
5757
});
5858

59+
it("routes a Full Disk Access refusal to its own screen", () => {
60+
expect(outcomeToStep({ kind: "blocked", reason: "needsFullDiskAccess" })).toEqual({
61+
step: "fullDiskAccess",
62+
});
63+
});
64+
5965
it("surfaces every other failure on the blocked screen", () => {
6066
expect(outcomeToStep({ kind: "blocked", reason: "readFailed" })).toEqual({
6167
step: "blocked",

apps/web/src/components/settings/browserImportWizard.logic.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type ImportOutcome =
2121
*/
2222
export type WizardStep =
2323
| { readonly step: "quit" }
24+
| { readonly step: "fullDiskAccess" }
2425
| { readonly step: "configure" }
2526
| { readonly step: "importing" }
2627
| {
@@ -58,6 +59,7 @@ export function outcomeToStep(outcome: ImportOutcome): WizardStep {
5859
// other failure surfaces on the blocked screen, which offers a retry when
5960
// one could help.
6061
if (outcome.reason === "browserRunning") return { step: "quit" };
62+
if (outcome.reason === "needsFullDiskAccess") return { step: "fullDiskAccess" };
6163
return { step: "blocked", reason: outcome.reason };
6264
}
6365

0 commit comments

Comments
 (0)