Files
ComfyUI_frontend/src/base/common/downloadUtil.ts
Christian Byrne a94574d379 fix: open image in new tab on cloud fetches as blob to avoid GCS auto-download (#9122)
## 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)
2026-02-23 21:28:16 -08:00

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