Skip to content
60 changes: 56 additions & 4 deletions src/models/DownloadManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ import { strictIpcMain as ipcMain } from '@/infrastructure/ipcChannels';
import { DownloadStatus, IPC_CHANNELS } from '../constants';
import type { AppWindow } from '../main-process/appWindow';

const MAX_AUTO_RESUME_ATTEMPTS = 2;

export interface Download {
url: string;
filename: string;
tempPath: string; // Temporary filename until the download is complete.
savePath: string;
item: DownloadItem | null;
/** Number of times we have auto-resumed after an interrupt (stops interrupt→resume loops). */
interruptResumeCount: number;
}

export interface DownloadState {
Expand Down Expand Up @@ -67,6 +71,29 @@ export class DownloadManager {
item.on('updated', (event, state) => {
if (state === 'interrupted') {
log.info('Download is interrupted but can be resumed');
const totalBytes = item.getTotalBytes();
const progress = totalBytes > 0 ? item.getReceivedBytes() / totalBytes : 0;
const liveEntry = this.downloads.get(url);
const autoResumesLeft = MAX_AUTO_RESUME_ATTEMPTS - (liveEntry?.interruptResumeCount ?? 0);
const willAutoResume = item.canResume() && autoResumesLeft > 0;
this.reportProgress({
url,
progress,
filename: download.filename,
savePath: download.savePath,
status: DownloadStatus.PAUSED,
message: willAutoResume ? 'Interrupted, resuming…' : 'Interrupted, can be resumed',
});
if (item.canResume() && autoResumesLeft > 0) {
setTimeout(() => {
const entry = this.downloads.get(url);
if (entry?.item === item && item.getState() === 'interrupted') {
entry.interruptResumeCount += 1;
log.info('Auto-resuming interrupted download');
item.resume();
}
}, 500);
}
} else if (state === 'progressing') {
const progress = item.getReceivedBytes() / item.getTotalBytes();
if (item.isPaused()) {
Expand Down Expand Up @@ -117,6 +144,7 @@ export class DownloadManager {
status: DownloadStatus.ERROR,
savePath: download.savePath,
});
this.downloads.delete(url);
}
});
});
Expand Down Expand Up @@ -167,7 +195,14 @@ export class DownloadManager {

log.info(`Starting download ${url} to ${localSavePath}`);
const tempPath = this.getTempPath(filename, savePath);
this.downloads.set(url, { url, savePath: localSavePath, tempPath, filename, item: null });
this.downloads.set(url, {
url,
savePath: localSavePath,
tempPath,
filename,
item: null,
interruptResumeCount: 0,
});

// TODO(robinhuang): Add offset support for resuming downloads.
// Can use https://www.electronjs.org/docs/latest/api/session#sescreateinterrupteddownloadoptions
Expand Down Expand Up @@ -255,14 +290,15 @@ export class DownloadManager {
case 'cancelled':
return DownloadStatus.CANCELLED;
case 'interrupted':
return DownloadStatus.ERROR;
return DownloadStatus.PAUSED;
default:
return DownloadStatus.ERROR;
}
}

private getTempPath(filename: string, savePath: string): string {
return path.join(this.modelsDirectory, savePath, `Unconfirmed ${filename}.tmp`);
const subPath = this.resolveSavePath(savePath, filename);
return path.join(this.modelsDirectory, subPath, `Unconfirmed ${filename}.tmp`);
}

// Only allow .safetensors files to be downloaded.
Expand All @@ -285,8 +321,24 @@ export class DownloadManager {
}
}

/**
* Resolve savePath to a path under modelsDirectory.
* If the caller passes an absolute path that is under modelsDirectory (e.g. from the UI),
* we use the relative part so path.join does not duplicate the base path.
*/
private resolveSavePath(savePath: string, filename: string): string {
const base = path.resolve(this.modelsDirectory);
const resolved = path.resolve(savePath);
if (resolved.startsWith(base)) {
const rel = path.relative(base, resolved);
return rel.endsWith(filename) ? path.dirname(rel) : rel;
}
return savePath;
}

private getLocalSavePath(filename: string, savePath: string): string {
return path.join(this.modelsDirectory, savePath, filename);
const subPath = this.resolveSavePath(savePath, filename);
return path.join(this.modelsDirectory, subPath, filename);
}

private isPathInModelsDirectory(filePath: string): boolean {
Expand Down