mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
fix: clear exported mail from the browser's storage and say plainly where an export goes
- The private file an export writes in Firefox and Safari is deleted when the app starts and when someone signs in, not only at the next export, and at once on sign-out. Close keeps it until its download had time to finish. Each tab holds its own files under a Web Lock, so one tab's sweep no longer deletes another tab's running export. - A browser whose private file system can't write files (Safari before 26) is told the export stays in memory instead of being promised a download. - The export's live region announces only the phase and pauses; the count next to it no longer floods screen readers, and the format group is named once.
This commit is contained in:
@@ -129,4 +129,26 @@ describe('MailExportDialog', () => {
|
||||
expect(screen.getByTestId('mail-transfer-chip').textContent).toContain('chip');
|
||||
expect(controller.signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the running count out of the live region', () => {
|
||||
render(<MailTransferHost />);
|
||||
act(() => {
|
||||
openMailExport({ mailboxId: 'inbox' });
|
||||
useMailTransferStore.getState().startJob({ kind: 'export', controller: new AbortController(), progress: { phase: 'running', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } });
|
||||
});
|
||||
expect(screen.getByTestId('mail-export-status').textContent).toBe('running_title');
|
||||
expect(screen.getByTestId('mail-export-count').textContent).toBe('status_exported');
|
||||
});
|
||||
|
||||
it('says the export stays in memory where the private file system can\'t write files', () => {
|
||||
vi.stubGlobal('navigator', { ...navigator, storage: { getDirectory: async () => ({}) } });
|
||||
vi.stubGlobal('FileSystemFileHandle', class {});
|
||||
try {
|
||||
render(<MailTransferHost />);
|
||||
act(() => openMailExport());
|
||||
expect(screen.getByText('save_memory')).toBeTruthy();
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { isJobActive, useMailTransferStore, type ExportRequest } from "@/stores/mail-transfer-store";
|
||||
import type { ExportFormat, ExportProgress } from "@/lib/mail-export/engine";
|
||||
import { safeName } from "@/lib/mail-export/engine";
|
||||
import { canPickSaveFile, memorySink, pickSaveFile, privateFileSink, sweepPrivateExports, type ExportSink } from "@/lib/mail-export/sinks";
|
||||
import { canPickSaveFile, canWritePrivateFile, memorySink, pickSaveFile, privateFileSink, sweepPrivateExports, type ExportSink } from "@/lib/mail-export/sinks";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { formatClock, TransferBar } from "./transfer-progress";
|
||||
import { startExport } from "./start-export";
|
||||
@@ -21,8 +21,7 @@ function today(): string {
|
||||
/** Where the file will go in this browser, said before the export starts. */
|
||||
function saveMode(): "picker" | "download" | "memory" {
|
||||
if (canPickSaveFile()) return "picker";
|
||||
const storage = typeof navigator !== "undefined" ? (navigator.storage as { getDirectory?: unknown } | undefined) : undefined;
|
||||
return typeof storage?.getDirectory === "function" ? "download" : "memory";
|
||||
return canWritePrivateFile() ? "download" : "memory";
|
||||
}
|
||||
|
||||
export function MailExportDialog({ request }: { request: ExportRequest }) {
|
||||
@@ -103,7 +102,7 @@ export function MailExportDialog({ request }: { request: ExportRequest }) {
|
||||
{progress.phase === "done" && progress.file?.download && (
|
||||
<Button variant="outline" onClick={() => progress.file?.download?.()} data-testid="mail-export-download">{t("download_again")}</Button>
|
||||
)}
|
||||
<Button onClick={close}>{t("close")}</Button>
|
||||
<Button onClick={() => close()}>{t("close")}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -120,14 +119,14 @@ export function MailExportDialog({ request }: { request: ExportRequest }) {
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={close}
|
||||
onClose={() => close()}
|
||||
size="full-on-phone"
|
||||
title={folder ? t("title_folder", { name: mailbox?.name ?? "" }) : t("title_account")}
|
||||
description={folder ? t("description_folder") : t("description_account")}
|
||||
testId="mail-export-dialog"
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={close}>{t("cancel")}</Button>
|
||||
<Button variant="outline" onClick={() => close()}>{t("cancel")}</Button>
|
||||
<Button onClick={() => void onStart()} disabled={starting || (folder && !mailbox)} data-testid="mail-export-start">{t("start")}</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -135,7 +134,7 @@ export function MailExportDialog({ request }: { request: ExportRequest }) {
|
||||
<div className="mt-4 space-y-5">
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-sm font-medium text-foreground">{t("format_label")}</legend>
|
||||
<div className="space-y-2" role="radiogroup" aria-label={t("format_label")}>
|
||||
<div className="space-y-2">
|
||||
{formats.map((f) => (
|
||||
<label key={f.value} className="flex items-start gap-2 rounded-md border border-border p-3 text-sm text-foreground has-[:checked]:border-primary">
|
||||
<input
|
||||
@@ -166,16 +165,18 @@ type T = ReturnType<typeof useTranslations>;
|
||||
|
||||
function ExportProgressBody({ progress: p, t, locale, statusId }: { progress: ExportProgress; t: T; locale: string; statusId: string }) {
|
||||
const running = p.phase === "running" || p.phase === "paused";
|
||||
// The live region says only what changes the user's plans (paused, done);
|
||||
// the count beside it would otherwise be read out several times a second.
|
||||
let status: string;
|
||||
if (p.phase === "paused" && p.pausedUntil) status = t("paused", { time: formatClock(p.pausedUntil, locale) });
|
||||
else if (running) status = t("status_exported", { count: p.exported, total: p.total });
|
||||
else if (p.phase === "done") status = t("summary_exported", { count: p.exported });
|
||||
else if (running) status = t("running_title");
|
||||
else status = t("summary_exported", { count: p.exported });
|
||||
const tooLarge = p.phase === "failed" && /larger than this browser can keep in memory/.test(p.error ?? "");
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
{running && <TransferBar fraction={p.total > 0 ? p.fraction : null} label={t("progress_label")} paused={p.phase === "paused"} />}
|
||||
<p id={statusId} role="status" aria-live="polite" className="text-sm text-foreground tabular-nums" data-testid="mail-export-status">{status}</p>
|
||||
{running && <p className="text-sm text-foreground tabular-nums" data-testid="mail-export-count">{t("status_exported", { count: p.exported, total: p.total })}</p>}
|
||||
<p id={statusId} role="status" aria-live="polite" className={running ? "text-sm text-muted-foreground" : "text-sm text-foreground"} data-testid="mail-export-status">{status}</p>
|
||||
{p.phase === "done" && p.file && (
|
||||
<p className="text-sm text-muted-foreground break-words" data-testid="mail-export-file">{t("summary_file", { name: p.file.name, size: formatFileSize(p.file.size) })}</p>
|
||||
)}
|
||||
|
||||
@@ -144,7 +144,7 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
|
||||
<Button variant="destructive" onClick={() => cancelJob()} aria-describedby={`${id}-status`}>{t("cancel_import")}</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={close}>{t("close")}</Button>
|
||||
<Button onClick={() => close()}>{t("close")}</Button>
|
||||
)}
|
||||
>
|
||||
<ProgressBody progress={progress} t={t} locale={locale} statusId={`${id}-status`} />
|
||||
@@ -157,7 +157,7 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onClose={close}
|
||||
onClose={() => close()}
|
||||
size="full-on-phone"
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
@@ -165,7 +165,7 @@ export function MailImportDialog({ request }: { request: ImportRequest }) {
|
||||
initialFocus={chooseButton}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" onClick={close}>{t("cancel")}</Button>
|
||||
<Button variant="outline" onClick={() => close()}>{t("cancel")}</Button>
|
||||
<Button onClick={onStart} disabled={!canStart} data-testid="mail-import-start">{t("start")}</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Loader2, Pause } from "@/components/icons";
|
||||
import { isJobActive, useMailTransferStore } from "@/stores/mail-transfer-store";
|
||||
import { onBeforeSignOut } from "@/lib/sign-out-cleanup";
|
||||
import { clearCheckpointsFor } from "@/lib/mail-import/checkpoints";
|
||||
import { sweepPrivateExports } from "@/lib/mail-export/sinks";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { MailImportDialog } from "./mail-import-dialog";
|
||||
import { MailExportDialog } from "./mail-export-dialog";
|
||||
import { formatClock } from "./transfer-progress";
|
||||
@@ -35,11 +37,18 @@ export function MailTransferHost() {
|
||||
store.cancelJob("signout");
|
||||
await Promise.race([job.finished, new Promise((resolve) => setTimeout(resolve, SIGN_OUT_WAIT_MS))]);
|
||||
}
|
||||
useMailTransferStore.getState().close();
|
||||
useMailTransferStore.getState().close({ now: true });
|
||||
}
|
||||
if (accountId !== "all") await clearCheckpointsFor(accountId);
|
||||
}), []);
|
||||
|
||||
// Export files a closed tab or a crash left in the browser's private
|
||||
// storage go when the app starts and when someone signs in.
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
useEffect(() => {
|
||||
if (!isJobActive(useMailTransferStore.getState().job)) void sweepPrivateExports();
|
||||
}, [activeAccountId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{request?.kind === "import" && <MailImportDialog request={request} />}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { canWritePrivateFile, downloadGraceMs, privateFileSink, sweepPrivateExports } from '@/lib/mail-export/sinks';
|
||||
|
||||
/** A tiny origin private file system: one directory of files. */
|
||||
function fakeOpfs() {
|
||||
const files = new Map<string, Uint8Array[]>();
|
||||
const dir = {
|
||||
getFileHandle: async (name: string) => {
|
||||
if (!files.has(name)) files.set(name, []);
|
||||
return {
|
||||
createWritable: async () => ({
|
||||
write: async (data: Uint8Array) => { files.get(name)!.push(data); },
|
||||
close: async () => {},
|
||||
abort: async () => {},
|
||||
}),
|
||||
getFile: async () => new File(files.get(name)! as BlobPart[], name),
|
||||
};
|
||||
},
|
||||
removeEntry: async (name: string) => { files.delete(name); },
|
||||
keys: async function* () { yield* [...files.keys()]; },
|
||||
};
|
||||
const root = {
|
||||
getDirectoryHandle: async (name: string, opts?: { create?: boolean }) => {
|
||||
if (name !== 'bulwark-exports') throw new Error('no');
|
||||
if (!opts?.create && files.size === 0 && !created) throw new DOMException('missing', 'NotFoundError');
|
||||
created = true;
|
||||
return dir;
|
||||
},
|
||||
removeEntry: vi.fn(async () => { files.clear(); }),
|
||||
};
|
||||
let created = false;
|
||||
return { files, root };
|
||||
}
|
||||
|
||||
/** Web Locks where `held` names belong to another tab. */
|
||||
function fakeLocks(held: Set<string>) {
|
||||
return {
|
||||
request: async (name: string, _o: unknown, cb: (lock: unknown) => unknown) => cb(held.has(name) ? null : { name }),
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('sweepPrivateExports', () => {
|
||||
it('deletes the files no tab holds and leaves another tab\'s running export alone', async () => {
|
||||
const opfs = fakeOpfs();
|
||||
opfs.files.set('1-old', [new Uint8Array(3)]);
|
||||
opfs.files.set('2-busy', [new Uint8Array(3)]);
|
||||
vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set(['bulwark-export-file:2-busy'])) });
|
||||
await sweepPrivateExports();
|
||||
expect([...opfs.files.keys()]).toEqual(['2-busy']);
|
||||
});
|
||||
|
||||
it('sweeps nothing without Web Locks unless everyone signed out', async () => {
|
||||
const opfs = fakeOpfs();
|
||||
opfs.files.set('1-old', [new Uint8Array(3)]);
|
||||
vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root } });
|
||||
await sweepPrivateExports();
|
||||
expect(opfs.files.size).toBe(1);
|
||||
await sweepPrivateExports({ force: true });
|
||||
expect(opfs.root.removeEntry).toHaveBeenCalledWith('bulwark-exports', { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('privateFileSink', () => {
|
||||
it('keeps the file while its download may still read it, and deletes it after', async () => {
|
||||
vi.useFakeTimers();
|
||||
const opfs = fakeOpfs();
|
||||
vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) });
|
||||
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: vi.fn() }));
|
||||
const sink = (await privateFileSink('a.mbox', 'application/mbox'))!;
|
||||
await sink.write(new Uint8Array(10));
|
||||
const file = await sink.close();
|
||||
const gone = file.discard();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(opfs.files.size).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(downloadGraceMs(10));
|
||||
await gone;
|
||||
expect(opfs.files.size).toBe(0);
|
||||
});
|
||||
|
||||
it('deletes at once on sign-out', async () => {
|
||||
const opfs = fakeOpfs();
|
||||
vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) });
|
||||
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: vi.fn() }));
|
||||
const sink = (await privateFileSink('a.mbox', 'application/mbox'))!;
|
||||
await sink.write(new Uint8Array(10));
|
||||
await (await sink.close()).discard(true);
|
||||
expect(opfs.files.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canWritePrivateFile', () => {
|
||||
it('is false where the private file system has no writable files (Safari before 26)', () => {
|
||||
vi.stubGlobal('navigator', { storage: { getDirectory: async () => ({}) } });
|
||||
vi.stubGlobal('FileSystemFileHandle', class {});
|
||||
expect(canWritePrivateFile()).toBe(false);
|
||||
vi.stubGlobal('FileSystemFileHandle', class { createWritable() {} });
|
||||
expect(canWritePrivateFile()).toBe(true);
|
||||
});
|
||||
});
|
||||
+74
-10
@@ -1,11 +1,16 @@
|
||||
import { hasWebLocks, holdLock } from "@/lib/mail-import/locks";
|
||||
|
||||
/**
|
||||
* Where an export's bytes go, so a large mailbox never has to fit in memory:
|
||||
*
|
||||
* 1. File System Access (Chrome, Edge): the user picks the file first and the
|
||||
* export writes straight into it.
|
||||
* 2. The origin private file system (Firefox, Safari): written to a private
|
||||
* file on disk, then downloaded. The file is deleted when the dialog
|
||||
* closes, on sign-out, and by a sweep when the next export starts.
|
||||
* file on disk, then downloaded. The file is deleted a while after the
|
||||
* dialog closes (so the download can finish), at once on sign-out, and by
|
||||
* a sweep when the app starts, someone signs in or the next export starts.
|
||||
* Each file is held under a Web Lock while its tab uses it, so the sweep
|
||||
* of another tab leaves it alone.
|
||||
* 3. Memory, for small exports only, where neither exists.
|
||||
*
|
||||
* Writes are gathered into 1 MB pieces: a message arrives in many small parts.
|
||||
@@ -16,8 +21,8 @@ export interface ExportFile {
|
||||
size: number;
|
||||
/** Saves the file (again) where the browser keeps downloads; absent when the user picked the place. */
|
||||
download?: () => void;
|
||||
/** Frees what the export kept in this browser. */
|
||||
discard: () => Promise<void>;
|
||||
/** Frees what the export kept in this browser: after the download had time to finish, or `now`. */
|
||||
discard: (now?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ExportSink {
|
||||
@@ -37,6 +42,11 @@ export class ExportTooLargeError extends Error {
|
||||
/** The most a memory-only export may hold. */
|
||||
export const MEMORY_EXPORT_LIMIT = 200 * 1024 * 1024;
|
||||
const PRIVATE_DIR = "bulwark-exports";
|
||||
const LOCK_PREFIX = "bulwark-export-file:";
|
||||
/** How long a closed export's private file stays for its download: a minute, plus a second per 50 MB. */
|
||||
export function downloadGraceMs(size: number): number {
|
||||
return 60_000 + Math.ceil(size / 50_000_000) * 1000;
|
||||
}
|
||||
const FLUSH_BYTES = 1 << 20;
|
||||
|
||||
interface WritableLike {
|
||||
@@ -86,6 +96,13 @@ function triggerDownload(url: string, name: string): void {
|
||||
|
||||
type SavePicker = (options: { suggestedName?: string; types?: { description?: string; accept: Record<string, string[]> }[] }) => Promise<{ createWritable(): Promise<WritableLike> }>;
|
||||
|
||||
/** Whether this browser can write a private file from the page (Safari before 26 can't). */
|
||||
export function canWritePrivateFile(): boolean {
|
||||
if (typeof navigator === "undefined" || typeof (navigator.storage as { getDirectory?: unknown } | undefined)?.getDirectory !== "function") return false;
|
||||
const handle = (globalThis as { FileSystemFileHandle?: { prototype: object } }).FileSystemFileHandle;
|
||||
return !!handle && "createWritable" in handle.prototype;
|
||||
}
|
||||
|
||||
export function canPickSaveFile(): boolean {
|
||||
return typeof window !== "undefined" && typeof (window as unknown as { showSaveFilePicker?: unknown }).showSaveFilePicker === "function";
|
||||
}
|
||||
@@ -126,6 +143,8 @@ export async function privateFileSink(name: string, mime: string): Promise<Expor
|
||||
const dir = await privateDir();
|
||||
if (!dir) return null;
|
||||
const entry = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
// Held until the file is gone, so no other tab's sweep deletes it meanwhile.
|
||||
const release = (await holdLock(`${LOCK_PREFIX}${entry}`)) ?? (() => {});
|
||||
let handle: FileSystemFileHandle;
|
||||
let writable: WritableLike;
|
||||
try {
|
||||
@@ -133,15 +152,20 @@ export async function privateFileSink(name: string, mime: string): Promise<Expor
|
||||
const create = (handle as unknown as { createWritable?: () => Promise<WritableLike> }).createWritable;
|
||||
if (typeof create !== "function") {
|
||||
await dir.removeEntry(entry).catch(() => {});
|
||||
release();
|
||||
return null;
|
||||
}
|
||||
writable = await create.call(handle);
|
||||
} catch {
|
||||
await dir.removeEntry(entry).catch(() => {});
|
||||
release();
|
||||
return null;
|
||||
}
|
||||
const buf = buffered((chunk) => writable.write(chunk as BufferSource));
|
||||
const remove = () => dir.removeEntry(entry).catch(() => {});
|
||||
const remove = async () => {
|
||||
await dir.removeEntry(entry).catch(() => {});
|
||||
release();
|
||||
};
|
||||
return {
|
||||
kind: "private",
|
||||
write: (chunk) => buf.write(chunk),
|
||||
@@ -155,7 +179,12 @@ export async function privateFileSink(name: string, mime: string): Promise<Expor
|
||||
name,
|
||||
size: file.size,
|
||||
download: () => triggerDownload(url, name),
|
||||
discard: async () => { URL.revokeObjectURL(url); await remove(); },
|
||||
discard: async (now = false) => {
|
||||
// Deleting the file under a download that is still reading it would break the download.
|
||||
if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(file.size)));
|
||||
URL.revokeObjectURL(url);
|
||||
await remove();
|
||||
},
|
||||
};
|
||||
},
|
||||
abort: async () => { await writable.abort().catch(() => {}); await remove(); },
|
||||
@@ -178,17 +207,52 @@ export function memorySink(name: string, mime: string, limit = MEMORY_EXPORT_LIM
|
||||
parts = [];
|
||||
const url = URL.createObjectURL(blob);
|
||||
triggerDownload(url, name);
|
||||
return { name, size: blob.size, download: () => triggerDownload(url, name), discard: async () => URL.revokeObjectURL(url) };
|
||||
return {
|
||||
name,
|
||||
size: blob.size,
|
||||
download: () => triggerDownload(url, name),
|
||||
discard: async (now = false) => {
|
||||
if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(blob.size)));
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
},
|
||||
abort: async () => { parts = []; },
|
||||
};
|
||||
}
|
||||
|
||||
/** Deletes private export files left behind (a closed tab, a crash). */
|
||||
export async function sweepPrivateExports(): Promise<void> {
|
||||
/**
|
||||
* Deletes private export files no tab uses any more (a closed tab, a crash),
|
||||
* so no copy of the mail stays in the browser profile. A file another tab
|
||||
* holds is left alone; `force` deletes everything (everyone signed out).
|
||||
* Without Web Locks only `force` sweeps, since another tab can't be asked.
|
||||
*/
|
||||
export async function sweepPrivateExports({ force = false }: { force?: boolean } = {}): Promise<void> {
|
||||
if (!force && !hasWebLocks()) return;
|
||||
try {
|
||||
const root = await navigator.storage?.getDirectory?.();
|
||||
await root?.removeEntry(PRIVATE_DIR, { recursive: true });
|
||||
if (!root) return;
|
||||
if (force) {
|
||||
await root.removeEntry(PRIVATE_DIR, { recursive: true }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
let dir: FileSystemDirectoryHandle;
|
||||
try {
|
||||
dir = await root.getDirectoryHandle(PRIVATE_DIR);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const names: string[] = [];
|
||||
for await (const name of (dir as unknown as { keys(): AsyncIterable<string> }).keys()) names.push(name);
|
||||
for (const name of names) {
|
||||
const release = await holdLock(`${LOCK_PREFIX}${name}`);
|
||||
if (!release) continue;
|
||||
try {
|
||||
await dir.removeEntry(name).catch(() => {});
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* nothing there */
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function purgeSignedOutData(): void {
|
||||
// Where mail imports stopped: file names and folders of the accounts.
|
||||
void clearAllCheckpoints().catch(() => {});
|
||||
// Mail exports written to the browser's private files and not deleted yet.
|
||||
void sweepPrivateExports().catch(() => {});
|
||||
void sweepPrivateExports({ force: true }).catch(() => {});
|
||||
for (const cleanup of signedOutCleanups) {
|
||||
try {
|
||||
cleanup();
|
||||
|
||||
@@ -44,8 +44,11 @@ interface MailTransferState {
|
||||
/** Hides the dialog; a running job goes on. */
|
||||
hide: () => void;
|
||||
show: () => void;
|
||||
/** Closes the dialog and forgets a finished job (an export's private file is deleted). */
|
||||
close: () => void;
|
||||
/**
|
||||
* Closes the dialog and forgets a finished job. An export's private file
|
||||
* is deleted once its download had time to finish, or at once with `now`.
|
||||
*/
|
||||
close: (options?: { now?: boolean }) => void;
|
||||
startJob: (job: TransferJob) => void;
|
||||
updateProgress: (progress: ImportProgress | ExportProgress) => void;
|
||||
/** `reason` "signout": the account signs out, so nothing more is written for it. */
|
||||
@@ -56,8 +59,8 @@ export function isJobActive(job: TransferJob | null): boolean {
|
||||
return !!job && (job.progress.phase === "running" || job.progress.phase === "paused");
|
||||
}
|
||||
|
||||
function discardExport(job: TransferJob | null): void {
|
||||
if (job?.kind === "export") void job.progress.file?.discard().catch(() => {});
|
||||
function discardExport(job: TransferJob | null, now = false): void {
|
||||
if (job?.kind === "export") void job.progress.file?.discard(now).catch(() => {});
|
||||
}
|
||||
|
||||
export const useMailTransferStore = create<MailTransferState>()((set, get) => ({
|
||||
@@ -75,12 +78,12 @@ export const useMailTransferStore = create<MailTransferState>()((set, get) => ({
|
||||
},
|
||||
hide: () => set({ visible: false }),
|
||||
show: () => set({ visible: true }),
|
||||
close: () => {
|
||||
close: (options) => {
|
||||
if (isJobActive(get().job)) {
|
||||
set({ visible: false });
|
||||
return;
|
||||
}
|
||||
discardExport(get().job);
|
||||
discardExport(get().job, options?.now);
|
||||
set({ request: null, visible: false, job: null });
|
||||
},
|
||||
startJob: (job) => set({ job }),
|
||||
|
||||
Reference in New Issue
Block a user