From 29017940ed75d2172427d688dbbb3bf86ee3b48e Mon Sep 17 00:00:00 2001 From: Jonathan LoBue Date: Tue, 21 Jul 2026 22:18:09 -0700 Subject: [PATCH 01/10] Fix NFT video/audio playback in the GUI Video and audio NFTs frequently render as a blank tile while images work. The media pipeline had several defects that disproportionately hit video: - The cache: scheme was never registered with registerSchemesAsPrivileged, so media elements expected a buffered response and stalled on the streamed body served by protocol.handle. Register it with stream: true (plus standard/secure/supportFetchAPI) before app ready. - The cache protocol handler ignored Range requests, returning 200 with the full file. Seeking was broken for all media and MP4 files without faststart (moov atom at the end) could fail to start playing at all. Serve 206 partial content from the cached file with accept-ranges. - The 30s download timeout was a total-transfer budget, so any video that could not be fully downloaded in 30s never displayed. Make it an inactivity timeout that resets on each received chunk. - Aborts caused by the 100MB size cap surfaced as generic "Request aborted"/"Response aborted" errors, which are treated as transient, so oversized files were re-downloaded on every gallery visit. Report a distinct "Maximum file size exceeded" error, persist it, and only retry when the caller lifts the size limit. - maxSize <= 0 now disables the size limit instead of aborting every download on the first chunk, fixing the ignoreSizeLimit path (useNFTVerifyHash passes maxSize: -1) and NFTPreview now forwards the same override to getURI so verification and display stay consistent. - Drive-by: download progress used Math.max instead of Math.min and was pinned at 100%. Co-Authored-By: Claude Fable 5 --- .../gui/src/components/nfts/NFTPreview.tsx | 14 ++- packages/gui/src/electron/CacheManager.ts | 111 +++++++++++++++--- packages/gui/src/electron/main.tsx | 19 ++- .../gui/src/electron/utils/downloadFile.ts | 34 ++++-- 4 files changed, 153 insertions(+), 25 deletions(-) diff --git a/packages/gui/src/components/nfts/NFTPreview.tsx b/packages/gui/src/components/nfts/NFTPreview.tsx index ccb8b805c2..4d790d1391 100644 --- a/packages/gui/src/components/nfts/NFTPreview.tsx +++ b/packages/gui/src/components/nfts/NFTPreview.tsx @@ -210,7 +210,7 @@ export default function NFTPreview(props: NFTPreviewProps) { } `; - const cachedURI = await getURI(preview.uri); + const cachedURI = await getURI(preview.uri, { maxSize: ignoreSizeLimit ? -1 : undefined }); if (!cachedURI || !cachedURI.startsWith('cache://')) { setPreviewContent(undefined, signal); return; @@ -237,7 +237,17 @@ export default function NFTPreview(props: NFTPreviewProps) { setError(e as Error, signal); } }, - [preview, fit, getURI, previewFileType, disableInteractions, isDarkMode, setPreviewContent, setError], + [ + preview, + fit, + getURI, + ignoreSizeLimit, + previewFileType, + disableInteractions, + isDarkMode, + setPreviewContent, + setError, + ], ); useEffect(() => { diff --git a/packages/gui/src/electron/CacheManager.ts b/packages/gui/src/electron/CacheManager.ts index 8b6e1315f1..4db24639b8 100644 --- a/packages/gui/src/electron/CacheManager.ts +++ b/packages/gui/src/electron/CacheManager.ts @@ -1,8 +1,10 @@ -import { BrowserWindow, net, dialog, type Protocol } from 'electron'; +import { BrowserWindow, dialog, type Protocol } from 'electron'; import { EventEmitter } from 'events'; import crypto from 'node:crypto'; +import { createReadStream } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { Readable } from 'node:stream'; import debug from 'debug'; import isURL from 'validator/lib/isURL'; @@ -14,7 +16,7 @@ import CacheState from '../constants/CacheState'; import limit from '../util/limit'; import CacheAPI from './constants/CacheAPI'; -import downloadFile from './utils/downloadFile'; +import downloadFile, { MAX_FILE_SIZE_EXCEEDED_ERROR } from './utils/downloadFile'; import ensureDirectoryExists from './utils/ensureDirectoryExists'; import getChecksum from './utils/getChecksum'; import ipcMainHandle from './utils/ipcMainHandle'; @@ -24,7 +26,51 @@ import sanitizeNumber from './utils/sanitizeNumber'; const log = debug('chia-gui:CacheManager'); -const CACHE_PROTOCOL = 'cache'; +export const CACHE_PROTOCOL = 'cache'; + +// A single-range `bytes=start-end` Range header, parsed against the file size. +// 'ignore' means the header is absent or uses a form we do not support +// (e.g. multiple ranges), in which case the full file is served with a 200. +type ParsedRange = { start: number; end: number } | 'invalid' | 'ignore'; + +function parseRangeHeader(rangeHeader: string | null, fileSize: number): ParsedRange { + if (!rangeHeader) { + return 'ignore'; + } + + const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim()); + if (!match) { + return 'ignore'; + } + + const [, startString, endString] = match; + + if (startString === '' && endString === '') { + return 'invalid'; + } + + if (startString === '') { + // suffix range: the last N bytes of the file + const suffixLength = Number.parseInt(endString, 10); + if (suffixLength === 0 || fileSize === 0) { + return 'invalid'; + } + + return { start: Math.max(fileSize - suffixLength, 0), end: fileSize - 1 }; + } + + const start = Number.parseInt(startString, 10); + if (start >= fileSize) { + return 'invalid'; + } + + const end = endString === '' ? fileSize - 1 : Math.min(Number.parseInt(endString, 10), fileSize - 1); + if (start > end) { + return 'invalid'; + } + + return { start, end }; +} async function safeUnlink(filePath: string) { try { @@ -112,8 +158,11 @@ export default class CacheManager extends EventEmitter { }); } - const response = await net.fetch(`file://${filePath}`); - if (!response.ok) { + let fileSize: number; + try { + const stats = await fs.stat(filePath); + fileSize = stats.size; + } catch (error) { return new Response('Not found', { status: 404, headers: { @@ -122,18 +171,45 @@ export default class CacheManager extends EventEmitter { }); } - const { headers } = cacheInfo; - const updatedHeaders = new Headers(response.headers); + const contentTypeHeader = cacheInfo.headers?.['content-type']; + const contentType = + (Array.isArray(contentTypeHeader) ? contentTypeHeader[0] : contentTypeHeader) || 'application/octet-stream'; - if (headers['content-type']) { - const contentType = Array.isArray(headers['content-type']) - ? headers['content-type'][0] - : headers['content-type']; - updatedHeaders.set('content-type', contentType); + const responseHeaders: Record = { + 'content-type': contentType, + 'accept-ranges': 'bytes', + }; + + // Media elements seek by sending Range requests. Without 206 responses + // seeking is broken and MP4 files with the moov atom at the end of the + // file never start playing. + const range = parseRangeHeader(request.headers.get('range'), fileSize); + + if (range === 'invalid') { + return new Response('Range Not Satisfiable', { + status: 416, + headers: { + 'content-type': 'text/plain', + 'content-range': `bytes */${fileSize}`, + }, + }); + } + + if (range !== 'ignore') { + responseHeaders['content-length'] = String(range.end - range.start + 1); + responseHeaders['content-range'] = `bytes ${range.start}-${range.end}/${fileSize}`; + + const partialStream = createReadStream(filePath, { start: range.start, end: range.end }); + return new Response(Readable.toWeb(partialStream) as unknown as ReadableStream, { + status: 206, + headers: responseHeaders, + }); } - return new Response(response.body, { - headers: updatedHeaders, + responseHeaders['content-length'] = String(fileSize); + + return new Response(Readable.toWeb(createReadStream(filePath)) as unknown as ReadableStream, { + headers: responseHeaders, }); }); } @@ -332,7 +408,12 @@ export default class CacheManager extends EventEmitter { if (cacheInfo.state === CacheState.ERROR) { log(`Url already downloaded with error: ${cacheInfo.error}`, url); - if (!['Response aborted', 'Request aborted'].includes(cacheInfo.error)) { + + const isTransientError = ['Response aborted', 'Request aborted'].includes(cacheInfo.error); + // A persisted size-limit error is only retried when the caller lifts + // the limit, so oversized files are not re-downloaded on every visit. + const isSizeLimitLifted = cacheInfo.error === MAX_FILE_SIZE_EXCEEDED_ERROR && maxSize <= 0; + if (!isTransientError && !isSizeLimitLifted) { return cacheInfo; } diff --git a/packages/gui/src/electron/main.tsx b/packages/gui/src/electron/main.tsx index c028d74567..fcf045b7d7 100644 --- a/packages/gui/src/electron/main.tsx +++ b/packages/gui/src/electron/main.tsx @@ -10,6 +10,7 @@ import { Notification, type MenuItemConstructorOptions, nativeTheme, + protocol, } from 'electron'; import fs from 'node:fs'; import path from 'node:path'; @@ -29,7 +30,7 @@ import { WcError, WcErrorCode, encodeWcErrorForIpc } from '../@types/WcError'; import AppIcon from '../assets/img/chia64x64.png'; import { i18n } from '../config/locales'; -import CacheManager from './CacheManager'; +import CacheManager, { CACHE_PROTOCOL } from './CacheManager'; import { checkNFTOwnership } from './api/checkNFTOwnership'; import { getKeyDetails } from './api/getKeyDetails'; import { getNetworkInfo } from './api/getNetworkInfo'; @@ -94,6 +95,22 @@ type ConfirmDialogResult = { app.disableHardwareAcceleration(); app.commandLine.appendSwitch('disable-http-cache'); +// The cache: scheme serves NFT media to /