Compare commits

...

13 Commits

Author SHA1 Message Date
bymyself
061918dd9a chore: ignore downloads types.ts in knip until store is wired
The DownloadService union type is only consumed by downloadServiceStore,
which is itself ignored as part of the incremental migration (PR 1 of N).
Adding types.ts to the same ignore group until consumers land.
2026-05-04 14:43:45 -07:00
bymyself
9e10a100b3 fix: avoid false-positive testing-library/prefer-presence-queries
The eslint-plugin-testing-library prefer-presence-queries rule
auto-fixes `service.getById(...)` to `service.queryById(...)` when
inside `expect(...).toBeNull()`, mistaking our service method for a
Testing Library query. Extracting the call into a variable breaks the
pattern match without changing semantics or the public API.
2026-05-04 14:43:33 -07:00
bymyself
a8d5140298 fix: prune terminal download entries from electron service
Bound memory growth by removing entries and their listeners from the
in-memory maps once a download reaches a terminal state (completed,
cancelled, error). Listeners still receive the final notification
before being cleaned up — consumers that need terminal state should
snapshot it in their onProgress callback.

Addresses review feedback on PR #11437.
2026-05-04 14:43:33 -07:00
bymyself
16220e7d5e refactor: discriminate DownloadService union by supportsPauseResume
Split DownloadService into PausableDownloadService and
NonPausableDownloadService discriminated by supportsPauseResume.
Removes pause/resume stubs from cloud and browser providers; the
compiler now enforces narrowing before pause/resume calls.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11437#discussion_r3113480116
2026-05-04 14:43:33 -07:00
Glary-Bot
6f9ece1426 fix: format downloadServiceStore and add startDownload failure test
- Fix oxfmt formatting issue in downloadServiceStore.ts
- Add test for startDownload returning false (18 tests now)
2026-05-04 14:43:33 -07:00
Glary-Bot
4007e7294f refactor: address human review feedback
- Make createElectronDownloadService async, remove separate initialize()
- Simplify store to inline provider creation during setup
- Inline notifyListeners into upsertFromProgress
- Prune entries on cancel
- Move status validation helpers to module scope
2026-05-04 14:43:33 -07:00
Glary-Bot
1d87d3e14c refactor: use type predicate for download status validation
Replace 'as DownloadStatus' casts with isDownloadStatus type predicate
for cleaner type narrowing without explicit casts.
2026-05-04 14:43:33 -07:00
Glary-Bot
af073de0e8 fix: address CodeRabbit round 3 feedback
- Validate startDownload return value to prevent phantom pending entries
- Add toDownloadStatus normalizer for external status values
- Replace unsafe 'as DownloadStatus' casts with validated normalization
2026-05-04 14:43:33 -07:00
Glary-Bot
a83862bbd5 fix: address CodeRabbit round 2 feedback
- Reset _initPromise on failure in store for retry support
- Guard electronAPI() before .DownloadManager access
- Add concurrent initialize() promise guard in electron provider
2026-05-04 14:43:33 -07:00
Glary-Bot
ece3254e9c fix: address CodeRabbit review feedback
- Guard concurrent initialize() with shared promise in store
- Guard DownloadManager availability with actionable error
- Reset initialized flag on transient failure for retry
- Clean up empty listener Sets on unsubscribe
2026-05-04 14:43:33 -07:00
Glary-Bot
fefddd85f5 fix: add downloadServiceStore to knip ignore for stacked PR
The store has no consumers yet — they arrive in follow-up PRs.
Follows the existing CustomNodesPanel.vue precedent.
2026-05-04 14:43:33 -07:00
Glary-Bot
57f4ee3ae0 fix: address review feedback for download service abstraction
- Add DownloadStartParams.tags for cloud category metadata
- Guard electron initialize() for idempotency
- Add cloud download service unit tests (6 tests)
- Use DownloadStartParams type across all providers
2026-05-04 14:43:12 -07:00
Glary-Bot
adb7b11a96 feat: add download service abstraction with functional composition
Introduce a unified DownloadService interface with per-distribution
providers (Electron, Browser, Cloud) selected at build time via
__DISTRIBUTION__. Tree-shaking ensures only the active provider ships.

