Compare commits

...

8 Commits

Author SHA1 Message Date
jaeone94
262d25d1b5 test: cover store cancel translation, retry reset, and composable phase
electronDownloadStore.test.ts
- ERROR following user cancel is translated to CANCELLED and the flag is
  single-shot: a second ERROR for the same URL is NOT remapped.
- Sentry receives exactly one report per ERROR transition, not on repeats.
- Retry-after-failure (start called again) resets status to PENDING so
  the next ERROR surfaces as a fresh transition and is reported.
- remove() drops the entry and clears the cancel flag; a later ERROR for
  the same URL is treated as a genuine failure.
- cancel/start/remove forward to DownloadManager correctly.

useElectronDownload.test.ts
- `download` computed resolves reactively from the store by URL and is
  undefined when the URL getter yields undefined.
- `phase` returns 'none' / 'active' / 'stopped' for each status.
- Action functions forward to the store with the resolved URL and no-op
  when the URL getter is undefined.
2026-04-22 18:10:52 +09:00
jaeone94
7d4f2ad02e fix: re-validate URL whitelist inside downloadModel as defense-in-depth
Existing callers (MissingModelRow's handleDownload and the retry flow)
already gate on isModelDownloadable before invoking downloadModel, but
this function hands an arbitrary URL to Electron's privileged
DownloadManager. A single call site that forgets to guard would open
an SSRF-adjacent hole. Re-running the whitelist check inside the
function means the invariant is enforced at the trust boundary rather
than left to every caller.
2026-04-22 17:11:00 +09:00
jaeone94
7162681f11 refactor: use DownloadStatus enum and semantic tokens in stopped notice
- Replace raw 'error' string comparison with DownloadStatus.ERROR so a
  future enum-value change surfaces as a type error rather than silent
  UI drift.
- Swap the raw red palette (bg-red-700, text-red-500) for the
  danger-100 semantic token used elsewhere in the app; keeps the notice
  on-theme if the palette ever shifts.
- Downgrade role="alert" to role="status" for the user-initiated cancel
  case. "Alert" is assertive and appropriate for a genuine failure, but
  cancellation is expected and shouldn't interrupt screen reader flow.
2026-04-22 17:09:42 +09:00
jaeone94
611ec191fb refactor: expose download phase from composable, fix library-select gate
- Add `phase` (none/active/stopped) to useElectronDownload so consumers
  don't have to duplicate the DownloadStatus taxonomy. The classification
  now lives with the rest of the electronDownload domain.
- MissingModelRow: replace activeElectronDownload/stoppedElectronDownload
  computeds with the phase discriminator.
- Fix the library-select gate: previously `!electronDownload` left the
  combo permanently hidden for any URL that had ever started a download
  (including completed, cancelled, and errored states that still hold a
  store entry). Tighten to `phase === 'none'` so the combo reappears when
  the Download button does — matching the intended mutual-exclusivity with
  progress/stopped notice.
2026-04-22 17:08:45 +09:00
jaeone94
f12853ec74 fix: harden Sentry payload for Electron download failures
- On URL parse failure, return placeholders instead of echoing the raw URL
  (the previous fallback shipped query-string tokens into Sentry extras).
- Sanitize the Electron-provided message by replacing any inline URLs with
  host+path so signed-URL tokens embedded in free-form error text are
  redacted before capture.
- Use a fixed Error message plus an explicit fingerprint (host + sanitized
  reason) so Sentry groups failures by origin and cause rather than
  fragmenting across every signed URL.
- Keep the full message in extras only, not in the Error's message string.
- Rename the tag from `origin` to `host` so it matches what's actually
  captured (hostname, not full URL origin).
2026-04-22 17:07:06 +09:00
jaeone94
3afe7c2e0e fix: release user-cancel flag on consumption and reset retry status
- Delete a URL from the user-cancelled set right after the translated
  ERROR→CANCELLED event is consumed, so a later genuine ERROR for the
  same URL is no longer silently mis-labeled as CANCELLED and dropped
  from Sentry.
- Reset the existing entry's status to PENDING when start() is called
  again for the same URL. Without this, a retry-after-failure keeps the
  previous ERROR status and the next transition check (previousStatus !==
  ERROR) never fires, so follow-on failures are invisible in Sentry.
2026-04-22 17:06:15 +09:00
jaeone94
2742780b17 refactor: move Electron download store into its own platform domain
- Relocate src/stores/electronDownloadStore.ts to src/platform/electronDownload/
  so the store is colocated with its composable and UI components, matching the
  convention used by sibling domains (missingModel, missingMedia, assets, ...).
- Extract the Sentry failure reporter into a standalone
  src/platform/electronDownload/downloadFailureReporter.ts module so the store
  stays focused on IPC-backed state management and the reporter becomes
  straightforward to mock in tests.
- Update all eight import sites (sidebar, composables, missing-model download
  flow, and the test mock path). No behavior change.
2026-04-22 17:05:11 +09:00
jaeone94
e739665727 feat: surface Electron download state in missing model row
- Extract ElectronDownloadProgress and ElectronDownloadStoppedNotice into
  src/platform/electronDownload so sidebar DownloadItem and MissingModelRow
  render the same visual language for in-progress, cancelled, and error
  states without duplication.
- Introduce useElectronDownload(url) composable so components never touch
  the Pinia store directly.
- Show progress/cancelled/error inline inside the missing model card; hide
  "or" / library select while any download entry exists, surface it again
  only after dismiss.
- Translate the ERROR event Electron emits on user cancel into CANCELLED
  by tracking user-initiated cancels in the store, giving the UI a
  distinct "Download cancelled" notice separate from real failures.
- Propagate the download message field and display it in the error notice.
- Add a remove(url) store action to replace the inline \$patch delete.
- Report genuine ERROR transitions to Sentry once per failure with host
  and filename; URL query/hash are stripped so tokens are not captured.
2026-04-22 16:42:05 +09:00
15 changed files with 762 additions and 173 deletions

View File

@@ -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>

View File

@@ -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'

View File

@@ -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 => {

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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()
})
})

View File

@@ -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)
}
}

View 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'
]
})
}

View 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)
})
})
})

View 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
)
)
}
})

View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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
)
)
}
})