From 6bab6fbe97577fbb9ec7969f1053c31f3d20fcce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 30 Sep 2026 01:53:47 +0800 Subject: [PATCH] feat(desktop): preview documents in the workspace and images in chat Documents the agent writes open in the workspace panel instead of another application, and local images the agent mentions show up in the conversation. Workspace preview - PDF (pdf.js with its own layout and text layer), Word (docx-preview inside a scripts-disabled sandboxed iframe) and Excel (SheetJS; .xlsx, .xlsm, .xls) open in the side panel with zoom and fit, per-file scroll/zoom/sheet memory, and a refresh when the agent rewrites the file. The engines load lazily. - Bytes come from a new GET /api/sessions/:id/workspace/raw route, with an extension allowlist, size caps, the workspace boundary and canonical-path checks. The file endpoint returns metadata and a version for documents. The client fetches with the bearer credential, so it works in Electron, LAN H5 and remote access alike. - Chat links, output cards and the change card open pdf/docx/xlsx in the workspace; documents outside the workdir still go to the system application. - Image viewer with fit, zoom and pan, and "open in system app". Chat images - Markdown images outside the workdir, at ~/, C:\ and file:// paths render, open in the viewer, and offer "open original" (pictures only). - Images returned by tools such as Read appear as thumbnails under the call. Hardening found in review - previewFsUrl escapes each path segment; a double-escaped %2e%2e used to leave /preview-fs//. - The CORS, API timing and remote-access header decorators set headers in place. Rebuilding the response buffered whole files in memory and dropped Content-Length. - The engine owns the pdf.js worker, so closing one document no longer fails the next open. - Office archives are inflated in steps to check their real sizes, not the sizes they declare. - A viewer that fails to load stays in its panel instead of taking the window down. Adds pdfjs-dist, docx-preview, xlsx (SheetJS 0.20.3 tarball) and fflate as renderer dev dependencies; Vite bundles them. Refs #1397 --- THIRD_PARTY_LICENSES.md | 13 + desktop/bun.lock | 56 ++ desktop/package.json | 6 +- desktop/scripts/vite-pdfjs-assets.test.ts | 236 ++++++ desktop/scripts/vite-pdfjs-assets.ts | 86 ++ .../__tests__/documentEngineImports.test.ts | 122 +++ desktop/src/api/sessions.test.ts | 67 ++ desktop/src/api/sessions.ts | 34 +- .../chat/ActivityGroup.images.test.tsx | 400 ++++++++++ desktop/src/components/chat/ActivityGroup.tsx | 65 ++ .../chat/AssistantMessage.images.test.tsx | 233 ++++++ .../src/components/chat/AssistantMessage.tsx | 37 +- .../chat/AttachmentGallery.test.tsx | 27 + .../src/components/chat/AttachmentGallery.tsx | 2 +- .../chat/CurrentTurnChangeCard.test.tsx | 78 +- .../components/chat/CurrentTurnChangeCard.tsx | 8 +- .../chat/ImageGalleryModal.test.tsx | 128 ++- .../src/components/chat/ImageGalleryModal.tsx | 45 +- .../chat/ImageGenerationBlock.test.tsx | 51 ++ .../components/chat/ImageGenerationBlock.tsx | 3 +- .../chat/InlineImageGallery.test.tsx | 49 +- .../components/chat/InlineImageGallery.tsx | 7 +- .../chat/ToolCallBlock.images.test.tsx | 395 +++++++++ desktop/src/components/chat/ToolCallBlock.tsx | 20 + .../chat/ToolResultBlock.images.test.tsx | 97 +++ .../src/components/chat/ToolResultBlock.tsx | 20 +- .../components/chat/ToolResultImages.test.tsx | 461 +++++++++++ .../src/components/chat/ToolResultImages.tsx | 153 ++++ .../markdown/MarkdownRenderer.test.tsx | 149 ++++ .../components/markdown/MarkdownRenderer.tsx | 58 +- .../src/components/ui/ZoomControls.test.tsx | 92 +++ desktop/src/components/ui/ZoomControls.tsx | 111 +++ .../src/components/ui/ZoomableImage.test.tsx | 319 ++++++++ desktop/src/components/ui/ZoomableImage.tsx | 309 ++++++++ .../workbench/WorkspaceFileTab.test.tsx | 175 +++- .../components/workbench/WorkspaceFileTab.tsx | 38 +- .../workspace/surfaces/ImagePreview.test.tsx | 184 ++++- .../workspace/surfaces/ImagePreview.tsx | 79 +- .../surfaces/OpenInSystemButton.test.tsx | 51 ++ .../workspace/surfaces/OpenInSystemButton.tsx | 27 + .../workspace/surfaces/PanelMessage.test.tsx | 29 + .../workspace/surfaces/PanelMessage.tsx | 23 +- .../document/DocumentFailure.test.tsx | 73 ++ .../surfaces/document/DocumentFailure.tsx | 42 + .../document/DocumentSurface.test.tsx | 363 +++++++++ .../surfaces/document/DocumentSurface.tsx | 142 ++++ .../document/DocumentToolbar.test.tsx | 109 +++ .../surfaces/document/DocumentToolbar.tsx | 70 ++ .../document/DocumentViewerBoundary.test.tsx | 78 ++ .../document/DocumentViewerBoundary.tsx | 39 + .../surfaces/document/DocxSurface.test.tsx | 750 ++++++++++++++++++ .../surfaces/document/DocxSurface.tsx | 295 +++++++ .../surfaces/document/PdfPage.test.tsx | 374 +++++++++ .../workspace/surfaces/document/PdfPage.tsx | 154 ++++ .../surfaces/document/PdfSurface.test.tsx | 748 +++++++++++++++++ .../surfaces/document/PdfSurface.tsx | 89 +++ .../workspace/surfaces/document/PdfViewer.tsx | 330 ++++++++ .../surfaces/document/SheetGrid.test.tsx | 313 ++++++++ .../workspace/surfaces/document/SheetGrid.tsx | 191 +++++ .../document/SpreadsheetSurface.test.tsx | 592 ++++++++++++++ .../surfaces/document/SpreadsheetSurface.tsx | 180 +++++ .../surfaces/document/documentViewers.test.ts | 18 + .../surfaces/document/documentViewers.ts | 66 ++ .../surfaces/document/docxEngine.test.ts | 189 +++++ .../workspace/surfaces/document/docxEngine.ts | 101 +++ .../surfaces/document/docxFrame.test.ts | 457 +++++++++++ .../workspace/surfaces/document/docxFrame.ts | 229 ++++++ .../workspace/surfaces/document/pdf.worker.ts | 8 + .../workspace/surfaces/document/pdfAssets.ts | 30 + .../surfaces/document/pdfEngine.node.test.ts | 401 ++++++++++ .../workspace/surfaces/document/pdfEngine.ts | 312 ++++++++ .../surfaces/document/pdfLayout.test.ts | 237 ++++++ .../workspace/surfaces/document/pdfLayout.ts | 172 ++++ .../workspace/surfaces/document/pdfPage.css | 112 +++ .../surfaces/document/pdfPageStyles.test.ts | 222 ++++++ .../document/spreadsheetEngine.test.ts | 317 ++++++++ .../surfaces/document/spreadsheetEngine.ts | 266 +++++++ .../surfaces/document/useDocxDocument.ts | 163 ++++ .../surfaces/document/usePdfDocument.test.tsx | 294 +++++++ .../surfaces/document/usePdfDocument.ts | 85 ++ .../document/useSpreadsheetDocument.ts | 135 ++++ desktop/src/dev/ComponentGallery.tsx | 60 ++ desktop/src/hooks/useDebouncedValue.test.tsx | 90 +++ desktop/src/hooks/useDebouncedValue.ts | 23 + desktop/src/hooks/useElementSize.test.tsx | 115 +++ desktop/src/hooks/useElementSize.ts | 61 ++ .../hooks/useWorkspaceDocumentBlob.test.tsx | 159 ++++ desktop/src/hooks/useWorkspaceDocumentBlob.ts | 124 +++ desktop/src/i18n/locales/en.ts | 29 + desktop/src/i18n/locales/jp.ts | 29 + desktop/src/i18n/locales/kr.ts | 29 + desktop/src/i18n/locales/zh-TW.ts | 29 + desktop/src/i18n/locales/zh.ts | 29 + desktop/src/lib/assistantOutputTargets.ts | 3 +- desktop/src/lib/attachmentImages.ts | 4 +- desktop/src/lib/fileCapabilities.test.ts | 63 ++ desktop/src/lib/fileCapabilities.ts | 30 +- desktop/src/lib/handlePreviewLink.test.ts | 72 +- desktop/src/lib/handlePreviewLink.ts | 28 +- desktop/src/lib/markdownImages.test.ts | 286 ++++++- desktop/src/lib/markdownImages.ts | 243 +++++- desktop/src/lib/openPreviewLink.test.ts | 100 ++- desktop/src/lib/openPreviewLink.ts | 38 +- .../src/lib/openWithContextForHref.test.ts | 16 +- desktop/src/lib/openWithItems.test.ts | 15 + desktop/src/lib/openWithItems.ts | 10 +- desktop/src/lib/previewLinkRouter.test.ts | 37 +- desktop/src/lib/toolResultContent.test.ts | 560 +++++++++++++ desktop/src/lib/toolResultContent.ts | 208 +++++ .../lib/workspace/documentBlobCache.test.ts | 201 +++++ .../src/lib/workspace/documentBlobCache.ts | 165 ++++ .../src/lib/workspace/officeZipGuard.test.ts | 236 ++++++ desktop/src/lib/workspace/officeZipGuard.ts | 232 ++++++ desktop/src/lib/zoomPan.test.ts | 332 ++++++++ desktop/src/lib/zoomPan.ts | 201 +++++ .../src/stores/workspaceContentStore.test.ts | 151 +++- desktop/src/stores/workspaceContentStore.ts | 62 +- desktop/src/test/blobs.ts | 15 + desktop/src/test/fakeDocxEngine.ts | 72 ++ desktop/src/test/fakePdfEngine.ts | 116 +++ desktop/src/test/fakeSpreadsheetEngine.ts | 106 +++ desktop/src/test/fixtures/docx.ts | 147 ++++ desktop/src/test/fixtures/pdf.ts | 90 +++ desktop/src/test/fixtures/png.ts | 66 ++ desktop/src/test/fixtures/xlsx.ts | 61 ++ desktop/src/test/nodes.ts | 13 + desktop/src/theme/globals.css | 10 + desktop/src/theme/tokenUsage.test.ts | 2 + desktop/src/types/pdfjs-worker.d.ts | 7 + desktop/vite-config.test.ts | 9 + desktop/vite.config.ts | 3 +- docs/desktop/sessions.md | 1 + docs/desktop/workspace.md | 13 + docs/en/desktop/sessions.md | 1 + docs/en/desktop/workspace.md | 13 + scripts/pr/module-graph.test.ts | 9 + scripts/pr/module-graph.ts | 9 +- src/server/__tests__/filesystem.test.ts | 57 ++ src/server/__tests__/static-h5.test.ts | 69 ++ .../workspace-document-parity.test.ts | 54 ++ .../__tests__/workspace-raw-route.test.ts | 196 +++++ .../__tests__/workspace-service.test.ts | 220 ++++- src/server/api/filesystem.ts | 11 +- src/server/api/sessions.ts | 64 +- src/server/index.ts | 13 +- src/server/middleware/cors.ts | 6 + src/server/middleware/responseHeaders.test.ts | 86 ++ src/server/middleware/responseHeaders.ts | 32 + src/server/publicAccess.ts | 15 +- src/server/services/apiPerformanceMonitor.ts | 15 +- .../services/workspaceDocumentPreview.ts | 79 ++ src/server/services/workspaceService.ts | 87 +- src/server/staticH5.ts | 4 + 153 files changed, 18942 insertions(+), 188 deletions(-) create mode 100644 desktop/scripts/vite-pdfjs-assets.test.ts create mode 100644 desktop/scripts/vite-pdfjs-assets.ts create mode 100644 desktop/src/__tests__/documentEngineImports.test.ts create mode 100644 desktop/src/components/chat/ActivityGroup.images.test.tsx create mode 100644 desktop/src/components/chat/AssistantMessage.images.test.tsx create mode 100644 desktop/src/components/chat/ImageGenerationBlock.test.tsx create mode 100644 desktop/src/components/chat/ToolCallBlock.images.test.tsx create mode 100644 desktop/src/components/chat/ToolResultBlock.images.test.tsx create mode 100644 desktop/src/components/chat/ToolResultImages.test.tsx create mode 100644 desktop/src/components/chat/ToolResultImages.tsx create mode 100644 desktop/src/components/ui/ZoomControls.test.tsx create mode 100644 desktop/src/components/ui/ZoomControls.tsx create mode 100644 desktop/src/components/ui/ZoomableImage.test.tsx create mode 100644 desktop/src/components/ui/ZoomableImage.tsx create mode 100644 desktop/src/components/workspace/surfaces/OpenInSystemButton.test.tsx create mode 100644 desktop/src/components/workspace/surfaces/OpenInSystemButton.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/DocumentFailure.test.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/DocumentFailure.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/DocumentSurface.test.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/DocumentSurface.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/DocumentToolbar.test.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/DocumentToolbar.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/DocumentViewerBoundary.test.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/DocumentViewerBoundary.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/DocxSurface.test.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/DocxSurface.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/PdfPage.test.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/PdfPage.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/PdfSurface.test.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/PdfSurface.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/PdfViewer.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/SheetGrid.test.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/SheetGrid.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/SpreadsheetSurface.test.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/SpreadsheetSurface.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/documentViewers.test.ts create mode 100644 desktop/src/components/workspace/surfaces/document/documentViewers.ts create mode 100644 desktop/src/components/workspace/surfaces/document/docxEngine.test.ts create mode 100644 desktop/src/components/workspace/surfaces/document/docxEngine.ts create mode 100644 desktop/src/components/workspace/surfaces/document/docxFrame.test.ts create mode 100644 desktop/src/components/workspace/surfaces/document/docxFrame.ts create mode 100644 desktop/src/components/workspace/surfaces/document/pdf.worker.ts create mode 100644 desktop/src/components/workspace/surfaces/document/pdfAssets.ts create mode 100644 desktop/src/components/workspace/surfaces/document/pdfEngine.node.test.ts create mode 100644 desktop/src/components/workspace/surfaces/document/pdfEngine.ts create mode 100644 desktop/src/components/workspace/surfaces/document/pdfLayout.test.ts create mode 100644 desktop/src/components/workspace/surfaces/document/pdfLayout.ts create mode 100644 desktop/src/components/workspace/surfaces/document/pdfPage.css create mode 100644 desktop/src/components/workspace/surfaces/document/pdfPageStyles.test.ts create mode 100644 desktop/src/components/workspace/surfaces/document/spreadsheetEngine.test.ts create mode 100644 desktop/src/components/workspace/surfaces/document/spreadsheetEngine.ts create mode 100644 desktop/src/components/workspace/surfaces/document/useDocxDocument.ts create mode 100644 desktop/src/components/workspace/surfaces/document/usePdfDocument.test.tsx create mode 100644 desktop/src/components/workspace/surfaces/document/usePdfDocument.ts create mode 100644 desktop/src/components/workspace/surfaces/document/useSpreadsheetDocument.ts create mode 100644 desktop/src/hooks/useDebouncedValue.test.tsx create mode 100644 desktop/src/hooks/useDebouncedValue.ts create mode 100644 desktop/src/hooks/useElementSize.test.tsx create mode 100644 desktop/src/hooks/useElementSize.ts create mode 100644 desktop/src/hooks/useWorkspaceDocumentBlob.test.tsx create mode 100644 desktop/src/hooks/useWorkspaceDocumentBlob.ts create mode 100644 desktop/src/lib/fileCapabilities.test.ts create mode 100644 desktop/src/lib/toolResultContent.test.ts create mode 100644 desktop/src/lib/toolResultContent.ts create mode 100644 desktop/src/lib/workspace/documentBlobCache.test.ts create mode 100644 desktop/src/lib/workspace/documentBlobCache.ts create mode 100644 desktop/src/lib/workspace/officeZipGuard.test.ts create mode 100644 desktop/src/lib/workspace/officeZipGuard.ts create mode 100644 desktop/src/lib/zoomPan.test.ts create mode 100644 desktop/src/lib/zoomPan.ts create mode 100644 desktop/src/test/blobs.ts create mode 100644 desktop/src/test/fakeDocxEngine.ts create mode 100644 desktop/src/test/fakePdfEngine.ts create mode 100644 desktop/src/test/fakeSpreadsheetEngine.ts create mode 100644 desktop/src/test/fixtures/docx.ts create mode 100644 desktop/src/test/fixtures/pdf.ts create mode 100644 desktop/src/test/fixtures/png.ts create mode 100644 desktop/src/test/fixtures/xlsx.ts create mode 100644 desktop/src/test/nodes.ts create mode 100644 desktop/src/types/pdfjs-worker.d.ts create mode 100644 src/server/__tests__/static-h5.test.ts create mode 100644 src/server/__tests__/workspace-document-parity.test.ts create mode 100644 src/server/__tests__/workspace-raw-route.test.ts create mode 100644 src/server/middleware/responseHeaders.test.ts create mode 100644 src/server/middleware/responseHeaders.ts create mode 100644 src/server/services/workspaceDocumentPreview.ts diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index a6fbbf28..4fd679c4 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -10,6 +10,19 @@ This project includes code and binaries from the following open source projects. - License: dual-licensed under MIT or the Unlicense - License texts: bundled beside the executable under `ripgrep-licenses/` +## pdf.js + +- Project: pdf.js (https://github.com/mozilla/pdf.js), distributed as `pdfjs-dist` +- Included as: the PDF engine and its worker, loaded when a PDF is opened in the workspace, and the run-time data files it reads, emitted beside the app under `assets/pdfjs-/` +- Adapted in: `desktop/src/components/workspace/surfaces/document/pdfPage.css` (the text-layer rules of `web/pdf_viewer.css`) +- Version: 6.3.289 +- License: Apache-2.0 +- Bundled data files, each folder shipped unmodified with its own license files: + - `cmaps/`: character maps, Copyright 1990-2009 Adobe Systems Incorporated (BSD-style license, `cmaps/LICENSE`) + - `standard_fonts/`: Foxit fonts (Copyright PDFium Authors, BSD-3-Clause, `LICENSE_FOXIT`) and Liberation Sans fonts (Liberation Font License, GNU GPL v2 with a font exception, `LICENSE_LIBERATION`), kept as separate data files + - `wasm/`: image decoders OpenJPEG (BSD-2-Clause), JBIG2 from PDFium (BSD-3-Clause) and qcms (MIT), with pdf.js' wrappers for them; each license is in a `LICENSE_*` file beside the decoder + - `iccs/`: an ICC colour profile (CC0 1.0, `iccs/LICENSE`) + ## claude-tap - Project: claude-tap (https://github.com/liaohch3/claude-tap) diff --git a/desktop/bun.lock b/desktop/bun.lock index daa151c6..966be746 100644 --- a/desktop/bun.lock +++ b/desktop/bun.lock @@ -49,15 +49,19 @@ "@typescript-eslint/parser": "^8", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "3.2.4", + "docx-preview": "0.4.1", "electron": "^42.4.0", "electron-builder": "^26.8.1", "eslint": "^9", "eslint-plugin-react-hooks": "^5", + "fflate": "^0.8.3", "jsdom": "^25.0.1", + "pdfjs-dist": "6.3.289", "tailwindcss": "^4.0.0", "typescript": "^5.9.3", "vite": "^8.0.10", "vitest": "^3.0.4", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", }, }, }, @@ -332,6 +336,30 @@ "@mermaid-js/parser": ["@mermaid-js/parser@1.1.0", "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.1.0.tgz", { "dependencies": { "langium": "^4.0.0" } }, "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw=="], + "@napi-rs/canvas": ["@napi-rs/canvas@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-1.0.9.tgz", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "1.0.9", "@napi-rs/canvas-darwin-arm64": "1.0.9", "@napi-rs/canvas-darwin-x64": "1.0.9", "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9", "@napi-rs/canvas-linux-arm64-gnu": "1.0.9", "@napi-rs/canvas-linux-arm64-musl": "1.0.9", "@napi-rs/canvas-linux-riscv64-gnu": "1.0.9", "@napi-rs/canvas-linux-x64-gnu": "1.0.9", "@napi-rs/canvas-linux-x64-musl": "1.0.9", "@napi-rs/canvas-win32-arm64-msvc": "1.0.9", "@napi-rs/canvas-win32-x64-msvc": "1.0.9" } }, "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz", { "os": "android", "cpu": "arm64" }, "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz", { "os": "linux", "cpu": "arm" }, "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz", { "os": "linux", "cpu": "none" }, "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz", { "os": "linux", "cpu": "x64" }, "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz", { "os": "linux", "cpu": "x64" }, "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@1.0.9", "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz", { "os": "win32", "cpu": "x64" }, "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], "@ngrok/ngrok": ["@ngrok/ngrok@1.7.0", "https://registry.npmmirror.com/@ngrok/ngrok/-/ngrok-1.7.0.tgz", { "optionalDependencies": { "@ngrok/ngrok-android-arm64": "1.7.0", "@ngrok/ngrok-darwin-arm64": "1.7.0", "@ngrok/ngrok-darwin-universal": "1.7.0", "@ngrok/ngrok-darwin-x64": "1.7.0", "@ngrok/ngrok-freebsd-x64": "1.7.0", "@ngrok/ngrok-linux-arm-gnueabihf": "1.7.0", "@ngrok/ngrok-linux-arm64-gnu": "1.7.0", "@ngrok/ngrok-linux-arm64-musl": "1.7.0", "@ngrok/ngrok-linux-x64-gnu": "1.7.0", "@ngrok/ngrok-linux-x64-musl": "1.7.0", "@ngrok/ngrok-win32-arm64-msvc": "1.7.0", "@ngrok/ngrok-win32-ia32-msvc": "1.7.0", "@ngrok/ngrok-win32-x64-msvc": "1.7.0" } }, "sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g=="], @@ -934,6 +962,8 @@ "dmg-license": ["dmg-license@1.0.11", "https://registry.npmmirror.com/dmg-license/-/dmg-license-1.0.11.tgz", { "dependencies": { "@types/plist": "^3.0.1", "@types/verror": "^1.10.3", "ajv": "^6.10.0", "crc": "^3.8.0", "iconv-corefoundation": "^1.1.7", "plist": "^3.0.4", "smart-buffer": "^4.0.2", "verror": "^1.10.0" }, "os": "darwin", "bin": { "dmg-license": "bin/dmg-license.js" } }, "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q=="], + "docx-preview": ["docx-preview@0.4.1", "https://registry.npmmirror.com/docx-preview/-/docx-preview-0.4.1.tgz", { "dependencies": { "jszip": ">=3.0.0" } }, "sha512-Dv4g+LeE0swHpPvNC0lAT0YsR5Zq5pAxw7d3vHQ8EChBLgIrqqe9VPL5nVt2HBJap8bEZbXUI0TJob7kBeNU9w=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "dompurify": ["dompurify@3.3.3", "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.3.tgz", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="], @@ -1028,6 +1058,8 @@ "fdir": ["fdir@6.5.0", "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.8.3", "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "filelist": ["filelist@1.0.6", "https://registry.npmmirror.com/filelist/-/filelist-1.0.6.tgz", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="], @@ -1122,6 +1154,8 @@ "ignore": ["ignore@5.3.2", "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "immediate": ["immediate@3.0.6", "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "import-fresh": ["import-fresh@3.3.1", "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "imurmurhash": ["imurmurhash@0.1.4", "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], @@ -1156,6 +1190,8 @@ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "isarray": ["isarray@1.0.0", "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + "isbinaryfile": ["isbinaryfile@5.0.7", "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-5.0.7.tgz", {}, "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ=="], "isexe": ["isexe@3.1.5", "https://registry.npmmirror.com/isexe/-/isexe-3.1.5.tgz", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], @@ -1196,6 +1232,8 @@ "jsonfile": ["jsonfile@6.2.1", "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.1.tgz", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "jszip": ["jszip@3.10.2", "https://registry.npmmirror.com/jszip/-/jszip-3.10.2.tgz", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ=="], + "katex": ["katex@0.16.45", "https://registry.npmmirror.com/katex/-/katex-0.16.45.tgz", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA=="], "keyv": ["keyv@4.5.4", "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -1210,6 +1248,8 @@ "levn": ["levn@0.4.1", "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lie": ["lie@3.3.0", "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + "lightningcss": ["lightningcss@1.32.0", "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], @@ -1404,6 +1444,8 @@ "package-manager-detector": ["package-manager-detector@1.6.0", "https://registry.npmmirror.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + "pako": ["pako@1.0.11", "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + "parent-module": ["parent-module@1.0.1", "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], "parse-entities": ["parse-entities@4.0.2", "https://registry.npmmirror.com/parse-entities/-/parse-entities-4.0.2.tgz", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], @@ -1430,6 +1472,8 @@ "pathval": ["pathval@2.0.1", "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "pdfjs-dist": ["pdfjs-dist@6.3.289", "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz", { "optionalDependencies": { "@napi-rs/canvas": "^1.0.0" } }, "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw=="], + "pe-library": ["pe-library@0.4.1", "https://registry.npmmirror.com/pe-library/-/pe-library-0.4.1.tgz", {}, "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw=="], "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1460,6 +1504,8 @@ "proc-log": ["proc-log@6.1.0", "https://registry.npmmirror.com/proc-log/-/proc-log-6.1.0.tgz", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + "progress": ["progress@2.0.3", "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], "promise-retry": ["promise-retry@2.0.1", "https://registry.npmmirror.com/promise-retry/-/promise-retry-2.0.1.tgz", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], @@ -1502,6 +1548,8 @@ "read-binary-file-arch": ["read-binary-file-arch@1.0.6", "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="], + "readable-stream": ["readable-stream@2.3.8", "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "redent": ["redent@3.0.0", "https://registry.npmmirror.com/redent/-/redent-3.0.0.tgz", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], "regex": ["regex@6.1.0", "https://registry.npmmirror.com/regex/-/regex-6.1.0.tgz", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], @@ -1544,6 +1592,8 @@ "rw": ["rw@1.3.3", "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + "safe-buffer": ["safe-buffer@5.1.2", "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + "safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "sanitize-filename": ["sanitize-filename@1.6.4", "https://registry.npmmirror.com/sanitize-filename/-/sanitize-filename-1.6.4.tgz", { "dependencies": { "truncate-utf8-bytes": "^1.0.0" } }, "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg=="], @@ -1562,6 +1612,8 @@ "set-blocking": ["set-blocking@2.0.0", "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "setimmediate": ["setimmediate@1.0.5", "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + "sharp": ["sharp@0.34.5", "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1600,6 +1652,8 @@ "string-width-cjs": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string_decoder": ["string_decoder@1.1.1", "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "stringify-entities": ["stringify-entities@4.0.4", "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], "strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -1764,6 +1818,8 @@ "ws": ["ws@8.20.0", "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "xlsx": ["xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", { "bin": { "xlsx": "./bin/xlsx.njs" } }, "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA=="], + "xml-name-validator": ["xml-name-validator@5.0.0", "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], "xmlbuilder": ["xmlbuilder@15.1.1", "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], diff --git a/desktop/package.json b/desktop/package.json index 2899075f..e574345d 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -154,14 +154,18 @@ "@typescript-eslint/parser": "^8", "@vitejs/plugin-react": "^6.0.1", "@vitest/coverage-v8": "3.2.4", + "docx-preview": "0.4.1", "electron": "^42.4.0", "electron-builder": "^26.8.1", "eslint": "^9", "eslint-plugin-react-hooks": "^5", + "fflate": "^0.8.3", "jsdom": "^25.0.1", + "pdfjs-dist": "6.3.289", "tailwindcss": "^4.0.0", "typescript": "^5.9.3", "vite": "^8.0.10", - "vitest": "^3.0.4" + "vitest": "^3.0.4", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" } } diff --git a/desktop/scripts/vite-pdfjs-assets.test.ts b/desktop/scripts/vite-pdfjs-assets.test.ts new file mode 100644 index 00000000..26e9a2b6 --- /dev/null +++ b/desktop/scripts/vite-pdfjs-assets.test.ts @@ -0,0 +1,236 @@ +// @vitest-environment node +import fs from 'node:fs' +import { createRequire } from 'node:module' +import os from 'node:os' +import path from 'node:path' +import { PassThrough } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Plugin } from 'vite' +import { createPdfEngine } from '../src/components/workspace/surfaces/document/pdfEngine' +import { + PDFJS_ASSET_DIRS, + PDFJS_ASSET_FOLDERS, + pdfjsAssetsPrefix, +} from '../src/components/workspace/surfaces/document/pdfAssets' +import { listPdfjsAssets, pdfjsAssets } from './vite-pdfjs-assets' + +const realPackageDir = path.dirname(createRequire(import.meta.url).resolve('pdfjs-dist/package.json')) +const realVersion = (JSON.parse(fs.readFileSync(path.join(realPackageDir, 'package.json'), 'utf8')) as { version: string }).version + +const tempDirs: string[] = [] +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }) +}) + +/** A stand-in pdfjs-dist with a file or two in each data folder. */ +function fakePackage(options: { omit?: string } = {}): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdfjs-assets-')) + tempDirs.push(dir) + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'pdfjs-dist', version: '9.8.7' })) + fs.writeFileSync(path.join(dir, 'secret.txt'), 'not a data file') + for (const folder of PDFJS_ASSET_DIRS) { + if (folder === options.omit) continue + fs.mkdirSync(path.join(dir, folder)) + fs.writeFileSync(path.join(dir, folder, `${folder}-one.bin`), `${folder} one`) + fs.writeFileSync(path.join(dir, folder, 'LICENSE'), `${folder} licence`) + } + fs.writeFileSync(path.join(dir, 'wasm', 'quickjs-eval.wasm'), 'scripting sandbox') + fs.writeFileSync(path.join(dir, 'wasm', 'quickjs-eval.js'), 'scripting sandbox') + fs.writeFileSync(path.join(dir, 'wasm', 'decoder.wasm'), 'a decoder') + return dir +} + +type Emitted = { type: string; fileName: string; source: Buffer } + +function build(plugin: Plugin): Emitted[] { + const emitted: Emitted[] = [] + const generateBundle = plugin.generateBundle as (this: unknown, ...args: unknown[]) => void + generateBundle.call({ emitFile: (file: Emitted) => emitted.push(file) }, {}, {}, false) + return emitted +} + +type Middleware = (request: { url?: string }, response: unknown, next: () => void) => void + +function devServer(plugin: Plugin) { + const mounted: Array<{ at: string; handler: Middleware }> = [] + const configureServer = plugin.configureServer as (server: unknown) => void + configureServer({ middlewares: { use: (at: string, handler: Middleware) => mounted.push({ at, handler }) } }) + return mounted +} + +/** Ask the dev middleware for `url`; resolves with what it sent, or `null` if it passed the request on. */ +async function request(plugin: Plugin, url: string) { + const [middleware] = devServer(plugin) + const chunks: Buffer[] = [] + const headers: Record = {} + const response = Object.assign(new PassThrough(), { + setHeader: (name: string, value: string) => { headers[name.toLowerCase()] = value }, + }) + response.on('data', (chunk: Buffer) => chunks.push(chunk)) + return new Promise<{ body: Buffer; headers: Record } | null>((resolve) => { + response.on('end', () => resolve({ body: Buffer.concat(chunks), headers })) + middleware!.handler({ url }, response, () => resolve(null)) + }) +} + +describe('listPdfjsAssets', () => { + it('lists the four data folders, licences included', () => { + const files = listPdfjsAssets(fakePackage()).sort() + + expect(files).toEqual([ + 'cmaps/LICENSE', + 'cmaps/cmaps-one.bin', + 'iccs/LICENSE', + 'iccs/iccs-one.bin', + 'standard_fonts/LICENSE', + 'standard_fonts/standard_fonts-one.bin', + 'wasm/LICENSE', + 'wasm/decoder.wasm', + 'wasm/wasm-one.bin', + ]) + }) + + it('leaves out the scripting sandbox, which a read-only preview never runs', () => { + const files = listPdfjsAssets(fakePackage()) + + expect(files.filter((file) => file.includes('quickjs'))).toEqual([]) + expect(files).toContain('wasm/decoder.wasm') + }) + + it('does not ship anything else in the package', () => { + expect(listPdfjsAssets(fakePackage()).some((file) => file.includes('secret') || file.includes('package.json'))).toBe(false) + }) + + it('fails the build by naming the missing folder, rather than shipping a viewer that cannot read Chinese', () => { + expect(() => listPdfjsAssets(fakePackage({ omit: 'cmaps' }))).toThrow(/no "cmaps" folder/) + }) + + it('finds what a real pdfjs-dist ships: CJK CMaps, the standard fonts, the JPEG 2000 decoder, and their licences', () => { + const files = listPdfjsAssets(realPackageDir) + + expect(files).toEqual(expect.arrayContaining([ + 'cmaps/UniGB-UCS2-H.bcmap', + 'cmaps/Adobe-GB1-UCS2.bcmap', + 'standard_fonts/LiberationSans-Regular.ttf', + 'standard_fonts/FoxitSerif.pfb', + 'standard_fonts/LICENSE_FOXIT', + 'standard_fonts/LICENSE_LIBERATION', + 'wasm/openjpeg.wasm', + 'wasm/qcms_bg.wasm', + 'wasm/LICENSE_OPENJPEG', + 'iccs/CGATS001Compat-v2-micro.icc', + ])) + expect(files.some((file) => file.includes('quickjs-eval'))).toBe(false) + }) +}) + +describe('the build', () => { + it('emits every file under a folder named for the installed version, with fixed names', () => { + const dir = fakePackage() + + const emitted = build(pdfjsAssets({ packageDir: dir })) + + expect(emitted.every((file) => file.type === 'asset')).toBe(true) + expect(emitted.map((file) => file.fileName).sort()).toEqual( + listPdfjsAssets(dir).map((file) => `assets/pdfjs-9.8.7/${file}`).sort(), + ) + }) + + it('ships the bytes of the files, unchanged', () => { + const dir = fakePackage() + + const emitted = build(pdfjsAssets({ packageDir: dir })) + + expect(emitted.find((file) => file.fileName === 'assets/pdfjs-9.8.7/wasm/decoder.wasm')!.source.toString()).toBe('a decoder') + }) + + it('names the folder for the version that is installed, so an upgrade cannot be served stale data', () => { + const emitted = build(pdfjsAssets()) + + expect(emitted.length).toBeGreaterThan(100) + expect(emitted.every((file) => file.fileName.startsWith(`assets/pdfjs-${realVersion}/`))).toBe(true) + }) + + it('uses the same version string the engine builds its URLs from at run time', async () => { + const { version } = await import('pdfjs-dist/legacy/build/pdf.mjs') + + expect(version).toBe(realVersion) + }) +}) + +describe('the dev server', () => { + it('serves the data at the URL the app will use once built', () => { + const [middleware] = devServer(pdfjsAssets({ packageDir: fakePackage() })) + + expect(middleware!.at).toBe(`/${pdfjsAssetsPrefix('9.8.7')}`) + }) + + it('serves a data file, uncached', async () => { + const plugin = pdfjsAssets({ packageDir: fakePackage() }) + + const served = await request(plugin, '/cmaps/cmaps-one.bin') + + expect(served!.body.toString()).toBe('cmaps one') + expect(served!.headers['content-type']).toBe('application/octet-stream') + expect(served!.headers['cache-control']).toBe('no-cache') + }) + + it('serves wasm as wasm, which streaming compilation insists on', async () => { + const served = await request(pdfjsAssets({ packageDir: fakePackage() }), '/wasm/decoder.wasm') + + expect(served!.headers['content-type']).toBe('application/wasm') + }) + + it('ignores a query string', async () => { + const served = await request(pdfjsAssets({ packageDir: fakePackage() }), '/iccs/iccs-one.bin?v=1') + + expect(served!.body.toString()).toBe('iccs one') + }) + + // Every way out here leads to a file that exists, so that it is the guard that stops + // it, not the file's absence. + it.each([ + ['a file outside the data folders', () => '/package.json'], + ['a sibling file, by way of the data folder', () => '/cmaps/../secret.txt'], + ['an encoded slash way out', () => '/cmaps/..%2fsecret.txt'], + ['an encoded way out and back in again', (dir: string) => `/%2e%2e/${path.basename(dir)}/secret.txt`], + ['a way out and back in again', (dir: string) => `/../${path.basename(dir)}/secret.txt`], + ['a file that does not exist', () => '/cmaps/nothing.bin'], + ['the scripting sandbox', () => '/wasm/quickjs-eval.wasm'], + ['a folder', () => '/cmaps'], + ['the root', () => '/'], + ['a malformed escape', () => '/cmaps/%E0%A4%A'], + ])('passes on %s', async (_label, urlFor) => { + const dir = fakePackage() + + const served = await request(pdfjsAssets({ packageDir: dir }), urlFor(dir)) + + expect(served).toBeNull() + }) +}) + +describe('the engine and the plugin agree on where the data is', () => { + it('asks for exactly the folders that are shipped, under the prefix that is served', async () => { + const getDocument = vi.fn(() => ({ + promise: Promise.resolve({ numPages: 1 }), + destroy: async () => undefined, + })) + const pdfjs = { version: '9.8.7', getDocument } as never + const base = `http://app.test/${pdfjsAssetsPrefix('9.8.7')}` + const engine = createPdfEngine({ loadPdfjs: async () => pdfjs, assetBaseUrl: () => base }) + + await engine.open(new Uint8Array([1])) + + const options = getDocument.mock.calls[0]![0] as unknown as Record + const requested = Object.keys(PDFJS_ASSET_FOLDERS).map((option) => options[option]!) + expect(requested.sort()).toEqual([...PDFJS_ASSET_DIRS].map((dir) => `${base}${dir}/`).sort()) + // pdf.js ships CMaps compressed; without this it fetches names that do not exist. + expect(options.cMapPacked).toBe(true) + }) + + it('ships every folder that the engine can ask for', () => { + const shipped = new Set(listPdfjsAssets(realPackageDir).map((file) => file.split('/')[0])) + + expect([...shipped].sort()).toEqual([...PDFJS_ASSET_DIRS].sort()) + }) +}) diff --git a/desktop/scripts/vite-pdfjs-assets.ts b/desktop/scripts/vite-pdfjs-assets.ts new file mode 100644 index 00000000..7a4e9d69 --- /dev/null +++ b/desktop/scripts/vite-pdfjs-assets.ts @@ -0,0 +1,86 @@ +import fs from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' +import type { Plugin } from 'vite' +import { PDFJS_ASSET_DIRS, pdfjsAssetsPrefix } from '../src/components/workspace/surfaces/document/pdfAssets' + +/** + * Not shipped: pdf.js' scripting sandbox (JavaScript embedded in PDF forms). A + * read-only preview never runs it, and it is a sizeable wasm the app would carry + * for nothing. + */ +const EXCLUDED = /^wasm\/quickjs-eval\./ + +const MIME_TYPES: Record = { + '.wasm': 'application/wasm', + '.js': 'text/javascript; charset=utf-8', + '.ttf': 'font/ttf', +} + +/** Every file to ship, as `dir/name` paths relative to the pdfjs-dist package. */ +export function listPdfjsAssets(packageDir: string): string[] { + return PDFJS_ASSET_DIRS.flatMap((dir) => { + let names: string[] + try { + names = fs.readdirSync(path.join(packageDir, dir)) + } catch (error) { + throw new Error( + `pdfjs-dist has no "${dir}" folder at ${packageDir}. This version lays its data out differently; ` + + 'update PDFJS_ASSET_FOLDERS in pdfAssets.ts to match.', + { cause: error }, + ) + } + return names.map((name) => `${dir}/${name}`).filter((relative) => !EXCLUDED.test(relative)) + }) +} + +/** + * Ships pdf.js' run-time data with the app, and serves it from the dev server at + * the same URL, so the renderer finds it identically in `vite dev`, in the + * packaged Electron app (`file://` inside the asar) and on the H5 static host. + * + * `packageDir` is for tests; by default the installed pdfjs-dist is used. + */ +export function pdfjsAssets(options: { packageDir?: string } = {}): Plugin { + const packageDir = options.packageDir + ?? path.dirname(createRequire(import.meta.url).resolve('pdfjs-dist/package.json')) + const { version } = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as { version: string } + const prefix = pdfjsAssetsPrefix(version) + + return { + name: 'cc-haha:pdfjs-assets', + + configureServer(server) { + server.middlewares.use(`/${prefix}`, (request, response, next) => { + let relative: string + try { + relative = decodeURIComponent((request.url ?? '/').split('?')[0]!).replace(/^\/+/, '') + } catch { + next() // a malformed escape is not one of our files + return + } + const file = path.resolve(packageDir, relative) + // Only the data folders: nothing else in the package, and no way out of it. + const inDataDir = PDFJS_ASSET_DIRS.some((dir) => file.startsWith(path.join(packageDir, dir) + path.sep)) + if (!inDataDir || EXCLUDED.test(path.relative(packageDir, file).split(path.sep).join('/')) || !fs.existsSync(file)) { + next() + return + } + response.setHeader('Content-Type', MIME_TYPES[path.extname(file)] ?? 'application/octet-stream') + response.setHeader('Cache-Control', 'no-cache') + fs.createReadStream(file).pipe(response) + }) + }, + + generateBundle() { + for (const relative of listPdfjsAssets(packageDir)) { + this.emitFile({ + type: 'asset', + // A fixed name, not a content hash: the folder already carries the version. + fileName: prefix + relative, + source: fs.readFileSync(path.join(packageDir, relative)), + }) + } + }, + } +} diff --git a/desktop/src/__tests__/documentEngineImports.test.ts b/desktop/src/__tests__/documentEngineImports.test.ts new file mode 100644 index 00000000..9cebcb1e --- /dev/null +++ b/desktop/src/__tests__/documentEngineImports.test.ts @@ -0,0 +1,122 @@ +// @vitest-environment node +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import ts from 'typescript' +import { describe, expect, it } from 'vitest' + +/** + * pdf.js, docx-preview and SheetJS are hundreds of kilobytes apiece. The build does + * not warn when one of them slips into the main bundle (`INEFFECTIVE_DYNAMIC_IMPORT` + * is silenced in vite.config.ts), and every user would then pay for it on startup + * whether or not they ever open a document. + * + * So the rule is checked here, on the source: a document engine is reached by a + * dynamic `import()` from inside a function, never by an `import` at the top of a + * module. The one exception is the worker entry, which exists to be loaded on its + * own by `?worker` and is not part of any bundle that runs on the page. + */ + +const srcRoot = path.resolve(import.meta.dirname, '..') + +const HEAVY_PACKAGE = /^(pdfjs-dist|docx-preview|xlsx)(\/|$)/ + +/** Modules whose whole job is to be the far end of a dynamic import or a worker. */ +const ENTRY_POINTS = new Set(['components/workspace/surfaces/document/pdf.worker.ts']) + +/** `src/test`: fixtures and fakes that only tests import. They build documents with these very libraries. */ +const TEST_SUPPORT_DIRECTORY = path.join(srcRoot, 'test') + +function sourceFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(directory, entry.name) + if (entry.isDirectory()) return entry.name === 'node_modules' || full === TEST_SUPPORT_DIRECTORY ? [] : sourceFiles(full) + return /\.(ts|tsx)$/.test(entry.name) && !/\.(test|spec)\.tsx?$/.test(entry.name) && !entry.name.endsWith('.d.ts') ? [full] : [] + }) +} + +type StaticImport = { file: string; specifier: string; typeOnly: boolean } + +function staticImports(file: string): StaticImport[] { + const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true) + const found: StaticImport[] = [] + for (const node of source.statements) { + const isImport = ts.isImportDeclaration(node) + const isReExport = ts.isExportDeclaration(node) + if (!(isImport || isReExport) || !node.moduleSpecifier || !ts.isStringLiteral(node.moduleSpecifier)) continue + const typeOnly = isImport ? node.importClause?.isTypeOnly === true : node.isTypeOnly + found.push({ file: path.relative(srcRoot, file), specifier: node.moduleSpecifier.text, typeOnly }) + } + return found +} + +const files = sourceFiles(srcRoot) +const imports = files.flatMap(staticImports) + +describe('document engines stay out of the main bundle', () => { + it('reads the source tree it is guarding', () => { + // A scan that found nothing would pass every rule below. + expect(files.length).toBeGreaterThan(500) + expect(files.some((file) => file.endsWith('pdfEngine.ts'))).toBe(true) + expect(imports.some((statement) => statement.specifier === 'react')).toBe(true) + }) + + it('imports a document engine only dynamically, or as a type', () => { + const eager = imports.filter( + (statement) => HEAVY_PACKAGE.test(statement.specifier) && !statement.typeOnly && !ENTRY_POINTS.has(statement.file), + ) + + expect(eager.map((statement) => `${statement.file} imports ${statement.specifier}`)).toEqual([]) + }) + + it('loads pdf.js through the engine, which fetches it on first use', () => { + const engine = readFileSync(path.join(srcRoot, 'components/workspace/surfaces/document/pdfEngine.ts'), 'utf8') + + expect(engine).toMatch(/import\('pdfjs-dist\/legacy\/build\/pdf\.mjs'\)/) + }) + + it('never imports the worker entry from the page, only through ?worker', () => { + const direct = imports.filter((statement) => /(^|\/)pdf\.worker(\.ts)?$/.test(statement.specifier)) + + expect(direct.map((statement) => `${statement.file} imports ${statement.specifier}`)).toEqual([]) + }) + + it('reaches every document viewer through a lazy import, so opening a workspace loads none of them', () => { + const viewers = imports.filter((statement) => statement.file.endsWith('document/documentViewers.ts')) + + // Only React (for `lazy`) and types: a viewer named here would be in the main bundle. + expect(viewers.filter((statement) => !statement.typeOnly).map((statement) => statement.specifier)).toEqual(['react']) + }) + + it('does not let the panel that hosts documents import a viewer or engine directly', () => { + const host = imports.filter( + (statement) => statement.file.endsWith('workbench/WorkspaceFileTab.tsx') && !statement.typeOnly, + ) + + expect(host.filter((statement) => /(Pdf|Docx|Spreadsheet)(Surface|Viewer|Engine)/.test(statement.specifier))).toEqual([]) + }) +}) + +describe('document libraries are declared as what they are', () => { + const manifest = JSON.parse(readFileSync(path.resolve(srcRoot, '../package.json'), 'utf8')) as { + dependencies: Record + devDependencies: Record + } + + it.each(['pdfjs-dist', 'docx-preview', 'xlsx', 'fflate'])( + '%s is a devDependency: the renderer bundles it, and electron-builder packs every "dependencies" entry into app.asar', + (name) => { + // Listed under `dependencies` these four shipped a second, unused copy of about 45 MB + // in every installer, beside the copy Vite had already put in dist/. + expect(manifest.devDependencies[name]).toBeDefined() + expect(manifest.dependencies[name]).toBeUndefined() + }, + ) + + it('takes SheetJS from its own CDN release, because the npm package stops at 0.18.5 and has unfixed advisories', () => { + expect(manifest.devDependencies.xlsx).toBe('https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz') + }) + + it.each(['pdfjs-dist', 'docx-preview'])('pins %s to one version: the viewers are written against its internals', (name) => { + expect(manifest.devDependencies[name]).toMatch(/^\d+\.\d+\.\d+$/) + }) +}) diff --git a/desktop/src/api/sessions.test.ts b/desktop/src/api/sessions.test.ts index 7e674d28..c3496118 100644 --- a/desktop/src/api/sessions.test.ts +++ b/desktop/src/api/sessions.test.ts @@ -246,6 +246,73 @@ describe('sessionsApi', () => { expect(init).toMatchObject({ method: 'GET' }) }) + it('downloads a workspace document as a blob from the raw route with an encoded path', async () => { + const bytes = new Uint8Array([0x25, 0x50, 0x44, 0x46]) + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(bytes, { + status: 200, + headers: { 'Content-Type': 'application/pdf' }, + })) + + const blob = await sessionsApi.getWorkspaceRaw('session-1', 'docs/论文 final.pdf') + + expect(blob.size).toBe(bytes.byteLength) + expect(blob.type).toBe('application/pdf') + const [url, init] = fetchMock.mock.calls[0]! + expect(url).toBe('http://127.0.0.1:3456/api/sessions/session-1/workspace/raw?path=docs%2F%E8%AE%BA%E6%96%87+final.pdf') + expect(init).toMatchObject({ method: 'GET' }) + }) + + it('surfaces a refused document download as an API error instead of a blob', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response('too large', { status: 413 })) + + await expect(sessionsApi.getWorkspaceRaw('session-1', 'big.docx')).rejects.toMatchObject({ status: 413 }) + }) + + describe('document download timing', () => { + // A hung transfer must be cut off, but a whole file over a remote link is + // far slower than the JSON the default two-minute limit is sized for. + function hangingFetch() { + const seen: { signal?: AbortSignal } = {} + vi.spyOn(globalThis, 'fetch').mockImplementationOnce((_url, init) => new Promise((_resolve, reject) => { + seen.signal = init?.signal ?? undefined + seen.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))) + })) + return seen + } + + // What the promise rejected with, without leaving an unawaited assertion behind. + const settle = (promise: Promise) => promise.then(() => null, (error: unknown) => error) + + afterEach(() => { + vi.useRealTimers() + }) + + it('allows ten minutes rather than the default request timeout', async () => { + vi.useFakeTimers() + const seen = hangingFetch() + + const settled = settle(sessionsApi.getWorkspaceRaw('session-1', 'a.pdf')) + await vi.advanceTimersByTimeAsync(10 * 60_000 - 1) + expect(seen.signal?.aborted).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + expect(seen.signal?.aborted).toBe(true) + expect(await settled).toMatchObject({ name: 'AbortError' }) + }) + + it('stops as soon as the caller cancels', async () => { + const seen = hangingFetch() + const caller = new AbortController() + + const settled = settle(sessionsApi.getWorkspaceRaw('session-1', 'a.pdf', caller.signal)) + expect(seen.signal?.aborted).toBe(false) + + caller.abort() + expect(seen.signal?.aborted).toBe(true) + expect(await settled).toMatchObject({ name: 'AbortError' }) + }) + }) + it('preserves optional local index progress from session list responses', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ diff --git a/desktop/src/api/sessions.ts b/desktop/src/api/sessions.ts index 8d7ee9a7..60018070 100644 --- a/desktop/src/api/sessions.ts +++ b/desktop/src/api/sessions.ts @@ -1,4 +1,4 @@ -import { api, type ApiRequestOptions } from './client' +import { api, apiGetBlob, type ApiRequestOptions } from './client' import type { SlashCommandOption } from '../types/slashCommand' import type { AgentTaskNotification } from '../types/chat' import type { LocalIndexStatus, SessionListItem, MessageEntry } from '../types/session' @@ -325,10 +325,18 @@ export type WorkspaceStatusResult = { error?: string } +/** Formats the workspace renders itself from bytes fetched with {@link sessionsApi.getWorkspaceRaw}. */ +export type WorkspaceDocumentPreviewType = 'pdf' | 'docx' | 'xlsx' + export type WorkspaceReadFileResult = { state: 'ok' | 'binary' | 'too_large' | 'missing' | 'error' path: string - previewType?: 'text' | 'image' + /** + * `text` and `image` carry their payload in this response. A document type is + * metadata only — its bytes are fetched separately, so a watcher reload never + * re-downloads a large file as JSON. + */ + previewType?: 'text' | 'image' | WorkspaceDocumentPreviewType content?: string dataUrl?: string mimeType?: string @@ -336,6 +344,8 @@ export type WorkspaceReadFileResult = { size: number truncated?: boolean readBytes?: number + /** Document types only: changes whenever the bytes may have changed. */ + version?: string error?: string } @@ -400,9 +410,15 @@ function getSessionGitInfo(sessionId: string) { return trackedRequest } +/** + * A whole document is fetched in one request and can be large and slow to + * cross a remote link; the default request timeout is sized for JSON. + */ +const WORKSPACE_RAW_TIMEOUT_MS = 10 * 60_000 + function buildWorkspacePath( sessionId: string, - resource: 'status' | 'tree' | 'file' | 'diff', + resource: 'status' | 'tree' | 'file' | 'diff' | 'raw', workspacePath?: string, ) { const query = new URLSearchParams() @@ -606,6 +622,18 @@ export const sessionsApi = { return api.get(buildWorkspacePath(sessionId, 'file', workspacePath), { signal }) }, + /** + * The bytes of a document the workspace previews, as a Blob. Fetched with the + * bearer credential, the one form that works in the desktop shell, a LAN + * browser and remote access alike — an ``/`