fix(desktop): 修复 PDF 预览持续加载
This commit is contained in:
1 parent
663f76eeca
commit
262e8b7e7f
7 files changed
+143
-49
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
Vendored
+1
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user