Follows the existing createAssetService/createTaskService factory
pattern. This is PR 1 of the incremental migration — new files only,
no existing code changes yet.

Refs: DESK2-47, DESK2-48
2026-05-04 14:43:12 -07:00
9 changed files with 759 additions and 0 deletions

View File

@@ -54,6 +54,8 @@ const config: KnipConfig = {
'.github/workflows/ci-oss-assets-validation.yaml',
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
'src/platform/downloads/downloadServiceStore.ts',
'src/platform/downloads/types.ts',
// Marketing media tooling — adopted by pages in a follow-up PR
'apps/website/src/components/common/SiteVideo.vue',
'apps/website/src/utils/marketingImage.ts',

View File

@@ -0,0 +1,32 @@
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'
import type { DownloadService } from './types'
async function createDownloadServiceProvider(): Promise<DownloadService> {
if (__DISTRIBUTION__ === 'desktop') {
const { createElectronDownloadService } =
await import('./providers/createElectronDownloadService')
return createElectronDownloadService()
}
if (__DISTRIBUTION__ === 'cloud') {
const { createCloudDownloadService } =
await import('./providers/createCloudDownloadService')
return createCloudDownloadService()
}
const { createBrowserDownloadService } =
await import('./providers/createBrowserDownloadService')
return createBrowserDownloadService()
}
export const useDownloadServiceStore = defineStore('downloadService', () => {
const service = shallowRef<DownloadService | null>(null)
const ready = createDownloadServiceProvider().then((provider) => {
service.value = provider
})
return { service, ready }
})

View File

@@ -0,0 +1,61 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createBrowserDownloadService } from './createBrowserDownloadService'
describe('createBrowserDownloadService', () => {
let clickSpy: ReturnType<typeof vi.fn>
beforeEach(() => {
clickSpy = vi.fn()
vi.spyOn(document, 'createElement').mockReturnValue({
set href(_: string) {},
set download(_: string) {},
set target(_: string) {},
set rel(_: string) {},
click: clickSpy
} as unknown as HTMLAnchorElement)
})
it('returns a completed entry after triggering a browser download', async () => {
const service = createBrowserDownloadService()
const entry = await service.start({
url: 'https://example.com/model.safetensors',
savePath: '/models/checkpoints',
filename: 'model.safetensors'
})
expect(clickSpy).toHaveBeenCalledOnce()
expect(entry).toMatchObject({
id: 'https://example.com/model.safetensors',
url: 'https://example.com/model.safetensors',
filename: 'model.safetensors',
savePath: '/models/checkpoints',
status: 'completed',
progress: 1
})
})
it('does not support pause/resume', () => {
const service = createBrowserDownloadService()
expect(service.supportsPauseResume).toBe(false)
})
it('getAll returns empty array', () => {
const service = createBrowserDownloadService()
expect(service.getAll()).toEqual([])
})
it('getById returns null', () => {
const service = createBrowserDownloadService()
const entry = service.getById('anything')
expect(entry).toBeNull()
})
it('onProgress returns a no-op unsubscribe', () => {
const service = createBrowserDownloadService()
const unsubscribe = service.onProgress('id', () => {})
expect(typeof unsubscribe).toBe('function')
unsubscribe()
})
})

View File

@@ -0,0 +1,48 @@
import type {
DownloadEntry,
DownloadStartParams,
NonPausableDownloadService
} from '../types'
export function createBrowserDownloadService(): NonPausableDownloadService {
async function start(params: DownloadStartParams): Promise<DownloadEntry> {
const anchorElement = document.createElement('a')
anchorElement.href = params.url
anchorElement.download = params.filename
anchorElement.target = '_blank'
anchorElement.rel = 'noopener noreferrer'
anchorElement.click()
return {
id: params.url,
url: params.url,
filename: params.filename,
savePath: params.savePath,
status: 'completed',
progress: 1
}
}
async function cancel() {}
function getAll(): DownloadEntry[] {
return []
}
function getById(): null {
return null
}
function onProgress(): () => void {
return () => {}
}
return {
supportsPauseResume: false,
start,
cancel,
getAll,
getById,
onProgress
}
}

