From 045f8c8266fb75589712f658f737f87674eb6f95 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:31:22 -0700 Subject: [PATCH] feat(files): show images and PDFs from the byte-range preview path, local and remote (#35165) * feat(files): show images and PDFs from the byte-range preview path, local and remote ImageViewer and PdfViewer read an orca-media:// URL instead of base64 when the editor opens a file: images load by URL, and pdf.js reads PDFs by range, so a large PDF no longer arrives whole through files.readPreview or pages into memory first. The grant mints only for the editor's viewers; diffs, git blobs, markdown images and the paired web client keep base64. - Minting an image checks its header with the base64 path's rule, so an image proven too large to decode safely never reaches the decoder. - orca-media gains fetch and CORS privileges, and range responses expose Accept-Ranges and Content-Range, so pdf.js can read by range. - Grants report the file size the viewer used to estimate from base64. * fix(files): read PDFs by real range, check served images, keep old hosts' previews - pdf.js fetches ranges only over http(s), so an orca-media URL was read whole; a small range transport now hands it bytes=a-b reads from the grant, and a failed range fails the preview instead of hanging it. - A file can change after mint, so the raster header is checked again on the bytes each image request serves (the same handle locally). - A paired host without files.readChunk falls back to files.readPreview for images and PDFs, as before; only audio and video need the update. * fix(files): mint no local document grant for a renderer gone mid-mint The local image/PDF mint is async now (authorize, open, header check), so a renderer that closed meanwhile could leave a grant no teardown drops. It binds the renderer lifetime first and refuses to mint once it's gone, as the remote mint does. * fix(files): keep the 50 MB cap for URL images, and probe remote headers incrementally An decodes the whole file, so image grants keep the base64 path's 50 MB cap; PDFs, now read by range, don't need one. A remote header probe reads only each wider step's new bytes instead of re-reading from the start. A replaced image of a different size resets its zoom. * fix(files): tear down a PDF load whose range failed, and read 256 KiB ranges A failed range only showed the error and left the pdf.js task, its worker and pages alive; it now destroys that load first. pdf.js asks for one readChunk-sized range at a time instead of 64 KiB, and a Range preflight gets its CORS answer instead of 405. * fix(files): read PDF ranges over IPC so orca-media needs no fetch or CORS orca-media had gained supportFetchAPI and corsEnabled, and its responses sent Access-Control-Allow-Origin: *, so pdf.js could fetch ranges. That let any default-session frame holding a token read the bytes. pdf.js now reads a PDF grant's ranges through fs:readPreviewRange, which main answers only for the renderer that minted the token. The scheme is back to element-load privileges (standard, secure, stream), responses carry no CORS headers, and preflights get 405. Grant bookkeeping moves to preview-grant-registry.ts. An e2e spec proves an orca-media URL can't be fetched from the app page or read from another session. * ci(e2e): route the byte-range preview sources to the viewer specs that exercise them Changes to the orca-media grant path, the image/PDF viewers and the PDF range transport now run the PDF reload, rebuild and find specs, the remote large-file open, media playback and the partition-isolation spec. * fix(files): stop PDF range reads on abort, abort remote reads with the window, fix the CI route - A closed or reloaded viewer schedules no further pieces of a merged range, and a remote range read stops when its window goes away. - A URL without a size is an error rather than a fetch the scheme refuses. - The PR guard job's sparse checkout and its admission test include the new route module, and the route now also covers the mint handler, preload bridge, editor surfaces, scheme registration and read client. * fix(files): classify linked previews by their target, keep range reads off stopped WSL distros A local image or PDF reached through a link is classified by the file it resolves to, at mint and on every read, and a re-mint after the link is retargeted gets a fresh grant of the new type. A paired host's document whose first bytes are another kind defers to files.readPreview, which classifies the target there. Lazy local reads go through the non-waking WSL guard, as fs:readFile and fs:readFileChunk do. --- .github/workflows/pr.yml | 1 + .../pr-auxiliary-preflight-admission.test.mjs | 1 + config/scripts/pr-e2e-source-routing.mjs | 2 + .../pr-editor-preview-e2e-source-routes.mjs | 35 +++ .../filesystem-file-content-inspection.ts | 2 + .../filesystem/filesystem-read-handlers.ts | 15 +- src/main/media/local-preview-grant-file.ts | 27 +++ src/main/media/media-preview-protocol.test.ts | 181 +++++++++++++- src/main/media/media-preview-protocol.ts | 224 +++++++++--------- src/main/media/preview-document-signature.ts | 18 ++ .../media/preview-grant-range-read.test.ts | 78 ++++++ src/main/media/preview-grant-range-read.ts | 69 ++++++ src/main/media/preview-grant-registry.ts | 149 ++++++++++++ .../media/preview-raster-header-check.test.ts | 22 ++ src/main/media/preview-raster-header-check.ts | 79 ++++++ .../media/remote-file-preview-reader.test.ts | 17 ++ src/main/media/remote-file-preview-reader.ts | 28 +++ src/preload/api/filesystem-api.ts | 16 +- src/preload/api/fs-bridge.ts | 14 +- .../editor/EditorConflictReviewSurface.tsx | 2 + .../editor/EditorEditFileSurface.tsx | 4 +- .../components/editor/ImageViewer.test.tsx | 39 +++ .../src/components/editor/ImageViewer.tsx | 28 ++- .../src/components/editor/PdfViewer.tsx | 11 +- .../editor/editor-panel-content-types.ts | 1 + .../editor/pdf-document-loader.test.ts | 73 ++++++ .../components/editor/pdf-document-loader.ts | 73 ++++-- .../editor/pdf-js-document-options.ts | 12 +- .../pdf-orca-media-range-transport.test.ts | 67 ++++++ .../editor/pdf-orca-media-range-transport.ts | 64 +++++ .../pdf-viewer-failed-load-recovery.test.tsx | 6 +- .../src/runtime/runtime-file-client-types.ts | 2 + .../src/runtime/runtime-file-client.test.ts | 42 ++++ .../src/runtime/runtime-file-range-preview.ts | 4 +- .../src/runtime/runtime-file-read-client.ts | 4 +- .../src/web/preload-api/web-filesystem-api.ts | 4 + src/shared/file-preview-kind.ts | 7 +- src/shared/raster-image-base64-preview.ts | 4 +- src/shared/raster-image-prefix-limits.ts | 38 +++ .../media-preview-partition-isolation.spec.ts | 62 +++++ 40 files changed, 1360 insertions(+), 165 deletions(-) create mode 100644 config/scripts/pr-editor-preview-e2e-source-routes.mjs create mode 100644 src/main/media/local-preview-grant-file.ts create mode 100644 src/main/media/preview-document-signature.ts create mode 100644 src/main/media/preview-grant-range-read.test.ts create mode 100644 src/main/media/preview-grant-range-read.ts create mode 100644 src/main/media/preview-grant-registry.ts create mode 100644 src/main/media/preview-raster-header-check.test.ts create mode 100644 src/main/media/preview-raster-header-check.ts create mode 100644 src/renderer/src/components/editor/pdf-document-loader.test.ts create mode 100644 src/renderer/src/components/editor/pdf-orca-media-range-transport.test.ts create mode 100644 src/renderer/src/components/editor/pdf-orca-media-range-transport.ts create mode 100644 src/shared/raster-image-prefix-limits.ts create mode 100644 tests/e2e/media-preview-partition-isolation.spec.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index cf330430661..19b5f42eabb 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -81,6 +81,7 @@ jobs: /config/scripts/pr-layout-oracle-route.mjs /config/scripts/pr-e2e-source-routing.mjs /config/scripts/pr-browser-e2e-source-routes.mjs + /config/scripts/pr-editor-preview-e2e-source-routes.mjs /config/scripts/ci-e2e-job-selection.mjs /config/scripts/pr-ready-check-reuse.mjs sparse-checkout-cone-mode: false diff --git a/config/scripts/pr-auxiliary-preflight-admission.test.mjs b/config/scripts/pr-auxiliary-preflight-admission.test.mjs index f516e8b4bd9..872e282a710 100644 --- a/config/scripts/pr-auxiliary-preflight-admission.test.mjs +++ b/config/scripts/pr-auxiliary-preflight-admission.test.mjs @@ -388,6 +388,7 @@ function createMobileCiScopeRepository( 'config/scripts/git-pull-request-diff-base.mjs', 'config/scripts/pr-e2e-source-routing.mjs', 'config/scripts/pr-browser-e2e-source-routes.mjs', + 'config/scripts/pr-editor-preview-e2e-source-routes.mjs', 'config/scripts/ci-e2e-job-selection.mjs' ]) { mkdirSync(dirname(join(directory, file)), { recursive: true }) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index 6c3c1f50f4e..d1977d35202 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -8,6 +8,7 @@ import { import { MANAGED_SSH_E2E_SPEC_PREFIX } from './ci-e2e-job-selection.mjs' import { LAYOUT_ORACLE_E2E_SPECS } from './pr-layout-oracle-route.mjs' import { PR_BROWSER_E2E_SOURCE_ROUTES } from './pr-browser-e2e-source-routes.mjs' +import { PR_EDITOR_PREVIEW_E2E_SOURCE_ROUTES } from './pr-editor-preview-e2e-source-routes.mjs' const isProductSource = (file) => !/\.test\.tsx?$/.test(file) && !isUnitTestSupportSource(file) @@ -35,6 +36,7 @@ const ENABLED_AUTOMATION_MOVE = export const PR_E2E_SOURCE_ROUTES = [ ...PR_BROWSER_E2E_SOURCE_ROUTES, + ...PR_EDITOR_PREVIEW_E2E_SOURCE_ROUTES, { id: 'ssh.orcad-editor-ownership', specs: ['tests/e2e/ssh-orcad-editor-ownership.spec.ts'], diff --git a/config/scripts/pr-editor-preview-e2e-source-routes.mjs b/config/scripts/pr-editor-preview-e2e-source-routes.mjs new file mode 100644 index 00000000000..902a528b92f --- /dev/null +++ b/config/scripts/pr-editor-preview-e2e-source-routes.mjs @@ -0,0 +1,35 @@ +import { isUnitTestSupportSource } from './pr-code-change-scope.mjs' + +const isProductSource = (file) => !/\.test\.tsx?$/.test(file) && !isUnitTestSupportSource(file) + +export const PR_EDITOR_PREVIEW_E2E_SOURCE_ROUTES = [ + { + // Image, PDF and media viewers read orca-media grants; the PDF reloads exercise remint and range reads. + id: 'editor.byte-range-preview', + specs: [ + 'tests/e2e/editor-media-playback-position.spec.ts', + 'tests/e2e/media-preview-partition-isolation.spec.ts', + 'tests/e2e/paired-remote-large-file-open.spec.ts', + 'tests/e2e/pdf-find-counter.spec.ts', + 'tests/e2e/pdf-viewer-rebuild-recovery.spec.ts', + 'tests/e2e/pdf-viewer-reload-cleanup.spec.ts' + ], + matches: (file) => + isProductSource(file) && + (/^src\/main\/media\/[^/]+\.ts$/.test(file) || + /^src\/renderer\/src\/components\/editor\/(?:ImageViewer|PdfViewer|pdf-document-loader|pdf-js-document-options|pdf-orca-media-range-transport)\.tsx?$/.test( + file + ) || + /^src\/renderer\/src\/components\/editor\/(?:EditorEditFileSurface|EditorConflictReviewSurface)\.tsx$/.test( + file + ) || + [ + 'src/main/ipc/filesystem/filesystem-read-handlers.ts', + 'src/main/startup/privileged-custom-schemes.ts', + 'src/preload/api/fs-bridge.ts', + 'src/renderer/src/runtime/runtime-file-range-preview.ts', + 'src/renderer/src/runtime/runtime-file-read-client.ts', + 'src/shared/file-preview-kind.ts' + ].includes(file)) + } +] diff --git a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts index 94460a84fff..2014a4ee99e 100644 --- a/src/main/ipc/filesystem/filesystem-file-content-inspection.ts +++ b/src/main/ipc/filesystem/filesystem-file-content-inspection.ts @@ -17,6 +17,8 @@ export const MAX_PREVIEWABLE_BINARY_SIZE = 50 * 1024 * 1024 export type LocalFileContent = { mediaUrl?: string + /** Bytes behind `mediaUrl`, for viewers that no longer see the content. */ + size?: number content: string isBinary: boolean isImage?: boolean diff --git a/src/main/ipc/filesystem/filesystem-read-handlers.ts b/src/main/ipc/filesystem/filesystem-read-handlers.ts index 56aae691657..ee039362520 100644 --- a/src/main/ipc/filesystem/filesystem-read-handlers.ts +++ b/src/main/ipc/filesystem/filesystem-read-handlers.ts @@ -23,7 +23,8 @@ import { recordCrashBreadcrumb } from '../../crash-reporting/crash-breadcrumb-st import { buildReadDirErrorBreadcrumb, type ReadDirThrowSite } from '../readdir-error-diagnostics' import type { FilesystemHandlerContext } from './filesystem-handler-context' import { registerFilesystemChunkReadHandler } from './filesystem-chunk-read-handler' -import { grantRemoteFilePreview, readMediaPreview } from '../../media/media-preview-protocol' +import { grantRemoteFilePreview, readLocalFilePreview } from '../../media/media-preview-protocol' +import { readPreviewGrantRange } from '../../media/preview-grant-range-read' import { windowsFsPath } from '../../wsl/wsl-unc-drive-fs-path' import { assertWslUncPathRunning, guardWslUncPathAccess } from '../../wsl/wsl-unc-access-gate' import { parseRemoteFilePreviewAddress } from '../../../shared/remote-file-preview-address' @@ -98,6 +99,8 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext access?: LocalFileAccess /** A user act (open, expand, Retry); may start a stopped WSL distro. */ wakeWslDistro?: boolean + /** A viewer that reads images and PDFs by URL; other readers keep their base64. */ + rangePreview?: boolean } ): Promise => { if (args.connectionId) { @@ -107,7 +110,9 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext args.filePath, async () => { const filePath = await resolveLocalFileRequestPath(args.filePath, args.access, store) - const media = readMediaPreview(event, args, store) + const media = await readLocalFilePreview(event, args, store, { + documents: args.rangePreview === true + }) if (media) { return media } @@ -120,6 +125,12 @@ export function registerFilesystemReadHandlers(context: FilesystemHandlerContext } ) + ipcMain.handle( + 'fs:readPreviewRange', + (event, args: { url: string; offset: number; length: number }) => + readPreviewGrantRange(event.sender, String(args?.url), args?.offset, args?.length) + ) + ipcMain.handle('fs:grantRuntimeFilePreview', (event, args: unknown) => { const remote = parseRemoteFilePreviewAddress(args) if (!remote) { diff --git a/src/main/media/local-preview-grant-file.ts b/src/main/media/local-preview-grant-file.ts new file mode 100644 index 00000000000..45a2f3b6544 --- /dev/null +++ b/src/main/media/local-preview-grant-file.ts @@ -0,0 +1,27 @@ +import type { Store } from '../persistence' +import { filePreviewForPath, isDocumentPreview } from '../../shared/file-preview-kind' +import { resolveLocalFileRequestPath } from '../ipc/local-file-access-resolution' +import { openLocalRegularFile } from '../ipc/filesystem/local-regular-file-read' +import { guardWslUncPathAccess } from '../wsl/wsl-unc-access-gate' +import type { LocalPreviewTarget, PreviewGrant } from './preview-grant-registry' + +/** + * Opens a local grant's file for a lazy read. It never wakes a stopped WSL distro, and a document + * grant ends once its link points at another kind of file. + */ +export function openLocalPreviewGrantFile( + grant: Pick, + target: LocalPreviewTarget, + store: Store +): ReturnType { + return guardWslUncPathAccess(target.filePath, async () => { + const filePath = await resolveLocalFileRequestPath(target.filePath, target.access, store) + if ( + isDocumentPreview(grant.preview) && + filePreviewForPath(filePath)?.mimeType !== grant.mimeType + ) { + throw new Error('Preview is no longer available') + } + return openLocalRegularFile(filePath) + }) +} diff --git a/src/main/media/media-preview-protocol.test.ts b/src/main/media/media-preview-protocol.test.ts index 5014b4da32a..64367470d79 100644 --- a/src/main/media/media-preview-protocol.test.ts +++ b/src/main/media/media-preview-protocol.test.ts @@ -23,23 +23,36 @@ vi.mock('./remote-file-preview-reader', async (importOriginal) => ({ ...(await importOriginal()), statRemotePreviewFile: vi.fn(), readRemotePreviewChunk: vi.fn(), + readRemotePreviewRange: vi.fn(), assertRemoteChunkReadsSupported: vi.fn() })) import { + MEDIA_PREVIEW_CUSTOM_SCHEME, grantRemoteFilePreview, handleMediaPreviewRequest, - readMediaPreview, + readLocalFilePreview, revokeRemoteFilePreviewGrants } from './media-preview-protocol' import { + REMOTE_PREVIEW_UPDATE_REQUIRED_MESSAGE, RemotePreviewPairingChangedError, assertRemoteChunkReadsSupported, readRemotePreviewChunk, + readRemotePreviewRange, statRemotePreviewFile } from './remote-file-preview-reader' import type * as RemoteFilePreviewReader from './remote-file-preview-reader' +function png(width: number, height: number): Uint8Array { + const bytes = new Uint8Array(33) + bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]) + new DataView(bytes.buffer).setUint32(16, width) + new DataView(bytes.buffer).setUint32(20, height) + bytes.set([8, 6, 0, 0, 0], 24) + return bytes +} + describe('media preview protocol', () => { let directory: string const emitter = new EventEmitter() @@ -70,7 +83,7 @@ describe('media preview protocol', () => { const filePath = join(directory, `clip.${extension}`) await writeFile(filePath, Uint8Array.from([0, 1, 2, 3, 4, 5])) const target = { filePath, access: { kind: 'user-file' } as const } - const result = readMediaPreview(event, target, store) + const result = await readLocalFilePreview(event, target, store, { documents: false }) expect(result).toMatchObject({ content: '', isBinary: true }) expect(result?.mediaUrl).not.toContain(filePath) if (!result) { @@ -88,7 +101,7 @@ describe('media preview protocol', () => { it('rechecks authorization for every seek and revokes URLs with the renderer', async () => { const filePath = join(directory, 'clip.mp4') await writeFile(filePath, 'video') - const result = readMediaPreview(event, { filePath }, store) + const result = await readLocalFilePreview(event, { filePath }, store, { documents: false }) if (!result) { throw new Error('Missing preview') } @@ -100,7 +113,12 @@ describe('media preview protocol', () => { }) it('rejects directories, forged URLs and non-media paths', async () => { - const result = readMediaPreview(event, { filePath: join(directory, 'folder.mp4') }, store) + const result = await readLocalFilePreview( + event, + { filePath: join(directory, 'folder.mp4') }, + store, + { documents: false } + ) if (!result) { throw new Error('Missing preview') } @@ -109,14 +127,101 @@ describe('media preview protocol', () => { expect((await handleMediaPreviewRequest(new Request('orca-media://file/unknown'))).status).toBe( 404 ) - expect(readMediaPreview(event, { filePath: join(directory, 'secret.txt') }, store)).toBeNull() + expect( + await readLocalFilePreview(event, { filePath: join(directory, 'secret.txt') }, store, { + documents: false + }) + ).toBeNull() + }) + + it('mints an image or PDF URL only for a viewer that asks, with its size', async () => { + const filePath = join(directory, 'shot.png') + await writeFile(filePath, png(4, 3)) + await expect( + readLocalFilePreview(event, { filePath }, store, { documents: false }) + ).resolves.toBeNull() + const result = await readLocalFilePreview(event, { filePath }, store, { documents: true }) + expect(result).toMatchObject({ isImage: true, mimeType: 'image/png', size: 33 }) + const response = await handleMediaPreviewRequest(new Request(result?.mediaUrl ?? '')) + // No CORS: no other origin can read these bytes with fetch. + expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull() + expect(new Uint8Array(await response.arrayBuffer())).toEqual(png(4, 3)) + }) + + it('classifies a linked image or PDF by its target, then and on every read', async () => { + const filePath = join(directory, 'shot.png') + const drawing = join(directory, 'drawing.svg') + await writeFile(filePath, png(4, 3)) + await writeFile(drawing, '') + authorize.mockResolvedValueOnce(drawing) + await expect( + readLocalFilePreview(event, { filePath }, store, { documents: true }) + ).resolves.toMatchObject({ mimeType: 'image/svg+xml' }) + authorize.mockResolvedValueOnce(join(directory, 'notes.txt')) + expect(await readLocalFilePreview(event, { filePath }, store, { documents: true })).toBeNull() + const result = await readLocalFilePreview(event, { filePath }, store, { documents: true }) + authorize.mockResolvedValueOnce(drawing) + expect((await handleMediaPreviewRequest(new Request(result?.mediaUrl ?? ''))).status).toBe(404) + }) + + it('refuses an image whose header proves it too large to decode safely', async () => { + const filePath = join(directory, 'bomb.png') + await writeFile(filePath, png(100_000, 100_000)) + await expect( + readLocalFilePreview(event, { filePath }, store, { documents: true }) + ).rejects.toThrow('Image dimensions exceed the preview safety limit') + }) + + it('mints nothing for a renderer torn down while the image was being checked', async () => { + const filePath = join(directory, 'late.png') + await writeFile(filePath, png(4, 3)) + let release: () => void = () => {} + authorize.mockImplementationOnce( + (path) => new Promise((resolve) => (release = () => resolve(path))) + ) + const pending = readLocalFilePreview(event, { filePath }, store, { documents: true }) + await vi.waitFor(() => expect(authorize).toHaveBeenCalled()) + emitter.emit('destroyed') + release() + await expect(pending).rejects.toThrow() + }) + + it('gives another origin no CORS answer, preflight or not', async () => { + const filePath = join(directory, 'doc.pdf') + await writeFile(filePath, '%PDF-1.4') + const result = await readLocalFilePreview(event, { filePath }, store, { documents: true }) + const url = result?.mediaUrl ?? '' + const fromPage = await handleMediaPreviewRequest( + new Request(url, { headers: { origin: 'https://example.com' } }) + ) + expect(fromPage.headers.get('Access-Control-Allow-Origin')).toBeNull() + const preflight = await handleMediaPreviewRequest(new Request(url, { method: 'OPTIONS' })) + expect(preflight.status).toBe(405) + expect(preflight.headers.get('Access-Control-Allow-Origin')).toBeNull() + }) + + it('claims only the element-load privileges, never fetch or CORS', () => { + expect(MEDIA_PREVIEW_CUSTOM_SCHEME.privileges).toEqual({ + standard: true, + secure: true, + stream: true + }) + }) + + it('checks the image it serves, not just the one it minted', async () => { + const filePath = join(directory, 'swapped.png') + await writeFile(filePath, png(4, 3)) + const result = await readLocalFilePreview(event, { filePath }, store, { documents: true }) + await writeFile(filePath, png(100_000, 100_000)) + expect((await handleMediaPreviewRequest(new Request(result?.mediaUrl ?? ''))).status).toBe(404) }) it('does not try a local read for an SSH host', async () => { - const result = readMediaPreview( + const result = await readLocalFilePreview( event, { filePath: '/remote/clip.mp4', connectionId: 'ssh-owner' }, - store + store, + { documents: false } ) if (!result) { throw new Error('Missing preview') @@ -173,6 +278,68 @@ describe('media preview protocol', () => { expect(readRemotePreviewChunk).toHaveBeenCalledWith(pinned, 2, 3, expect.any(AbortSignal)) }) + it('streams a remote image or PDF to its viewer, refusing a proven-oversized image', async () => { + vi.mocked(readRemotePreviewRange).mockImplementation(async (address) => + address.relativePath.endsWith('.pdf') ? new TextEncoder().encode('%PDF-1.7') : png(4, 3) + ) + await expect( + grantRemoteFilePreview(event, addr('docs/shot.png'), store) + ).resolves.toMatchObject({ + isImage: true, + mimeType: 'image/png', + size: 6 + }) + await expect( + grantRemoteFilePreview(event, addr('docs/spec.pdf'), store) + ).resolves.toMatchObject({ + isImage: true, + mimeType: 'application/pdf' + }) + vi.mocked(readRemotePreviewRange).mockResolvedValue(png(100_000, 100_000)) + await expect(grantRemoteFilePreview(event, addr('docs/bomb.png'), store)).rejects.toThrow( + 'Image dimensions exceed the preview safety limit' + ) + vi.mocked(readRemotePreviewRange).mockImplementation(async (address) => + address.relativePath.endsWith('.pdf') ? new TextEncoder().encode('%PDF-1.7') : png(4, 3) + ) + vi.mocked(statRemotePreviewFile).mockResolvedValueOnce({ size: 60 * 1024 * 1024, mtime: 1 }) + // An decodes the whole file, so images keep the 50 MB cap; PDFs read by range don't. + await expect(grantRemoteFilePreview(event, addr('docs/huge.png'), store)).rejects.toThrow( + 'File too large' + ) + vi.mocked(statRemotePreviewFile).mockResolvedValueOnce({ size: 60 * 1024 * 1024, mtime: 1 }) + await expect( + grantRemoteFilePreview(event, addr('docs/huge.pdf'), store) + ).resolves.toMatchObject({ + size: 60 * 1024 * 1024 + }) + }) + + it("leaves an older host's images and PDFs on files.readPreview, and refuses a swapped image", async () => { + vi.mocked(assertRemoteChunkReadsSupported).mockRejectedValueOnce( + new Error(REMOTE_PREVIEW_UPDATE_REQUIRED_MESSAGE) + ) + await expect(grantRemoteFilePreview(event, addr('docs/spec.pdf'), store)).resolves.toBeNull() + + vi.mocked(readRemotePreviewRange).mockResolvedValueOnce(png(4, 3)) + const minted = await grantRemoteFilePreview(event, addr('docs/shot.png'), store) + vi.mocked(readRemotePreviewRange).mockResolvedValueOnce(png(100_000, 100_000)) + expect((await range(minted?.mediaUrl ?? '')).status).not.toBe(206) + }) + + it('leaves a remote image or PDF whose bytes are another kind to files.readPreview', async () => { + vi.mocked(readRemotePreviewRange).mockResolvedValue(new TextEncoder().encode('')) + await expect(grantRemoteFilePreview(event, addr('docs/shot.png'), store)).resolves.toBeNull() + await expect(grantRemoteFilePreview(event, addr('docs/spec.pdf'), store)).resolves.toBeNull() + await expect( + grantRemoteFilePreview(event, addr('docs/drawing.svg'), store) + ).resolves.toMatchObject({ mimeType: 'image/svg+xml' }) + vi.mocked(readRemotePreviewRange).mockResolvedValue(png(4, 3)) + await expect( + grantRemoteFilePreview(event, addr('docs/drawing.svg'), store) + ).resolves.toBeNull() + }) + it('fails the mint, not the player, when the host cannot serve chunk reads', async () => { vi.mocked(assertRemoteChunkReadsSupported).mockRejectedValue(new Error('update Orca')) await expect(grantRemoteFilePreview(event, addr('out/clip.mp4'), store)).rejects.toThrow( diff --git a/src/main/media/media-preview-protocol.ts b/src/main/media/media-preview-protocol.ts index ff4acb650f1..4a7d859e50e 100644 --- a/src/main/media/media-preview-protocol.ts +++ b/src/main/media/media-preview-protocol.ts @@ -1,116 +1,101 @@ -import { randomUUID } from 'node:crypto' -import { protocol, type WebContents } from 'electron' +import { protocol } from 'electron' import type { Store } from '../persistence' -import type { LocalFileAccess } from '../../shared/local-file-access' import { MEDIA_PREVIEW_SCHEME } from '../../shared/media-file-extensions' -import { filePreviewForPath, streamsPreviewByRange } from '../../shared/file-preview-kind' +import { + filePreviewForPath, + isDocumentPreview, + streamsPreviewByRange, + type FilePreview +} from '../../shared/file-preview-kind' +import { + assertLocalPreviewRasterHeader, + assertPreviewableRasterHeader, + assertRemotePreviewRasterHeader, + incrementalPrefixReader +} from './preview-raster-header-check' +import { + DOCUMENT_SIGNATURE_PROBE_BYTES, + prefixFitsDocumentPreview +} from './preview-document-signature' +import { openLocalPreviewGrantFile } from './local-preview-grant-file' import type { RemoteFilePreviewAddress } from '../../shared/remote-file-preview-address' import { resolveLocalFileRequestPath } from '../ipc/local-file-access-resolution' import { openLocalRegularFile } from '../ipc/filesystem/local-regular-file-read' import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort' import { createMediaRangeResponse } from './media-range-response' import { + REMOTE_PREVIEW_UPDATE_REQUIRED_MESSAGE, RemotePreviewPairingChangedError, assertRemoteChunkReadsSupported, readRemotePreviewChunk, + readRemotePreviewRange, statRemotePreviewFile, type RemotePreviewFileSnapshot } from './remote-file-preview-reader' import { readRegisteredPairingRevision } from '../ipc/runtime-environments/runtime-environment-registry-root' +import { + grants, + mintPreviewUrl, + remoteSnapshot, + revokeGrant, + type LocalPreviewTarget, + type PreviewGrant, + type PreviewSender, + type PreviewUrl +} from './preview-grant-registry' -type LocalPreviewTarget = { filePath: string; connectionId?: string; access?: LocalFileAccess } -type RemotePreviewTarget = { remote: RemoteFilePreviewAddress } -type PreviewTarget = LocalPreviewTarget | RemotePreviewTarget -type PreviewGrant = { - target: PreviewTarget - store: Store - mimeType: string - senderId: number - key: string - // Why: a seek or prefetch burst opens several ranges at once; one stat answers all of them. - stat?: { at: number; snapshot: Promise } -} -type PreviewUrl = { content: string; isBinary: true; mimeType: string; mediaUrl: string } - -const REMOTE_STAT_REUSE_MS = 1_000 -const grants = new Map() -const senders = new Map>() +export { revokeRemoteFilePreviewGrants } from './preview-grant-registry' export const MEDIA_PREVIEW_CUSTOM_SCHEME: Electron.CustomScheme = { scheme: MEDIA_PREVIEW_SCHEME, + // No fetch or CORS: only /