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 <img> 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.
This commit is contained in:
Neil
2026-10-28 09:31:22 -07:00
committed by GitHub
parent 07a98c2c6d
commit 045f8c8266
40 changed files with 1360 additions and 165 deletions
+1
View File
@@ -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
@@ -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 })
+2
View File
@@ -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'],
@@ -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))
}
]
@@ -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
@@ -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<LocalFileContent> => {
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) {
@@ -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<PreviewGrant, 'preview' | 'mimeType'>,
target: LocalPreviewTarget,
store: Store
): ReturnType<typeof openLocalRegularFile> {
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)
})
}
+174 -7
View File
@@ -23,23 +23,36 @@ vi.mock('./remote-file-preview-reader', async (importOriginal) => ({
...(await importOriginal<typeof RemoteFilePreviewReader>()),
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, '<svg/>')
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 <img> 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('<svg/>'))
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(
+108 -116
View File
@@ -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<RemotePreviewFileSnapshot> }
}
type PreviewUrl = { content: string; isBinary: true; mimeType: string; mediaUrl: string }
const REMOTE_STAT_REUSE_MS = 1_000
const grants = new Map<string, PreviewGrant>()
const senders = new Map<number, Map<string, string>>()
export { revokeRemoteFilePreviewGrants } from './preview-grant-registry'
export const MEDIA_PREVIEW_CUSTOM_SCHEME: Electron.CustomScheme = {
scheme: MEDIA_PREVIEW_SCHEME,
// No fetch or CORS: only <img>/<video>/<audio> elements load these URLs; PDF ranges go over IPC.
privileges: { standard: true, secure: true, stream: true }
}
type PreviewSender = Pick<WebContents, 'id' | 'on' | 'removeListener'>
function mintPreviewUrl(
sender: PreviewSender,
target: PreviewTarget,
store: Store,
mimeType: string,
snapshot?: RemotePreviewFileSnapshot
): PreviewUrl {
let owned = senders.get(sender.id)
if (!owned) {
owned = new Map()
senders.set(sender.id, owned)
const lifetime = abortWhenRendererGone(sender)
const entries = owned
lifetime.signal.addEventListener(
'abort',
() => {
for (const token of entries.values()) {
grants.delete(token)
}
senders.delete(sender.id)
lifetime.dispose()
},
{ once: true }
)
}
const key = JSON.stringify(target)
let token = owned.get(key)
if (!token || !grants.has(token)) {
token = randomUUID()
owned.set(key, token)
grants.set(token, { target, store, mimeType, senderId: sender.id, key })
}
const grant = grants.get(token)
if (grant && snapshot) {
// A reload re-mints the same token; its fresh stat must replace a cached older size.
grant.stat = { at: Date.now(), snapshot: Promise.resolve(snapshot) }
}
return {
content: '',
isBinary: true,
mimeType,
mediaUrl: `${MEDIA_PREVIEW_SCHEME}://file/${token}?revision=${randomUUID()}`
}
}
function revokeGrant(token: string): void {
const grant = grants.get(token)
if (!grant) {
return
}
grants.delete(token)
const owned = senders.get(grant.senderId)
if (owned?.get(grant.key) === token) {
owned.delete(grant.key)
}
}
/** A local audio/video file as an `orca-media://` URL its viewer streams by range. */
export function readMediaPreview(
/**
* A local file as an `orca-media://` URL its viewer reads by range: audio and video always,
* images and PDFs only for a viewer that asks (`documents`); other callers keep base64.
*/
export async function readLocalFilePreview(
event: { sender: PreviewSender },
target: LocalPreviewTarget,
store: Store
): PreviewUrl | null {
const preview = filePreviewForPath(target.filePath)
if (!preview || !streamsPreviewByRange(preview)) {
store: Store,
options: { documents: boolean }
): Promise<PreviewUrl | null> {
const named = filePreviewForPath(target.filePath)
if (!named) {
return null
}
return mintPreviewUrl(event.sender, target, store, preview.mimeType)
if (streamsPreviewByRange(named)) {
return mintPreviewUrl(event.sender, target, store, named)
}
if (!options.documents || target.connectionId) {
return null
}
const lifetime = abortWhenRendererGone(event.sender)
let preview: FilePreview
let size: number
try {
const filePath = await resolveLocalFileRequestPath(target.filePath, target.access, store)
// Classify the target, not the link: `shot.png -> drawing.svg` is an SVG, and base64 says so.
const resolved = filePreviewForPath(filePath)
if (!resolved || !isDocumentPreview(resolved)) {
return null
}
preview = resolved
const { handle, stats } = await openLocalRegularFile(filePath)
try {
await assertLocalPreviewRasterHeader(preview, handle, stats.size)
} finally {
await handle.close()
}
size = stats.size
// A renderer gone mid-mint would leave a grant no teardown ever drops.
lifetime.signal.throwIfAborted()
} finally {
lifetime.dispose()
}
return mintPreviewUrl(event.sender, target, store, preview, undefined, size)
}
/** A paired runtime's file as an `orca-media://` URL, streamed by range over files.stat/readChunk. */
@@ -120,7 +105,7 @@ export async function grantRemoteFilePreview(
store: Store
): Promise<PreviewUrl | null> {
const preview = filePreviewForPath(address.relativePath)
if (!preview || !streamsPreviewByRange(preview)) {
if (!preview) {
return null
}
// Why: proving the file and range reads here makes a missing file or an old host fail in the tab, not as a silent player error.
@@ -138,37 +123,36 @@ export async function grantRemoteFilePreview(
if (snapshot.size > 0) {
await assertRemoteChunkReadsSupported(pinned, lifetime.signal)
}
} catch (error) {
lifetime.dispose()
// An older host still previews images and PDFs whole through files.readPreview.
if (
isDocumentPreview(preview) &&
error instanceof Error &&
error.message === REMOTE_PREVIEW_UPDATE_REQUIRED_MESSAGE
) {
return null
}
throw error
}
try {
const readPrefix = incrementalPrefixReader((offset, length) =>
readRemotePreviewRange(pinned, offset, length, snapshot.size, lifetime.signal)
)
// files.stat names no link target, but the host's readPreview classifies it; defer on a mislabel.
if (
isDocumentPreview(preview) &&
!prefixFitsDocumentPreview(preview, await readPrefix(DOCUMENT_SIGNATURE_PROBE_BYTES))
) {
return null
}
await assertPreviewableRasterHeader(preview, readPrefix, snapshot.size)
// A renderer gone mid-mint would leave a grant no teardown ever drops.
lifetime.signal.throwIfAborted()
} finally {
lifetime.dispose()
}
return mintPreviewUrl(event.sender, { remote: pinned }, store, preview.mimeType, snapshot)
}
/** Removing a paired runtime ends every preview it backs. */
export function revokeRemoteFilePreviewGrants(environmentId: string): void {
for (const [token, grant] of grants) {
if ('remote' in grant.target && grant.target.remote.environmentId === environmentId) {
revokeGrant(token)
}
}
}
function remoteSnapshot(grant: PreviewGrant, remote: RemoteFilePreviewAddress) {
const now = Date.now()
if (!grant.stat || now - grant.stat.at > REMOTE_STAT_REUSE_MS) {
// Not tied to one request's abort: other ranges in the burst may be waiting on it.
const snapshot = statRemotePreviewFile(remote, new AbortController().signal)
const stat = { at: now, snapshot }
grant.stat = stat
snapshot.catch(() => {
if (grant.stat === stat) {
grant.stat = undefined
}
})
}
return grant.stat.snapshot
return mintPreviewUrl(event.sender, { remote: pinned }, store, preview, snapshot)
}
async function handleRemotePreviewRequest(
@@ -188,6 +172,8 @@ async function handleRemotePreviewRequest(
try {
// Re-stat (at most once a second) like a local file re-opens per request, so a growing recording keeps playing.
const { size } = await remoteSnapshot(grant, remote)
// The file can change after mint; check the bytes this response serves.
await assertRemotePreviewRasterHeader(grant.preview, remote, size, controller.signal)
return await createMediaRangeResponse(request, grant.mimeType, {
size,
read: (offset, length) => readRemotePreviewChunk(remote, offset, length, controller.signal),
@@ -226,8 +212,14 @@ export async function handleMediaPreviewRequest(request: Request): Promise<Respo
if (target.connectionId) {
return new Response(null, { status: 404 })
}
const filePath = await resolveLocalFileRequestPath(target.filePath, target.access, store)
const { handle, stats } = await openLocalRegularFile(filePath)
const { handle, stats } = await openLocalPreviewGrantFile(grant, target, store)
try {
// The file can change after mint; check the handle this response serves.
await assertLocalPreviewRasterHeader(grant.preview, handle, stats.size)
} catch (error) {
await handle.close()
throw error
}
return await createMediaRangeResponse(request, mimeType, {
size: stats.size,
read: async (offset, length) => {
@@ -0,0 +1,18 @@
import type { FilePreview } from '../../shared/file-preview-kind'
/** pdf.js accepts a PDF header anywhere in the first 1 KiB. */
export const DOCUMENT_SIGNATURE_PROBE_BYTES = 1024
/**
* Whether a document's first bytes fit its extension: a PDF names `%PDF-`, an SVG opens with markup,
* and a raster image does neither. Only catches a mislabel, such as a link to another kind of file.
*/
export function prefixFitsDocumentPreview(preview: FilePreview, prefix: Uint8Array): boolean {
const head = new TextDecoder('latin1').decode(prefix)
const isPdf = head.includes('%PDF-')
if (preview.kind === 'pdf') {
return isPdf
}
const isMarkup = /^(?:)?\s*</.test(head)
return preview.mimeType === 'image/svg+xml' ? isMarkup : !isPdf && !isMarkup
}
@@ -0,0 +1,78 @@
import { EventEmitter } from 'node:events'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
vi.mock('electron', () => ({ protocol: { handle: vi.fn() } }))
vi.mock('../ipc/local-file-access-resolution', () => ({
resolveLocalFileRequestPath: vi.fn(async (filePath: string) => filePath)
}))
vi.mock('../wsl/wsl-unc-access-gate', () => ({
guardWslUncPathAccess: vi.fn((_path: string, access: () => Promise<unknown>) => access())
}))
import { resolveLocalFileRequestPath } from '../ipc/local-file-access-resolution'
import { guardWslUncPathAccess } from '../wsl/wsl-unc-access-gate'
import { readLocalFilePreview } from './media-preview-protocol'
import { readPreviewGrantRange } from './preview-grant-range-read'
const emitter = new EventEmitter()
const sender = {
id: 7101,
on: vi.fn().mockImplementation((name, callback) => emitter.on(name, callback)),
removeListener: vi
.fn()
.mockImplementation((name, callback) => emitter.removeListener(name, callback))
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked authorization resolver never reads Store members.
const store = {} as Store
describe('readPreviewGrantRange', () => {
let directory: string
beforeEach(async () => {
directory = await mkdtemp(join(tmpdir(), 'orca-preview-range-'))
})
afterEach(async () => {
emitter.emit('destroyed')
await rm(directory, { recursive: true, force: true })
})
async function mint(name: string, bytes: string): Promise<string> {
const filePath = join(directory, name)
await writeFile(filePath, bytes)
const result = await readLocalFilePreview({ sender }, { filePath }, store, { documents: true })
return result?.mediaUrl ?? ''
}
it('reads a PDF range for the renderer that minted it, and for no other', async () => {
const url = await mint('doc.pdf', '%PDF-1.4 body')
expect(new TextDecoder().decode(await readPreviewGrantRange(sender, url, 1, 3))).toBe('PDF')
await expect(
readPreviewGrantRange({ ...sender, id: sender.id + 1 }, url, 0, 4)
).rejects.toThrow('Preview is no longer available')
})
it('never wakes a stopped WSL distro to read a range', async () => {
const url = await mint('doc.pdf', '%PDF-1.4 body')
vi.mocked(resolveLocalFileRequestPath).mockClear()
vi.mocked(guardWslUncPathAccess).mockRejectedValueOnce(new Error('Ubuntu is stopped'))
await expect(readPreviewGrantRange(sender, url, 0, 4)).rejects.toThrow('Ubuntu is stopped')
expect(resolveLocalFileRequestPath).not.toHaveBeenCalled()
})
it('serves PDF grants only, within bounds, and nothing after teardown', async () => {
const pdf = await mint('doc.pdf', '%PDF-1.4')
const image = await mint('shot.svg', '<svg/>')
await expect(readPreviewGrantRange(sender, image, 0, 4)).rejects.toThrow()
await expect(readPreviewGrantRange(sender, pdf, -1, 4)).rejects.toThrow('Invalid preview range')
await expect(readPreviewGrantRange(sender, pdf, 0, 64 * 1024 * 1024)).rejects.toThrow(
'Invalid preview range'
)
emitter.emit('destroyed')
await expect(readPreviewGrantRange(sender, pdf, 0, 4)).rejects.toThrow(
'Preview is no longer available'
)
})
})
@@ -0,0 +1,69 @@
import { openLocalPreviewGrantFile } from './local-preview-grant-file'
import {
previewGrantForSender,
remoteSnapshot,
revokeGrant,
type PreviewSender
} from './preview-grant-registry'
import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort'
import {
RemotePreviewPairingChangedError,
readRemotePreviewRange
} from './remote-file-preview-reader'
/** pdf.js asks in 256 KiB chunks but may merge neighbours; the renderer splits anything larger. */
export const PREVIEW_RANGE_READ_MAX_BYTES = 4 * 1024 * 1024
/**
* A PDF grant's bytes for the renderer that minted it. pdf.js reads ranges here instead of over
* fetch, so orca-media needs no fetch or CORS privileges and no other page or frame can read them.
*/
export async function readPreviewGrantRange(
sender: PreviewSender,
url: string,
offset: number,
length: number
): Promise<Uint8Array> {
if (
!Number.isSafeInteger(offset) ||
offset < 0 ||
!Number.isSafeInteger(length) ||
length < 0 ||
length > PREVIEW_RANGE_READ_MAX_BYTES
) {
throw new Error('Invalid preview range')
}
// Per window: its iframes share the id, but only its main frame has the preload API.
const found = previewGrantForSender(url, sender.id)
if (!found || found.grant.preview.kind !== 'pdf') {
throw new Error('Preview is no longer available')
}
const { token, grant } = found
if ('remote' in grant.target) {
const remote = grant.target.remote
// A closed window stops its remote chunk reads instead of draining them.
const lifetime = abortWhenRendererGone(sender)
try {
const { size } = await remoteSnapshot(grant, remote)
return await readRemotePreviewRange(remote, offset, length, size, lifetime.signal)
} catch (error) {
if (error instanceof RemotePreviewPairingChangedError) {
revokeGrant(token)
}
throw error
} finally {
lifetime.dispose()
}
}
if (grant.target.connectionId) {
throw new Error('Preview is no longer available')
}
const { handle, stats } = await openLocalPreviewGrantFile(grant, grant.target, grant.store)
try {
const buffer = Buffer.alloc(Math.max(0, Math.min(length, stats.size - offset)))
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset)
return new Uint8Array(buffer.buffer, buffer.byteOffset, bytesRead)
} finally {
await handle.close()
}
}
+149
View File
@@ -0,0 +1,149 @@
import { randomUUID } from 'node:crypto'
import type { WebContents } 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 { isDocumentPreview, type FilePreview } from '../../shared/file-preview-kind'
import type { RemoteFilePreviewAddress } from '../../shared/remote-file-preview-address'
import { abortWhenRendererGone } from '../ipc/renderer-lifetime-abort'
import { statRemotePreviewFile, type RemotePreviewFileSnapshot } from './remote-file-preview-reader'
export type LocalPreviewTarget = {
filePath: string
connectionId?: string
access?: LocalFileAccess
}
export type RemotePreviewTarget = { remote: RemoteFilePreviewAddress }
export type PreviewTarget = LocalPreviewTarget | RemotePreviewTarget
export type PreviewGrant = {
target: PreviewTarget
store: Store
preview: FilePreview
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<RemotePreviewFileSnapshot> }
}
export type PreviewUrl = {
content: string
isBinary: true
mimeType: string
mediaUrl: string
// Images and PDFs: the editor's selector for its document viewers, as base64 previews set it.
isImage?: true
size?: number
}
const REMOTE_STAT_REUSE_MS = 1_000
export const grants = new Map<string, PreviewGrant>()
const senders = new Map<number, Map<string, string>>()
export type PreviewSender = Pick<WebContents, 'id' | 'on' | 'removeListener'>
export function mintPreviewUrl(
sender: PreviewSender,
target: PreviewTarget,
store: Store,
preview: FilePreview,
snapshot?: RemotePreviewFileSnapshot,
size?: number
): PreviewUrl {
const { mimeType } = preview
let owned = senders.get(sender.id)
if (!owned) {
owned = new Map()
senders.set(sender.id, owned)
const lifetime = abortWhenRendererGone(sender)
const entries = owned
lifetime.signal.addEventListener(
'abort',
() => {
for (const token of entries.values()) {
grants.delete(token)
}
senders.delete(sender.id)
lifetime.dispose()
},
{ once: true }
)
}
// A retargeted link re-mints as its new kind, never as the old grant's type.
const key = JSON.stringify([target, mimeType])
let token = owned.get(key)
if (!token || !grants.has(token)) {
token = randomUUID()
owned.set(key, token)
grants.set(token, { target, store, preview, mimeType, senderId: sender.id, key })
}
const grant = grants.get(token)
if (grant && snapshot) {
// A reload re-mints the same token; its fresh stat must replace a cached older size.
grant.stat = { at: Date.now(), snapshot: Promise.resolve(snapshot) }
}
const byteSize = snapshot?.size ?? size
return {
content: '',
isBinary: true,
mimeType,
mediaUrl: `${MEDIA_PREVIEW_SCHEME}://file/${token}?revision=${randomUUID()}`,
...(isDocumentPreview(preview) ? { isImage: true as const } : {}),
...(byteSize !== undefined ? { size: byteSize } : {})
}
}
export function revokeGrant(token: string): void {
const grant = grants.get(token)
if (!grant) {
return
}
grants.delete(token)
const owned = senders.get(grant.senderId)
if (owned?.get(grant.key) === token) {
owned.delete(grant.key)
}
}
/** Removing a paired runtime ends every preview it backs. */
export function revokeRemoteFilePreviewGrants(environmentId: string): void {
for (const [token, grant] of grants) {
if ('remote' in grant.target && grant.target.remote.environmentId === environmentId) {
revokeGrant(token)
}
}
}
export function remoteSnapshot(grant: PreviewGrant, remote: RemoteFilePreviewAddress) {
const now = Date.now()
if (!grant.stat || now - grant.stat.at > REMOTE_STAT_REUSE_MS) {
// Not tied to one request's abort: other ranges in the burst may be waiting on it.
const snapshot = statRemotePreviewFile(remote, new AbortController().signal)
const stat = { at: now, snapshot }
grant.stat = stat
snapshot.catch(() => {
if (grant.stat === stat) {
grant.stat = undefined
}
})
}
return grant.stat.snapshot
}
/** The grant behind an orca-media URL, only for the renderer that minted it. */
export function previewGrantForSender(
url: string,
senderId: number
): { token: string; grant: PreviewGrant } | null {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return null
}
const token =
parsed.protocol === `${MEDIA_PREVIEW_SCHEME}:` && parsed.hostname === 'file'
? parsed.pathname.slice(1)
: ''
const grant = grants.get(token)
return grant && grant.senderId === senderId ? { token, grant } : null
}
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest'
import { incrementalPrefixReader } from './preview-raster-header-check'
describe('incrementalPrefixReader', () => {
it('reads only the new tail of each wider probe, and nothing past EOF', async () => {
const file = Uint8Array.from({ length: 100 }, (_, index) => index)
const readRange = vi.fn(async (offset: number, length: number) =>
file.subarray(offset, offset + length)
)
const readPrefix = incrementalPrefixReader(readRange)
expect(await readPrefix(10)).toEqual(file.subarray(0, 10))
expect(await readPrefix(64)).toEqual(file.subarray(0, 64))
expect(await readPrefix(1024)).toEqual(file)
expect(await readPrefix(4096)).toEqual(file)
expect(readRange.mock.calls).toEqual([
[0, 10],
[10, 54],
[64, 960]
])
})
})
@@ -0,0 +1,79 @@
import type { FileHandle } from 'node:fs/promises'
import type { FilePreview } from '../../shared/file-preview-kind'
import { rasterImageExceedsPreviewLimits } from '../../shared/raster-image-prefix-limits'
import { RASTER_IMAGE_PREVIEW_TOO_LARGE_ERROR } from '../../shared/raster-image-preview-limits'
import { MAX_PREVIEWABLE_BINARY_SIZE } from '../ipc/filesystem/filesystem-file-content-inspection'
import { fileTooLargeError } from '../ipc/filesystem/local-regular-file-read'
import type { RemoteFilePreviewAddress } from '../../shared/remote-file-preview-address'
import { readRemotePreviewRange } from './remote-file-preview-reader'
/** A URL preview keeps the base64 path's guard: an image whose header proves it oversized never reaches the decoder. */
export async function assertPreviewableRasterHeader(
preview: FilePreview,
readPrefix: (bytes: number) => Promise<Uint8Array>,
size: number
): Promise<void> {
if (preview.kind !== 'image') {
return
}
// An <img> decodes the whole file, so images keep the base64 path's size cap; PDFs load by range.
if (size > MAX_PREVIEWABLE_BINARY_SIZE) {
throw fileTooLargeError(size, MAX_PREVIEWABLE_BINARY_SIZE)
}
if (await rasterImageExceedsPreviewLimits(readPrefix, preview.mimeType)) {
throw new Error(RASTER_IMAGE_PREVIEW_TOO_LARGE_ERROR)
}
}
/** A local file's first bytes for the check, read from the handle that serves it. */
export function localPrefixReader(handle: FileHandle, size: number) {
return async (bytes: number): Promise<Uint8Array> => {
const buffer = Buffer.alloc(Math.min(bytes, size))
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
return buffer.subarray(0, bytesRead)
}
}
/** Grows one prefix and reads only its new tail, so each wider header probe costs only the new bytes. */
export function incrementalPrefixReader(
readRange: (offset: number, length: number) => Promise<Uint8Array>
) {
let prefix = new Uint8Array(0)
let exhausted = false
return async (bytes: number): Promise<Uint8Array> => {
if (!exhausted && bytes > prefix.byteLength) {
const tail = await readRange(prefix.byteLength, bytes - prefix.byteLength)
const grown = new Uint8Array(prefix.byteLength + tail.byteLength)
grown.set(prefix)
grown.set(tail, prefix.byteLength)
exhausted = tail.byteLength < bytes - prefix.byteLength
prefix = grown
}
return prefix.subarray(0, Math.min(bytes, prefix.byteLength))
}
}
/** The check against a local file, read from the handle that serves it. */
export function assertLocalPreviewRasterHeader(
preview: FilePreview,
handle: FileHandle,
size: number
): Promise<void> {
return assertPreviewableRasterHeader(preview, localPrefixReader(handle, size), size)
}
/** The check against a paired runtime's file, reading only each wider probe's new bytes. */
export function assertRemotePreviewRasterHeader(
preview: FilePreview,
address: RemoteFilePreviewAddress,
size: number,
signal: AbortSignal
): Promise<void> {
return assertPreviewableRasterHeader(
preview,
incrementalPrefixReader((offset, length) =>
readRemotePreviewRange(address, offset, length, size, signal)
),
size
)
}
@@ -12,6 +12,7 @@ import {
REMOTE_PREVIEW_UPDATE_REQUIRED_MESSAGE,
RemotePreviewPairingChangedError,
assertRemoteChunkReadsSupported,
readRemotePreviewRange,
readRemotePreviewChunk,
statRemotePreviewFile
} from './remote-file-preview-reader'
@@ -123,4 +124,20 @@ describe('remote file preview reader', () => {
assertRemoteChunkReadsSupported({ ...address, environmentId: 'env-old' }, signal)
).rejects.toThrow(REMOTE_PREVIEW_UPDATE_REQUIRED_MESSAGE)
})
it('reads a range in host-sized chunks, clipped to the file and stopping at EOF', async () => {
callRuntimeEnvironment
.mockResolvedValueOnce(
ok({
contentBase64: Buffer.alloc(262_144, 1).toString('base64'),
bytesRead: 262_144,
eof: false
})
)
.mockResolvedValueOnce(ok({ contentBase64: 'AQID', bytesRead: 3, eof: true }))
const range = await readRemotePreviewRange(address, 10, 900_000, 262_157, signal)
expect(range.byteLength).toBe(262_147)
expect(callRuntimeEnvironment.mock.calls[0]?.[3]).toMatchObject({ offset: 10, length: 262_144 })
expect(callRuntimeEnvironment.mock.calls[1]?.[3]).toMatchObject({ offset: 262_154, length: 3 })
})
})
@@ -1,3 +1,4 @@
import { MAX_FILE_RANGE_READ_BYTES } from '../../shared/file-range-read'
import type { RemoteFilePreviewAddress } from '../../shared/remote-file-preview-address'
import type { RuntimeFileReadChunkResult } from '../../shared/runtime-file-contracts'
import { callRuntimeEnvironment } from '../ipc/runtime-environments/runtime-environment-transport-routing'
@@ -143,3 +144,30 @@ export async function assertRemoteChunkReadsSupported(
}
chunkReadVerdicts.add(key)
}
/** `length` bytes from `offset` (clipped to `size`), in chunks the host accepts; fewer at EOF. */
export async function readRemotePreviewRange(
address: RemoteFilePreviewAddress,
offset: number,
length: number,
size: number,
signal: AbortSignal
): Promise<Uint8Array> {
const end = Math.min(offset + length, size)
const range = new Uint8Array(Math.max(0, end - offset))
let read = 0
while (offset + read < end) {
const chunk = await readRemotePreviewChunk(
address,
offset + read,
Math.min(MAX_FILE_RANGE_READ_BYTES, end - offset - read),
signal
)
if (chunk.byteLength === 0) {
break
}
range.set(chunk, read)
read += chunk.byteLength
}
return range.subarray(0, read)
}
+13 -3
View File
@@ -76,17 +76,27 @@ export type FilesystemApi = {
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<{
content: string
isBinary: boolean
mediaUrl?: string
size?: number
isImage?: boolean
mimeType?: string
fileIdentity?: string
}>
grantRuntimeFilePreview: (
args: RemoteFilePreviewAddress
) => Promise<{ content: string; isBinary: true; mimeType: string; mediaUrl: string } | null>
/** A PDF preview's bytes, for the renderer that minted its orca-media URL only. */
readPreviewRange: (args: { url: string; offset: number; length: number }) => Promise<Uint8Array>
grantRuntimeFilePreview: (args: RemoteFilePreviewAddress) => Promise<{
content: string
isBinary: true
mimeType: string
mediaUrl: string
isImage?: true
size?: number
} | null>
readLocalLogTail: (args: LocalLogTailReadArgs) => Promise<LocalLogTailReadResult>
startLocalLogTail: (args: LocalLogTailWatchArgs) => Promise<void>
stopLocalLogTail: (args: { subscriptionId: string }) => Promise<void>
+12 -2
View File
@@ -56,18 +56,28 @@ export const fsApi = {
includeLocalLogMetadata?: boolean
access?: LocalFileAccess
wakeWslDistro?: boolean
rangePreview?: boolean
}): Promise<{
content: string
isBinary: boolean
isImage?: boolean
mediaUrl?: string
size?: number
mimeType?: string
fileIdentity?: string
}> => ipcRenderer.invoke('fs:readFile', args),
readPreviewRange: (args: { url: string; offset: number; length: number }): Promise<Uint8Array> =>
ipcRenderer.invoke('fs:readPreviewRange', args),
grantRuntimeFilePreview: (
args: RemoteFilePreviewAddress
): Promise<{ content: string; isBinary: true; mimeType: string; mediaUrl: string } | null> =>
ipcRenderer.invoke('fs:grantRuntimeFilePreview', args),
): Promise<{
content: string
isBinary: true
mimeType: string
mediaUrl: string
isImage?: true
size?: number
} | null> => ipcRenderer.invoke('fs:grantRuntimeFilePreview', args),
readLocalLogTail: (args: LocalLogTailReadArgs): Promise<LocalLogTailReadResult> =>
ipcRenderer.invoke('fs:readLocalLogTail', args),
startLocalLogTail: (args: LocalLogTailWatchArgs): Promise<void> =>
@@ -163,6 +163,8 @@ export function EditorConflictReviewSurface({
<div className={className}>
<ImageViewer
content={fileContent.content}
src={fileContent.mediaUrl}
byteSize={fileContent.size}
filePath={contentFile.filePath}
mimeType={fileContent.mimeType}
/>
@@ -127,7 +127,7 @@ export function EditorEditFileSurface({
)
}
if (fileContent.isBinary) {
if (fileContent.mediaUrl) {
if (fileContent.mediaUrl && !fileContent.isImage) {
return (
<MediaViewer
key={fileContent.mediaUrl}
@@ -147,6 +147,8 @@ export function EditorEditFileSurface({
return (
<ImageViewer
content={fileContent.content}
src={fileContent.mediaUrl}
byteSize={fileContent.size}
filePath={activeFile.filePath}
mimeType={fileContent.mimeType}
preferenceKey={pdfPreferenceKey}
@@ -263,3 +263,42 @@ describe('ImageViewer pre-load layout box', () => {
}
})
})
describe('ImageViewer reading its bytes by URL', () => {
beforeEach(() => {
reactHookRuntime.states = []
reactHookRuntime.index = 0
vi.clearAllMocks()
})
it('shows an image from its orca-media URL, sized from the grant', async () => {
reactHookRuntime.index = 0
const module = await import('./ImageViewer')
const rendered = expandNode(
module.default({
src: 'orca-media://file/token?revision=r1',
byteSize: 2048,
filePath: '/repo/preview.png',
mimeType: 'image/png'
})
)
expect(findPreviewImage(rendered).props.src).toBe('orca-media://file/token?revision=r1')
expect(JSON.stringify(rendered)).toContain('2.0 KB')
})
it("hands a PDF's URL to PdfViewer instead of base64", async () => {
reactHookRuntime.index = 0
const module = await import('./ImageViewer')
const [pdf] = findElementsByType(
expandNode(
module.default({
src: 'orca-media://file/doc?revision=r1',
filePath: '/repo/report.pdf',
mimeType: 'application/pdf'
})
),
'PdfViewer'
)
expect(pdf?.props).toMatchObject({ src: 'orca-media://file/doc?revision=r1', content: '' })
})
})
@@ -26,7 +26,12 @@ import { buildImageDataUri } from '../../../../shared/image-data-uri'
const FALLBACK_IMAGE_MIME_TYPE = 'image/png'
type ImageViewerProps = {
content: string
/** Base64 bytes, for git blobs, diffs and the web client. */
content?: string
/** An orca-media URL the image or PDF is read from by range; wins over `content`. */
src?: string
/** The file's size when `src` hides the bytes. */
byteSize?: number
filePath: string
mimeType?: string
layout?: 'fill' | 'intrinsic'
@@ -37,7 +42,9 @@ type ImageViewerProps = {
}
export default function ImageViewer({
content,
content = '',
src,
byteSize,
filePath,
mimeType = FALLBACK_IMAGE_MIME_TYPE,
layout = 'fill',
@@ -57,7 +64,8 @@ export default function ImageViewer({
const filename = useMemo(() => filePath.split(/[/\\]/).pop() || filePath, [filePath])
const cleanedContent = useMemo(() => content.replace(/\s/g, ''), [content])
const imageStateKey = `${filePath}\n${mimeType}\n${cleanedContent}`
// A remint changes only the URL's revision; keep zoom unless the grant or the file's size changed.
const imageStateKey = `${filePath}\n${mimeType}\n${src ? `${src.split('?')[0]}\n${byteSize ?? ''}` : cleanedContent}`
const [lastImageStateKey, setLastImageStateKey] = useState(imageStateKey)
if (lastImageStateKey !== imageStateKey) {
setLastImageStateKey(imageStateKey)
@@ -68,14 +76,18 @@ export default function ImageViewer({
const isPdf = mimeType === 'application/pdf'
const isIntrinsicLayout = layout === 'intrinsic'
const previewSrc = useMemo(
() => buildImageDataUri(mimeType, cleanedContent),
[cleanedContent, mimeType]
() =>
src && mimeType.startsWith('image/') ? src : buildImageDataUri(mimeType, cleanedContent),
[cleanedContent, mimeType, src]
)
const imageError =
(previewSrc === null && cleanedContent.length > 0) ||
(previewSrc !== null && failedPreviewSrc === previewSrc)
const estimatedSize = useMemo(() => {
const bytes = Math.floor((cleanedContent.length * 3) / 4)
if (src && byteSize === undefined) {
return ''
}
const bytes = byteSize ?? Math.floor((cleanedContent.length * 3) / 4)
if (bytes < 1024) {
return `${bytes} B`
}
@@ -83,7 +95,7 @@ export default function ImageViewer({
return `${(bytes / 1024).toFixed(1)} KB`
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}, [cleanedContent])
}, [byteSize, cleanedContent, src])
const inlineZoomPercent = Math.round(inlineZoom * 100)
const inlineImageLayoutSize = useMemo(
() =>
@@ -248,6 +260,8 @@ export default function ImageViewer({
return (
<PdfViewer
content={cleanedContent}
src={src}
byteSize={byteSize}
filePath={filePath}
preferenceKey={preferenceKey}
scrollCacheKey={scrollCacheKey}
@@ -33,7 +33,12 @@ const SCALE_STEP = 1.25
const SCALE_BOUNDS = { min: MIN_SCALE, max: MAX_SCALE, step: SCALE_STEP }
type PdfViewerProps = {
/** Base64 bytes, for git blobs, diffs and the web client. */
content: string
/** An orca-media URL pdf.js reads by range; wins over `content`. */
src?: string
/** The file's size, which pdf.js needs to read `src` by range. */
byteSize?: number
filePath: string
// Persist zoom only with owner-qualified identity; diff/conflict callers must not use a path-only key.
preferenceKey?: string | null
@@ -52,6 +57,8 @@ export default function PdfViewer(props: PdfViewerProps): JSX.Element {
function PdfDocumentViewer({
content,
src,
byteSize,
filePath,
preferenceKey = null,
scrollCacheKey = null
@@ -133,9 +140,9 @@ function PdfDocumentViewer({
}, [filePath, preferenceKey, recordScalePreference, scrollCacheKey])
useEffect(() => {
loaderRef.current?.load(cleanedContent)
loaderRef.current?.load(src ? { url: src, length: byteSize } : { base64: cleanedContent })
// Identical bytes still require reload when ownership changes.
}, [cleanedContent, filePath, preferenceKey, scrollCacheKey])
}, [byteSize, cleanedContent, filePath, preferenceKey, scrollCacheKey, src])
const closeFindBar = useCallback(() => {
const eventBus = eventBusRef.current
@@ -21,6 +21,7 @@ export const WORKTREE_HOST_UNRESOLVED_ERROR =
export type FileContent = {
mediaUrl?: string
size?: number
csvPreview?: CsvFilePreview
content: string
isBinary: boolean
@@ -0,0 +1,73 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
type PdfOptions = Record<string, unknown> & {
range?: { requestDataRange: (begin: number, end: number) => void }
}
const { getDocument } = vi.hoisted(() => ({
getDocument: vi.fn((_options: PdfOptions) => ({
promise: new Promise(() => {}),
destroy: vi.fn(async () => {})
}))
}))
vi.mock('pdfjs-dist', () => ({
getDocument,
PDFDataRangeTransport: class {
constructor(readonly length: number) {}
onDataRange(): void {}
}
}))
import { createPdfDocumentLoader } from './pdf-document-loader'
describe('PDF document loader sources', () => {
beforeEach(() => {
getDocument.mockClear()
})
it('reads a sized URL by range, and still decodes base64 for diffs and the web client', () => {
const loader = createPdfDocumentLoader({ display: () => () => {}, onError: vi.fn() })
loader.load({ url: 'orca-media://file/doc?revision=r2', length: 4096 })
expect(getDocument.mock.lastCall?.[0]).toMatchObject({ range: { length: 4096 } })
expect(getDocument.mock.lastCall?.[0]).not.toHaveProperty('url')
getDocument.mockClear()
const onError = vi.fn()
const unsized = createPdfDocumentLoader({ display: () => () => {}, onError })
unsized.load({ url: 'orca-media://file/empty?revision=r3' })
expect(getDocument).not.toHaveBeenCalled()
expect(onError).toHaveBeenLastCalledWith('Failed to load PDF preview')
loader.load({ base64: 'JVBERg==' })
expect(getDocument.mock.lastCall?.[0]).toMatchObject({
data: new Uint8Array([0x25, 0x50, 0x44, 0x46])
})
loader.dispose()
})
afterEach(() => vi.unstubAllGlobals())
it('tears down the load whose range failed, not just its message', async () => {
vi.stubGlobal('window', {
...window,
api: {
fs: {
readPreviewRange: vi.fn(async () => {
throw new Error('gone')
})
}
}
})
const onError = vi.fn()
const loader = createPdfDocumentLoader({ display: () => () => {}, onError })
loader.load({ url: 'orca-media://file/doc?revision=r1', length: 4096 })
const task = getDocument.mock.results.at(-1)?.value
getDocument.mock.lastCall?.[0].range?.requestDataRange(0, 1024)
await vi.waitFor(() => expect(onError).toHaveBeenLastCalledWith('Failed to load PDF preview'))
expect(task?.destroy).toHaveBeenCalledOnce()
loader.dispose()
})
})
@@ -1,10 +1,14 @@
import * as pdfjsLib from 'pdfjs-dist'
import { buildPdfJsDocumentOptions } from './pdf-js-document-options'
import { createOrcaMediaRangeTransport } from './pdf-orca-media-range-transport'
type PdfLoadingTask = ReturnType<typeof pdfjsLib.getDocument>
/** Bytes as base64, or an orca-media URL (with its size) pdf.js reads by range. */
export type PdfDocumentSource = { base64: string } | { url: string; length?: number }
export type PdfDocumentLoader = {
load: (content: string) => void
load: (source: PdfDocumentSource) => void
dispose: () => void
}
@@ -34,7 +38,7 @@ export function createPdfDocumentLoader({
}
return {
load: (content) => {
load: (source) => {
if (disposed) {
return
}
@@ -53,30 +57,50 @@ export function createPdfDocumentLoader({
}
}
onError(null)
if (!content) {
onError('Failed to load PDF preview')
// Bound to the task once it exists: a failed range tears down that load, not just its message.
let failRange: (error: unknown) => void = fail
const documentSource =
'url' in source
? source.length
? {
range: createOrcaMediaRangeTransport(source.url, source.length, (error) =>
failRange(error)
)
}
: // orca-media refuses fetch, so a URL is only readable through its sized range transport.
'Failed to load PDF preview'
: decodePdfBase64(source.base64)
if (typeof documentSource === 'string') {
onError(documentSource)
return
}
let binary: string
try {
binary = window.atob(content)
} catch {
onError('Failed to decode PDF content')
return
}
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
let task: PdfLoadingTask
try {
task = pdfjsLib.getDocument(buildPdfJsDocumentOptions(bytes, document.baseURI))
task = pdfjsLib.getDocument(buildPdfJsDocumentOptions(documentSource, document.baseURI))
} catch (error) {
fail(error)
return
}
tasks.add(task)
pending = task
failRange = (error) => {
if (disposed || !tasks.has(task)) {
return
}
if (pending === task) {
pending = null
}
const shown = displayed?.task === task ? displayed : null
try {
shown?.detach()
} finally {
if (shown) {
displayed = null
}
destroy(task)
fail(error)
}
}
void task.promise.then(
(doc) => {
if (disposed || pending !== task) {
@@ -118,3 +142,20 @@ export function createPdfDocumentLoader({
}
}
}
function decodePdfBase64(content: string): { data: Uint8Array } | string {
if (!content) {
return 'Failed to load PDF preview'
}
let binary: string
try {
binary = window.atob(content)
} catch {
return 'Failed to decode PDF content'
}
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index)
}
return { data: bytes }
}
@@ -1,6 +1,14 @@
export function buildPdfJsDocumentOptions(data: Uint8Array, baseUrl: string) {
import type { PDFDataRangeTransport } from 'pdfjs-dist'
import { MAX_FILE_RANGE_READ_BYTES } from '../../../../shared/file-range-read'
export function buildPdfJsDocumentOptions(
source: { data: Uint8Array } | { range: PDFDataRangeTransport },
baseUrl: string
) {
return {
data,
...source,
// One readChunk-sized range per request instead of pdf.js's 64 KiB default.
...('range' in source ? { rangeChunkSize: MAX_FILE_RANGE_READ_BYTES } : {}),
cMapUrl: new URL('cmaps/', baseUrl).href,
cMapPacked: true,
standardFontDataUrl: new URL('standard_fonts/', baseUrl).href,
@@ -0,0 +1,67 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('pdfjs-dist', () => ({
PDFDataRangeTransport: class {
constructor(
readonly length: number,
readonly initialData: unknown
) {}
onDataRange(_begin: number, _chunk: Uint8Array): void {}
}
}))
import { createOrcaMediaRangeTransport } from './pdf-orca-media-range-transport'
afterEach(() => vi.unstubAllGlobals())
function stubRangeReads(
readPreviewRange: (args: { url: string; offset: number; length: number }) => Promise<Uint8Array>
) {
vi.stubGlobal('window', { ...window, api: { fs: { readPreviewRange } } })
}
describe('orca-media range transport for pdf.js', () => {
it('reads each range pdf.js asks for over IPC and hands back its bytes', async () => {
const readPreviewRange = vi.fn(async () => new Uint8Array([7, 8, 9]))
stubRangeReads(readPreviewRange)
const transport = createOrcaMediaRangeTransport('orca-media://file/doc', 100, vi.fn())
const onDataRange = vi.spyOn(transport, 'onDataRange')
transport.requestDataRange(10, 13)
await vi.waitFor(() => expect(onDataRange).toHaveBeenCalledWith(10, new Uint8Array([7, 8, 9])))
expect(readPreviewRange).toHaveBeenCalledWith({
url: 'orca-media://file/doc',
offset: 10,
length: 3
})
})
it('reports a failed range instead of leaving pdf.js waiting', async () => {
stubRangeReads(
vi.fn(async () => {
throw new Error('Preview is no longer available')
})
)
const onFailure = vi.fn()
createOrcaMediaRangeTransport('orca-media://file/doc', 100, onFailure).requestDataRange(0, 10)
await vi.waitFor(() => expect(onFailure).toHaveBeenCalledOnce())
})
it('stops reading the rest of a merged range once the viewer aborts', async () => {
let release: (bytes: Uint8Array) => void = () => {}
const readPreviewRange = vi.fn(() => new Promise<Uint8Array>((resolve) => (release = resolve)))
stubRangeReads(readPreviewRange)
const transport = createOrcaMediaRangeTransport('orca-media://file/doc', 12 << 20, vi.fn())
transport.requestDataRange(0, 12 << 20)
await vi.waitFor(() => expect(readPreviewRange).toHaveBeenCalledOnce())
transport.abort()
release(new Uint8Array(4 << 20))
await new Promise((resolve) => setTimeout(resolve, 0))
expect(readPreviewRange).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,64 @@
import { PDFDataRangeTransport } from 'pdfjs-dist'
// Main's per-call bound for preview range reads; pdf.js may merge neighbouring chunks past it.
const PREVIEW_RANGE_READ_MAX_BYTES = 4 * 1024 * 1024
/**
* pdf.js only fetches ranges over http(s); this reads an orca-media grant's ranges over IPC, which
* main answers only for the renderer that minted it, so the scheme needs no fetch or CORS.
*/
export function createOrcaMediaRangeTransport(
url: string,
length: number,
onFailure: (error: unknown) => void
): PDFDataRangeTransport {
let aborted = false
class OrcaMediaRangeTransport extends PDFDataRangeTransport {
override requestDataRange(begin: number, end: number): void {
void readRange(url, begin, end, () => aborted)
.then((chunk) => {
if (!aborted) {
this.onDataRange(begin, chunk)
}
})
.catch((error: unknown) => {
// pdf.js has no range-failure callback; without this it would wait forever.
if (!aborted) {
onFailure(error)
}
})
}
override abort(): void {
aborted = true
}
}
return new OrcaMediaRangeTransport(length, null)
}
async function readRange(
url: string,
begin: number,
end: number,
isAborted: () => boolean
): Promise<Uint8Array> {
const range = new Uint8Array(end - begin)
let read = 0
while (begin + read < end) {
// A closed or reloaded viewer schedules no further pieces.
if (isAborted()) {
throw new Error('PDF range read cancelled')
}
const piece = await window.api.fs.readPreviewRange({
url,
offset: begin + read,
length: Math.min(PREVIEW_RANGE_READ_MAX_BYTES, end - begin - read)
})
if (piece.byteLength === 0) {
throw new Error('PDF ended before the requested range')
}
range.set(piece, read)
read += piece.byteLength
}
return range
}
@@ -21,7 +21,11 @@ const localizationResources = vi.hoisted(
(): { active: boolean; destroy: ReturnType<typeof vi.fn<() => Promise<void>>> }[] => []
)
vi.mock('pdfjs-dist', () => ({ GlobalWorkerOptions: {}, getDocument }))
vi.mock('pdfjs-dist', () => ({
GlobalWorkerOptions: {},
getDocument,
PDFDataRangeTransport: class {}
}))
vi.mock('pdfjs-dist/web/pdf_viewer.mjs', () => {
class EventBus {
listeners = new Map<string, Set<(event: { location?: unknown; scale: number }) => void>>()
@@ -3,6 +3,8 @@ import type { LocalFileAccess } from '../../../shared/local-file-access'
export type RuntimeReadableFileContent = {
mediaUrl?: string
/** Bytes behind `mediaUrl`, for viewers that no longer see the content. */
size?: number
content: string
isBinary: boolean
isImage?: boolean
@@ -413,6 +413,48 @@ describe('runtime file client', () => {
expect(fsGrantRuntimeFilePreview).toHaveBeenCalledOnce()
})
it('streams a remote PDF or image to its viewer the same way', async () => {
fsGrantRuntimeFilePreview.mockResolvedValue({
...media,
mimeType: 'application/pdf',
isImage: true
})
await expect(
readRuntimeFileContent({
target: env,
filePath: '/remote/repo/docs/spec.pdf',
relativePath: 'docs/spec.pdf',
worktreeId: 'wt-1',
pageOversizedBinary: true
})
).resolves.toMatchObject({ isImage: true, mediaUrl: 'orca-media://file/t' })
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('asks a local read for a URL only when a viewer renders the bytes', async () => {
fsReadFile.mockResolvedValue({ content: '', isBinary: true })
const local = { kind: 'local' as const }
await readRuntimeFileContent({
target: local,
filePath: '/repo/a.png',
relativePath: 'a.png',
worktreeId: 'wt-1'
})
await readRuntimeFileContent({
target: local,
filePath: '/repo/a.png',
relativePath: 'a.png',
worktreeId: 'wt-1',
pageOversizedBinary: true
})
expect(fsReadFile).toHaveBeenNthCalledWith(
1,
expect.not.objectContaining({ rangePreview: true })
)
expect(fsReadFile).toHaveBeenNthCalledWith(2, expect.objectContaining({ rangePreview: true }))
})
it('streams a chat scratch video from its own address', async () => {
fsGrantRuntimeFilePreview.mockResolvedValue(media)
const root = `/state/chat-scratch/${'a'.repeat(32)}`
@@ -1,4 +1,4 @@
import { filePreviewForPath, streamsPreviewByRange } from '../../../shared/file-preview-kind'
import { filePreviewForPath } from '../../../shared/file-preview-kind'
import type { RuntimeRemoteFileAddress } from './runtime-file-range-client'
import type { RuntimeReadableFileContent } from './runtime-file-client-types'
@@ -6,7 +6,7 @@ import type { RuntimeReadableFileContent } from './runtime-file-client-types'
export async function readRemoteRangePreview(
remote: RuntimeRemoteFileAddress
): Promise<RuntimeReadableFileContent | null> {
if (!streamsPreviewByRange(filePreviewForPath(remote.relativePath))) {
if (!filePreviewForPath(remote.relativePath)) {
return null
}
// Null when this client cannot serve orca-media URLs (the paired web client).
@@ -61,7 +61,9 @@ export async function readRuntimeFileContent({
connectionId,
includeLocalLogMetadata,
...localAccess(connectionId, access),
...(wakeWslDistro && !connectionId ? { wakeWslDistro } : {})
...(wakeWslDistro && !connectionId ? { wakeWslDistro } : {}),
// A viewer reads images and PDFs by URL; baseline checks keep base64.
...(pageOversizedBinary && !connectionId ? { rangePreview: true } : {})
})
}
// Why: decided synchronously so ordinary reads pin their pairing without an extra await.
@@ -48,6 +48,10 @@ export function createFileApi(): NonNullable<Partial<PreloadApi>['fs']> {
},
// No orca-media protocol in a browser; audio and video keep the binary placeholder.
grantRuntimeFilePreview: async () => null,
// Never minted here, so no viewer asks.
readPreviewRange: async () => {
throw new Error('Preview ranges are not available in the web client')
},
readFile: async ({ filePath }) => {
const file = await resolveRuntimeFilePath(filePath)
return callRuntimeResult('files.readPreview', {
+6 -1
View File
@@ -32,7 +32,12 @@ export function filePreviewForPath(filePath: string): FilePreview | null {
return null
}
/** Viewers that read a URL by byte range, so a remote file streams instead of arriving whole. */
/** Audio and video always stream by range; their players can't take base64. */
export function streamsPreviewByRange(preview: FilePreview | null): boolean {
return preview?.kind === 'audio' || preview?.kind === 'video'
}
/** Images and PDFs read a URL when their viewer asks, and base64 elsewhere (diffs, the web client). */
export function isDocumentPreview(preview: FilePreview | null): boolean {
return preview?.kind === 'image' || preview?.kind === 'pdf'
}
+2 -2
View File
@@ -131,9 +131,9 @@ export function decodeBase64Prefix(content: string, maxBytes: number): Uint8Arra
}
// 64 bytes clears fixed headers/JFIF SOF, measuring ordinary screenshots/icons without decoding the full image.
const RASTER_IMAGE_HEADER_PROBE_BYTES = 64
export const RASTER_IMAGE_HEADER_PROBE_BYTES = 64
// JPEG metadata can push SOF arbitrarily far; geometric growth keeps repeated decoding within ~1.07x of needed header bytes.
const RASTER_IMAGE_HEADER_PROBE_GROWTH = 16
export const RASTER_IMAGE_HEADER_PROBE_GROWTH = 16
/** Only confidently oversized images suppress previews; unknown/truncated headers mean unmeasurable, not invalid images. */
export function exceedsRasterImagePreviewLimits(
+38
View File
@@ -0,0 +1,38 @@
import {
RASTER_IMAGE_HEADER_PROBE_BYTES,
RASTER_IMAGE_HEADER_PROBE_GROWTH
} from './raster-image-base64-preview'
import { readRasterImageDimensions } from './raster-image-dimensions'
import {
isKnownRasterImageMimeType,
isRasterImagePreviewDimensions,
RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES
} from './raster-image-preview-limits'
/**
* The base64 preview rule for an image read by URL: only a header proving it oversized refuses it.
* `readPrefix(n)` returns the file's first n bytes, or fewer at EOF.
*/
export async function rasterImageExceedsPreviewLimits(
readPrefix: (bytes: number) => Promise<Uint8Array>,
mimeType: string | undefined
): Promise<boolean> {
if (!isKnownRasterImageMimeType(mimeType)) {
return false
}
let probeBytes = RASTER_IMAGE_HEADER_PROBE_BYTES
for (;;) {
const prefix = await readPrefix(probeBytes)
const dimensions = readRasterImageDimensions(prefix)
if (dimensions !== null) {
return !isRasterImagePreviewDimensions(dimensions)
}
if (prefix.byteLength < probeBytes || probeBytes >= RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES) {
return false
}
probeBytes = Math.min(
probeBytes * RASTER_IMAGE_HEADER_PROBE_GROWTH,
RASTER_IMAGE_PREVIEW_HEADER_MAX_BYTES
)
}
}
@@ -0,0 +1,62 @@
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { expect, test } from './helpers/orca-app'
// 1x1 PNG.
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
'base64'
)
test('orca-media URLs load only as app elements: no fetch, and no other session can read them', async ({
orcaPage,
electronApp,
seededRepoPath,
registerPostElectronShutdownCleanup
}) => {
const filePath = path.join(seededRepoPath, 'isolation-probe.png')
writeFileSync(filePath, PNG)
registerPostElectronShutdownCleanup(async () => rmSync(filePath, { force: true }))
await orcaPage.evaluate((filePath) => {
const state = window.__store?.getState()
if (!state?.activeWorktreeId) {
throw new Error('Missing fixture worktree')
}
state.openFile({
filePath,
relativePath: 'isolation-probe.png',
worktreeId: state.activeWorktreeId,
language: 'plaintext',
mode: 'edit'
})
}, filePath)
const image = orcaPage.locator('img[src^="orca-media://"]').first()
await expect(image).toBeVisible()
const url = await image.getAttribute('src')
expect(url).toBeTruthy()
// The app's own page shows the image but cannot read its bytes: the scheme has no fetch privilege.
const pageFetch = await orcaPage.evaluate(async (url) => {
try {
await fetch(url)
return 'read'
} catch {
return 'refused'
}
}, url ?? '')
expect(pageFetch).toBe('refused')
const sessions = await electronApp.evaluate(async ({ session }, url) => {
const probe = async (target: Electron.Session): Promise<string> =>
target.fetch(url).then(
(response) => String(response.status),
() => 'refused'
)
return {
app: await probe(session.defaultSession),
browserPartition: await probe(session.fromPartition('persist:orca-media-isolation-probe'))
}
}, url ?? '')
// Main's own session serves the grant; a browser-pane-style partition has no handler at all.
expect(sessions).toEqual({ app: '200', browserPartition: 'refused' })
})