From e7644110e4eb95a7156cfee56dbad6ace33e3fb7 Mon Sep 17 00:00:00 2001 From: Amin Ya Date: Sat, 14 Mar 2026 02:40:26 -0700 Subject: [PATCH 1/8] fix: auto-resume interrupted downloads Electron can emit an updated event with state === 'interrupted' when the download is paused by the network (e.g. connection drop, timeout), not by the user. In that case the download can be resumed. --- src/models/DownloadManager.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/models/DownloadManager.ts b/src/models/DownloadManager.ts index 0bbf98a84..32791f3b0 100644 --- a/src/models/DownloadManager.ts +++ b/src/models/DownloadManager.ts @@ -67,6 +67,24 @@ 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; + this.reportProgress({ + url, + progress, + filename: download.filename, + savePath: download.savePath, + status: DownloadStatus.PAUSED, + message: 'Interrupted, resuming…', + }); + if (item.canResume()) { + setTimeout(() => { + if (download.item === item && item.getState() === 'interrupted') { + log.info('Auto-resuming interrupted download'); + item.resume(); + } + }, 500); + } } else if (state === 'progressing') { const progress = item.getReceivedBytes() / item.getTotalBytes(); if (item.isPaused()) { From fb4da8a20d1280553376ae70928441ba9f4706d5 Mon Sep 17 00:00:00 2001 From: Amin Ya Date: Sat, 14 Mar 2026 02:52:41 -0700 Subject: [PATCH 2/8] fix: handle user interrupt and report it as paused --- src/models/DownloadManager.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/models/DownloadManager.ts b/src/models/DownloadManager.ts index 32791f3b0..af7aac5ff 100644 --- a/src/models/DownloadManager.ts +++ b/src/models/DownloadManager.ts @@ -79,7 +79,8 @@ export class DownloadManager { }); if (item.canResume()) { setTimeout(() => { - if (download.item === item && item.getState() === 'interrupted') { + const liveEntry = this.downloads.get(url); + if (liveEntry?.item === item && item.getState() === 'interrupted') { log.info('Auto-resuming interrupted download'); item.resume(); } @@ -135,6 +136,7 @@ export class DownloadManager { status: DownloadStatus.ERROR, savePath: download.savePath, }); + this.downloads.delete(url); } }); }); @@ -273,7 +275,7 @@ export class DownloadManager { case 'cancelled': return DownloadStatus.CANCELLED; case 'interrupted': - return DownloadStatus.ERROR; + return DownloadStatus.PAUSED; default: return DownloadStatus.ERROR; } From afe80a6d072204b49ddd9d03cb084d36c3ab2997 Mon Sep 17 00:00:00 2001 From: Amin Ya Date: Sat, 14 Mar 2026 02:54:20 -0700 Subject: [PATCH 3/8] fix: attempt download 2 times --- src/models/DownloadManager.ts | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/models/DownloadManager.ts b/src/models/DownloadManager.ts index af7aac5ff..885a9a60d 100644 --- a/src/models/DownloadManager.ts +++ b/src/models/DownloadManager.ts @@ -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 { @@ -69,18 +73,22 @@ export class DownloadManager { 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: 'Interrupted, resuming…', + message: willAutoResume ? 'Interrupted, resuming…' : 'Interrupted, can be resumed', }); - if (item.canResume()) { + if (item.canResume() && autoResumesLeft > 0) { setTimeout(() => { - const liveEntry = this.downloads.get(url); - if (liveEntry?.item === item && item.getState() === 'interrupted') { + const entry = this.downloads.get(url); + if (entry?.item === item && item.getState() === 'interrupted') { + entry.interruptResumeCount += 1; log.info('Auto-resuming interrupted download'); item.resume(); } @@ -187,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 From 50722f69e17aaf6abc0ec91c7416b9de6f4cbf85 Mon Sep 17 00:00:00 2001 From: Amin Ya Date: Sat, 14 Mar 2026 03:06:00 -0700 Subject: [PATCH 4/8] fix: normalize model download paths --- src/models/DownloadManager.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/models/DownloadManager.ts b/src/models/DownloadManager.ts index 885a9a60d..2bf47ba6f 100644 --- a/src/models/DownloadManager.ts +++ b/src/models/DownloadManager.ts @@ -297,7 +297,8 @@ export class DownloadManager { } 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. @@ -320,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 { From 235869c9e01e63675f3cef6cdf765e8170848dc3 Mon Sep 17 00:00:00 2001 From: Amin Ya Date: Sat, 14 Mar 2026 03:12:48 -0700 Subject: [PATCH 5/8] fix: default to 0 to reduce diff --- src/models/DownloadManager.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/models/DownloadManager.ts b/src/models/DownloadManager.ts index 2bf47ba6f..23f0ba480 100644 --- a/src/models/DownloadManager.ts +++ b/src/models/DownloadManager.ts @@ -17,7 +17,7 @@ export interface Download { savePath: string; item: DownloadItem | null; /** Number of times we have auto-resumed after an interrupt (stops interrupt→resume loops). */ - interruptResumeCount: number; + interruptResumeCount?: number; } export interface DownloadState { @@ -88,7 +88,7 @@ export class DownloadManager { setTimeout(() => { const entry = this.downloads.get(url); if (entry?.item === item && item.getState() === 'interrupted') { - entry.interruptResumeCount += 1; + entry.interruptResumeCount = (entry.interruptResumeCount ?? 0) + 1; log.info('Auto-resuming interrupted download'); item.resume(); } @@ -195,14 +195,7 @@ 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, - interruptResumeCount: 0, - }); + this.downloads.set(url, { url, savePath: localSavePath, tempPath, filename, item: null }); // TODO(robinhuang): Add offset support for resuming downloads. // Can use https://www.electronjs.org/docs/latest/api/session#sescreateinterrupteddownloadoptions From ba22887474e1276f4ac25cfec43c19e289385b7c Mon Sep 17 00:00:00 2001 From: Amin Ya Date: Sat, 14 Mar 2026 03:18:20 -0700 Subject: [PATCH 6/8] fix: more robust path comparison --- src/models/DownloadManager.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/models/DownloadManager.ts b/src/models/DownloadManager.ts index 23f0ba480..d0e059aaf 100644 --- a/src/models/DownloadManager.ts +++ b/src/models/DownloadManager.ts @@ -322,8 +322,9 @@ export class DownloadManager { 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); + const rel = path.relative(base, resolved); + const inside = rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel)); + if (inside) { return rel.endsWith(filename) ? path.dirname(rel) : rel; } return savePath; @@ -335,9 +336,10 @@ export class DownloadManager { } private isPathInModelsDirectory(filePath: string): boolean { - const absoluteFilePath = path.resolve(filePath); const absoluteModelsDir = path.resolve(this.modelsDirectory); - return absoluteFilePath.startsWith(absoluteModelsDir); + const resolved = path.resolve(filePath); + const rel = path.relative(absoluteModelsDir, resolved); + return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel)); } private reportProgress(report: DownloadReport): void { From 18549807ee379a0f8ed417ed7965a0ce381e7399 Mon Sep 17 00:00:00 2001 From: Amin Ya Date: Sat, 14 Mar 2026 03:20:44 -0700 Subject: [PATCH 7/8] fix: keep download link in case of failure --- src/models/DownloadManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/models/DownloadManager.ts b/src/models/DownloadManager.ts index d0e059aaf..f0b3a2b9d 100644 --- a/src/models/DownloadManager.ts +++ b/src/models/DownloadManager.ts @@ -144,7 +144,6 @@ export class DownloadManager { status: DownloadStatus.ERROR, savePath: download.savePath, }); - this.downloads.delete(url); } }); }); From a237de66e839ca99718aed1219cc927234aed729 Mon Sep 17 00:00:00 2001 From: Amin Ya Date: Sat, 14 Mar 2026 03:27:47 -0700 Subject: [PATCH 8/8] fix: guard against divide by zero and race issue --- src/models/DownloadManager.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/models/DownloadManager.ts b/src/models/DownloadManager.ts index f0b3a2b9d..eb202ec6e 100644 --- a/src/models/DownloadManager.ts +++ b/src/models/DownloadManager.ts @@ -85,17 +85,19 @@ export class DownloadManager { message: willAutoResume ? 'Interrupted, resuming…' : 'Interrupted, can be resumed', }); if (item.canResume() && autoResumesLeft > 0) { + if (liveEntry !== undefined) { + liveEntry.interruptResumeCount = (liveEntry.interruptResumeCount ?? 0) + 1; + } setTimeout(() => { - const entry = this.downloads.get(url); - if (entry?.item === item && item.getState() === 'interrupted') { - entry.interruptResumeCount = (entry.interruptResumeCount ?? 0) + 1; + if (this.downloads.get(url)?.item === item && item.getState() === 'interrupted') { log.info('Auto-resuming interrupted download'); item.resume(); } }, 500); } } else if (state === 'progressing') { - const progress = item.getReceivedBytes() / item.getTotalBytes(); + const totalBytes = item.getTotalBytes(); + const progress = totalBytes > 0 ? item.getReceivedBytes() / totalBytes : 0; if (item.isPaused()) { log.info('Download is paused'); this.reportProgress({ @@ -136,7 +138,8 @@ export class DownloadManager { this.downloads.delete(url); } else { log.info(`Download failed: ${state}`); - const progress = item.getReceivedBytes() / item.getTotalBytes(); + const totalBytes = item.getTotalBytes(); + const progress = totalBytes > 0 ? item.getReceivedBytes() / totalBytes : 0; this.reportProgress({ url, filename: download.filename,