/** * `shareDownloader`: the download page's file list. It remembers, in this browser, which files the * recipient has downloaded, and where the browser can write to a folder (the File System Access * API: Chrome and Edge on a computer) it downloads every file into the folder the recipient picks, * one at a time, in its folder path. * * One request per file keeps every request short enough for a proxy in front of the server. A * request that breaks off is retried after 1, 2, 4, 8 and 16 seconds from the byte the file on * disk has reached (an HTTP Range request); a file already there at its full size is skipped, so * running it again after a stop picks up where it stopped. A file that still fails is listed for * the Retry button; a share that is gone (404) stops the run. */ const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000] const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] function formatSize(bytes) { let unit = 0 while (bytes >= 1024 && unit < UNITS.length - 1) { bytes /= 1024 unit++ } return `${bytes.toFixed(unit === 0 ? 0 : 1)} ${UNITS[unit]}` } function readStored(key) { try { return JSON.parse(window.localStorage.getItem(key)) ?? [] } catch { return [] } } /** The file's handle under the picked folder, creating its subfolders; never a path out of it. */ async function fileHandle(directory, path) { const segments = path.split('/').filter((segment) => segment !== '' && segment !== '.' && segment !== '..') const name = segments.pop() for (const segment of segments) { directory = await directory.getDirectoryHandle(segment, { create: true }) } return directory.getFileHandle(name, { create: true }) } document.addEventListener('alpine:init', () => { window.Alpine.data('shareDownloader', ({ token, files, messages }) => { const storageKey = `sealshare.downloaded.${token}` const total = files.reduce((sum, file) => sum + file.size, 0) // Kept out of Alpine's reactive data: a proxied native handle throws on its own methods. let directory = null let controller = null return { supported: window.isSecureContext && 'showDirectoryPicker' in window, /** The ids of the files this browser has downloaded. */ done: readStored(storageKey), running: false, /** Bytes of the run's files on disk, for the progress bar. */ received: 0, /** How many of the run's files are in the folder. */ saved: 0, /** The file on its way. */ current: null, /** The files that failed, kept for the Retry button. */ failed: [], /** Why the run ended early, if it did. */ error: null, finished: false, get progress() { return total === 0 ? 100 : (this.received / total) * 100 }, get status() { return messages.progress .replace(':saved', this.saved) .replace(':count', files.length) .replace(':received', formatSize(this.received)) .replace(':total', formatSize(total)) }, isDone(id) { return this.done.includes(id) }, markDone(id) { if (this.done.includes(id)) { return } this.done.push(id) try { window.localStorage.setItem(storageKey, JSON.stringify(this.done)) } catch { // Private mode or storage blocked: the marks last as long as the page. } }, async downloadAll() { try { directory = await window.showDirectoryPicker({ id: 'sealshare', mode: 'readwrite' }) } catch { return } this.run(files) }, retry() { this.run(this.failed) }, cancel() { controller?.abort() }, async run(list) { controller = new AbortController() this.running = true this.finished = false this.error = null this.failed = [] this.saved = files.length - list.length this.received = total - list.reduce((sum, file) => sum + file.size, 0) this.downloaded = true for (const file of list) { const before = this.received this.current = file.path try { await this.save(file, controller.signal) this.saved++ this.markDone(file.id) } catch (error) { if (controller.signal.aborted) { break } if (error.status === 404) { this.error = messages.gone break } this.failed.push(file) this.received = before + file.size } } this.running = false this.current = null this.finished = ! controller.signal.aborted }, async save(file, signal) { const handle = await fileHandle(directory, file.path) const before = this.received // ponytail: a file is matched by name and size only, so a different file of the same name and size already in the picked folder is kept; compare a checksum if that ever bites. let written = (await handle.getFile()).size if (written > file.size) { written = 0 } for (let attempt = 0; written < file.size; attempt++) { try { written = await this.fetchInto(handle, file, written, signal, (bytes) => (this.received = before + bytes)) } catch (error) { if (signal.aborted || error.status === 404 || attempt >= RETRY_DELAYS.length) { throw error } await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[attempt])) written = (await handle.getFile()).size } } this.received = before + file.size }, /** Write the file from byte `offset` on; the bytes that arrived are kept even when the connection breaks. */ async fetchInto(handle, file, offset, signal, onProgress) { const response = await fetch(file.url, { headers: offset > 0 ? { Range: `bytes=${offset}-` } : {}, signal, }) if (! response.ok) { throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status }) } if (response.status !== 206) { offset = 0 } const writable = await handle.createWritable({ keepExistingData: offset > 0 }) const reader = response.body.getReader() try { await writable.truncate(offset) await writable.seek(offset) for (;;) { const { done, value } = await reader.read() if (done) { break } await writable.write(value) offset += value.length onProgress(offset) } } finally { // Closing commits what was written, so a retry continues from there. await writable.close().catch(() => {}) } if (offset < file.size) { throw new Error('The download ended early') } return offset }, warnBeforeLeaving(event) { if (this.running) { event.preventDefault() event.returnValue = '' } }, } }) })