diff --git a/src/renderer/src/components/editor/EditorEditFileSurface.tsx b/src/renderer/src/components/editor/EditorEditFileSurface.tsx
index c27df188d11..7363a4ea687 100644
--- a/src/renderer/src/components/editor/EditorEditFileSurface.tsx
+++ b/src/renderer/src/components/editor/EditorEditFileSurface.tsx
@@ -127,7 +127,7 @@ export function EditorEditFileSurface({
)
}
if (fileContent.isBinary) {
- if (fileContent.mediaUrl) {
+ if (fileContent.mediaUrl && !fileContent.isImage) {
return (
{
}
})
})
+
+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: '' })
+ })
+})
diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx
index 48722aeb649..5e97c85fe7a 100644
--- a/src/renderer/src/components/editor/ImageViewer.tsx
+++ b/src/renderer/src/components/editor/ImageViewer.tsx
@@ -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 (
{
- 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
diff --git a/src/renderer/src/components/editor/editor-panel-content-types.ts b/src/renderer/src/components/editor/editor-panel-content-types.ts
index c11025a7add..6195e4a9cc8 100644
--- a/src/renderer/src/components/editor/editor-panel-content-types.ts
+++ b/src/renderer/src/components/editor/editor-panel-content-types.ts
@@ -21,6 +21,7 @@ export const WORKTREE_HOST_UNRESOLVED_ERROR =
export type FileContent = {
mediaUrl?: string
+ size?: number
csvPreview?: CsvFilePreview
content: string
isBinary: boolean
diff --git a/src/renderer/src/components/editor/pdf-document-loader.test.ts b/src/renderer/src/components/editor/pdf-document-loader.test.ts
new file mode 100644
index 00000000000..c3bef44503c
--- /dev/null
+++ b/src/renderer/src/components/editor/pdf-document-loader.test.ts
@@ -0,0 +1,73 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+type PdfOptions = Record & {
+ 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()
+ })
+})
diff --git a/src/renderer/src/components/editor/pdf-document-loader.ts b/src/renderer/src/components/editor/pdf-document-loader.ts
index f3c4f4e37fd..1cf4813a470 100644
--- a/src/renderer/src/components/editor/pdf-document-loader.ts
+++ b/src/renderer/src/components/editor/pdf-document-loader.ts
@@ -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
+/** 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 }
+}
diff --git a/src/renderer/src/components/editor/pdf-js-document-options.ts b/src/renderer/src/components/editor/pdf-js-document-options.ts
index 06a1414474d..f6f10aed68b 100644
--- a/src/renderer/src/components/editor/pdf-js-document-options.ts
+++ b/src/renderer/src/components/editor/pdf-js-document-options.ts
@@ -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,
diff --git a/src/renderer/src/components/editor/pdf-orca-media-range-transport.test.ts b/src/renderer/src/components/editor/pdf-orca-media-range-transport.test.ts
new file mode 100644
index 00000000000..0379ed0bab8
--- /dev/null
+++ b/src/renderer/src/components/editor/pdf-orca-media-range-transport.test.ts
@@ -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
+) {
+ 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((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()
+ })
+})
diff --git a/src/renderer/src/components/editor/pdf-orca-media-range-transport.ts b/src/renderer/src/components/editor/pdf-orca-media-range-transport.ts
new file mode 100644
index 00000000000..a5e0663f4e0
--- /dev/null
+++ b/src/renderer/src/components/editor/pdf-orca-media-range-transport.ts
@@ -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 {
+ 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
+}
diff --git a/src/renderer/src/components/editor/pdf-viewer-failed-load-recovery.test.tsx b/src/renderer/src/components/editor/pdf-viewer-failed-load-recovery.test.tsx
index 5e1fce37035..acdaab5cada 100644
--- a/src/renderer/src/components/editor/pdf-viewer-failed-load-recovery.test.tsx
+++ b/src/renderer/src/components/editor/pdf-viewer-failed-load-recovery.test.tsx
@@ -21,7 +21,11 @@ const localizationResources = vi.hoisted(
(): { active: boolean; destroy: ReturnType Promise>> }[] => []
)
-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 void>>()
diff --git a/src/renderer/src/runtime/runtime-file-client-types.ts b/src/renderer/src/runtime/runtime-file-client-types.ts
index d0e3a5d6948..1b734324351 100644
--- a/src/renderer/src/runtime/runtime-file-client-types.ts
+++ b/src/renderer/src/runtime/runtime-file-client-types.ts
@@ -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
diff --git a/src/renderer/src/runtime/runtime-file-client.test.ts b/src/renderer/src/runtime/runtime-file-client.test.ts
index 53e42231368..08d9b557071 100644
--- a/src/renderer/src/runtime/runtime-file-client.test.ts
+++ b/src/renderer/src/runtime/runtime-file-client.test.ts
@@ -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)}`
diff --git a/src/renderer/src/runtime/runtime-file-range-preview.ts b/src/renderer/src/runtime/runtime-file-range-preview.ts
index 9df4c6101ce..097e2b0a946 100644
--- a/src/renderer/src/runtime/runtime-file-range-preview.ts
+++ b/src/renderer/src/runtime/runtime-file-range-preview.ts
@@ -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 {
- if (!streamsPreviewByRange(filePreviewForPath(remote.relativePath))) {
+ if (!filePreviewForPath(remote.relativePath)) {
return null
}
// Null when this client cannot serve orca-media URLs (the paired web client).
diff --git a/src/renderer/src/runtime/runtime-file-read-client.ts b/src/renderer/src/runtime/runtime-file-read-client.ts
index 808d20c36ef..6df8250bb30 100644
--- a/src/renderer/src/runtime/runtime-file-read-client.ts
+++ b/src/renderer/src/runtime/runtime-file-read-client.ts
@@ -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.
diff --git a/src/renderer/src/web/preload-api/web-filesystem-api.ts b/src/renderer/src/web/preload-api/web-filesystem-api.ts
index b7eab3fbf63..a250556673b 100644
--- a/src/renderer/src/web/preload-api/web-filesystem-api.ts
+++ b/src/renderer/src/web/preload-api/web-filesystem-api.ts
@@ -48,6 +48,10 @@ export function createFileApi(): NonNullable['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', {
diff --git a/src/shared/file-preview-kind.ts b/src/shared/file-preview-kind.ts
index 1bc770f2a8f..ce438712441 100644
--- a/src/shared/file-preview-kind.ts
+++ b/src/shared/file-preview-kind.ts
@@ -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'
+}
diff --git a/src/shared/raster-image-base64-preview.ts b/src/shared/raster-image-base64-preview.ts
index 3cf44c167d1..116bd4dafd1 100644
--- a/src/shared/raster-image-base64-preview.ts
+++ b/src/shared/raster-image-base64-preview.ts
@@ -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(
diff --git a/src/shared/raster-image-prefix-limits.ts b/src/shared/raster-image-prefix-limits.ts
new file mode 100644
index 00000000000..50d67b12aa2
--- /dev/null
+++ b/src/shared/raster-image-prefix-limits.ts
@@ -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,
+ mimeType: string | undefined
+): Promise {
+ 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
+ )
+ }
+}
diff --git a/tests/e2e/media-preview-partition-isolation.spec.ts b/tests/e2e/media-preview-partition-isolation.spec.ts
new file mode 100644
index 00000000000..22ef317e933
--- /dev/null
+++ b/tests/e2e/media-preview-partition-isolation.spec.ts
@@ -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 =>
+ 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' })
+})