diff --git a/src/app/repo/resource.ts b/src/app/repo/resource.ts index c705d35c8..311f17777 100644 --- a/src/app/repo/resource.ts +++ b/src/app/repo/resource.ts @@ -16,6 +16,50 @@ export interface Resource { updatetime?: number; } +export interface ResourceListItem { + key: string; + url: string; + type: ResourceType; + contentType: string; + byteSize: number; +} + +export interface ResourceListPage { + items: ResourceListItem[]; + offset: number; + limit: number; + total: number; + nextOffset?: number; +} + +export interface ResourceChunkRequest { + uuid: string; + url: string; + offset: number; + length: number; +} + +export interface ResourceChunk { + url: string; + offset: number; + length: number; + total: number; + /** Base64-encoded bytes without a data-URI prefix. */ + base64: string; +} + +export const RESOURCE_LIST_PAGE_SIZE = 100; +export const RESOURCE_CHUNK_BYTES = 16 * 1024 * 1024; + +export function getResourceByteSize(resource: { content: string; base64?: string }): number { + if (resource.base64) { + const comma = resource.base64.indexOf(","); + const encoded = comma === -1 ? resource.base64 : resource.base64.slice(comma + 1); + return atob(encoded).length; + } + return new TextEncoder().encode(resource.content).byteLength; +} + export interface ResourceHash { md5: string; sha1: string; diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 07ea7b6fb..b58a27912 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -1,5 +1,10 @@ import type { Script, ScriptCode, ScriptRunResource, TClientPageLoadInfo } from "@App/app/repo/scripts"; -import { type Resource } from "@App/app/repo/resource"; +import { + RESOURCE_LIST_PAGE_SIZE, + type ResourceChunk, + type ResourceChunkRequest, + type ResourceListPage, +} from "@App/app/repo/resource"; import { type Subscribe } from "@App/app/repo/subscribe"; import { type Logger } from "@App/app/repo/logger"; import { type Permission } from "@App/app/repo/permission"; @@ -298,8 +303,12 @@ export class ResourceClient extends Client { super(msgSender, "serviceWorker/resource"); } - getScriptResources(script: Script): Promise<{ [key: string]: Resource }> { - return this.doThrow("getScriptResources", script); + getScriptResources(script: Script, offset = 0, limit = RESOURCE_LIST_PAGE_SIZE): Promise { + return this.doThrow("getScriptResources", { script, offset, limit }); + } + + getResourceChunk(params: ResourceChunkRequest): Promise { + return this.doThrow("getResourceChunk", params); } deleteResource(url: string) { diff --git a/src/app/service/service_worker/resource.test.ts b/src/app/service/service_worker/resource.test.ts index c9fc1f176..9651016a7 100644 --- a/src/app/service/service_worker/resource.test.ts +++ b/src/app/service/service_worker/resource.test.ts @@ -6,7 +6,7 @@ import type { IMessageQueue } from "@Packages/message/message_queue"; import { parseUrlSRI } from "./utils"; import type { Script } from "@App/app/repo/scripts"; import { SCRIPT_RUN_STATUS_COMPLETE, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; -import type { Resource } from "@App/app/repo/resource"; +import { RESOURCE_CHUNK_BYTES, RESOURCE_LIST_PAGE_SIZE, type Resource } from "@App/app/repo/resource"; initTestEnv(); @@ -412,6 +412,133 @@ describe("ResourceService - getResourceByTypes", () => { }); }); +// 生产环境的 base64 是 blobToBase64 产出的 data URI,resourceModel 的 btoa(content) 不是这种形式 +function dataUriResource(): Resource { + return { + ...resourceModel("https://example.com/logo.png", ""), + contentType: "image/png", + base64: "data:image/png;base64,iVBORw0KGgr/AQ==", + }; +} + +describe("ResourceService - resource list and chunks", () => { + let service: ResourceService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new ResourceService({} as Group, {} as IMessageQueue); + }); + + it("returns paged resource metadata without transferring content or base64", async () => { + const resource = { ...resourceModel("https://example.com/data.txt", "text"), content: "你好", base64: "" }; + vi.spyOn(service, "getScriptResourceValue").mockResolvedValue({ + alias: resource, + }); + + const page = await service.getScriptResourcePage(normalScript("script-page", {}), 0, 1); + + expect(page).toEqual({ + items: [ + { + key: "alias", + url: resource.url, + type: resource.type, + contentType: resource.contentType, + byteSize: new TextEncoder().encode(resource.content).byteLength, + }, + ], + offset: 0, + limit: 1, + total: 1, + nextOffset: undefined, + }); + expect(page.items[0]).not.toHaveProperty("content"); + expect(page.items[0]).not.toHaveProperty("base64"); + }); + + it("returns a bounded UTF-8 byte range as raw base64", async () => { + const resource = { ...resourceModel("https://example.com/data.txt", "text"), content: "你好abc", base64: "" }; + vi.spyOn(service.resourceDAO, "get").mockResolvedValue(resource); + + const chunk = await service.getResourceChunk({ + uuid: "old-script", + url: resource.url, + offset: 1, + length: 4, + }); + + expect(chunk).toMatchObject({ url: resource.url, offset: 1, length: 4, total: 9 }); + expect([...Uint8Array.from(atob(chunk.base64), (char) => char.charCodeAt(0))]).toEqual([0xbd, 0xa0, 0xe5, 0xa5]); + }); + + it("measures byteSize by decoded bytes, not by the encoded data-URI string", async () => { + vi.spyOn(service, "getScriptResourceValue").mockResolvedValue({ logo: dataUriResource() }); + + const page = await service.getScriptResourcePage(normalScript("script-binary", {}), 0, 1); + + expect(page.items[0]).toMatchObject({ key: "logo", contentType: "image/png", byteSize: 10 }); + }); + + it("slices a data-URI base64 resource by decoded byte offsets", async () => { + const resource = dataUriResource(); + vi.spyOn(service.resourceDAO, "get").mockResolvedValue(resource); + + const chunk = await service.getResourceChunk({ uuid: "old-script", url: resource.url, offset: 2, length: 4 }); + + expect(chunk).toMatchObject({ offset: 2, length: 4, total: 10, base64: "TkcNCg==" }); + }); + + it("keeps advancing nextOffset until the last page", async () => { + const first = { ...resourceModel("https://example.com/a.txt", "a"), base64: "" }; + const second = { ...resourceModel("https://example.com/b.txt", "b"), base64: "" }; + vi.spyOn(service, "getScriptResourceValue").mockResolvedValue({ a: first, b: second }); + const script = normalScript("script-pages", {}); + + const page0 = await service.getScriptResourcePage(script, 0, 1); + const page1 = await service.getScriptResourcePage(script, page0.nextOffset!, 1); + + expect(page0).toMatchObject({ offset: 0, total: 2, nextOffset: 1 }); + expect(page0.items.map((item) => item.url)).toEqual([first.url]); + expect(page1).toMatchObject({ offset: 1, total: 2, nextOffset: undefined }); + expect(page1.items.map((item) => item.url)).toEqual([second.url]); + }); + + it("accepts the exact page-size limit and rejects a larger one", async () => { + vi.spyOn(service, "getScriptResourceValue").mockResolvedValue({}); + const script = normalScript("script-limit", {}); + + await expect(service.getScriptResourcePage(script, 0, RESOURCE_LIST_PAGE_SIZE)).resolves.toMatchObject({ + limit: RESOURCE_LIST_PAGE_SIZE, + }); + await expect(service.getScriptResourcePage(script, 0, RESOURCE_LIST_PAGE_SIZE + 1)).rejects.toThrow( + /resource list limit must be between/ + ); + }); + + it("accepts the exact chunk-size limit and rejects a larger one", async () => { + const resource = { ...resourceModel("https://example.com/data.txt", "data"), base64: "" }; + vi.spyOn(service.resourceDAO, "get").mockResolvedValue(resource); + const request = { uuid: "old-script", url: resource.url, offset: 0 }; + + await expect(service.getResourceChunk({ ...request, length: RESOURCE_CHUNK_BYTES })).resolves.toMatchObject({ + length: 4, + total: 4, + }); + await expect(service.getResourceChunk({ ...request, length: RESOURCE_CHUNK_BYTES + 1 })).rejects.toThrow( + /resource chunk length must be between/ + ); + }); + + it("does not expose chunks to a script that does not own the resource", async () => { + const resource = resourceModel("https://example.com/private.txt", "private"); + vi.spyOn(service.resourceDAO, "get").mockResolvedValue(resource); + + await expect( + service.getResourceChunk({ uuid: "other-script", url: resource.url, offset: 0, length: 1 }) + ).rejects.toThrow("resource not found"); + }); +}); + describe("ResourceService - updateResourceByTypes", () => { let service: ResourceService; diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 5b1110e07..4ca60c9b9 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -1,6 +1,16 @@ import LoggerCore from "@App/app/logger/core"; import Logger from "@App/app/logger/logger"; -import type { Resource, ResourceHash, ResourceType } from "@App/app/repo/resource"; +import { + getResourceByteSize, + RESOURCE_CHUNK_BYTES, + RESOURCE_LIST_PAGE_SIZE, + type Resource, + type ResourceChunk, + type ResourceChunkRequest, + type ResourceHash, + type ResourceListPage, + type ResourceType, +} from "@App/app/repo/resource"; import { ResourceDAO } from "@App/app/repo/resource"; import type { Script, ScriptResource, ScriptResourceByType } from "@App/app/repo/scripts"; import { type IMessageQueue } from "@Packages/message/message_queue"; @@ -459,12 +469,63 @@ export class ResourceService { return await this.resourceDAO.save(res); } - requestGetScriptResources(script: Script): Promise { - return this.getScriptResourceValue(script); + async getScriptResourcePage(script: Script, offset = 0, limit = RESOURCE_LIST_PAGE_SIZE): Promise { + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new Error("resource list offset must be a non-negative integer"); + } + if (!Number.isSafeInteger(limit) || limit < 1 || limit > RESOURCE_LIST_PAGE_SIZE) { + throw new Error(`resource list limit must be between 1 and ${RESOURCE_LIST_PAGE_SIZE}`); + } + + const resources = await this.getScriptResourceValue(script); + const entries = Object.entries(resources); + const items = entries.slice(offset, offset + limit).map(([key, resource]) => ({ + key, + url: resource.url, + type: resource.type, + contentType: resource.contentType, + byteSize: getResourceByteSize(resource), + })); + const nextOffset = offset + items.length < entries.length ? offset + items.length : undefined; + return { items, offset, limit, total: entries.length, nextOffset }; + } + + async getResourceChunk(params: ResourceChunkRequest): Promise { + const { uuid, url, offset, length } = params; + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new Error("resource chunk offset must be a non-negative integer"); + } + if (!Number.isSafeInteger(length) || length < 1 || length > RESOURCE_CHUNK_BYTES) { + throw new Error(`resource chunk length must be between 1 and ${RESOURCE_CHUNK_BYTES}`); + } + + const resource = await this.resourceDAO.get(url); + if (!resource || !resource.link[uuid]) { + throw new Error("resource not found"); + } + const source = resource.base64 + ? base64ToBlob(resource.base64) + : new Blob([resource.content], { type: resource.contentType }); + const total = source.size; + const chunk = source.slice(offset, Math.min(offset + length, total), resource.contentType); + const dataUri = await blobToBase64(chunk); + const comma = dataUri.indexOf(","); + return { + url, + offset, + length: chunk.size, + total, + base64: comma === -1 ? dataUri : dataUri.slice(comma + 1), + }; + } + + requestGetScriptResources(params: { script: Script; offset?: number; limit?: number }): Promise { + return this.getScriptResourcePage(params.script, params.offset, params.limit); } init() { this.group.on("getScriptResources", this.requestGetScriptResources.bind(this)); + this.group.on("getResourceChunk", this.getResourceChunk.bind(this)); this.group.on("deleteResource", this.deleteResource.bind(this)); // 删除相关资源 diff --git a/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.test.tsx b/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.test.tsx index 63cb5477d..75e913a49 100644 --- a/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.test.tsx +++ b/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.test.tsx @@ -6,41 +6,51 @@ import { initTestLanguage } from "@Tests/initTestLanguage"; import { renderWithTooltip as render } from "@Tests/renderWithTooltip"; // 资源数据走后台消息,统一打桩;用 hoisted 以便在 vi.mock 工厂内引用 -const { fetchScript, getScriptResources, deleteResource } = vi.hoisted(() => ({ +const { fetchScript, getScriptResources, getResourceChunk, deleteResource } = vi.hoisted(() => ({ fetchScript: vi.fn(), getScriptResources: vi.fn(), + getResourceChunk: vi.fn(), deleteResource: vi.fn(), })); vi.mock("@App/pages/store/features/script", () => ({ fetchScript, - resourceClient: { getScriptResources, deleteResource }, + resourceClient: { getScriptResources, getResourceChunk, deleteResource }, })); import ResourcePane, { invalidateResourcePane, usePreloadResourcePane } from "./ResourcePane"; -const sampleResources = () => ({ - "https://cdn.test/jquery.min.js": { +const sampleResources = () => [ + { + key: "https://cdn.test/jquery.min.js", url: "https://cdn.test/jquery.min.js", type: "require", contentType: "application/javascript", - content: "var a=1;", - base64: "", + byteSize: 8, }, - "https://cdn.test/theme.css": { + { + key: "https://cdn.test/theme.css", url: "https://cdn.test/theme.css", type: "resource", contentType: "text/css", - content: "body{}", - base64: "", + byteSize: 6, }, -}); +]; + +const samplePage = () => ({ items: sampleResources(), offset: 0, limit: 100, total: 2 }); beforeAll(() => initTestLanguage("zh-CN")); beforeEach(() => { vi.clearAllMocks(); fetchScript.mockResolvedValue({ uuid: "u1", name: "脚本A" }); - getScriptResources.mockResolvedValue(sampleResources()); + getScriptResources.mockResolvedValue(samplePage()); + getResourceChunk.mockResolvedValue({ + url: "https://cdn.test/jquery.min.js", + offset: 0, + length: 8, + total: 8, + base64: "dmFyIGE9MTs=", + }); deleteResource.mockResolvedValue(undefined); }); afterEach(() => { @@ -59,6 +69,82 @@ describe("ResourcePane 资源面板", () => { expect(screen.getByText("application/javascript")).toBeInTheDocument(); }); + it("下载应按元数据请求脚本绑定的资源块", async () => { + const downloadSpy = vi.spyOn(chrome.downloads, "download").mockResolvedValue(undefined); + render(); + await screen.findByText("jquery.min.js"); + + fireEvent.click(screen.getAllByLabelText(t("download"))[0]); + + await waitFor(() => + expect(getResourceChunk).toHaveBeenCalledWith({ + uuid: "u1", + url: "https://cdn.test/jquery.min.js", + offset: 0, + length: 8, + }) + ); + expect(downloadSpy).toHaveBeenCalledWith(expect.objectContaining({ filename: "jquery.min.js", saveAs: true })); + }); + + it("资源大于单块时应按 offset 续取并拼回完整内容", async () => { + // 服务端按块回传,客户端必须用 chunk.length 推进 offset,否则下载到的文件会截断或重复 + getResourceChunk.mockImplementation(({ offset }: { offset: number }) => + Promise.resolve({ + url: "https://cdn.test/jquery.min.js", + offset, + length: 4, + total: 8, + base64: offset === 0 ? "dmFyIA==" : "YT0xOw==", + }) + ); + vi.spyOn(chrome.downloads, "download").mockResolvedValue(undefined); + const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:resource-pane"); + render(); + await screen.findByText("jquery.min.js"); + + fireEvent.click(screen.getAllByLabelText(t("download"))[0]); + + await waitFor(() => expect(createObjectURL).toHaveBeenCalled()); + expect(getResourceChunk.mock.calls.map(([params]) => params.offset)).toEqual([0, 4]); + const blobs = createObjectURL.mock.calls + .map(([value]) => value) + .filter((value): value is Blob => value instanceof Blob); + expect(blobs).toHaveLength(1); + await expect(blobs[0].text()).resolves.toBe("var a=1;"); + }); + + it("资源块与列表元数据不一致时应报错且不触发下载", async () => { + getResourceChunk.mockResolvedValue({ + url: "https://cdn.test/jquery.min.js", + offset: 0, + length: 4, + total: 4, // 与列表里的 byteSize: 8 不一致,说明资源在两次请求之间被改写 + base64: "dmFyIA==", + }); + const downloadSpy = vi.spyOn(chrome.downloads, "download").mockResolvedValue(undefined); + const toastError = vi.spyOn(notify, "error"); + render(); + await screen.findByText("jquery.min.js"); + + fireEvent.click(screen.getAllByLabelText(t("download"))[0]); + + await waitFor(() => expect(toastError).toHaveBeenCalled()); + expect(downloadSpy).not.toHaveBeenCalled(); + }); + + it("列表分页应按 nextOffset 续取并合并展示", async () => { + getScriptResources + .mockResolvedValueOnce({ items: [sampleResources()[0]], offset: 0, limit: 1, total: 2, nextOffset: 1 }) + .mockResolvedValueOnce({ items: [sampleResources()[1]], offset: 1, limit: 1, total: 2 }); + + render(); + + expect(await screen.findByText("jquery.min.js")).toBeInTheDocument(); + expect(await screen.findByText("theme.css")).toBeInTheDocument(); + expect(getScriptResources.mock.calls.map(([, offset]) => offset)).toEqual([0, 1]); + }); + it("行内删除应二次确认后才调用 deleteResource 并移除该行", async () => { render(); await screen.findByText("jquery.min.js"); @@ -90,7 +176,7 @@ describe("ResourcePane 资源面板", () => { }); it("无资源时应展示空状态", async () => { - getScriptResources.mockResolvedValue({}); + getScriptResources.mockResolvedValue({ items: [], offset: 0, limit: 100, total: 0 }); render(); expect(await screen.findByText(t("no_data"))).toBeInTheDocument(); }); @@ -108,7 +194,7 @@ describe("ResourcePane 资源面板", () => { it("缓存失效后应重新加载同一脚本的资源", async () => { render(); await screen.findByText("jquery.min.js"); - getScriptResources.mockResolvedValue({}); + getScriptResources.mockResolvedValue({ items: [], offset: 0, limit: 100, total: 0 }); invalidateResourcePane("u1"); @@ -154,10 +240,10 @@ describe("ResourcePane 资源面板", () => { }); it("预加载取消不应展示错误", async () => { - let resolveFirst!: (resources: ReturnType) => void; + let resolveFirst!: (resources: ReturnType) => void; getScriptResources .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))) - .mockResolvedValueOnce(sampleResources()); + .mockResolvedValueOnce(samplePage()); const toastError = vi.spyOn(notify, "error"); const { rerender } = renderHook(({ uuid }) => usePreloadResourcePane(uuid), { @@ -166,7 +252,7 @@ describe("ResourcePane 资源面板", () => { await waitFor(() => expect(getScriptResources).toHaveBeenCalledTimes(1)); rerender({ uuid: "u2" }); await waitFor(() => expect(getScriptResources).toHaveBeenCalledTimes(2)); - await act(async () => resolveFirst(sampleResources())); + await act(async () => resolveFirst(samplePage())); expect(toastError).not.toHaveBeenCalled(); }); @@ -175,7 +261,7 @@ describe("ResourcePane 资源面板", () => { const preloader = renderHook(() => usePreloadResourcePane("u1")); await waitFor(() => expect(getScriptResources).toHaveBeenCalledTimes(1)); preloader.unmount(); - getScriptResources.mockResolvedValue({}); + getScriptResources.mockResolvedValue({ items: [], offset: 0, limit: 100, total: 0 }); render(); diff --git a/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.tsx b/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.tsx index 7bed98cf1..6f1a4329f 100644 --- a/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.tsx +++ b/src/pages/options/routes/ScriptEditor/tabs/ResourcePane.tsx @@ -2,9 +2,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Download, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { notify } from "@App/pages/components/ui/toast"; -import type { Resource } from "@App/app/repo/resource"; +import { RESOURCE_CHUNK_BYTES, RESOURCE_LIST_PAGE_SIZE, type ResourceListItem } from "@App/app/repo/resource"; import { fetchScript, resourceClient } from "@App/pages/store/features/script"; -import { base64ToBlob, formatBytes, makeBlobURL } from "@App/pkg/utils/utils"; +import { formatBytes, makeBlobURL } from "@App/pkg/utils/utils"; import { Badge } from "@App/pages/components/ui/badge"; import { Button } from "@App/pages/components/ui/button"; import { DataPanel, DataPanelEmpty, DataPanelHeader, DataPanelRow } from "@App/pages/components/ui/data-panel"; @@ -13,7 +13,7 @@ import { SearchInput } from "@App/pages/components/ui/search-input"; import { TooltipIconButton } from "@App/pages/components/ui/tooltip-icon-button"; import { createPreloadableQuery } from "@App/pages/preloadable-query"; -type ResItem = Resource & { key: string }; +type ResItem = ResourceListItem; // 资源类型 -> 展示用的元数据标记 const TYPE_BADGE: Record = { @@ -31,13 +31,17 @@ const resourcePaneQuery = createPreloadableQuery({ if (signal.aborted || !script) return []; - const res = await resourceClient.getScriptResources(script); - - if (signal.aborted) { - throw new DOMException("ResourcePane preload aborted", "AbortError"); + const items: ResItem[] = []; + let offset = 0; + for (;;) { + if (signal.aborted) { + throw new DOMException("ResourcePane preload aborted", "AbortError"); + } + const page = await resourceClient.getScriptResources(script, offset, RESOURCE_LIST_PAGE_SIZE); + items.push(...page.items); + if (page.nextOffset === undefined) return items; + offset = page.nextOffset; } - - return Object.keys(res).map((k) => ({ ...res[k], key: k })); }, }); @@ -61,18 +65,6 @@ export function usePreloadResourcePane(uuid?: string) { }, [uuid, t]); } -// 估算资源字节大小:优先用文本内容,其次用 base64 解码后的长度 -function resourceByteSize(r: Resource): number { - if (r.content) return new Blob([r.content]).size; - if (r.base64) { - const idx = r.base64.indexOf(","); - const b64 = idx >= 0 ? r.base64.slice(idx + 1) : r.base64; - const pad = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0; - return Math.max(0, Math.floor((b64.length * 3) / 4) - pad); - } - return 0; -} - function fileName(url: string): string { return url.split("/").pop() || url; } @@ -92,14 +84,14 @@ export default function ResourcePane({ uuid }: ResourcePaneProps) { return kw ? list.filter((r) => r.key.toLowerCase().includes(kw)) : list; }, [list, keyword]); - const totalBytes = useMemo(() => list.reduce((s, r) => s + resourceByteSize(r), 0), [list]); + const totalBytes = useMemo(() => list.reduce((s, r) => s + r.byteSize, 0), [list]); const onDelete = useCallback( - (url: string) => { + (resource: ResItem) => { resourceClient - .deleteResource(url) + .deleteResource(resource.url) .then(() => { - resources.setData((prev) => (prev ?? EMPTY_RESOURCES).filter((r) => r.key !== url)); + resources.setData((prev) => (prev ?? EMPTY_RESOURCES).filter((r) => r.key !== resource.key)); notify.success(t("delete_success")); }) .catch((e) => notify.error(`${t("editor:delete_failed")}: ${e.message}`)); @@ -108,7 +100,7 @@ export default function ResourcePane({ uuid }: ResourcePaneProps) { ); const onClear = useCallback(() => { - const urls = list.map((r) => r.key); + const urls = list.map((r) => r.url); Promise.all(urls.map((u) => resourceClient.deleteResource(u))) .then(() => { resources.setData([]); @@ -117,10 +109,38 @@ export default function ResourcePane({ uuid }: ResourcePaneProps) { .catch((e) => notify.error(`${t("editor:delete_failed")}: ${e.message}`)); }, [list, resources, t]); - const onDownload = useCallback((r: ResItem) => { - const url = makeBlobURL({ blob: base64ToBlob(r.base64), persistence: false }) as string; - void chrome.downloads.download({ url, saveAs: true, filename: fileName(r.key) }); - }, []); + const onDownload = useCallback( + (r: ResItem) => { + void (async () => { + const chunks: ArrayBuffer[] = []; + let offset = 0; + while (offset < r.byteSize) { + const chunk = await resourceClient.getResourceChunk({ + uuid, + url: r.url, + offset, + length: Math.min(RESOURCE_CHUNK_BYTES, r.byteSize - offset), + }); + if (chunk.offset !== offset || chunk.total !== r.byteSize || chunk.length === 0) { + throw new Error("resource chunk response is inconsistent"); + } + const binary = atob(chunk.base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + chunks.push(bytes.buffer as ArrayBuffer); + offset += chunk.length; + } + const url = await makeBlobURL({ + blob: new Blob(chunks, { type: r.contentType }), + persistence: false, + }); + await chrome.downloads.download({ url: url as string, saveAs: true, filename: fileName(r.key) }); + })().catch((e) => notify.error(`${t("script:operation_failed")}: ${e.message}`)); + }, + [t, uuid] + ); return (
@@ -189,9 +209,7 @@ export default function ResourcePane({ uuid }: ResourcePaneProps) { {/* md:contents 让大小/操作在桌面端回到与表头对齐的独立列 */}
- - {formatBytes(resourceByteSize(r))} - + {formatBytes(r.byteSize)}
onDelete(r.key)} + onConfirm={() => onDelete(r)} >