mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-14 19:27:09 +00:00
## 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.
221 lines
6.1 KiB
TypeScript
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)
|
|
}
|
|
}
|