View File

@@ -0,0 +1,120 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createCloudDownloadService } from './createCloudDownloadService'
const mockUploadAssetAsync = vi.fn()
vi.mock('@/platform/assets/services/assetService', () => ({
assetService: {
uploadAssetAsync: (...args: unknown[]) => mockUploadAssetAsync(...args)
}
}))
describe('createCloudDownloadService', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns an in_progress entry when the upload is async', async () => {
mockUploadAssetAsync.mockResolvedValueOnce({
type: 'async',
task: { task_id: 'task-123', status: 'running' }
})
const service = createCloudDownloadService()
const entry = await service.start({
url: 'https://example.com/model.safetensors',
savePath: '/models/checkpoints',
filename: 'model.safetensors'
})
expect(entry).toMatchObject({
id: 'task-123',
url: 'https://example.com/model.safetensors',
status: 'in_progress',
progress: 0
})
expect(service.getById('task-123')).toEqual(entry)
})
it('returns a completed entry when the upload resolves synchronously', async () => {
mockUploadAssetAsync.mockResolvedValueOnce({
type: 'sync',
asset: { id: 'asset-456', name: 'model.safetensors' }
})
const service = createCloudDownloadService()
const entry = await service.start({
url: 'https://example.com/model.safetensors',
savePath: '/models/checkpoints',
filename: 'model.safetensors'
})
expect(entry).toMatchObject({
id: 'https://example.com/model.safetensors',
status: 'completed',
progress: 1
})
})
it('passes custom tags to uploadAssetAsync', async () => {
mockUploadAssetAsync.mockResolvedValueOnce({
type: 'sync',
asset: { id: 'asset-789' }
})
const service = createCloudDownloadService()
await service.start({
url: 'https://example.com/lora.safetensors',
savePath: '/models/loras',
filename: 'lora.safetensors',
tags: ['models', 'loras']
})
expect(mockUploadAssetAsync).toHaveBeenCalledWith({
source_url: 'https://example.com/lora.safetensors',
tags: ['models', 'loras']
})
})
it('falls back to [models] when no tags provided', async () => {
mockUploadAssetAsync.mockResolvedValueOnce({
type: 'sync',
asset: { id: 'asset-000' }
})
const service = createCloudDownloadService()
await service.start({
url: 'https://example.com/model.safetensors',
savePath: '/models',
filename: 'model.safetensors'
})
expect(mockUploadAssetAsync).toHaveBeenCalledWith({
source_url: 'https://example.com/model.safetensors',
tags: ['models']
})
})
it('does not support pause/resume', () => {
const service = createCloudDownloadService()
expect(service.supportsPauseResume).toBe(false)
})
it('tracks entries in getAll after start', async () => {
mockUploadAssetAsync.mockResolvedValueOnce({
type: 'async',
task: { task_id: 'task-track', status: 'running' }
})
const service = createCloudDownloadService()
await service.start({
url: 'https://example.com/a.safetensors',
savePath: '/models',
filename: 'a.safetensors'
})
expect(service.getAll()).toHaveLength(1)
expect(service.getAll()[0].id).toBe('task-track')
})
})

View File

