Files
ComfyUI_frontend/src/platform/missingModel/missingModelDownload.ts
jaeone94 72d1261983 [bugfix] Use Desktop2 bridge for missing model downloads (#12710)
## Summary

Fixes the Desktop2 missing-model download path so the frontend calls the
Desktop2 download bridge directly when it is available, instead of
relying on the browser `<a download>` fallback that Desktop2 currently
has to intercept indirectly.

This addresses Linear FE-956, where missing-model downloads on Windows
could open the OS Save As dialog. The issue was reproducible when the
frontend language was not English: switching the UI language back to
English made the download succeed again.

## Root Cause

Desktop2 currently has compatibility logic that watches/intercepts the
frontend missing-model download flow from outside the FE code. That
interception depends on FE-rendered DOM details, including localized
accessible labels such as the missing-model download button
`aria-label`.

In English, Desktop2 could find the expected download controls and cache
the missing-model metadata before the FE-created `<a>` download was
clicked. In non-English locales, the localized label no longer matched
Desktop2's selector, so the Desktop2 interception path missed the
download. The FE then continued down the browser download path, which
Electron surfaced as a native Save As dialog on Windows.

## Changes

- Adds a narrow Desktop2 runtime bridge check in
`missingModelDownload.ts`:
  - if `window.__comfyDesktop2.downloadModel` exists
  - and `window.__comfyDesktop2Remote` is not set
- then FE calls `window.__comfyDesktop2.downloadModel(model.url,
model.name, model.directory)` directly and returns early.
- Keeps remote Desktop2 sessions on the existing browser fallback path
by preserving the `__comfyDesktop2Remote` guard.
- Leaves the existing OSS browser fallback and legacy desktop
`isDesktop` download-store path intact.
- Logs Desktop2 bridge failures so rejected promises or synchronous
bridge throws do not become unhandled errors.
- Adds regression coverage for:
- Desktop2 bridge path taking priority over browser and legacy desktop
fallbacks.
- rejected Desktop2 bridge calls being logged without falling back to
browser download.
- synchronously thrown Desktop2 bridge failures being logged without
crashing or falling back to browser download.
  - remote Desktop2 sessions continuing to use browser fallback.

## User Impact

Desktop2 users should no longer depend on localized FE DOM text for
missing-model downloads. In particular, non-English UI locales should
route missing-model downloads through Desktop2's managed downloader
instead of opening the OS Save As dialog.

## Validation

- Manually verified the issue is fixed in Desktop2 using a locally built
FE dist served through ComfyUI with `--front-end-root`.
- Verified Korean locale no longer triggers the Save As dialog and the
missing-model download succeeds through Desktop2.
- Verified the new regression test fails when the production bridge fix
is reverted.
- Covered the FE-side contract with unit tests because a true end-to-end
assertion of the Windows native Save As dialog is not currently
practical in the FE browser-test infrastructure. The FE tests can verify
that clicking missing-model download routes into
`window.__comfyDesktop2.downloadModel`; they cannot directly prove
Electron/Windows native dialog behavior. That full native-dialog
regression belongs in Desktop2/Electron integration coverage.
- Ran:
- `pnpm exec oxfmt --check
src/platform/missingModel/missingModelDownload.ts
src/platform/missingModel/missingModelDownload.test.ts`
  - `pnpm lint:unstaged`
- `pnpm exec vitest run
src/platform/missingModel/missingModelDownload.test.ts`
  - `pnpm typecheck`
  - `pnpm build`
- Pre-commit hook passed: `oxfmt`, `oxlint`, `eslint`, `typecheck`.
- Pre-push hook passed: `knip --cache` completed with existing tag hints
only.
- Ran a 3-round local Claude review loop; final verdict was approve with
no Blocker/Major findings.

## Follow-up Work

- Define and document the FE/Desktop2 bridge contract explicitly,
including the expected semantics of `downloadModel` resolving `false`
versus rejecting.
- Add a shared or canonical TypeScript declaration for
`window.__comfyDesktop2` and `window.__comfyDesktop2Remote` if more FE
code starts depending on these globals.
- Remove Desktop2's DOM/aria/class-based missing-model download
interception after a sufficient FE compatibility window, so Desktop2 no
longer depends on FE DOM structure or localized labels.
- Add Desktop2 integration/e2e coverage for missing-model downloads in
non-English locales, ideally including Windows where the Save As dialog
was observed. This is the right layer for a true native Save As
regression test.
- Optionally add a lighter FE browser E2E that injects a fake
`window.__comfyDesktop2.downloadModel` and verifies the missing-model UI
calls that bridge. This would validate the FE contract, but it would
still not replace Desktop2/Electron coverage for native dialog behavior.
- Decide on user-facing failure UX for Desktop2 bridge download failures
once Desktop2 defines whether failures, cancellations, and
already-queued downloads are represented by rejection or by `false`.

## Notes

This intentionally does not fall back to browser download when the
Desktop2 bridge resolves `false`. Falling back there could reintroduce
the exact Save As dialog behavior this PR fixes, and the meaning of
`false` should be clarified in the Desktop2 bridge contract before FE
invents user-facing behavior for it.

A true E2E test for this bug would need to exercise Desktop2/Electron on
Windows and assert that the native Save As dialog is not opened. The
current FE browser-test infrastructure cannot observe that native
Desktop2 behavior directly, so this PR uses focused unit regression
coverage for the FE routing contract plus manual Desktop2 verification.
2026-06-09 16:42:19 +00:00

221 lines
6.1 KiB
TypeScript

import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
import { isDesktop } from '@/platform/distribution/types'
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
interface ComfyDesktop2Bridge {
downloadModel: (
url: string,
filename: string,
directory: string
) => Promise<boolean>
}
declare global {
interface Window {
__comfyDesktop2?: ComfyDesktop2Bridge
__comfyDesktop2Remote?: boolean
}
}
const ALLOWED_SOURCES = [
'https://civitai.com/',
'https://civitai.red/',
'https://huggingface.co/',
'http://localhost:'
] as const
// Intentionally restrictive subset of model extensions permitted for download.
// Does not include .bin, .onnx, .gguf — see MODEL_FILE_EXTENSIONS in
// missingModelScan.ts for the broader scanning set.
const ALLOWED_SUFFIXES = [
'.safetensors',
'.sft',
'.ckpt',
'.pth',
'.pt'
] as const
const WHITE_LISTED_URLS: ReadonlySet<string> = new Set([
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
const MODEL_LIBRARY_TAB_ID = 'model-library'
export interface ModelWithUrl {
name: string
url: string
directory: string
}
async function startDesktop2ModelDownload(
bridge: ComfyDesktop2Bridge,
model: ModelWithUrl
): Promise<void> {
try {
await bridge.downloadModel(model.url, model.name, model.directory)
} catch (error: unknown) {
console.error('Failed to start Desktop2 model download:', error)
}
}
/**
* Converts a model download URL to a browsable page URL.
* - HuggingFace: `/resolve/` → `/blob/` (file page with model info)
* - Civitai: strips `/api/download` or `/api/v1` prefix (model page)
*/
export function toBrowsableUrl(url: string): string {
if (isCivitaiModelUrl(url)) {
return url.replace('/api/download/', '/').replace('/api/v1/', '/')
}
if (url.includes('huggingface.co')) {
return url.replace('/resolve/', '/blob/')
}
return url
}
export function isModelDownloadable(model: ModelWithUrl): boolean {
if (WHITE_LISTED_URLS.has(model.url)) return true
if (!ALLOWED_SOURCES.some((source) => model.url.startsWith(source)))
return false
if (!ALLOWED_SUFFIXES.some((suffix) => model.name.endsWith(suffix)))
return false
return true
}
export function downloadModel(
model: ModelWithUrl,
paths: Record<string, string[]>
): void {
const desktop2Bridge = window.__comfyDesktop2
if (desktop2Bridge?.downloadModel && !window.__comfyDesktop2Remote) {
void startDesktop2ModelDownload(desktop2Bridge, model)
return
}
if (!isDesktop) {
const link = document.createElement('a')
link.href = model.url
link.download = model.name
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.click()
return
}
const modelPaths = paths[model.directory]
if (modelPaths?.[0]) {
useSidebarTabStore().activeSidebarTabId = MODEL_LIBRARY_TAB_ID
void useElectronDownloadStore().start({
url: model.url,
savePath: modelPaths[0],
filename: model.name
})
}
}
interface ModelMetadata {
fileSize: number | null
gatedRepoUrl: string | null
}
interface CivitaiModelFile {
sizeKB: number
downloadUrl: string
}
interface CivitaiModelVersionResponse {
files: CivitaiModelFile[]
}
const metadataCache = new Map<string, ModelMetadata>()
const inflight = new Map<string, Promise<ModelMetadata>>()
async function fetchCivitaiMetadata(url: string): Promise<ModelMetadata> {
try {
const pathname = new URL(url).pathname
const versionIdMatch =
pathname.match(/^\/api\/download\/models\/(\d+)$/) ??
pathname.match(/^\/api\/v1\/models-versions\/(\d+)$/)
if (!versionIdMatch) return { fileSize: null, gatedRepoUrl: null }
const [, modelVersionId] = versionIdMatch
const apiUrl = `https://civitai.com/api/v1/model-versions/${modelVersionId}`
const res = await fetch(apiUrl)
if (!res.ok) return { fileSize: null, gatedRepoUrl: null }
const data: CivitaiModelVersionResponse = await res.json()
const matchingFile = data.files?.find((file) => {
const downloadUrl = file.downloadUrl
return (
typeof downloadUrl === 'string' &&
downloadUrl.length > 0 &&
downloadUrl.startsWith(url)
)
})
const fileSize = matchingFile?.sizeKB ? matchingFile.sizeKB * 1024 : null
return { fileSize, gatedRepoUrl: null }
} catch {
return { fileSize: null, gatedRepoUrl: null }
}
}
const GATED_STATUS_CODES = new Set([401, 403, 451])
async function fetchHeadMetadata(url: string): Promise<ModelMetadata> {
try {
const response = await fetch(url, { method: 'HEAD' })
if (!response.ok) {
if (
url.includes('huggingface.co') &&
GATED_STATUS_CODES.has(response.status)
) {
return { fileSize: null, gatedRepoUrl: downloadUrlToHfRepoUrl(url) }
}
return { fileSize: null, gatedRepoUrl: null }
}
const size = response.headers.get('content-length')
const parsedSize = size ? parseInt(size, 10) : null
return {
fileSize:
parsedSize !== null && !Number.isNaN(parsedSize) ? parsedSize : null,
gatedRepoUrl: null
}
} catch {
return { fileSize: null, gatedRepoUrl: null }
}
}
function isComplete(metadata: ModelMetadata): boolean {
return metadata.fileSize !== null || metadata.gatedRepoUrl !== null
}
export async function fetchModelMetadata(url: string): Promise<ModelMetadata> {
const cached = metadataCache.get(url)
if (cached !== undefined) return cached
const existing = inflight.get(url)
if (existing) return existing
const promise = (async () => {
const metadata = isCivitaiModelUrl(url)
? await fetchCivitaiMetadata(url)
: await fetchHeadMetadata(url)
if (isComplete(metadata)) {
metadataCache.set(url, metadata)
}
return metadata
})()
inflight.set(url, promise)
try {
return await promise
} finally {
inflight.delete(url)
}
}