- FolderPickerWriter: throw AbortError on cancel instead of returning null - FolderPickerWriter: add try/catch with abort() to release file locks on failure - ZipNeutralinoWriter: move writeBinaryFile after response.body validation - bulkDownloadSettings: migrate legacy key and validate stored values - download-utils: catch ffmpeg cancellation via signal.aborted - downloads.js: use consistent Neutralino detection with bridge module - download-utils: use strict equality for flac extension check
189 lines
7.2 KiB
TypeScript
189 lines
7.2 KiB
TypeScript
import { triggerDownload } from './download-utils';
|
|
|
|
/**
|
|
* A single entry to be included in a ZIP archive or written directly to a folder.
|
|
*/
|
|
export interface WriterEntry {
|
|
name: string;
|
|
lastModified: Date;
|
|
input: Blob | string | ArrayBuffer | Uint8Array;
|
|
}
|
|
|
|
/** Minimal interface for the Neutralino bridge used by ZipNeutralinoWriter */
|
|
interface NeutralinoBridge {
|
|
os: {
|
|
showSaveDialog(
|
|
title: string,
|
|
options: { defaultPath: string; filters: Array<{ name: string; extensions: string[] }> }
|
|
): Promise<string | null>;
|
|
};
|
|
filesystem: {
|
|
writeBinaryFile(path: string, buffer: ArrayBuffer): Promise<void>;
|
|
appendBinaryFile(path: string, buffer: ArrayBuffer): Promise<void>;
|
|
};
|
|
}
|
|
|
|
async function loadClientZip() {
|
|
try {
|
|
return await import('client-zip');
|
|
} catch (error) {
|
|
console.error('Failed to load client-zip:', error);
|
|
throw new Error('Failed to load ZIP library');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Interface for writing a collection of file entries to an output destination.
|
|
* Each implementation handles its own output selection (save dialog, directory picker, etc.)
|
|
* and throws a DOMException with name 'AbortError' if the user cancels.
|
|
*/
|
|
export interface IBulkDownloadWriter {
|
|
write(files: AsyncIterable<WriterEntry>): Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Streams a ZIP archive to a file via the File System Access API.
|
|
* Prompts the user to choose a save location with showSaveFilePicker.
|
|
*/
|
|
export class ZipStreamWriter implements IBulkDownloadWriter {
|
|
constructor(private readonly suggestedFilename: string) {}
|
|
|
|
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
|
// showSaveFilePicker is part of the File System Access API (not yet in all TS DOM libs)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const fileHandle = await (window as any).showSaveFilePicker({
|
|
suggestedName: this.suggestedFilename,
|
|
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
|
|
});
|
|
const { downloadZip } = await loadClientZip();
|
|
const writable = await fileHandle.createWritable();
|
|
const response = downloadZip(files);
|
|
if (!response.body) throw new Error('ZIP response body is null');
|
|
await response.body.pipeTo(writable);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Collects a ZIP archive into a Blob and triggers a browser download.
|
|
* Works on all browsers without requiring the File System Access API.
|
|
*/
|
|
export class ZipBlobWriter implements IBulkDownloadWriter {
|
|
constructor(private readonly filename: string) {}
|
|
|
|
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
|
const { downloadZip } = await loadClientZip();
|
|
const response = downloadZip(files);
|
|
const blob = await response.blob();
|
|
triggerDownload(blob, this.filename);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Writes a ZIP archive to the filesystem via the Neutralino desktop bridge,
|
|
* showing a native save dialog first.
|
|
*/
|
|
export class ZipNeutralinoWriter implements IBulkDownloadWriter {
|
|
constructor(private readonly folderName: string) {}
|
|
|
|
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
|
const bridge = (await import('./desktop/neutralino-bridge.js')) as unknown as NeutralinoBridge;
|
|
|
|
const savePath = await bridge.os.showSaveDialog(`Select save location for ${this.folderName}.zip`, {
|
|
defaultPath: `${this.folderName}.zip`,
|
|
filters: [{ name: 'ZIP Archive', extensions: ['zip'] }],
|
|
});
|
|
|
|
if (!savePath) {
|
|
throw new DOMException('User cancelled save dialog', 'AbortError');
|
|
}
|
|
|
|
const { downloadZip } = await loadClientZip();
|
|
const response = downloadZip(files);
|
|
if (!response.body) throw new Error('ZIP response body is null');
|
|
|
|
await bridge.filesystem.writeBinaryFile(savePath, new ArrayBuffer(0));
|
|
|
|
const reader = response.body.getReader();
|
|
let receivedLength = 0;
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
|
|
await bridge.filesystem.appendBinaryFile(savePath, chunk);
|
|
receivedLength += value.length;
|
|
}
|
|
|
|
console.log(`[ZIP] Download complete. Total size: ${receivedLength} bytes.`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Writes files directly into a user-chosen folder using the standard browser
|
|
* File System Access API (showDirectoryPicker). Subdirectories embedded in
|
|
* file entry names are created automatically.
|
|
*
|
|
* Use the static {@link FolderPickerWriter.create} method to obtain an instance;
|
|
* the constructor is private so the directory handle is always set before use.
|
|
*/
|
|
export class FolderPickerWriter implements IBulkDownloadWriter {
|
|
private constructor(private readonly dirHandle: FileSystemDirectoryHandle) {}
|
|
|
|
/**
|
|
* Prompts the user to pick a writable directory.
|
|
* Returns a new {@link FolderPickerWriter} bound to the chosen directory.
|
|
* If the user dismisses the picker, the promise rejects with a DOMException
|
|
* whose name is "AbortError".
|
|
*/
|
|
static async create(): Promise<FolderPickerWriter> {
|
|
// showDirectoryPicker is part of the File System Access API (not yet in all TS DOM libs)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
try {
|
|
const dirHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker({
|
|
mode: 'readwrite',
|
|
});
|
|
return new FolderPickerWriter(dirHandle);
|
|
} catch (error) {
|
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
throw error;
|
|
}
|
|
throw new DOMException('User cancelled directory picker', 'AbortError');
|
|
}
|
|
}
|
|
|
|
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
|
|
for await (const file of files) {
|
|
const parts = file.name.split('/').filter(Boolean);
|
|
if (parts.length === 0) continue;
|
|
|
|
let currentDir: FileSystemDirectoryHandle = this.dirHandle;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
currentDir = await currentDir.getDirectoryHandle(parts[i], { create: true });
|
|
}
|
|
|
|
const filename = parts[parts.length - 1];
|
|
const fileHandle = await currentDir.getFileHandle(filename, { create: true });
|
|
const writable = await fileHandle.createWritable();
|
|
|
|
try {
|
|
const { input } = file;
|
|
if (input instanceof Blob) {
|
|
await writable.write(input);
|
|
} else if (typeof input === 'string') {
|
|
await writable.write(new Blob([input], { type: 'text/plain' }));
|
|
} else {
|
|
const buf =
|
|
input instanceof Uint8Array
|
|
? input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)
|
|
: input;
|
|
await writable.write(new Blob([buf as ArrayBuffer]));
|
|
}
|
|
|
|
await writable.close();
|
|
} catch (error) {
|
|
await writable.abort();
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
}
|