@@ -0,0 +1,67 @@
import type {
DownloadEntry,
DownloadStartParams,
NonPausableDownloadService
} from '../types'
export function createCloudDownloadService(): NonPausableDownloadService {
const entries = new Map<string, DownloadEntry>()
const progressListeners = new Map<
string,
Set<(entry: DownloadEntry) => void>
>()
async function start(params: DownloadStartParams): Promise<DownloadEntry> {
const { assetService } =
await import('@/platform/assets/services/assetService')
const result = await assetService.uploadAssetAsync({
source_url: params.url,
tags: params.tags ?? ['models']
})
const id = result.type === 'async' ? result.task.task_id : params.url
const entry: DownloadEntry = {
id,
url: params.url,
filename: params.filename,
savePath: params.savePath,
status: result.type === 'async' ? 'in_progress' : 'completed',
progress: result.type === 'async' ? 0 : 1
}
entries.set(id, entry)
return entry
}
async function cancel() {}
function getAll(): DownloadEntry[] {
return [...entries.values()]
}
function getById(id: string): DownloadEntry | null {
return entries.get(id) ?? null
}
function onProgress(
id: string,
cb: (entry: DownloadEntry) => void
): () => void {
if (!progressListeners.has(id)) {
progressListeners.set(id, new Set())
}
progressListeners.get(id)!.add(cb)
return () => {
progressListeners.get(id)?.delete(cb)
}
}
return {
supportsPauseResume: false,
start,
cancel,
getAll,
getById,
onProgress
}
}

View File

@@ -0,0 +1,201 @@
import { DownloadStatus } from '@comfyorg/comfyui-electron-types'
import type { DownloadProgressUpdate } from '@comfyorg/comfyui-electron-types'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createElectronDownloadService } from './createElectronDownloadService'
let progressCallback: ((update: DownloadProgressUpdate) => void) | null = null
const mockDownloadManager = {
getAllDownloads: vi.fn().mockResolvedValue([]),
startDownload: vi.fn().mockResolvedValue(true),
pauseDownload: vi.fn().mockResolvedValue(undefined),
resumeDownload: vi.fn().mockResolvedValue(undefined),
cancelDownload: vi.fn().mockResolvedValue(undefined),
deleteModel: vi.fn().mockResolvedValue(true),
onDownloadProgress: vi.fn((cb: (update: DownloadProgressUpdate) => void) => {
progressCallback = cb
})
}
vi.mock('@/utils/envUtil', () => ({
electronAPI: () => ({ DownloadManager: mockDownloadManager })
}))
describe('createElectronDownloadService', () => {
beforeEach(() => {
vi.clearAllMocks()
progressCallback = null
})
it('initializes with existing downloads from DownloadManager', async () => {
mockDownloadManager.getAllDownloads.mockResolvedValueOnce([
{
url: 'https://example.com/model.safetensors',
filename: 'model.safetensors',
state: 'in_progress',
receivedBytes: 500,
totalBytes: 1000,
isPaused: false
}
])
const service = await createElectronDownloadService()
const allDownloads = service.getAll()
expect(allDownloads).toHaveLength(1)
expect(allDownloads[0]).toMatchObject({
id: 'https://example.com/model.safetensors',
filename: 'model.safetensors',
status: 'in_progress',
progress: 0.5
})
})
it('start delegates to DownloadManager and returns a pending entry', async () => {
const service = await createElectronDownloadService()
const entry = await service.start({
url: 'https://example.com/new-model.safetensors',
savePath: '/models/checkpoints',
filename: 'new-model.safetensors'
})
expect(mockDownloadManager.startDownload).toHaveBeenCalledWith(
'https://example.com/new-model.safetensors',
'/models/checkpoints',
'new-model.safetensors'
)
expect(entry).toMatchObject({
status: 'pending',
progress: 0
})
expect(service.getById(entry.id)).toEqual(entry)
})
it('throws when startDownload returns false', async () => {
mockDownloadManager.startDownload.mockResolvedValueOnce(false)
const service = await createElectronDownloadService()
await expect(
service.start({
url: 'https://example.com/fail.safetensors',
savePath: '/models',
filename: 'fail.safetensors'
})
).rejects.toThrow('Download could not be started')
})
it('updates entries on progress callbacks from DownloadManager', async () => {
const service = await createElectronDownloadService()
await service.start({
url: 'https://example.com/dl.safetensors',
savePath: '/models',
filename: 'dl.safetensors'
})
progressCallback!({
url: 'https://example.com/dl.safetensors',
filename: 'dl.safetensors',
savePath: '/models',
progress: 0.75,
status: DownloadStatus.IN_PROGRESS
})
const updated = service.getById('https://example.com/dl.safetensors')
expect(updated?.progress).toBe(0.75)
expect(updated?.status).toBe('in_progress')
})
it('onProgress notifies subscribers when entries update', async () => {
const service = await createElectronDownloadService()
const url = 'https://example.com/sub.safetensors'
await service.start({
url,
savePath: '/models',
filename: 'sub.safetensors'
})
const listener = vi.fn()
const unsubscribe = service.onProgress(url, listener)
progressCallback!({
url,
filename: 'sub.safetensors',
savePath: '/models',
progress: 0.5,
status: DownloadStatus.IN_PROGRESS
})
expect(listener).toHaveBeenCalledOnce()
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ progress: 0.5 })
)
unsubscribe()
progressCallback!({
url,
filename: 'sub.safetensors',
savePath: '/models',
progress: 1,
status: DownloadStatus.COMPLETED
})
expect(listener).toHaveBeenCalledOnce()
})
it('pause/resume/cancel delegate to DownloadManager', async () => {
const service = await createElectronDownloadService()
await service.pause('url1')
expect(mockDownloadManager.pauseDownload).toHaveBeenCalledWith('url1')
await service.resume('url2')
expect(mockDownloadManager.resumeDownload).toHaveBeenCalledWith('url2')
await service.cancel('url3')
expect(mockDownloadManager.cancelDownload).toHaveBeenCalledWith('url3')
})
it('supports pause/resume', async () => {
const service = await createElectronDownloadService()
expect(service.supportsPauseResume).toBe(true)
})
it.each([
['completed', DownloadStatus.COMPLETED],
['cancelled', DownloadStatus.CANCELLED],
['error', DownloadStatus.ERROR]
])(
'prunes terminal entries (%s) after notifying listeners',
async (_label, terminalStatus) => {
const service = await createElectronDownloadService()
const url = 'https://example.com/terminal.safetensors'
await service.start({
url,
savePath: '/models',
filename: 'terminal.safetensors'
})
const listener = vi.fn()
service.onProgress(url, listener)
progressCallback!({
url,
filename: 'terminal.safetensors',
savePath: '/models',
progress: 1,
status: terminalStatus
})
// Listener still receives the final notification
expect(listener).toHaveBeenCalledOnce()
// Entry pruned to bound memory growth
const prunedEntry = service.getById(url)
expect(prunedEntry).toBeNull()
expect(service.getAll()).toHaveLength(0)
}
)
})

