mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-08 16:17:58 +00:00
Compare commits
13 Commits
API-builde
...
glary/down
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
061918dd9a | ||
|
|
9e10a100b3 | ||
|
|
a8d5140298 | ||
|
|
16220e7d5e | ||
|
|
6f9ece1426 | ||
|
|
4007e7294f | ||
|
|
1d87d3e14c | ||
|
|
af073de0e8 | ||
|
|
a83862bbd5 | ||
|
|
ece3254e9c | ||
|
|
fefddd85f5 | ||
|
|
57f4ee3ae0 | ||
|
|
adb7b11a96 |
@@ -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',
|
||||
|
||||
32
src/platform/downloads/downloadServiceStore.ts
Normal file
32
src/platform/downloads/downloadServiceStore.ts
Normal 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 }
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
67
src/platform/downloads/types.ts
Normal file
67
src/platform/downloads/types.ts
Normal 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
|
||||
Reference in New Issue
Block a user