mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
## Summary - Add `import-x/no-restricted-paths` ESLint rule enforcing the `base → platform → workbench → renderer` layer hierarchy - Set to `error` with eslint-disable comments on existing violations (~11 suppressions) - Consolidate zone definitions using array syntax for `from`/`target` - Add `layer-audit` Claude skill for auditing violations - Fix knip false positive for `zod` dependency in ingest-types ## Context Ref: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10021#discussion_r2939392141 The codebase is migrating toward a layered architecture. This adds static enforcement so new violations are caught in PR CI. ### Layer rules | Layer | Can import from | |---|---| | `base/` | nothing | | `platform/` | `base/` | | `workbench/` | `platform/`, `base/` | | `renderer/` | `workbench/`, `platform/`, `base/` | ### Current violations (pre-existing, suppressed with eslint-disable) | Direction | Count | |---|---| | base → platform | 2 | | platform → workbench | 3 | | platform → renderer | 5 | | workbench → renderer | 1 | ## Test plan - [x] `pnpm lint` passes (0 errors, 0 warnings) - [x] `pnpm typecheck` passes - [x] `pnpm knip` passes - [ ] CI green --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
186 lines
5.5 KiB
TypeScript
186 lines
5.5 KiB
TypeScript
/**
|
|
* Utility functions for downloading files
|
|
*/
|
|
import { t } from '@/i18n'
|
|
// eslint-disable-next-line import-x/no-restricted-paths
|
|
import { isCloud } from '@/platform/distribution/types'
|
|
// eslint-disable-next-line import-x/no-restricted-paths
|
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
|
|
|
// Constants
|
|
const DEFAULT_DOWNLOAD_FILENAME = 'download.png'
|
|
|
|
/**
|
|
* Trigger a download by creating a temporary anchor element
|
|
* @param href - The URL or blob URL to download
|
|
* @param filename - The filename to suggest to the browser
|
|
*/
|
|
function triggerLinkDownload(href: string, filename: string): void {
|
|
const link = document.createElement('a')
|
|
link.href = href
|
|
link.download = filename
|
|
link.style.display = 'none'
|
|
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
document.body.removeChild(link)
|
|
}
|
|
|
|
/**
|
|
* Download a file from a URL by creating a temporary anchor element
|
|
* @param url - The URL of the file to download (must be a valid URL string)
|
|
* @param filename - Optional filename override (will use URL filename or default if not provided)
|
|
* @throws {Error} If the URL is invalid or empty
|
|
*/
|
|
export function downloadFile(url: string, filename?: string): void {
|
|
if (!url || typeof url !== 'string' || url.trim().length === 0) {
|
|
throw new Error('Invalid URL provided for download')
|
|
}
|
|
|
|
const inferredFilename =
|
|
filename || extractFilenameFromUrl(url) || DEFAULT_DOWNLOAD_FILENAME
|
|
|
|
if (isCloud) {
|
|
// Assets from cross-origin (e.g., GCS) cannot be downloaded this way
|
|
void downloadViaBlobFetch(url, inferredFilename).catch((error) => {
|
|
console.error('Failed to download file', error)
|
|
})
|
|
return
|
|
}
|
|
|
|
triggerLinkDownload(url, inferredFilename)
|
|
}
|
|
|
|
/**
|
|
* Download a Blob by creating a temporary object URL and anchor element
|
|
* @param filename - The filename to suggest to the browser
|
|
* @param blob - The Blob to download
|
|
*/
|
|
export function downloadBlob(filename: string, blob: Blob): void {
|
|
const url = URL.createObjectURL(blob)
|
|
|
|
triggerLinkDownload(url, filename)
|
|
|
|
// Revoke on the next microtask to give the browser time to start the download
|
|
queueMicrotask(() => URL.revokeObjectURL(url))
|
|
}
|
|
|
|
/**
|
|
* Extract filename from a URL's query parameters
|
|
* @param url - The URL to extract filename from
|
|
* @returns The extracted filename or null if not found
|
|
*/
|
|
const extractFilenameFromUrl = (url: string): string | null => {
|
|
try {
|
|
const urlObj = new URL(url, window.location.origin)
|
|
return urlObj.searchParams.get('filename')
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract filename from Content-Disposition header
|
|
* Handles both simple format: attachment; filename="name.png"
|
|
* And RFC 5987 format: attachment; filename="fallback.png"; filename*=UTF-8''encoded%20name.png
|
|
* @param header - The Content-Disposition header value
|
|
* @returns The extracted filename or null if not found
|
|
*/
|
|
export function extractFilenameFromContentDisposition(
|
|
header: string | null
|
|
): string | null {
|
|
if (!header) return null
|
|
|
|
// Try RFC 5987 extended format first (filename*=UTF-8''...)
|
|
const extendedMatch = header.match(/filename\*=UTF-8''([^;]+)/i)
|
|
if (extendedMatch?.[1]) {
|
|
try {
|
|
return decodeURIComponent(extendedMatch[1])
|
|
} catch {
|
|
// Fall through to simple format
|
|
}
|
|
}
|
|
|
|
// Try simple quoted format: filename="..."
|
|
const quotedMatch = header.match(/filename="([^"]+)"/i)
|
|
if (quotedMatch?.[1]) {
|
|
return quotedMatch[1]
|
|
}
|
|
|
|
// Try unquoted format: filename=...
|
|
const unquotedMatch = header.match(/filename=([^;\s]+)/i)
|
|
if (unquotedMatch?.[1]) {
|
|
return unquotedMatch[1]
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Fetch a URL and return its body as a Blob.
|
|
* Shared by download and open-in-new-tab cloud paths.
|
|
*/
|
|
async function fetchAsBlob(url: string): Promise<Response> {
|
|
const response = await fetch(url)
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch ${url}: ${response.status}`)
|
|
}
|
|
return response
|
|
}
|
|
|
|
async function downloadViaBlobFetch(
|
|
href: string,
|
|
fallbackFilename: string
|
|
): Promise<void> {
|
|
const response = await fetchAsBlob(href)
|
|
|
|
// Try to get filename from Content-Disposition header (set by backend)
|
|
const contentDisposition = response.headers.get('Content-Disposition')
|
|
const headerFilename =
|
|
extractFilenameFromContentDisposition(contentDisposition)
|
|
|
|
const blob = await response.blob()
|
|
downloadBlob(headerFilename ?? fallbackFilename, blob)
|
|
}
|
|
|
|
/**
|
|
* Open a file URL in a new browser tab.
|
|
* On cloud, fetches the resource as a blob first to avoid GCS redirects
|
|
* that would trigger an auto-download instead of displaying the file.
|
|
*
|
|
* Opens the tab synchronously to preserve the user-gesture context
|
|
* (browsers block window.open after an await), then navigates it to
|
|
* the blob URL once the fetch completes.
|
|
*/
|
|
export async function openFileInNewTab(url: string): Promise<void> {
|
|
if (!isCloud) {
|
|
window.open(url, '_blank')
|
|
return
|
|
}
|
|
|
|
// Open immediately to preserve user-gesture activation.
|
|
const tab = window.open('', '_blank')
|
|
|
|
try {
|
|
const response = await fetchAsBlob(url)
|
|
const blob = await response.blob()
|
|
const blobUrl = URL.createObjectURL(blob)
|
|
|
|
if (tab && !tab.closed) {
|
|
tab.location.href = blobUrl
|
|
// Revoke after the tab has had time to load the blob.
|
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
|
|
} else {
|
|
URL.revokeObjectURL(blobUrl)
|
|
}
|
|
} catch (error) {
|
|
tab?.close()
|
|
console.error('Failed to open image:', error)
|
|
useToastStore().addAlert(
|
|
t('toastMessages.errorOpenImage', {
|
|
error: error instanceof Error ? error.message : String(error)
|
|
})
|
|
)
|
|
}
|
|
}
|