1import type { BrowserImportFailureReason, BrowserImportSource } from "@t3tools/contracts";
2
3export interface WizardTargetProfile {
4 readonly id: string;
5 readonly name: string;
6}
7
8export type WizardTarget =
9 | { readonly kind: "new"; readonly profileId: string }
10 | { readonly kind: "existing"; readonly profileId: string; readonly name: string };
11
12export type WizardTargetSelection =
13 | { readonly kind: "new" }
14 | { readonly kind: "existing"; readonly profileId: string };
15
16export function initialTargetSelection(
17 canCreateProfile: boolean,
18 targetProfiles: ReadonlyArray<WizardTargetProfile>,
19): WizardTargetSelection {
20 if (canCreateProfile) return { kind: "new" };
21 const first = targetProfiles[0];
22 return first ? { kind: "existing", profileId: first.id } : { kind: "new" };
23}
24
25export function resolveWizardTarget(
26 selection: WizardTargetSelection,
27 newProfileId: string,
28 targetProfiles: ReadonlyArray<WizardTargetProfile>,
29): WizardTarget | undefined {
30 if (selection.kind === "new") return { kind: "new", profileId: newProfileId };
31 const profile = targetProfiles.find((candidate) => candidate.id === selection.profileId);
32 if (profile === undefined) return undefined;
33 return {
34 kind: "existing",
35 profileId: selection.profileId,
36 name: profile.name,
37 };
38}
39
40/**
41 * What the import wizard produces once it has actually tried to import. The
42 * parent runs the import and classifies the result; the wizard only reacts to
43 * it, which keeps the step transitions pure and testable.
44 */
45export type ImportOutcome =
46 | {
47 readonly kind: "imported";
48 readonly imported: number;
49 readonly skipped: number;
50 readonly skippedDomains: ReadonlyArray<string>;
51 readonly targetName: string;
52 }
53 | { readonly kind: "blocked"; readonly reason: BrowserImportFailureReason };
54
55/**
56 * The wizard's screens. Every one is a place the user can act from — there are
57 * no dead ends. `blocked` covers the reasons no local step recovers.
58 */
59export type WizardStep =
60 | { readonly step: "quit" }
61 | {
62 readonly step: "fullDiskAccess";
63 readonly resume: "configure" | "import";
64 readonly checked?: boolean;
65 }
66 | { readonly step: "configure" }
67 | { readonly step: "checking"; readonly check: "browser" | "fullDiskAccess" }
68 | { readonly step: "importing" }
69 | {
70 readonly step: "done";
71 readonly imported: number;
72 readonly skipped: number;
73 readonly skippedDomains: ReadonlyArray<string>;
74 readonly targetName: string;
75 }
76 | { readonly step: "blocked"; readonly reason: BrowserImportFailureReason };
77
78/** The import owns its target partition until the write finishes. */
79export function canCloseWizard(step: WizardStep): boolean {
80 return step.step !== "importing";
81}
82
83/**
84 * Where the wizard opens for a source. A running browser is the one thing we
85 * know up front, from the source listing; everything else is discovered by
86 * trying, so the wizard starts by letting the user choose what to import.
87 */
88export function initialWizardStep(source: BrowserImportSource): WizardStep {
89 if (source.unavailable === "browserRunning") return { step: "quit" };
90 if (source.unavailable === "needsFullDiskAccess") {
91 return { step: "fullDiskAccess", resume: "configure" };
92 }
93 if (source.unavailable !== undefined) return { step: "blocked", reason: source.unavailable };
94 if (source.profiles.length === 0) return { step: "blocked", reason: "unknownSourceProfile" };
95 return { step: "configure" };
96}
97
98/** Where an attempted import lands the wizard, by how it turned out. */
99export function outcomeToStep(outcome: ImportOutcome): WizardStep {
100 if (outcome.kind === "imported") {
101 return {
102 step: "done",
103 imported: outcome.imported,
104 skipped: outcome.skipped,
105 skippedDomains: outcome.skippedDomains,
106 targetName: outcome.targetName,
107 };
108 }
109 // A browser that reopened mid-import routes back to the quit screen; every
110 // other failure surfaces on the blocked screen, which offers a retry when
111 // one could help.
112 if (outcome.reason === "browserRunning") return { step: "quit" };
113 if (outcome.reason === "needsFullDiskAccess") {
114 // The failed import already checked access, so explain that it is still
115 // denied instead of returning to an indistinguishable permission screen.
116 return { step: "fullDiskAccess", resume: "import", checked: true };
117 }
118 return { step: "blocked", reason: outcome.reason };
119}
120
121/** Where a fresh availability check lands the wizard after the user quits. */
122export function refreshedSourceStep(source: BrowserImportSource | undefined): WizardStep {
123 if (source === undefined) return { step: "blocked", reason: "unknownSource" };
124 return initialWizardStep(source);
125}
126
127/** A denied FDA recheck returns to the permission step with visible feedback. */
128export function fullDiskAccessRecheckStep(source: BrowserImportSource | undefined): WizardStep {
129 const next = refreshedSourceStep(source);
130 return next.step === "fullDiskAccess" ? { ...next, checked: true } : next;
131}
132
133/** Preserve the chosen source profile when a post-quit refresh still lists it. */
134export function refreshedSourceProfileDirectory(
135 currentDirectory: string,
136 source: BrowserImportSource,
137): string {
138 if (source.profiles.some((profile) => profile.directory === currentDirectory)) {
139 return currentDirectory;
140 }
141 return source.profiles[0]?.directory ?? "";
142}
143
144/**
145 * Whether retrying could clear a failure. The keychain prompt can be approved
146 * on a second try, a missing key appears once the user signs in to the
147 * browser (which is what its copy asks for), and a read or session error may
148 * be transient; an unsupported browser will not change, so it gets no retry
149 * button.
150 */
151export function isRetryableReason(reason: BrowserImportFailureReason): boolean {
152 switch (reason) {
153 case "needsKeychainApproval":
154 case "keychainItemMissing":
155 case "keychainUnavailable":
156 case "readFailed":
157 case "sessionUnavailable":
158 case "profileNotSaved":
159 return true;
160 default:
161 return false;
162 }
163}
164
165/**
166 * Names the sites whose cookies were skipped: "example.com and google.com",
167 * or "a, b, c and 4 more" past a few, so the line stays short.
168 */
169export function formatSkippedDomains(domains: ReadonlyArray<string>): string {
170 if (domains.length === 0) return "";
171 if (domains.length === 1) return domains[0]!;
172 if (domains.length <= 3) return `${domains.slice(0, -1).join(", ")} and ${domains.at(-1)}`;
173 return `${domains.slice(0, 3).join(", ")} and ${domains.length - 3} more`;
174}