View File

@@ -0,0 +1,161 @@
import type { DownloadProgressUpdate } from '@comfyorg/comfyui-electron-types'
import type {
DownloadEntry,
DownloadStartParams,
DownloadStatus,
PausableDownloadService
} from '../types'
import { electronAPI } from '@/utils/envUtil'
const VALID_STATUSES: ReadonlySet<string> = new Set([
'pending',
'in_progress',
'paused',
'completed',
'cancelled',
'error'
])
const TERMINAL_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'completed',
'cancelled',
'error'
])
function isDownloadStatus(value: unknown): value is DownloadStatus {
return typeof value === 'string' && VALID_STATUSES.has(value)
}
function toDownloadStatus(
value: unknown,
fallback: DownloadStatus = 'error'
): DownloadStatus {
return isDownloadStatus(value) ? value : fallback
}
export async function createElectronDownloadService(): Promise<PausableDownloadService> {
const api = electronAPI()
const downloadManager = api?.DownloadManager
if (!downloadManager) {
throw new Error(
'DownloadManager is unavailable. Verify Electron preload exposes DownloadManager.'
)
}
const entries = new Map<string, DownloadEntry>()
const progressListeners = new Map<
string,
Set<(entry: DownloadEntry) => void>
>()
function upsertFromProgress(update: DownloadProgressUpdate) {
const existing = entries.get(update.url)
const entry: DownloadEntry = {
id: update.url,
url: update.url,
filename: update.filename ?? existing?.filename ?? '',
savePath: update.savePath ?? existing?.savePath ?? '',
status: toDownloadStatus(update.status, 'in_progress'),
progress: update.progress ?? 0
}
entries.set(update.url, entry)
progressListeners.get(update.url)?.forEach((cb) => cb(entry))
// Prune terminal entries to bound memory growth. Consumers that need
// final state should snapshot it in their onProgress callback above.
if (TERMINAL_STATUSES.has(entry.status)) {
entries.delete(update.url)
progressListeners.delete(update.url)
}
}
const existingDownloads = await downloadManager.getAllDownloads()
for (const download of existingDownloads) {
entries.set(download.url, {
id: download.url,
url: download.url,
filename: download.filename,
savePath: '',
status: toDownloadStatus(download.state),
progress: download.totalBytes
? download.receivedBytes / download.totalBytes
: 0
})
}
downloadManager.onDownloadProgress(upsertFromProgress)
async function start(params: DownloadStartParams): Promise<DownloadEntry> {
const started = await downloadManager.startDownload(
params.url,
params.savePath,
params.filename
)
if (started === false) {
throw new Error(
`Download could not be started for ${params.url}. Verify the URL and try again.`
)
}
const entry: DownloadEntry = {
id: params.url,
url: params.url,
filename: params.filename,
savePath: params.savePath,
status: 'pending',
progress: 0
}
entries.set(params.url, entry)
return entry
}
async function pause(id: string) {
await downloadManager.pauseDownload(id)
}
async function resume(id: string) {
await downloadManager.resumeDownload(id)
}
async function cancel(id: string) {
await downloadManager.cancelDownload(id)
entries.delete(id)
}
function getAll(): DownloadEntry[] {
return [...entries.values()]
}
function getById(id: string): DownloadEntry | null {
return entries.get(id) ?? null
}
function onProgress(
id: string,
cb: (entry: DownloadEntry) => void
): () => void {
if (!progressListeners.has(id)) {
progressListeners.set(id, new Set())
}
progressListeners.get(id)!.add(cb)
return () => {
const listeners = progressListeners.get(id)
if (!listeners) return
listeners.delete(cb)
if (listeners.size === 0) {
progressListeners.delete(id)
}
}
}
return {
supportsPauseResume: true,
start,
pause,
resume,
cancel,
getAll,
getById,
onProgress
}
}

