mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +00:00
Compare commits
8 Commits
playwright
...
glary/miss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
262d25d1b5 | ||
|
|
7d4f2ad02e | ||
|
|
7162681f11 | ||
|
|
611ec191fb | ||
|
|
f12853ec74 | ||
|
|
3afe7c2e0e | ||
|
|
2742780b17 | ||
|
|
e739665727 |
@@ -3,83 +3,15 @@
|
||||
<div>
|
||||
{{ getDownloadLabel(download.savePath ?? '') }}
|
||||
</div>
|
||||
<div v-if="['cancelled', 'error'].includes(download.status ?? '')">
|
||||
<Chip
|
||||
class="mt-2 h-6 bg-red-700 text-sm font-light"
|
||||
removable
|
||||
@remove="handleRemoveDownload"
|
||||
>
|
||||
{{ t('electronFileDownload.cancelled') }}
|
||||
</Chip>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
['in_progress', 'paused', 'completed'].includes(download.status ?? '')
|
||||
"
|
||||
class="mt-2 flex flex-row items-center gap-2"
|
||||
>
|
||||
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
|
||||
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
|
||||
-->
|
||||
<ProgressBar
|
||||
class="flex-1"
|
||||
:value="Number(((download.progress ?? 0) * 100).toFixed(1))"
|
||||
:show-value="(download.progress ?? 0) > 0.1"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="download.status === 'in_progress'"
|
||||
v-tooltip.top="t('electronFileDownload.pause')"
|
||||
class="size-[22px] rounded-full"
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
:aria-label="t('electronFileDownload.pause')"
|
||||
@click="triggerPauseDownload"
|
||||
>
|
||||
<i class="icon-[lucide--pause] size-3" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="download.status === 'paused'"
|
||||
v-tooltip.top="t('electronFileDownload.resume')"
|
||||
class="size-[22px] rounded-full"
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
:aria-label="t('electronFileDownload.resume')"
|
||||
@click="triggerResumeDownload"
|
||||
>
|
||||
<i class="icon-[lucide--play] size-3" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="['in_progress', 'paused'].includes(download.status ?? '')"
|
||||
v-tooltip.top="t('electronFileDownload.cancel')"
|
||||
class="size-[22px] rounded-full"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
:aria-label="t('electronFileDownload.cancel')"
|
||||
@click="triggerCancelDownload"
|
||||
>
|
||||
<i class="icon-[lucide--x-circle] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<ElectronDownloadProgress :download />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Chip from 'primevue/chip'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ElectronDownloadProgress from '@/platform/electronDownload/components/ElectronDownloadProgress.vue'
|
||||
import type { ElectronDownload } from '@/platform/electronDownload/electronDownloadStore'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import type { ElectronDownload } from '@/stores/electronDownloadStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const electronDownloadStore = useElectronDownloadStore()
|
||||
|
||||
const props = defineProps<{
|
||||
const { download } = defineProps<{
|
||||
download: ElectronDownload
|
||||
}>()
|
||||
|
||||
@@ -90,19 +22,4 @@ const getDownloadLabel = (savePath: string) => {
|
||||
const dir = parts.pop()
|
||||
return `${dir}/${name}`
|
||||
}
|
||||
|
||||
const triggerCancelDownload = () =>
|
||||
electronDownloadStore.cancel(props.download.url)
|
||||
const triggerPauseDownload = () =>
|
||||
electronDownloadStore.pause(props.download.url)
|
||||
const triggerResumeDownload = () =>
|
||||
electronDownloadStore.resume(props.download.url)
|
||||
|
||||
const handleRemoveDownload = () => {
|
||||
electronDownloadStore.$patch((state) => {
|
||||
state.downloads = state.downloads.filter(
|
||||
({ url }) => url !== props.download.url
|
||||
)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { useElectronDownloadStore } from '@/platform/electronDownload/electronDownloadStore'
|
||||
|
||||
import DownloadItem from './DownloadItem.vue'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { markRaw } from 'vue'
|
||||
|
||||
import ModelLibrarySidebarTab from '@/components/sidebar/tabs/ModelLibrarySidebarTab.vue'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { useElectronDownloadStore } from '@/platform/electronDownload/electronDownloadStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
|
||||
|
||||
@@ -1191,7 +1191,11 @@
|
||||
"paused": "Paused",
|
||||
"resume": "Resume Download",
|
||||
"cancel": "Cancel Download",
|
||||
"cancelled": "Cancelled"
|
||||
"cancelled": "Cancelled",
|
||||
"cancelledNotice": "Download cancelled",
|
||||
"failed": "Download failed",
|
||||
"retry": "Try Again",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"maskEditor": {
|
||||
"title": "Mask Editor",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div v-if="['cancelled', 'error'].includes(download.status ?? '')">
|
||||
<Chip
|
||||
class="mt-2 h-6 bg-red-700 text-sm font-light"
|
||||
removable
|
||||
@remove="remove"
|
||||
>
|
||||
{{ t('electronFileDownload.cancelled') }}
|
||||
</Chip>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
['in_progress', 'paused', 'completed'].includes(download.status ?? '')
|
||||
"
|
||||
class="mt-2 flex flex-row items-center gap-2"
|
||||
>
|
||||
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
|
||||
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
|
||||
-->
|
||||
<ProgressBar
|
||||
class="flex-1"
|
||||
:value="Number(((download.progress ?? 0) * 100).toFixed(1))"
|
||||
:show-value="(download.progress ?? 0) > 0.1"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="download.status === 'in_progress'"
|
||||
v-tooltip.top="t('electronFileDownload.pause')"
|
||||
class="size-[22px] rounded-full"
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
:aria-label="t('electronFileDownload.pause')"
|
||||
@click="pause"
|
||||
>
|
||||
<i class="icon-[lucide--pause] size-3" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="download.status === 'paused'"
|
||||
v-tooltip.top="t('electronFileDownload.resume')"
|
||||
class="size-[22px] rounded-full"
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
:aria-label="t('electronFileDownload.resume')"
|
||||
@click="resume"
|
||||
>
|
||||
<i class="icon-[lucide--play] size-3" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="['in_progress', 'paused'].includes(download.status ?? '')"
|
||||
v-tooltip.top="t('electronFileDownload.cancel')"
|
||||
class="size-[22px] rounded-full"
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
:aria-label="t('electronFileDownload.cancel')"
|
||||
@click="cancel"
|
||||
>
|
||||
<i class="icon-[lucide--x-circle] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Chip from 'primevue/chip'
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useElectronDownload } from '@/platform/electronDownload/composables/useElectronDownload'
|
||||
import type { ElectronDownload } from '@/platform/electronDownload/electronDownloadStore'
|
||||
|
||||
const { download } = defineProps<{
|
||||
download: ElectronDownload
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { pause, resume, cancel, remove } = useElectronDownload(
|
||||
() => download.url
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'mt-2 flex w-full items-center gap-2 rounded-lg border px-2 py-1.5',
|
||||
isError
|
||||
? 'border-danger-100/40 bg-danger-100/10'
|
||||
: 'border-border bg-muted/20'
|
||||
)
|
||||
"
|
||||
:role="isError ? 'alert' : 'status'"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
:class="
|
||||
cn(
|
||||
'size-4 shrink-0',
|
||||
isError
|
||||
? 'icon-[lucide--alert-circle] text-danger-100'
|
||||
: 'icon-[lucide--x-circle] text-muted-foreground'
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="text-foreground truncate text-sm font-medium">
|
||||
{{
|
||||
isError
|
||||
? t('electronFileDownload.failed')
|
||||
: t('electronFileDownload.cancelledNotice')
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="isError && download.message"
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
:title="download.message"
|
||||
>
|
||||
{{ download.message }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-tooltip.top="t('electronFileDownload.retry')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-7 shrink-0 rounded-md"
|
||||
:aria-label="t('electronFileDownload.retry')"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="text-foreground mr-1 icon-[lucide--rotate-ccw] size-3"
|
||||
/>
|
||||
<span class="text-xs">{{ t('electronFileDownload.retry') }}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-tooltip.top="t('electronFileDownload.dismiss')"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 rounded-full text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('electronFileDownload.dismiss')"
|
||||
@click="remove"
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--x] size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadStatus } from '@comfyorg/comfyui-electron-types'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useElectronDownload } from '@/platform/electronDownload/composables/useElectronDownload'
|
||||
import type { ElectronDownload } from '@/platform/electronDownload/electronDownloadStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { download } = defineProps<{
|
||||
download: ElectronDownload
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { remove } = useElectronDownload(() => download.url)
|
||||
|
||||
const isError = computed(() => download.status === DownloadStatus.ERROR)
|
||||
</script>
|
||||
@@ -0,0 +1,123 @@
|
||||
import { DownloadStatus } from '@comfyorg/comfyui-electron-types'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useElectronDownload } from '@/platform/electronDownload/composables/useElectronDownload'
|
||||
import { useElectronDownloadStore } from '@/platform/electronDownload/electronDownloadStore'
|
||||
import type { ElectronDownload } from '@/platform/electronDownload/electronDownloadStore'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => ({ DownloadManager: undefined })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/electronDownload/downloadFailureReporter', () => ({
|
||||
reportDownloadFailure: vi.fn()
|
||||
}))
|
||||
|
||||
function seedDownload(overrides: Partial<ElectronDownload> = {}) {
|
||||
const store = useElectronDownloadStore()
|
||||
const entry: ElectronDownload = {
|
||||
url: 'https://civitai.com/api/download/models/1',
|
||||
filename: 'model.safetensors',
|
||||
savePath: '/tmp/checkpoints/model.safetensors',
|
||||
progress: 0.25,
|
||||
status: DownloadStatus.IN_PROGRESS,
|
||||
...overrides
|
||||
}
|
||||
store.downloads.push(entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
describe('useElectronDownload', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('resolves download reactively from the store by URL', () => {
|
||||
const seeded = seedDownload()
|
||||
const { download } = useElectronDownload(() => seeded.url)
|
||||
expect(download.value?.url).toBe(seeded.url)
|
||||
|
||||
// Mutate through the store's reactive entry so computed dependencies fire.
|
||||
const store = useElectronDownloadStore()
|
||||
const stored = store.findByUrl(seeded.url)!
|
||||
stored.progress = 0.5
|
||||
expect(download.value?.progress).toBe(0.5)
|
||||
})
|
||||
|
||||
it('returns undefined when the URL getter yields undefined', () => {
|
||||
seedDownload()
|
||||
const urlRef = ref<string | undefined>(undefined)
|
||||
const { download } = useElectronDownload(() => urlRef.value)
|
||||
expect(download.value).toBeUndefined()
|
||||
})
|
||||
|
||||
it('classifies phase correctly for each status', () => {
|
||||
const url = 'https://civitai.com/api/download/models/7'
|
||||
const { download, phase } = useElectronDownload(() => url)
|
||||
|
||||
expect(phase.value).toBe('none')
|
||||
expect(download.value).toBeUndefined()
|
||||
|
||||
seedDownload({ url, status: DownloadStatus.IN_PROGRESS })
|
||||
const store = useElectronDownloadStore()
|
||||
const stored = store.findByUrl(url)!
|
||||
expect(phase.value).toBe('active')
|
||||
|
||||
stored.status = DownloadStatus.PAUSED
|
||||
expect(phase.value).toBe('active')
|
||||
|
||||
stored.status = DownloadStatus.CANCELLED
|
||||
expect(phase.value).toBe('stopped')
|
||||
|
||||
stored.status = DownloadStatus.ERROR
|
||||
expect(phase.value).toBe('stopped')
|
||||
})
|
||||
|
||||
it('forwards pause/resume/cancel/remove to the store with the current URL', () => {
|
||||
const seeded = seedDownload({
|
||||
url: 'https://civitai.com/api/download/models/21',
|
||||
status: DownloadStatus.IN_PROGRESS
|
||||
})
|
||||
const store = useElectronDownloadStore()
|
||||
const pauseSpy = vi
|
||||
.spyOn(store, 'pause')
|
||||
.mockImplementation(() => Promise.resolve())
|
||||
const resumeSpy = vi
|
||||
.spyOn(store, 'resume')
|
||||
.mockImplementation(() => Promise.resolve())
|
||||
const cancelSpy = vi
|
||||
.spyOn(store, 'cancel')
|
||||
.mockImplementation(() => Promise.resolve())
|
||||
const removeSpy = vi.spyOn(store, 'remove')
|
||||
|
||||
const { pause, resume, cancel, remove } = useElectronDownload(
|
||||
() => seeded.url
|
||||
)
|
||||
pause()
|
||||
resume()
|
||||
cancel()
|
||||
remove()
|
||||
|
||||
expect(pauseSpy).toHaveBeenCalledWith(seeded.url)
|
||||
expect(resumeSpy).toHaveBeenCalledWith(seeded.url)
|
||||
expect(cancelSpy).toHaveBeenCalledWith(seeded.url)
|
||||
expect(removeSpy).toHaveBeenCalledWith(seeded.url)
|
||||
})
|
||||
|
||||
it('no-ops actions when the URL getter is undefined', () => {
|
||||
const store = useElectronDownloadStore()
|
||||
const pauseSpy = vi
|
||||
.spyOn(store, 'pause')
|
||||
.mockImplementation(() => Promise.resolve())
|
||||
|
||||
const { pause } = useElectronDownload(() => undefined)
|
||||
pause()
|
||||
|
||||
expect(pauseSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DownloadStatus } from '@comfyorg/comfyui-electron-types'
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import { useElectronDownloadStore } from '@/platform/electronDownload/electronDownloadStore'
|
||||
|
||||
/**
|
||||
* Classification of the download's current status from a UI-slot perspective:
|
||||
* - `none` — no entry exists for this URL; render the Download action.
|
||||
* - `active` — download is live (pending/in-progress/paused/completed);
|
||||
* render the progress UI.
|
||||
* - `stopped` — download finished in a non-success terminal state
|
||||
* (cancelled/error); render the stopped notice.
|
||||
*
|
||||
* Exposed from the composable so consumers don't have to duplicate the
|
||||
* DownloadStatus taxonomy.
|
||||
*/
|
||||
type ElectronDownloadPhase = 'none' | 'active' | 'stopped'
|
||||
|
||||
export function useElectronDownload(url: MaybeRefOrGetter<string | undefined>) {
|
||||
const store = useElectronDownloadStore()
|
||||
|
||||
const download = computed(() => {
|
||||
const value = toValue(url)
|
||||
return value ? store.findByUrl(value) : undefined
|
||||
})
|
||||
|
||||
const phase = computed<ElectronDownloadPhase>(() => {
|
||||
const dl = download.value
|
||||
if (!dl) return 'none'
|
||||
if (
|
||||
dl.status === DownloadStatus.CANCELLED ||
|
||||
dl.status === DownloadStatus.ERROR
|
||||
) {
|
||||
return 'stopped'
|
||||
}
|
||||
return 'active'
|
||||
})
|
||||
|
||||
const withUrl = (action: (url: string) => unknown) => () => {
|
||||
const value = toValue(url)
|
||||
if (value) action(value)
|
||||
}
|
||||
|
||||
return {
|
||||
download,
|
||||
phase,
|
||||
pause: withUrl(store.pause),
|
||||
resume: withUrl(store.resume),
|
||||
cancel: withUrl(store.cancel),
|
||||
remove: withUrl(store.remove)
|
||||
}
|
||||
}
|
||||
59
src/platform/electronDownload/downloadFailureReporter.ts
Normal file
59
src/platform/electronDownload/downloadFailureReporter.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as Sentry from '@sentry/vue'
|
||||
|
||||
import type { ElectronDownload } from '@/platform/electronDownload/electronDownloadStore'
|
||||
|
||||
/** Strip query/hash from a URL to avoid leaking tokens in reporting. */
|
||||
function safeUrlParts(raw: string): { host: string; path: string } {
|
||||
try {
|
||||
const u = new URL(raw)
|
||||
return { host: u.host, path: u.pathname }
|
||||
} catch {
|
||||
return { host: 'unparseable', path: 'unparseable' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace any inline URL in an error message with its host+path so tokens
|
||||
* carried in query strings (e.g. signed download URLs) aren't persisted to
|
||||
* reporting. Returns undefined for empty/missing input so callers can omit
|
||||
* the field entirely.
|
||||
*/
|
||||
function sanitizeMessage(message: string | undefined): string | undefined {
|
||||
if (!message) return undefined
|
||||
return message.replace(/https?:\/\/\S+/g, (url) => {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
return `${u.host}${u.pathname}`
|
||||
} catch {
|
||||
return '[url]'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const FAILURE_ERROR_MESSAGE = 'Electron model download failed'
|
||||
|
||||
export function reportDownloadFailure(download: ElectronDownload) {
|
||||
const { host, path } = safeUrlParts(download.url)
|
||||
const sanitizedMessage = sanitizeMessage(download.message)
|
||||
|
||||
Sentry.captureException(new Error(FAILURE_ERROR_MESSAGE), {
|
||||
tags: {
|
||||
feature: 'electron_download',
|
||||
error_type: 'download_failed',
|
||||
host
|
||||
},
|
||||
extra: {
|
||||
filename: download.filename,
|
||||
url_path: path,
|
||||
progress: download.progress,
|
||||
message: sanitizedMessage ?? null
|
||||
},
|
||||
// Stable grouping keyed on host + sanitized reason so Sentry issues don't
|
||||
// fragment across every signed URL or random error string.
|
||||
fingerprint: [
|
||||
'electron-download-failure',
|
||||
host,
|
||||
sanitizedMessage ?? 'unknown'
|
||||
]
|
||||
})
|
||||
}
|
||||
187
src/platform/electronDownload/electronDownloadStore.test.ts
Normal file
187
src/platform/electronDownload/electronDownloadStore.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { DownloadStatus } from '@comfyorg/comfyui-electron-types'
|
||||
import type {
|
||||
DownloadProgressUpdate,
|
||||
DownloadState
|
||||
} from '@comfyorg/comfyui-electron-types'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type ProgressHandler = (data: DownloadProgressUpdate) => void
|
||||
|
||||
const progressHandler = vi.hoisted(() => {
|
||||
const state: { current: ProgressHandler | null } = { current: null }
|
||||
return state
|
||||
})
|
||||
|
||||
const downloadManager = vi.hoisted(() => ({
|
||||
startDownload: vi.fn(),
|
||||
pauseDownload: vi.fn(),
|
||||
resumeDownload: vi.fn(),
|
||||
cancelDownload: vi.fn(),
|
||||
getAllDownloads: vi.fn(async () => [] as DownloadState[]),
|
||||
onDownloadProgress: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isDesktop: true }))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: () => ({ DownloadManager: downloadManager })
|
||||
}))
|
||||
|
||||
const reportDownloadFailure = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/electronDownload/downloadFailureReporter', () => ({
|
||||
reportDownloadFailure
|
||||
}))
|
||||
|
||||
import { useElectronDownloadStore } from '@/platform/electronDownload/electronDownloadStore'
|
||||
|
||||
function emitProgress(overrides: Partial<DownloadProgressUpdate> = {}) {
|
||||
const payload: DownloadProgressUpdate = {
|
||||
url: 'https://civitai.com/api/download/models/1',
|
||||
filename: 'model.safetensors',
|
||||
savePath: '/tmp/checkpoints/model.safetensors',
|
||||
progress: 0.25,
|
||||
status: DownloadStatus.IN_PROGRESS,
|
||||
...overrides
|
||||
}
|
||||
progressHandler.current?.(payload)
|
||||
return payload
|
||||
}
|
||||
|
||||
async function loadStore() {
|
||||
const store = useElectronDownloadStore()
|
||||
// Flush the microtasks scheduled by `void initialize()` so the progress
|
||||
// handler is registered before tests start emitting events.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
return store
|
||||
}
|
||||
|
||||
describe('useElectronDownloadStore', () => {
|
||||
beforeEach(() => {
|
||||
progressHandler.current = null
|
||||
downloadManager.startDownload.mockReset()
|
||||
downloadManager.pauseDownload.mockReset()
|
||||
downloadManager.resumeDownload.mockReset()
|
||||
downloadManager.cancelDownload.mockReset()
|
||||
downloadManager.getAllDownloads
|
||||
.mockReset()
|
||||
.mockImplementation(async () => [] as DownloadState[])
|
||||
downloadManager.onDownloadProgress.mockReset().mockImplementation((cb) => {
|
||||
progressHandler.current = cb as ProgressHandler
|
||||
})
|
||||
reportDownloadFailure.mockReset()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('cancel translation', () => {
|
||||
it('translates the ERROR event following a user cancel into CANCELLED', async () => {
|
||||
const store = await loadStore()
|
||||
const url = 'https://civitai.com/api/download/models/7'
|
||||
emitProgress({ url, status: DownloadStatus.IN_PROGRESS })
|
||||
void store.cancel(url)
|
||||
emitProgress({ url, status: DownloadStatus.ERROR, progress: 0 })
|
||||
|
||||
expect(store.findByUrl(url)?.status).toBe(DownloadStatus.CANCELLED)
|
||||
})
|
||||
|
||||
it('does not suppress a later genuine ERROR for the same URL', async () => {
|
||||
const store = await loadStore()
|
||||
const url = 'https://civitai.com/api/download/models/8'
|
||||
emitProgress({ url, status: DownloadStatus.IN_PROGRESS })
|
||||
void store.cancel(url)
|
||||
emitProgress({ url, status: DownloadStatus.ERROR, progress: 0 })
|
||||
// A second ERROR must not be translated, because the flag is single-shot.
|
||||
emitProgress({ url, status: DownloadStatus.ERROR, progress: 0 })
|
||||
|
||||
expect(store.findByUrl(url)?.status).toBe(DownloadStatus.ERROR)
|
||||
})
|
||||
|
||||
it('reports a genuine ERROR (not preceded by cancel) to Sentry', async () => {
|
||||
const store = await loadStore()
|
||||
const url = 'https://civitai.com/api/download/models/9'
|
||||
emitProgress({ url, status: DownloadStatus.IN_PROGRESS })
|
||||
emitProgress({ url, status: DownloadStatus.ERROR, progress: 0 })
|
||||
|
||||
expect(reportDownloadFailure).toHaveBeenCalledTimes(1)
|
||||
expect(store.findByUrl(url)?.status).toBe(DownloadStatus.ERROR)
|
||||
})
|
||||
|
||||
it('does not report to Sentry when the ERROR was a user cancel', async () => {
|
||||
const store = await loadStore()
|
||||
const url = 'https://civitai.com/api/download/models/10'
|
||||
emitProgress({ url, status: DownloadStatus.IN_PROGRESS })
|
||||
void store.cancel(url)
|
||||
emitProgress({ url, status: DownloadStatus.ERROR, progress: 0 })
|
||||
|
||||
expect(reportDownloadFailure).not.toHaveBeenCalled()
|
||||
expect(store.findByUrl(url)?.status).toBe(DownloadStatus.CANCELLED)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sentry dedup and retry', () => {
|
||||
it('reports once per ERROR transition, not on repeat ERRORs', async () => {
|
||||
await loadStore()
|
||||
const url = 'https://civitai.com/api/download/models/11'
|
||||
emitProgress({ url, status: DownloadStatus.IN_PROGRESS })
|
||||
emitProgress({ url, status: DownloadStatus.ERROR, progress: 0 })
|
||||
emitProgress({ url, status: DownloadStatus.ERROR, progress: 0 })
|
||||
|
||||
expect(reportDownloadFailure).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports the next ERROR after a retry (start resets prior status)', async () => {
|
||||
const store = await loadStore()
|
||||
const url = 'https://civitai.com/api/download/models/12'
|
||||
emitProgress({ url, status: DownloadStatus.IN_PROGRESS })
|
||||
emitProgress({ url, status: DownloadStatus.ERROR, progress: 0 })
|
||||
expect(reportDownloadFailure).toHaveBeenCalledTimes(1)
|
||||
|
||||
void store.start({ url, savePath: '/tmp/x', filename: 'x.safetensors' })
|
||||
expect(store.findByUrl(url)?.status).toBe(DownloadStatus.PENDING)
|
||||
|
||||
emitProgress({ url, status: DownloadStatus.ERROR, progress: 0.1 })
|
||||
expect(reportDownloadFailure).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('actions', () => {
|
||||
it('remove(url) drops the entry and clears the cancel flag', async () => {
|
||||
const store = await loadStore()
|
||||
const url = 'https://civitai.com/api/download/models/13'
|
||||
emitProgress({ url, status: DownloadStatus.IN_PROGRESS })
|
||||
void store.cancel(url)
|
||||
store.remove(url)
|
||||
|
||||
expect(store.findByUrl(url)).toBeUndefined()
|
||||
|
||||
// After removal, a brand-new progress ERROR for the same URL must be
|
||||
// treated as a genuine failure, not a lingering user cancel.
|
||||
emitProgress({ url, status: DownloadStatus.ERROR, progress: 0 })
|
||||
expect(store.findByUrl(url)?.status).toBe(DownloadStatus.ERROR)
|
||||
})
|
||||
|
||||
it('start forwards to DownloadManager and clears the cancel flag', async () => {
|
||||
const store = await loadStore()
|
||||
const url = 'https://civitai.com/api/download/models/14'
|
||||
void store.cancel(url)
|
||||
void store.start({ url, savePath: '/tmp/x', filename: 'x.safetensors' })
|
||||
|
||||
expect(downloadManager.startDownload).toHaveBeenCalledWith(
|
||||
url,
|
||||
'/tmp/x',
|
||||
'x.safetensors'
|
||||
)
|
||||
|
||||
// Cancel flag must be cleared so subsequent ERROR for this URL surfaces
|
||||
// as a genuine failure.
|
||||
emitProgress({ url, status: DownloadStatus.ERROR, progress: 0 })
|
||||
expect(store.findByUrl(url)?.status).toBe(DownloadStatus.ERROR)
|
||||
})
|
||||
})
|
||||
})
|
||||
126
src/platform/electronDownload/electronDownloadStore.ts
Normal file
126
src/platform/electronDownload/electronDownloadStore.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { DownloadStatus } from '@comfyorg/comfyui-electron-types'
|
||||
import type { DownloadState } from '@comfyorg/comfyui-electron-types'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { reportDownloadFailure } from '@/platform/electronDownload/downloadFailureReporter'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
export interface ElectronDownload extends Pick<
|
||||
DownloadState,
|
||||
'url' | 'filename'
|
||||
> {
|
||||
progress?: number
|
||||
savePath?: string
|
||||
status?: DownloadStatus
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** Electron downloads store handler */
|
||||
export const useElectronDownloadStore = defineStore('downloads', () => {
|
||||
const downloads = ref<ElectronDownload[]>([])
|
||||
const DownloadManager = isDesktop ? electronAPI().DownloadManager : undefined
|
||||
|
||||
// Electron reports user-initiated cancels as status=ERROR (not CANCELLED), so
|
||||
// we remember URLs whose cancel we dispatched and translate the next error
|
||||
// event to CANCELLED in the progress handler.
|
||||
const userCancelledUrls = new Set<string>()
|
||||
|
||||
const findByUrl = (url: string) =>
|
||||
downloads.value.find((download) => url === download.url)
|
||||
|
||||
const initialize = async () => {
|
||||
if (!isDesktop || !DownloadManager) return
|
||||
|
||||
const allDownloads = await DownloadManager.getAllDownloads()
|
||||
|
||||
for (const download of allDownloads) {
|
||||
downloads.value.push(download)
|
||||
}
|
||||
|
||||
DownloadManager.onDownloadProgress((data) => {
|
||||
// Translate the error event that Electron emits on user cancel into
|
||||
// CANCELLED so the UI can visually differentiate intentional stops.
|
||||
const userCancelled =
|
||||
userCancelledUrls.has(data.url) && data.status === DownloadStatus.ERROR
|
||||
|
||||
const existing = findByUrl(data.url)
|
||||
const previousStatus = existing?.status
|
||||
if (!existing) {
|
||||
downloads.value.push(data)
|
||||
}
|
||||
|
||||
const download = findByUrl(data.url)
|
||||
if (!download) return
|
||||
|
||||
download.progress = data.progress
|
||||
download.status = userCancelled ? DownloadStatus.CANCELLED : data.status
|
||||
download.filename = data.filename
|
||||
download.savePath = data.savePath
|
||||
download.message = data.message
|
||||
|
||||
// The cancel flag is single-shot: consume it on the first matching
|
||||
// ERROR event so later genuine ERRORs for the same URL aren't silently
|
||||
// masked as CANCELLED.
|
||||
if (userCancelled) {
|
||||
userCancelledUrls.delete(data.url)
|
||||
}
|
||||
|
||||
// Report genuine failures to Sentry once per ERROR transition. User
|
||||
// cancels are already translated to CANCELLED above so they're skipped.
|
||||
if (
|
||||
download.status === DownloadStatus.ERROR &&
|
||||
previousStatus !== DownloadStatus.ERROR
|
||||
) {
|
||||
reportDownloadFailure(download)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
void initialize()
|
||||
|
||||
const start = ({
|
||||
url,
|
||||
savePath,
|
||||
filename
|
||||
}: {
|
||||
url: string
|
||||
savePath: string
|
||||
filename: string
|
||||
}) => {
|
||||
userCancelledUrls.delete(url)
|
||||
// Reset the prior terminal status so the next ERROR on this URL is
|
||||
// detected as a fresh transition (otherwise retry-after-failure would
|
||||
// see previousStatus === ERROR and never report to Sentry).
|
||||
const existing = findByUrl(url)
|
||||
if (existing) existing.status = DownloadStatus.PENDING
|
||||
return DownloadManager!.startDownload(url, savePath, filename)
|
||||
}
|
||||
const pause = (url: string) => DownloadManager!.pauseDownload(url)
|
||||
const resume = (url: string) => DownloadManager!.resumeDownload(url)
|
||||
const cancel = (url: string) => {
|
||||
userCancelledUrls.add(url)
|
||||
return DownloadManager!.cancelDownload(url)
|
||||
}
|
||||
const remove = (url: string) => {
|
||||
userCancelledUrls.delete(url)
|
||||
downloads.value = downloads.value.filter((download) => download.url !== url)
|
||||
}
|
||||
|
||||
return {
|
||||
downloads,
|
||||
start,
|
||||
pause,
|
||||
resume,
|
||||
cancel,
|
||||
remove,
|
||||
findByUrl,
|
||||
initialize,
|
||||
inProgressDownloads: computed(() =>
|
||||
downloads.value.filter(
|
||||
({ status }) => status !== DownloadStatus.COMPLETED
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -151,7 +151,18 @@
|
||||
v-else-if="!isCloud && downloadable"
|
||||
class="flex w-full items-start py-1"
|
||||
>
|
||||
<ElectronDownloadProgress
|
||||
v-if="electronDownloadPhase === 'active' && electronDownload"
|
||||
:download="electronDownload"
|
||||
class="w-full"
|
||||
/>
|
||||
<ElectronDownloadStoppedNotice
|
||||
v-else-if="electronDownloadPhase === 'stopped' && electronDownload"
|
||||
:download="electronDownload"
|
||||
@retry="handleDownload"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
data-testid="missing-model-download"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
@@ -171,7 +182,7 @@
|
||||
|
||||
<TransitionCollapse>
|
||||
<MissingModelLibrarySelect
|
||||
v-if="!urlInputs[modelKey]"
|
||||
v-if="!urlInputs[modelKey] && electronDownloadPhase === 'none'"
|
||||
:model-value="getComboValue(model.representative)"
|
||||
:options="comboOptions"
|
||||
:show-divider="isAssetSupported || downloadable"
|
||||
@@ -190,6 +201,9 @@ import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
|
||||
import ElectronDownloadProgress from '@/platform/electronDownload/components/ElectronDownloadProgress.vue'
|
||||
import ElectronDownloadStoppedNotice from '@/platform/electronDownload/components/ElectronDownloadStoppedNotice.vue'
|
||||
import { useElectronDownload } from '@/platform/electronDownload/composables/useElectronDownload'
|
||||
import MissingModelStatusCard from '@/platform/missingModel/components/MissingModelStatusCard.vue'
|
||||
import MissingModelUrlInput from '@/platform/missingModel/components/MissingModelUrlInput.vue'
|
||||
import MissingModelLibrarySelect from '@/platform/missingModel/components/MissingModelLibrarySelect.vue'
|
||||
@@ -230,6 +244,9 @@ const modelKey = computed(() =>
|
||||
getModelStateKey(model.name, directory, isAssetSupported)
|
||||
)
|
||||
|
||||
const { download: electronDownload, phase: electronDownloadPhase } =
|
||||
useElectronDownload(() => model.representative.url)
|
||||
|
||||
const downloadStatus = computed(() => getDownloadStatus(modelKey.value))
|
||||
const comboOptions = computed(() => getComboOptions(model.representative))
|
||||
const canConfirm = computed(() => isSelectionConfirmable(modelKey.value))
|
||||
|
||||
@@ -10,7 +10,7 @@ const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
|
||||
vi.mock('@/stores/electronDownloadStore', () => ({}))
|
||||
vi.mock('@/platform/electronDownload/electronDownloadStore', () => ({}))
|
||||
|
||||
let testId = 0
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { useElectronDownloadStore } from '@/platform/electronDownload/electronDownloadStore'
|
||||
|
||||
const ALLOWED_SOURCES = [
|
||||
'https://civitai.com/',
|
||||
@@ -60,6 +60,12 @@ export function downloadModel(
|
||||
model: ModelWithUrl,
|
||||
paths: Record<string, string[]>
|
||||
): void {
|
||||
// Defense-in-depth: even though every caller is expected to gate on
|
||||
// isModelDownloadable() first, re-check here so a URL that somehow reaches
|
||||
// this function (e.g. via a stale retry path or a future caller that forgets
|
||||
// the guard) can't trigger a download against an unvetted source.
|
||||
if (!isModelDownloadable(model)) return
|
||||
|
||||
if (!isDesktop) {
|
||||
const link = document.createElement('a')
|
||||
link.href = model.url
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { DownloadStatus } from '@comfyorg/comfyui-electron-types'
|
||||
import type { DownloadState } from '@comfyorg/comfyui-electron-types'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
export interface ElectronDownload extends Pick<
|
||||
DownloadState,
|
||||
'url' | 'filename'
|
||||
> {
|
||||
progress?: number
|
||||
savePath?: string
|
||||
status?: DownloadStatus
|
||||
}
|
||||
|
||||
/** Electron downloads store handler */
|
||||
export const useElectronDownloadStore = defineStore('downloads', () => {
|
||||
const downloads = ref<ElectronDownload[]>([])
|
||||
const DownloadManager = isDesktop ? electronAPI().DownloadManager : undefined
|
||||
|
||||
const findByUrl = (url: string) =>
|
||||
downloads.value.find((download) => url === download.url)
|
||||
|
||||
const initialize = async () => {
|
||||
if (!isDesktop || !DownloadManager) return
|
||||
|
||||
const allDownloads = await DownloadManager.getAllDownloads()
|
||||
|
||||
for (const download of allDownloads) {
|
||||
downloads.value.push(download)
|
||||
}
|
||||
|
||||
DownloadManager.onDownloadProgress((data) => {
|
||||
if (!findByUrl(data.url)) {
|
||||
downloads.value.push(data)
|
||||
}
|
||||
|
||||
const download = findByUrl(data.url)
|
||||
|
||||
if (download) {
|
||||
download.progress = data.progress
|
||||
download.status = data.status
|
||||
download.filename = data.filename
|
||||
download.savePath = data.savePath
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
void initialize()
|
||||
|
||||
const start = ({
|
||||
url,
|
||||
savePath,
|
||||
filename
|
||||
}: {
|
||||
url: string
|
||||
savePath: string
|
||||
filename: string
|
||||
}) => DownloadManager!.startDownload(url, savePath, filename)
|
||||
const pause = (url: string) => DownloadManager!.pauseDownload(url)
|
||||
const resume = (url: string) => DownloadManager!.resumeDownload(url)
|
||||
const cancel = (url: string) => DownloadManager!.cancelDownload(url)
|
||||
|
||||
return {
|
||||
downloads,
|
||||
start,
|
||||
pause,
|
||||
resume,
|
||||
cancel,
|
||||
findByUrl,
|
||||
initialize,
|
||||
inProgressDownloads: computed(() =>
|
||||
downloads.value.filter(
|
||||
({ status }) => status !== DownloadStatus.COMPLETED
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user