fix(desktop): 修复 PDF 预览持续加载

This commit is contained in:
tianma-if committed 2026-08-27 14:50:43 +08:00
1 parent 663f76eeca
commit 262e8b7e7f
7 files changed
+143 -49

No files matched your search

+60 -47
View File
@@ -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();
+1
View File
@@ -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);
+4 -1
View File
@@ -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);
+4 -1
View File
@@ -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);
@@ -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);
});
});
@@ -0,0 +1,38 @@
type PdfResourceBridge = Pick<EdgeEverDesktopBridge, "readResource" | "readStagedResource">;
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<PdfDocumentSource> => {
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 };
};
+1
View File
@@ -41,6 +41,7 @@ interface EdgeEverDesktopBridge {
listStagedResources(): Promise<Array<{ id: string; memoId: string; name: string; type: string; size: number }>>;
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<void>;
onCommand(callback: (command: string) => void): () => void;
onImportMarkdown(callback: (payload: { name: string; content: string }) => void): () => void;