import { Buffer } from 'node:buffer' import { nativeImage } from 'electron' import { fetchDesktopApiURL, resolveDesktopApiURL } from '../transport/api-client' import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from '../user-agent' import { adapterFetch, mergeAbortSignals, timeoutSignal, type AdapterFetchOptions } from './common' export interface ImageAssetBlob { blob: Blob width: number height: number fileName: string } export interface RawImageAssetBlob { blob: Blob fileName: string } export interface CropRect { x: number y: number width: number height: number } export type AssetImageFormat = 'png' | 'jpg' | 'jpeg' export interface FetchImageAssetBlobOptions extends AdapterFetchOptions { preferredFormat?: AssetImageFormat passthroughTypes?: string[] headers?: HeadersInit } const defaultPassthroughImageTypes = new Set(['image/png', 'image/jpeg', 'image/jpg', 'image/gif']) function normalizeAssetImageFormat(format: AssetImageFormat | undefined): AssetImageFormat { return format === 'jpg' || format === 'jpeg' ? 'jpg' : 'png' } function buildDesktopAssetFallbackURL( publicAssetUrl: URL, preferredFormat: AssetImageFormat, ): string | null { if (!publicAssetUrl.pathname.startsWith('/api/public/assets/')) { return null } const token = publicAssetUrl.pathname.slice('/api/public/assets/'.length) if (!token) { return null } const fallbackURL = new URL(resolveDesktopApiURL(`/api/desktop/content/assets/${token}`)) publicAssetUrl.searchParams.forEach((value, key) => { fallbackURL.searchParams.set(key, value) }) fallbackURL.searchParams.set('format', preferredFormat) return fallbackURL.toString() } export function buildAssetURLCandidates( sourceUrl: string, options: { preferredFormat?: AssetImageFormat } = {}, ): string[] { const trimmed = sourceUrl.trim() if (!trimmed) { return [] } if (/^(data|blob):/i.test(trimmed)) { return [trimmed] } if (/^\/\//.test(trimmed)) { return [`https:${trimmed}`] } const candidates: string[] = [] const pushCandidate = (value: string) => { if (!candidates.includes(value)) { candidates.push(value) } } try { const preferredFormat = normalizeAssetImageFormat(options.preferredFormat) const parsed = new URL(trimmed, resolveDesktopApiURL('/')) if (parsed.pathname.startsWith('/api/')) { const apiAssetUrl = new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`)) if (apiAssetUrl.pathname.startsWith('/api/public/assets/')) { apiAssetUrl.searchParams.set('format', preferredFormat) } pushCandidate(apiAssetUrl.toString()) const desktopAssetFallbackURL = buildDesktopAssetFallbackURL(apiAssetUrl, preferredFormat) if (desktopAssetFallbackURL) { pushCandidate(desktopAssetFallbackURL) } } pushCandidate(parsed.toString()) } catch { pushCandidate(trimmed) } return candidates } function shouldUseDesktopAssetFetch(candidate: string): boolean { try { return new URL(candidate).pathname.startsWith('/api/desktop/content/assets/') } catch { return false } } function fileNameForImageType(sourceType: string): string { if (sourceType === 'image/gif') { return 'image.gif' } if (sourceType === 'image/jpeg' || sourceType === 'image/jpg') { return 'image.jpg' } if (sourceType === 'image/webp') { return 'image.webp' } return 'image.png' } function imageFetchHeaders(options: Pick): Headers { const headers = new Headers(options.headers) if (!headers.has('accept')) { headers.set('accept', 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8') } if (!headers.has('accept-language')) { headers.set('accept-language', STANDARD_ACCEPT_LANGUAGES) } if (!headers.has('user-agent')) { headers.set('user-agent', STANDARD_USER_AGENT) } return headers } export async function fetchRawImageAssetBlob( sourceUrl: string, options: FetchImageAssetBlobOptions = {}, ): Promise { for (const candidate of buildAssetURLCandidates(sourceUrl, { preferredFormat: options.preferredFormat, })) { const headers = imageFetchHeaders(options) const response = await ( shouldUseDesktopAssetFetch(candidate) ? fetchDesktopApiURL(candidate, { signal: mergeAbortSignals([options.signal, timeoutSignal(options.timeoutMs ?? 10_000)]), headers, }) : adapterFetch( candidate, { headers }, { timeoutMs: options.timeoutMs ?? 10_000, signal: options.signal }, ) ).catch(() => null) if (!response?.ok) { continue } const sourceBlob = await response.blob().catch(() => null) if (!sourceBlob || !sourceBlob.type.startsWith('image/')) { continue } return { blob: sourceBlob, fileName: fileNameForImageType(sourceBlob.type.toLowerCase()), } } return null } export async function normalizeImageAssetBlob( sourceBlob: Blob, options: Pick = {}, ): Promise { if (!sourceBlob.type.startsWith('image/')) { return null } const image = nativeImage.createFromBuffer(Buffer.from(await sourceBlob.arrayBuffer())) if (image.isEmpty()) { return null } const size = image.getSize() const passthroughImageTypes = new Set( (options.passthroughTypes ?? [...defaultPassthroughImageTypes]).map((type) => type.toLowerCase(), ), ) const sourceType = sourceBlob.type.toLowerCase() if (passthroughImageTypes.has(sourceType)) { return { blob: sourceBlob, width: size.width, height: size.height, fileName: fileNameForImageType(sourceType), } } return { blob: new Blob([new Uint8Array(image.toPNG())], { type: 'image/png' }), width: size.width, height: size.height, fileName: 'image.png', } } export async function fetchImageAssetBlob( sourceUrl: string, options: FetchImageAssetBlobOptions = {}, ): Promise { const raw = await fetchRawImageAssetBlob(sourceUrl, options) return raw ? await normalizeImageAssetBlob(raw.blob, options) : null } export function getCenteredCropRect(width: number, height: number, ratio: number): CropRect { let cropWidth = width let cropHeight = cropWidth / ratio if (cropHeight > height) { cropHeight = height cropWidth = cropHeight * ratio } return { x: (width - cropWidth) / 2, y: (height - cropHeight) / 2, width: cropWidth, height: cropHeight, } } export async function cropImageAssetBlob( source: ImageAssetBlob, ratio: number, ): Promise { const image = nativeImage.createFromBuffer(Buffer.from(await source.blob.arrayBuffer())) if (image.isEmpty()) { return null } const crop = getCenteredCropRect(source.width, source.height, ratio) const cropped = image.crop({ x: Math.max(0, Math.round(crop.x)), y: Math.max(0, Math.round(crop.y)), width: Math.max(1, Math.round(crop.width)), height: Math.max(1, Math.round(crop.height)), }) if (cropped.isEmpty()) { return null } const size = cropped.getSize() return { blob: new Blob([new Uint8Array(cropped.toPNG())], { type: 'image/png' }), width: size.width, height: size.height, fileName: 'image.png', } }