import type { BrowserImportFailureReason, BrowserImportSource } from "@t3tools/contracts"; export interface WizardTargetProfile { readonly id: string; readonly name: string; } export type WizardTarget = | { readonly kind: "new"; readonly profileId: string } | { readonly kind: "existing"; readonly profileId: string; readonly name: string }; export type WizardTargetSelection = | { readonly kind: "new" } | { readonly kind: "existing"; readonly profileId: string }; export function initialTargetSelection( canCreateProfile: boolean, targetProfiles: ReadonlyArray, ): WizardTargetSelection { if (canCreateProfile) return { kind: "new" }; const first = targetProfiles[0]; return first ? { kind: "existing", profileId: first.id } : { kind: "new" }; } export function resolveWizardTarget( selection: WizardTargetSelection, newProfileId: string, targetProfiles: ReadonlyArray, ): WizardTarget | undefined { if (selection.kind === "new") return { kind: "new", profileId: newProfileId }; const profile = targetProfiles.find((candidate) => candidate.id === selection.profileId); if (profile === undefined) return undefined; return { kind: "existing", profileId: selection.profileId, name: profile.name, }; } /** * What the import wizard produces once it has actually tried to import. The * parent runs the import and classifies the result; the wizard only reacts to * it, which keeps the step transitions pure and testable. */ export type ImportOutcome = | { readonly kind: "imported"; readonly imported: number; readonly skipped: number; readonly skippedDomains: ReadonlyArray; readonly targetName: string; } | { readonly kind: "blocked"; readonly reason: BrowserImportFailureReason }; /** * The wizard's screens. Every one is a place the user can act from — there are * no dead ends. `blocked` covers the reasons no local step recovers. */ export type WizardStep = | { readonly step: "quit" } | { readonly step: "fullDiskAccess"; readonly resume: "configure" | "import"; readonly checked?: boolean; } | { readonly step: "configure" } | { readonly step: "checking"; readonly check: "browser" | "fullDiskAccess" } | { readonly step: "importing" } | { readonly step: "done"; readonly imported: number; readonly skipped: number; readonly skippedDomains: ReadonlyArray; readonly targetName: string; } | { readonly step: "blocked"; readonly reason: BrowserImportFailureReason }; /** The import owns its target partition until the write finishes. */ export function canCloseWizard(step: WizardStep): boolean { return step.step !== "importing"; } /** * Where the wizard opens for a source. A running browser is the one thing we * know up front, from the source listing; everything else is discovered by * trying, so the wizard starts by letting the user choose what to import. */ export function initialWizardStep(source: BrowserImportSource): WizardStep { if (source.unavailable === "browserRunning") return { step: "quit" }; if (source.unavailable === "needsFullDiskAccess") { return { step: "fullDiskAccess", resume: "configure" }; } if (source.unavailable !== undefined) return { step: "blocked", reason: source.unavailable }; if (source.profiles.length === 0) return { step: "blocked", reason: "unknownSourceProfile" }; return { step: "configure" }; } /** Where an attempted import lands the wizard, by how it turned out. */ export function outcomeToStep(outcome: ImportOutcome): WizardStep { if (outcome.kind === "imported") { return { step: "done", imported: outcome.imported, skipped: outcome.skipped, skippedDomains: outcome.skippedDomains, targetName: outcome.targetName, }; } // A browser that reopened mid-import routes back to the quit screen; every // other failure surfaces on the blocked screen, which offers a retry when // one could help. if (outcome.reason === "browserRunning") return { step: "quit" }; if (outcome.reason === "needsFullDiskAccess") { // The failed import already checked access, so explain that it is still // denied instead of returning to an indistinguishable permission screen. return { step: "fullDiskAccess", resume: "import", checked: true }; } return { step: "blocked", reason: outcome.reason }; } /** Where a fresh availability check lands the wizard after the user quits. */ export function refreshedSourceStep(source: BrowserImportSource | undefined): WizardStep { if (source === undefined) return { step: "blocked", reason: "unknownSource" }; return initialWizardStep(source); } /** A denied FDA recheck returns to the permission step with visible feedback. */ export function fullDiskAccessRecheckStep(source: BrowserImportSource | undefined): WizardStep { const next = refreshedSourceStep(source); return next.step === "fullDiskAccess" ? { ...next, checked: true } : next; } /** Preserve the chosen source profile when a post-quit refresh still lists it. */ export function refreshedSourceProfileDirectory( currentDirectory: string, source: BrowserImportSource, ): string { if (source.profiles.some((profile) => profile.directory === currentDirectory)) { return currentDirectory; } return source.profiles[0]?.directory ?? ""; } /** * Whether retrying could clear a failure. The keychain prompt can be approved * on a second try, a missing key appears once the user signs in to the * browser (which is what its copy asks for), and a read or session error may * be transient; an unsupported browser will not change, so it gets no retry * button. */ export function isRetryableReason(reason: BrowserImportFailureReason): boolean { switch (reason) { case "needsKeychainApproval": case "keychainItemMissing": case "keychainUnavailable": case "readFailed": case "sessionUnavailable": case "profileNotSaved": return true; default: return false; } } /** * Names the sites whose cookies were skipped: "example.com and google.com", * or "a, b, c and 4 more" past a few, so the line stays short. */ export function formatSkippedDomains(domains: ReadonlyArray): string { if (domains.length === 0) return ""; if (domains.length === 1) return domains[0]!; if (domains.length <= 3) return `${domains.slice(0, -1).join(", ")} and ${domains.at(-1)}`; return `${domains.slice(0, 3).join(", ")} and ${domains.length - 3} more`; }