diff --git a/apps/desktop/src/main/index.mjs b/apps/desktop/src/main/index.mjs index 1426e565..fde31e28 100644 --- a/apps/desktop/src/main/index.mjs +++ b/apps/desktop/src/main/index.mjs @@ -441,58 +441,60 @@ const createTray = () => { tray.on("double-click", () => showWindow(mainWindow)); }; -const registerResourceProtocol = () => { - protocol.handle("edgeever-resource", async (request) => { - const resourceId = resourceIdFromRequest(request.url); - if (!resourceId) return new Response("Invalid resource", { status: 400 }); +const handleResourceProtocolRequest = async (request) => { + const resourceId = resourceIdFromRequest(request.url); + if (!resourceId) return new Response("Invalid resource", { status: 400 }); - const directory = resourceCacheDirectory(); - const bytesPath = join(directory, `${resourceId}.bin`); - const metadataPath = join(directory, `${resourceId}.json`); + const directory = resourceCacheDirectory(); + const bytesPath = join(directory, `${resourceId}.bin`); + const metadataPath = join(directory, `${resourceId}.json`); - try { - const bytes = await readFile(bytesPath); - let metadata = {}; - try { metadata = JSON.parse(await readFile(metadataPath, "utf8")); } catch {} - return cachedResourceResponse(bytes, metadata.contentType, request.headers.get("range")); - } catch { - // Fall through to the instance while online, then persist the response. - } + try { + const bytes = await readFile(bytesPath); + let metadata = {}; + try { metadata = JSON.parse(await readFile(metadataPath, "utf8")); } catch {} + return cachedResourceResponse(bytes, metadata.contentType, request.headers.get("range")); + } catch { + // Fall through to the instance while online, then persist the response. + } - if (!configuredApiBaseUrl) return new Response("Resource is not cached", { status: 504 }); - const sourceUrl = `${configuredApiBaseUrl}/api/v1/resources/${encodeURIComponent(resourceId)}/blob`; - try { - const cookies = await session.defaultSession.cookies.get({ url: sourceUrl }); - const headers = resourceRequestHeaders({ cookies, sessionToken: desktopSessionToken }); - const rangeHeader = request.headers.get("range"); - if (rangeHeader) headers.set("range", rangeHeader); - const response = await net.fetch(sourceUrl, { headers }); - if (!response.ok) return new Response("Resource request failed", { status: response.status }); - const body = Buffer.from(await response.arrayBuffer()); - if (response.status === 206) { - const responseHeaders = new Headers({ - "Accept-Ranges": response.headers.get("accept-ranges") || "bytes", - "Cache-Control": "no-store", - "Content-Type": response.headers.get("content-type") || "application/octet-stream", - }); - for (const name of ["content-length", "content-range", "etag", "last-modified"]) { - const value = response.headers.get(name); - if (value) responseHeaders.set(name, value); - } - return new Response(body, { status: 206, headers: responseHeaders }); + if (!configuredApiBaseUrl) return new Response("Resource is not cached", { status: 504 }); + const sourceUrl = `${configuredApiBaseUrl}/api/v1/resources/${encodeURIComponent(resourceId)}/blob`; + try { + const cookies = await session.defaultSession.cookies.get({ url: sourceUrl }); + const headers = resourceRequestHeaders({ cookies, sessionToken: desktopSessionToken }); + const rangeHeader = request.headers.get("range"); + if (rangeHeader) headers.set("range", rangeHeader); + const response = await net.fetch(sourceUrl, { headers }); + if (!response.ok) return new Response("Resource request failed", { status: response.status }); + const body = Buffer.from(await response.arrayBuffer()); + if (response.status === 206) { + const responseHeaders = new Headers({ + "Accept-Ranges": response.headers.get("accept-ranges") || "bytes", + "Cache-Control": "no-store", + "Content-Type": response.headers.get("content-type") || "application/octet-stream", + }); + for (const name of ["content-length", "content-range", "etag", "last-modified"]) { + const value = response.headers.get(name); + if (value) responseHeaders.set(name, value); } - await mkdir(directory, { recursive: true }); - await restrictDirectory(directory); - await writeFile(bytesPath, body, { mode: 0o600 }); - await writeFile(metadataPath, JSON.stringify({ contentType: response.headers.get("content-type") || "application/octet-stream" }), { mode: 0o600 }); - await restrictFile(bytesPath); - await restrictFile(metadataPath); - return cachedResourceResponse(body, response.headers.get("content-type"), null); - } catch (error) { - void writeDiagnostic("resource.cache-failed", { resourceId, message: error.message }); - return new Response("Resource unavailable", { status: 504 }); + return new Response(body, { status: 206, headers: responseHeaders }); } - }); + await mkdir(directory, { recursive: true }); + await restrictDirectory(directory); + await writeFile(bytesPath, body, { mode: 0o600 }); + await writeFile(metadataPath, JSON.stringify({ contentType: response.headers.get("content-type") || "application/octet-stream" }), { mode: 0o600 }); + await restrictFile(bytesPath); + await restrictFile(metadataPath); + return cachedResourceResponse(body, response.headers.get("content-type"), null); + } catch (error) { + void writeDiagnostic("resource.cache-failed", { resourceId, message: error.message }); + return new Response("Resource unavailable", { status: 504 }); + } +}; + +const registerResourceProtocol = () => { + protocol.handle("edgeever-resource", handleResourceProtocolRequest); protocol.handle("edgeever-staged", async (request) => { const stagedId = resourceIdFromRequest(request.url); @@ -1093,6 +1095,17 @@ app.whenReady().then(async () => { const bytes = await readFile(join(directory, `${id}.bin`)); return { ...metadata, bytes: new Uint8Array(bytes) }; }); + ipcMain.handle("desktop:read-resource", async (_event, id) => { + if (!isSafeResourceId(id)) throw new Error("Invalid resource id"); + const response = await handleResourceProtocolRequest(new Request( + `edgeever-resource://resource/${encodeURIComponent(id)}`, + )); + if (!response.ok) throw new Error(`Resource request failed (${response.status})`); + return { + type: response.headers.get("content-type") || "application/octet-stream", + bytes: new Uint8Array(await response.arrayBuffer()), + }; + }); ipcMain.handle("desktop:remove-staged-resource", async (_event, id) => { if (!isSafeResourceId(id)) throw new Error("Invalid staged resource id"); const directory = stagedResourceDirectory(); diff --git a/apps/desktop/src/preload/index.cjs b/apps/desktop/src/preload/index.cjs index a05a7dc6..18a9f989 100644 --- a/apps/desktop/src/preload/index.cjs +++ b/apps/desktop/src/preload/index.cjs @@ -29,6 +29,7 @@ contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({ listStagedResources: () => ipcRenderer.invoke("desktop:list-staged-resources"), remapStagedResourceMemoIds: (mappings) => ipcRenderer.invoke("desktop:remap-staged-resource-memo-ids", mappings), readStagedResource: (id) => ipcRenderer.invoke("desktop:read-staged-resource", id), + readResource: (id) => ipcRenderer.invoke("desktop:read-resource", id), removeStagedResource: (id) => ipcRenderer.invoke("desktop:remove-staged-resource", id), onCommand: (callback) => { const listener = (_event, command) => callback(command); diff --git a/apps/web/src/components/pdf/PdfThumbnail.tsx b/apps/web/src/components/pdf/PdfThumbnail.tsx index 2748474d..728778d2 100644 --- a/apps/web/src/components/pdf/PdfThumbnail.tsx +++ b/apps/web/src/components/pdf/PdfThumbnail.tsx @@ -5,6 +5,7 @@ import { memo, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; import { loadPdfJs } from "./pdfjs-loader"; +import { loadPdfDocumentSource } from "./pdf-document-source"; import { calculatePdfThumbnailScale, isPdfThumbnailEligible, @@ -73,7 +74,9 @@ export const PdfThumbnail = memo(({ url, label, byteSize, className }: PdfThumbn if (cancelled) return; const pdfjs = await loadPdfJs(); if (cancelled) return; - loadingTask = pdfjs.getDocument({ url }); + const source = await loadPdfDocumentSource(url); + if (cancelled) return; + loadingTask = pdfjs.getDocument(source); const document = await loadingTask.promise; const firstPage = await document.getPage(1); if (!cancelled) setPage(firstPage); diff --git a/apps/web/src/components/pdf/PdfViewer.tsx b/apps/web/src/components/pdf/PdfViewer.tsx index 2b16ab8e..a06dfb38 100644 --- a/apps/web/src/components/pdf/PdfViewer.tsx +++ b/apps/web/src/components/pdf/PdfViewer.tsx @@ -20,6 +20,7 @@ import { useTranslation } from "react-i18next"; import { ButtonTooltip } from "@/components/ui/button-tooltip"; import { cn } from "@/lib/utils"; import { loadPdfJs } from "./pdfjs-loader"; +import { loadPdfDocumentSource } from "./pdf-document-source"; const MIN_ZOOM = 0.5; const MAX_ZOOM = 2.5; @@ -116,7 +117,9 @@ const PdfDocument = ({ url, active, fitWidth, zoom, onError }: PdfDocumentProps) void loadPdfJs().then(async (pdfjs) => { if (cancelled) return; - loadingTask = pdfjs.getDocument({ url }); + const source = await loadPdfDocumentSource(url); + if (cancelled) return; + loadingTask = pdfjs.getDocument(source); const loadedDocument = await loadingTask.promise; if (cancelled) return; setDocument(loadedDocument); diff --git a/apps/web/src/components/pdf/pdf-document-source.test.mjs b/apps/web/src/components/pdf/pdf-document-source.test.mjs new file mode 100644 index 00000000..4292d873 --- /dev/null +++ b/apps/web/src/components/pdf/pdf-document-source.test.mjs @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { loadPdfDocumentSource } from "./pdf-document-source.ts"; + +const bridge = { + readResource: async (id) => ({ type: "application/pdf", bytes: new Uint8Array([1, id.length]) }), + readStagedResource: async (id) => ({ name: "draft.pdf", type: "application/pdf", bytes: new Uint8Array([2, id.length]) }), +}; + +describe("PDF document sources", () => { + test("keeps normal web URLs for PDF.js network loading", async () => { + await expect(loadPdfDocumentSource("https://example.com/report.pdf", bridge)).resolves.toEqual({ + url: "https://example.com/report.pdf", + }); + }); + + test("loads synced desktop resources through the native bridge", async () => { + const source = await loadPdfDocumentSource("edgeever-resource://resource/res_123", bridge); + expect(source).toEqual({ data: new Uint8Array([1, 7]) }); + }); + + test("loads staged desktop resources before sync completes", async () => { + const source = await loadPdfDocumentSource("edgeever-staged://stage_123", bridge); + expect(source).toEqual({ data: new Uint8Array([2, 9]) }); + }); + + test("does not mutate or transfer the bridge-owned byte array", async () => { + const bytes = new Uint8Array([4, 5, 6]); + const source = await loadPdfDocumentSource("edgeever-resource://resource/res_copy", { + ...bridge, + readResource: async () => ({ type: "application/pdf", bytes }), + }); + expect(source.data).not.toBe(bytes); + expect(source.data).toEqual(bytes); + }); +}); diff --git a/apps/web/src/components/pdf/pdf-document-source.ts b/apps/web/src/components/pdf/pdf-document-source.ts new file mode 100644 index 00000000..80d06904 --- /dev/null +++ b/apps/web/src/components/pdf/pdf-document-source.ts @@ -0,0 +1,38 @@ +type PdfResourceBridge = Pick; + +export type PdfDocumentSource = { url: string } | { data: Uint8Array }; + +const parseDesktopResourceId = (url: string, scheme: "edgeever-resource:" | "edgeever-staged:") => { + try { + const parsed = new URL(url); + if (parsed.protocol !== scheme) return null; + const rawId = parsed.pathname.replace(/^\//, "") || parsed.hostname; + return rawId ? decodeURIComponent(rawId) : null; + } catch { + return null; + } +}; + +const copyBytes = (bytes: Uint8Array) => new Uint8Array(bytes); + +/** PDF.js only treats HTTP(S) as fetchable and can leave Electron custom URLs pending in XHR. */ +export const loadPdfDocumentSource = async ( + url: string, + bridge: PdfResourceBridge | undefined = typeof window === "undefined" ? undefined : window.edgeeverDesktop, +): Promise => { + if (!bridge) return { url }; + + const stagedId = parseDesktopResourceId(url, "edgeever-staged:"); + if (stagedId) { + const resource = await bridge.readStagedResource(stagedId); + return { data: copyBytes(resource.bytes) }; + } + + const resourceId = parseDesktopResourceId(url, "edgeever-resource:"); + if (resourceId) { + const resource = await bridge.readResource(resourceId); + return { data: copyBytes(resource.bytes) }; + } + + return { url }; +}; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 91a329be..5327007f 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -41,6 +41,7 @@ interface EdgeEverDesktopBridge { listStagedResources(): Promise>; remapStagedResourceMemoIds?(mappings: Array<[string, string]>): Promise<{ updated: number }>; readStagedResource(id: string): Promise<{ name: string; type: string; bytes: Uint8Array }>; + readResource(id: string): Promise<{ type: string; bytes: Uint8Array }>; removeStagedResource(id: string): Promise; onCommand(callback: (command: string) => void): () => void; onImportMarkdown(callback: (payload: { name: string; content: string }) => void): () => void;