View File

@@ -0,0 +1,67 @@
/**
* Download service abstraction types.
*
* Provides a unified contract for model downloads across all distributions
* (Desktop/Electron, Browser/Localhost, Cloud). Components depend only on
* this interface — platform-specific implementations are injected at build
* time via {@link __DISTRIBUTION__} and tree-shaken from other builds.
*/
export type DownloadStatus =
| 'pending'
| 'in_progress'
| 'paused'
| 'completed'
| 'cancelled'
| 'error'
export interface DownloadEntry {
/** Unique key — URL for electron, taskId for cloud */
readonly id: string
readonly url: string
readonly filename: string
readonly savePath: string
status: DownloadStatus
progress: number
error?: string
}
export interface DownloadStartParams {
url: string
savePath: string
filename: string
/** Cloud-specific metadata tags (e.g. ['models', 'checkpoints']). */
tags?: string[]
}
interface BaseDownloadService {
/** Resolves once the download is accepted, not when it completes. */
start(params: DownloadStartParams): Promise<DownloadEntry>
cancel(id: string): Promise<void>
getAll(): DownloadEntry[]
getById(id: string): DownloadEntry | null
/** Returns an unsubscribe function. */
onProgress(id: string, cb: (entry: DownloadEntry) => void): () => void
}
export interface PausableDownloadService extends BaseDownloadService {
readonly supportsPauseResume: true
pause(id: string): Promise<void>
resume(id: string): Promise<void>
}
export interface NonPausableDownloadService extends BaseDownloadService {
readonly supportsPauseResume: false
}
/**
* Discriminated union by {@link supportsPauseResume}. Callers must narrow
* on the discriminant before calling {@link PausableDownloadService.pause}
* or {@link PausableDownloadService.resume}; the compiler enforces this.
*/
export type DownloadService =
| PausableDownloadService
| NonPausableDownloadService