mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 01:20:03 +00:00
## Summary
Fix "Open Image" on cloud opening a new tab that auto-downloads the
asset instead of displaying it inline.
## Changes
- **What**: Add `openFileInNewTab()` to `downloadUtil.ts` that fetches
cross-origin URLs as blobs before opening in a new tab, avoiding GCS
`Content-Disposition: attachment` redirects. Opens the blank tab
synchronously to preserve user-gesture activation (avoiding popup
blockers), then navigates to a blob URL once the fetch completes. Blob
URLs are revoked after 60s or immediately if the tab was closed. Update
both call sites (`useImageMenuOptions` and `litegraphService`) to use
the new function.
## Review Focus
- The synchronous `window.open('', '_blank')` before the async fetch is
intentional to preserve user-gesture context and avoid popup blockers.
- Blob URL revocation strategy: 60s timeout for successful opens,
immediate revoke if tab was closed, tab closed on fetch failure.
- Shared `fetchAsBlob()` helper is also used by the existing
`downloadViaBlobFetch`.
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9122-fix-open-image-in-new-tab-on-cloud-fetches-as-blob-to-avoid-GCS-auto-download-3106d73d365081a3bfa6eb7d77fde99f)
by [Unito](https://www.unito.io)
184 lines
5.4 KiB
TypeScript
184 lines
5.4 KiB
TypeScript
/**
|
|
* Utility functions for downloading files
|
|
*/
|
|
import { t } from '@/i18n'
|
|
import { isCloud } from '@/platform/distribution/types'
|
|
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)
|
|
})
|
|
)
|
|
}
|
|
}
|