fix(web): boot rehydrate normalizes transfers; paused has its own visual

This commit is contained in:
Jannis Braun
2026-05-02 19:10:29 +02:00
parent 61be9d013f
commit 33cfc66ac4
4 changed files with 138 additions and 5 deletions
@@ -23,17 +23,35 @@ function fmt(bytes: number): string {
export function AttachmentProgress({ loaded, total, state, filename, error, onPause, onResume, onAbort, size = 'tile' }: Props) {
const pct = total > 0 ? Math.min(100, Math.round((loaded / total) * 100)) : 0;
const bg = state === 'failed' ? 'bg-accent-rose/30' : 'bg-accent-mint/30';
const bg = state === 'failed' ? 'bg-accent-rose/30' : state === 'paused' ? 'bg-white/5' : 'bg-accent-mint/30';
const isFinal = state === 'completed' || state === 'aborted';
// Desaturate the conic-gradient ring when paused so it can't be mistaken for
// active progress. Active uses mint; paused uses a muted grey.
const ringColor = state === 'paused' ? 'rgba(180,180,190,.5)' : 'rgba(180,220,200,.85)';
const ringTrack = 'rgba(255,255,255,.15)';
const ringTitle = state === 'failed'
? error
: state === 'paused'
? `Paused — ${pct}%`
: undefined;
return (
<div className={`absolute inset-0 flex flex-col items-center justify-center gap-2 backdrop-blur-[2px] ${state === 'failed' ? 'bg-accent-rose/20' : 'bg-black/50'} pointer-events-auto`}>
<div
className={`w-9 h-9 rounded-full ${bg} flex items-center justify-center`}
style={state !== 'failed' ? { background: `conic-gradient(rgba(180,220,200,.85) ${pct}%, rgba(255,255,255,.15) ${pct}%)` } : undefined}
title={state === 'failed' ? error : undefined}
style={state !== 'failed' ? { background: `conic-gradient(${ringColor} ${pct}%, ${ringTrack} ${pct}%)` } : undefined}
title={ringTitle}
>
<div className="w-7 h-7 rounded-full bg-surface-overlay text-[10px] text-txt-primary flex items-center justify-center font-medium">
{state === 'failed' ? '!' : `${pct}%`}
{state === 'paused' ? (
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor" aria-hidden="true">
<rect x="2" y="1.5" width="2" height="7" rx="0.5" />
<rect x="6" y="1.5" width="2" height="7" rx="0.5" />
</svg>
) : state === 'failed' ? (
'!'
) : (
`${pct}%`
)}
</div>
</div>
{size === 'tile' && (
+86
View File
@@ -688,6 +688,92 @@ export const useTransferStore = create<TransferStore>()(
)
),
}),
onRehydrateStorage: () => (state, error) => {
if (error || !state) return;
// Normalize on the next tick so the store is fully wired before we mutate
// it, and so async work (handle probe + auto-resume) doesn't block the
// rehydrate path.
queueMicrotask(() => {
void normalizeRehydratedTransfers();
});
},
},
),
);
/**
* Boot-time normalization of rehydrated transfers.
*
* 1. Demotes any leaked 'active' state to 'paused' (defensive — partialize already
* filters most, but a transfer mid-progress can still slip through if it had a
* tusUploadUrl).
* 2. Marks bytes-unrecoverable paused transfers as 'failed' with an actionable
* message — we have no in-memory File post-reload and no FS handle to reacquire
* bytes from, so showing a paused state would be misleading.
* 3. For paused transfers with a stored handle whose permission is already
* 'granted', silently auto-resumes. For 'prompt' / 'denied', leaves paused so
* the user's explicit Resume click provides the user-gesture for
* `requestPermission`.
*
* Idempotent — re-running is harmless.
*/
async function normalizeRehydratedTransfers(): Promise<void> {
const store = useTransferStore.getState();
const transfers = Array.from(store.transfers.values());
for (const t of transfers) {
// 1) Demote any leaked 'active' state — no live worker exists post-reload.
if (t.state === 'active') {
store.setState_(t.id, 'paused');
}
const current = useTransferStore.getState().get(t.id);
if (!current) continue;
// 2) Bytes-unrecoverable paused transfers → immediately fail.
if (current.state === 'paused') {
const isUpload = current.type === 'upload';
const handleId = isUpload ? current.fileHandleId : current.destFileHandleId;
if (!handleId) {
store.setError(current.id, {
message: isUpload
? 'File no longer available — discard and re-upload'
: 'Download cannot resume — bytes lost. Restart the download.',
permanent: true,
});
continue;
}
// 3) Auto-resume when permission is already 'granted'.
try {
const { getHandle, queryHandlePermission } = await import('../utils/idbHandles');
const handle = await getHandle(handleId);
if (!handle) {
store.setError(current.id, {
message: isUpload
? 'File handle missing — discard and re-upload'
: 'Destination handle missing — restart the download',
permanent: true,
});
continue;
}
const mode: 'read' | 'readwrite' = isUpload ? 'read' : 'readwrite';
const perm = await queryHandlePermission(handle, mode);
if (perm === 'granted') {
// Use the live store reference so subsequent state changes are visible
// to subscribers.
if (isUpload) {
void useTransferStore.getState().resumeUpload(current.id);
} else {
void useTransferStore.getState().resumeDownload(current.id);
}
}
// perm === 'prompt' or 'denied' → user must click Resume to trigger
// requestPermission with a user-gesture.
} catch {
// Probe failed (IDB unavailable, etc.) — leave paused. User can click
// Resume to retry through the normal path.
}
}
}
}
+17
View File
@@ -54,6 +54,23 @@ export function supportsDnDHandles(): boolean {
&& typeof (DataTransferItem.prototype as unknown as { getAsFileSystemHandle?: unknown }).getAsFileSystemHandle === 'function';
}
/**
* Silently query the current permission state on a stored handle.
* Never prompts; returns whatever the browser reports right now. Used by the
* boot-time rehydrate path so we can auto-resume only when permission is
* already 'granted' and avoid a "user gesture required" failure for 'prompt'.
*/
export async function queryHandlePermission(
handle: FileSystemHandle,
mode: 'read' | 'readwrite',
): Promise<PermissionState> {
const handleAny = handle as unknown as {
queryPermission?: (opts: { mode: string }) => Promise<PermissionState>;
};
if (typeof handleAny.queryPermission !== 'function') return 'denied';
return handleAny.queryPermission({ mode });
}
/**
* Re-prompt for permission on a stored handle. Returns 'granted', 'denied', or 'prompt'.
* Some non-standard FS Access surfaces don't expose `queryPermission`/`requestPermission` —