1import { PermissionChecklist, PermissionContinueButton } from "../permissions/PermissionChecklist";
2import { usePermissionStatus } from "../permissions/usePermissionStatus";
3import type { BrowserImportSource } from "@t3tools/contracts";
4import { BROWSER_IMPORT_FAILURE_COPY } from "@t3tools/contracts";
5import { ArrowDownIcon, ArrowRightIcon, CheckIcon, HardDriveIcon } from "lucide-react";
6import { useRef, useState } from "react";
7
8import { cn, randomUUID } from "~/lib/utils";
9
10import { Button } from "../ui/button";
11import {
12 Dialog,
13 DialogClose,
14 DialogDescription,
15 DialogFooter,
16 DialogHeader,
17 DialogPanel,
18 DialogPopup,
19 DialogTitle,
20} from "../ui/dialog";
21import { Spinner } from "../ui/spinner";
22import {
23 initialWizardStep,
24 initialTargetSelection,
25 canCloseWizard,
26 isRetryableReason,
27 formatSkippedDomains,
28 fullDiskAccessRecheckStep,
29 outcomeToStep,
30 refreshedSourceProfileDirectory,
31 refreshedSourceStep,
32 resolveWizardTarget,
33 type ImportOutcome,
34 type WizardTarget,
35 type WizardTargetProfile,
36 type WizardTargetSelection,
37 type WizardStep,
38} from "./browserImportWizard.logic";
39
40export type { WizardTarget } from "./browserImportWizard.logic";
41
42interface BrowserImportWizardProps {
43 readonly source: BrowserImportSource;
44 /** Captured when the wizard opens so destination copy and writes stay stable. */
45 readonly destinationEnvironmentName: string;
46 /** Existing profiles the import can go into. Incognito is excluded upstream. */
47 readonly targetProfiles: ReadonlyArray<WizardTargetProfile>;
48 /** Whether a new profile can still be created (profile cap). */
49 readonly canCreateProfile: boolean;
50 /**
51 * Runs the import and returns how it went. For a new target the caller only
52 * registers the profile once the import succeeds, so a blocked attempt never
53 * leaves an empty profile behind.
54 */
55 readonly onImport: (input: {
56 readonly sourceProfileDirectory: string;
57 readonly target: WizardTarget;
58 }) => Promise<ImportOutcome>;
59 /** Re-checks the source's availability after the user quits the browser. */
60 readonly onRefreshSource: () => Promise<BrowserImportSource | undefined>;
61 /** Opens the OS setting that grants access to a protected cookie store. */
62 readonly onOpenFullDiskAccessSettings: () => void | Promise<void>;
63 readonly onCheckFullDiskAccess?: (() => Promise<boolean>) | undefined;
64 readonly onClose: () => void;
65}
66
67/**
68 * Guides one browser's cookies into a profile.
69 *
70 * Every state the import can be in — the browser is open, a profile has to be
71 * chosen, the read failed — is a screen the user can move forward from, rather
72 * than a disabled row that only says no.
73 */
74export function BrowserImportWizard({
75 source: initialSource,
76 destinationEnvironmentName,
77 targetProfiles,
78 canCreateProfile,
79 onImport,
80 onRefreshSource,
81 onOpenFullDiskAccessSettings,
82 onCheckFullDiskAccess,
83 onClose,
84}: BrowserImportWizardProps) {
85 const [source, setSource] = useState(initialSource);
86 const [step, setStep] = useState<WizardStep>(() => initialWizardStep(initialSource));
87 const [sourceProfileDirectory, setSourceProfileDirectory] = useState(
88 () => initialSource.profiles[0]?.directory ?? "",
89 );
90 const [target, setTarget] = useState<WizardTargetSelection>(() =>
91 initialTargetSelection(canCreateProfile, targetProfiles),
92 );
93 const [targetError, setTargetError] = useState<string>();
94 // Stable across retries so a keychain re-approval lands in one profile, not
95 // a new one each time.
96 const newProfileId = useRef(`profile-${randomUUID()}`);
97 // A second Import click before React has left the configure screen would
98 // start a second run; the parent refuses it, and applying that refusal here
99 // would drop the wizard out of the importing step while the first write is
100 // still going. The ref settles synchronously where state does not.
101 const importInFlight = useRef(false);
102
103 const runImport = () => {
104 if (importInFlight.current) return;
105 const chosen = resolveWizardTarget(target, newProfileId.current, targetProfiles);
106 if (chosen === undefined) {
107 setTargetError("That profile is no longer available. Choose where to import these cookies.");
108 setStep({ step: "configure" });
109 return;
110 }
111 setTargetError(undefined);
112 importInFlight.current = true;
113 setStep({ step: "importing" });
114 void onImport({ sourceProfileDirectory, target: chosen })
115 .then((outcome) => setStep(outcomeToStep(outcome)))
116 .catch(() => setStep({ step: "blocked", reason: "readFailed" }))
117 .finally(() => {
118 importInFlight.current = false;
119 });
120 };
121
122 // Re-lists the source after the user did something outside the app (quit the
123 // browser, granted access) and routes to wherever the refreshed source says.
124 const recheckSource = (
125 check: "browser" | "fullDiskAccess",
126 nextStep: (refreshed: BrowserImportSource | undefined) => WizardStep,
127 ) => {
128 setStep({ step: "checking", check });
129 void onRefreshSource()
130 .then((refreshed) => {
131 if (refreshed) {
132 setSource(refreshed);
133 setSourceProfileDirectory((current) =>
134 refreshedSourceProfileDirectory(current, refreshed),
135 );
136 }
137 setStep(nextStep(refreshed));
138 })
139 .catch(() => setStep({ step: "blocked", reason: "readFailed" }));
140 };
141 const recheckAfterQuit = () => recheckSource("browser", refreshedSourceStep);
142 const recheckFullDiskAccess = () => recheckSource("fullDiskAccess", fullDiskAccessRecheckStep);
143
144 return (
145 <Dialog open onOpenChange={(open) => (open || !canCloseWizard(step) ? undefined : onClose())}>
146 <DialogPopup className="max-w-lg" showCloseButton={canCloseWizard(step)}>
147 {step.step === "quit" ? (
148 <QuitStep source={source} onCancel={onClose} onRechecked={recheckAfterQuit} />
149 ) : step.step === "fullDiskAccess" ? (
150 <FullDiskAccessStep
151 source={source}
152 onCancel={onClose}
153 onOpenSettings={onOpenFullDiskAccessSettings}
154 onCheck={
155 onCheckFullDiskAccess ??
156 (async () => {
157 const refreshed = await onRefreshSource();
158 return refreshed !== undefined && refreshed.unavailable === undefined;
159 })
160 }
161 onGranted={step.resume === "import" ? runImport : recheckFullDiskAccess}
162 stillRequired={step.checked === true}
163 />
164 ) : step.step === "importing" ? (
165 <ImportingStep />
166 ) : step.step === "checking" ? (
167 <CheckingStep sourceName={source.name} check={step.check} />
168 ) : step.step === "done" ? (
169 <DoneStep
170 {...step}
171 destinationEnvironmentName={destinationEnvironmentName}
172 onClose={onClose}
173 />
174 ) : step.step === "blocked" ? (
175 <BlockedStep
176 source={source}
177 reason={step.reason}
178 onClose={onClose}
179 onRetry={isRetryableReason(step.reason) ? runImport : undefined}
180 />
181 ) : (
182 <ConfigureStep
183 source={source}
184 destinationEnvironmentName={destinationEnvironmentName}
185 targetProfiles={targetProfiles}
186 canCreateProfile={canCreateProfile}
187 sourceProfileDirectory={sourceProfileDirectory}
188 onSourceProfileChange={setSourceProfileDirectory}
189 target={target}
190 onTargetChange={(selection) => {
191 setTarget(selection);
192 setTargetError(undefined);
193 }}
194 targetError={targetError}
195 onCancel={onClose}
196 onImport={runImport}
197 />
198 )}
199 </DialogPopup>
200 </Dialog>
201 );
202}
203
204function QuitStep({
205 source,
206 onCancel,
207 onRechecked,
208}: {
209 readonly source: BrowserImportSource;
210 readonly onCancel: () => void;
211 readonly onRechecked: () => void;
212}) {
213 return (
214 <>
215 <DialogHeader>
216 <DialogTitle>Quit {source.name} to import</DialogTitle>
217 <DialogDescription>
218 {source.name} is open, so its cookies can&rsquo;t be read yet. Quit it, then continue.
219 </DialogDescription>
220 </DialogHeader>
221 <DialogFooter>
222 <Button variant="outline" onClick={onCancel}>
223 Cancel
224 </Button>
225 <Button onClick={onRechecked}>I&rsquo;ve quit it</Button>
226 </DialogFooter>
227 </>
228 );
229}
230
231/** "5,065 cookies", or "no cookies", or nothing when the store is unreadable. */
232function cookieCountLabel(count: number | undefined): string | undefined {
233 if (count === undefined) return undefined;
234 if (count === 0) return "no cookies";
235 return `${count.toLocaleString()} ${count === 1 ? "cookie" : "cookies"}`;
236}
237
238function cookieResultCount(count: number): string {
239 return `${count.toLocaleString()} ${count === 1 ? "cookie" : "cookies"}`;
240}
241
242type ConfigureStepProps = {
243 readonly source: BrowserImportSource;
244 readonly destinationEnvironmentName: string;
245 readonly targetProfiles: ReadonlyArray<WizardTargetProfile>;
246 readonly canCreateProfile: boolean;
247 readonly sourceProfileDirectory: string;
248 readonly onSourceProfileChange: (directory: string) => void;
249 readonly target: WizardTargetSelection;
250 readonly targetError: string | undefined;
251 readonly onTargetChange: (target: WizardTargetSelection) => void;
252 readonly onCancel: () => void;
253 readonly onImport: () => void;
254};
255
256function FullDiskAccessStep({
257 source,
258 onCancel,
259 onOpenSettings,
260 onGranted,
261 stillRequired,
262 onCheck,
263}: {
264 readonly source: BrowserImportSource;
265 readonly onCancel: () => void;
266 readonly onOpenSettings: () => void | Promise<void>;
267 readonly onCheck: () => Promise<boolean>;
268 readonly onGranted: () => void;
269 readonly stillRequired: boolean;
270}) {
271 const [opening, setOpening] = useState(false);
272 const [openingError, setOpeningError] = useState<string | null>(null);
273 const permission = usePermissionStatus(async () => ({ fullDiskAccess: await onCheck() }), {
274 fullDiskAccess: false,
275 });
276 const allow = () => {
277 if (opening) return;
278 setOpening(true);
279 setOpeningError(null);
280 void Promise.resolve()
281 .then(onOpenSettings)
282 .catch(() => setOpeningError("Could not open System Settings. Try Allow again."))
283 .finally(() => setOpening(false));
284 };
285 return (
286 <>
287 <DialogHeader>
288 <DialogTitle>Let The Knife read {source.name}&rsquo;s cookies</DialogTitle>
289 <DialogDescription>
290 To import cookies from {source.name}, The Knife needs Full Disk Access. Turn it on in System
291 Settings, then come back to finish the import — you can revoke it again once the import is
292 done.
293 </DialogDescription>
294 </DialogHeader>
295 <DialogPanel>
296 <PermissionChecklist
297 busy={opening}
298 permissions={[
299 {
300 id: "fullDiskAccess",
301 icon: (
302 <HardDriveIcon
303 className="size-8 shrink-0 text-muted-foreground"
304 aria-hidden="true"
305 />
306 ),
307 title: "Full Disk Access",
308 description: `Read ${source.name}'s cookies for this import.`,
309 granted: permission.status.fullDiskAccess,
310 onAllow: () => void allow(),
311 },
312 ]}
313 />
314 {openingError || permission.error ? (
315 <p role="status" className="mt-3 text-xs text-muted-foreground">
316 {openingError ?? permission.error}
317 </p>
318 ) : null}
319 {!permission.isReady(["fullDiskAccess"]) ? (
320 <p className="mt-3 text-xs text-muted-foreground">
321 {stillRequired
322 ? "Access is still required. Quit and reopen The Knife if you just allowed it, then retry the import."
323 : "If access doesn't update after you allow it, quit and reopen The Knife, then retry the import."}
324 </p>
325 ) : null}
326 </DialogPanel>
327 <DialogFooter>
328 <Button variant="outline" onClick={onCancel}>
329 Cancel
330 </Button>
331 <PermissionContinueButton
332 ready={permission.isReady(["fullDiskAccess"])}
333 busy={opening}
334 onClick={onGranted}
335 >
336 Continue
337 </PermissionContinueButton>
338 </DialogFooter>
339 </>
340 );
341}
342function ConfigureStep({
343 source,
344 destinationEnvironmentName,
345 targetProfiles,
346 canCreateProfile,
347 sourceProfileDirectory,
348 onSourceProfileChange,
349 target,
350 targetError,
351 onTargetChange,
352 onCancel,
353 onImport,
354}: ConfigureStepProps) {
355 const targetMissing =
356 target.kind === "existing" &&
357 !targetProfiles.some((profile) => profile.id === target.profileId);
358 // The "New profile" tile is unrendered once the cap is reached, so a target
359 // chosen before that leaves nothing selected in "Into" — say so, the same
360 // way a vanished existing target is explained.
361 const targetUncreatable = target.kind === "new" && !canCreateProfile;
362 const targetFeedback =
363 targetError ??
364 (targetMissing
365 ? "That profile is no longer available. Choose where to import these cookies."
366 : targetUncreatable
367 ? "You've reached the profile limit. Choose an existing profile to import into."
368 : undefined);
369 return (
370 <>
371 <DialogHeader>
372 <DialogTitle>Import from {source.name}</DialogTitle>
373 <DialogDescription>
374 Choose which cookies to import for {destinationEnvironmentName}.
375 </DialogDescription>
376 </DialogHeader>
377 <DialogPanel>
378 {/* Side by side when the dialog has room, stacked when it doesn't. */}
379 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
380 <section className="flex-1 space-y-2">
381 <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
382 From
383 </p>
384 {source.profiles.map((profile) => (
385 <SelectableTile
386 key={profile.directory}
387 selected={sourceProfileDirectory === profile.directory}
388 title={profile.name}
389 subtitle={cookieCountLabel(profile.cookieCount)}
390 onSelect={() => onSourceProfileChange(profile.directory)}
391 />
392 ))}
393 </section>
394 <div className="flex shrink-0 items-center justify-center text-muted-foreground">
395 <ArrowDownIcon className="size-4 sm:hidden" />
396 <ArrowRightIcon className="hidden size-4 sm:block" />
397 </div>
398 <section className="flex-1 space-y-2">
399 <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
400 Into
401 </p>
402 {canCreateProfile ? (
403 <SelectableTile
404 selected={target.kind === "new"}
405 title="New profile"
406 subtitle="Created for these cookies"
407 onSelect={() => onTargetChange({ kind: "new" })}
408 />
409 ) : null}
410 {targetProfiles.map((profile) => (
411 <SelectableTile
412 key={profile.id}
413 selected={target.kind === "existing" && target.profileId === profile.id}
414 title={profile.name}
415 subtitle="Existing profile"
416 onSelect={() => onTargetChange({ kind: "existing", profileId: profile.id })}
417 />
418 ))}
419 </section>
420 </div>
421 {targetFeedback ? (
422 <p role="alert" className="mt-3 text-sm text-destructive">
423 {targetFeedback}
424 </p>
425 ) : null}
426 </DialogPanel>
427 <DialogFooter>
428 <Button variant="outline" onClick={onCancel}>
429 Cancel
430 </Button>
431 <Button
432 disabled={sourceProfileDirectory === "" || targetMissing || targetUncreatable}
433 onClick={onImport}
434 >
435 Import
436 </Button>
437 </DialogFooter>
438 </>
439 );
440}
441
442/** One selectable option: a name, an optional detail line, and a check. */
443function SelectableTile({
444 selected,
445 title,
446 subtitle,
447 onSelect,
448}: {
449 readonly selected: boolean;
450 readonly title: string;
451 readonly subtitle?: string | undefined;
452 readonly onSelect: () => void;
453}) {
454 return (
455 <button
456 type="button"
457 aria-pressed={selected}
458 onClick={onSelect}
459 className={cn(
460 "flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
461 selected
462 ? "border-primary bg-primary/8"
463 : "border-border/60 hover:border-border hover:bg-muted/40",
464 )}
465 >
466 <span className="min-w-0">
467 <span className="block truncate text-sm font-medium text-foreground">{title}</span>
468 {subtitle ? (
469 <span className="block truncate text-xs tabular-nums text-muted-foreground">
470 {subtitle}
471 </span>
472 ) : null}
473 </span>
474 <span
475 className={cn(
476 "grid size-4 shrink-0 place-items-center rounded-full border",
477 selected ? "border-primary bg-primary text-primary-foreground" : "border-input",
478 )}
479 >
480 {selected ? <CheckIcon className="size-2.5" /> : null}
481 </span>
482 </button>
483 );
484}
485
486function ImportingStep() {
487 return (
488 <>
489 <DialogHeader>
490 <DialogTitle>Importing cookies</DialogTitle>
491 <DialogDescription>This may take a moment.</DialogDescription>
492 </DialogHeader>
493 <DialogPanel>
494 <div className="flex items-center gap-3 py-2">
495 <Spinner size="md" tone="muted" />
496 <span className="text-sm text-muted-foreground">Importing…</span>
497 </div>
498 </DialogPanel>
499 </>
500 );
501}
502
503function CheckingStep({
504 sourceName,
505 check,
506}: {
507 readonly sourceName: string;
508 readonly check: "browser" | "fullDiskAccess";
509}) {
510 return (
511 <>
512 <DialogHeader>
513 <DialogTitle>Checking {sourceName}</DialogTitle>
514 <DialogDescription>
515 {check === "fullDiskAccess"
516 ? "Checking Full Disk Access."
517 : "Checking whether the browser has closed."}
518 </DialogDescription>
519 </DialogHeader>
520 <DialogPanel>
521 <div className="flex items-center gap-3 py-2">
522 <Spinner size="md" tone="muted" />
523 <span className="text-sm text-muted-foreground">
524 {check === "fullDiskAccess" ? "Checking access…" : "Checking…"}
525 </span>
526 </div>
527 </DialogPanel>
528 </>
529 );
530}
531
532function DoneStep({
533 imported,
534 skipped,
535 skippedDomains,
536 targetName,
537 destinationEnvironmentName,
538 onClose,
539}: {
540 readonly imported: number;
541 readonly skipped: number;
542 readonly skippedDomains: ReadonlyArray<string>;
543 readonly targetName: string;
544 readonly destinationEnvironmentName: string;
545 readonly onClose: () => void;
546}) {
547 return (
548 <>
549 <DialogHeader>
550 <DialogTitle>
551 {imported > 0
552 ? `Imported ${cookieResultCount(imported)}`
553 : skipped > 0
554 ? `Skipped ${cookieResultCount(skipped)}`
555 : "No cookies found"}
556 </DialogTitle>
557 <DialogDescription>
558 {imported > 0
559 ? `Added to ${targetName} for ${destinationEnvironmentName}.${skipped > 0 ? ` ${cookieResultCount(skipped)} skipped.` : ""}`
560 : skipped > 0
561 ? `No cookies were imported for ${destinationEnvironmentName}.`
562 : `There were no cookies to import for ${destinationEnvironmentName}.`}
563 </DialogDescription>
564 </DialogHeader>
565 {skippedDomains.length > 0 ? (
566 <DialogPanel>
567 <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
568 Skipped
569 </p>
570 <p className="mt-1 text-sm text-foreground">{formatSkippedDomains(skippedDomains)}</p>
571 </DialogPanel>
572 ) : null}
573 <DialogFooter>
574 <DialogClose render={<Button />} onClick={onClose}>
575 Done
576 </DialogClose>
577 </DialogFooter>
578 </>
579 );
580}
581
582function BlockedStep({
583 source,
584 reason,
585 onClose,
586 onRetry,
587}: {
588 readonly source: BrowserImportSource;
589 readonly reason: keyof typeof BROWSER_IMPORT_FAILURE_COPY;
590 readonly onClose: () => void;
591 readonly onRetry: (() => void) | undefined;
592}) {
593 return (
594 <>
595 <DialogHeader>
596 <DialogTitle>Couldn&rsquo;t import from {source.name}</DialogTitle>
597 <DialogDescription>{BROWSER_IMPORT_FAILURE_COPY[reason]}</DialogDescription>
598 </DialogHeader>
599 <DialogFooter>
600 <Button variant="outline" onClick={onClose}>
601 Close
602 </Button>
603 {onRetry ? <Button onClick={onRetry}>Try again</Button> : null}
604 </DialogFooter>
605 </>
606 );
607}