From 75f431a5c1c051be61f44382862f3abb77a8cf77 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 15 Sep 2026 05:54:53 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E5=A4=A7?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E4=B8=8E=E8=B5=84=E6=BA=90=E7=9A=84=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=A4=A7=E5=B0=8F=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/message/custom_event_message.ts | 2 + packages/message/extension_message.ts | 6 ++ packages/message/message_queue.ts | 21 ++-- packages/message/message_size.test.ts | 77 +++++++++++++ packages/message/message_size.ts | 102 ++++++++++++++++++ packages/message/server.test.ts | 13 +++ packages/message/server.ts | 38 +++++-- packages/message/window_message.ts | 15 ++- src/app/repo/resource.ts | 44 ++++++++ .../service/offscreen/event_page_manager.ts | 4 + .../offscreen/external-access-connect.test.ts | 25 +++++ .../offscreen/external-access-connect.ts | 22 +++- src/app/service/service_worker/client.ts | 15 ++- .../external_access/source.test.ts | 13 ++- .../service_worker/external_access/source.ts | 51 +++++++-- .../service/service_worker/resource.test.ts | 60 +++++++++++ src/app/service/service_worker/resource.ts | 67 +++++++++++- .../ScriptEditor/tabs/ResourcePane.test.tsx | 62 ++++++++--- .../routes/ScriptEditor/tabs/ResourcePane.tsx | 86 +++++++++------ 19 files changed, 639 insertions(+), 84 deletions(-) create mode 100644 packages/message/message_size.test.ts create mode 100644 packages/message/message_size.ts diff --git a/packages/message/custom_event_message.ts b/packages/message/custom_event_message.ts index 63b33038a..e9865bf50 100644 --- a/packages/message/custom_event_message.ts +++ b/packages/message/custom_event_message.ts @@ -13,6 +13,7 @@ import { } from "@Packages/message/common"; import { ReadyWrap } from "@App/pkg/utils/ready-wrap"; import type { ScriptEnvTag } from "@Packages/message/consts"; +import { assertStructuredMessageSize } from "./message_size"; // 避免页面载入后改动 Map.prototype 导致消息传递失败 const relatedTargetMapMethods = { @@ -133,6 +134,7 @@ export class CustomEventMessage implements Message { nativeSend(detail: any) { if (!this.readyWrap.isReady) throw new Error("custom_event_message is not ready."); + assertStructuredMessageSize(detail, "CustomEvent.detail"); pageDispatchCustomEvent(this.sendFlag, detail); } diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index e0702c98f..3a8f3c05c 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -1,6 +1,7 @@ import EventEmitter from "eventemitter3"; import type { Message, MessageConnect, MessageSend, RuntimeMessageSender, TMessage, TMessageCommAction } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; +import { assertStructuredMessageSize } from "./message_size"; const listenerMgr = new EventEmitter(); // 单一管理器 @@ -9,6 +10,7 @@ export class ExtensionMessage implements Message { connect(data: TMessage): Promise { return new Promise((resolve) => { + assertStructuredMessageSize(data, "chrome.runtime.connect"); const con = chrome.runtime.connect(); con.postMessage(data); resolve(new ExtensionMessageConnect(con)); @@ -18,6 +20,7 @@ export class ExtensionMessage implements Message { // 发送消息 注意不进行回调的内存泄漏 sendMessage(data: TMessage): Promise { return new Promise((resolve: ((value: T) => void) | null) => { + assertStructuredMessageSize(data, "chrome.runtime.sendMessage"); chrome.runtime.sendMessage(data, (resp: T) => { const lastError = chrome.runtime.lastError; if (lastError) { @@ -188,6 +191,7 @@ export class ExtensionMessageConnect implements MessageConnect { // 無法 sendMessage 不应该屏蔽错误 throw new Error("Attempted to sendMessage on a disconnected port."); } + assertStructuredMessageSize(data, "chrome.runtime.Port.postMessage"); this.con.postMessage(data); } @@ -242,6 +246,7 @@ export class ExtensionContentMessageSend implements MessageSend { sendMessage(data: TMessage): Promise { return new Promise((resolve) => { + assertStructuredMessageSize(data, "chrome.tabs.sendMessage"); if (!this.options?.documentId && !this.options?.frameId) { // 发送给指定的tab chrome.tabs.sendMessage(this.tabId, data, (resp: T) => { @@ -267,6 +272,7 @@ export class ExtensionContentMessageSend implements MessageSend { connect(data: TMessage): Promise { return new Promise((resolve) => { + assertStructuredMessageSize(data, "chrome.tabs.connect"); const con = chrome.tabs.connect(this.tabId, this.options); con.postMessage(data); resolve(new ExtensionMessageConnect(con)); diff --git a/packages/message/message_queue.ts b/packages/message/message_queue.ts index b681b0c73..e6a19fb8b 100644 --- a/packages/message/message_queue.ts +++ b/packages/message/message_queue.ts @@ -2,6 +2,7 @@ import EventEmitter from "eventemitter3"; import LoggerCore from "@App/app/logger/core"; import { type TMessage } from "./types"; import type { SystemConfigKey, SystemConfigValueType } from "@App/pkg/config/config"; +import { assertStructuredMessageSize, MessageSizeError } from "./message_size"; export type TKeyValue = { key: T; @@ -79,12 +80,13 @@ export class MessageQueue implements IMessageQueue { // "Could not establish connection. Receiving end does not exist."——不接住就会变成 // 未处理的 Promise rejection。publish 本身是"广播给任何在监听的人",无人监听应静默忽略。 const messageQueueLogger = LoggerCore.getInstance().logger({ service: "messageQueue" }); - chrome.runtime - .sendMessage({ - msgQueue: topic, - data: { action: "message", message }, - }) - .catch((e) => { + const outbound = { + msgQueue: topic, + data: { action: "message", message }, + }; + try { + assertStructuredMessageSize(outbound, "message-queue.publish"); + chrome.runtime.sendMessage(outbound).catch((e) => { const msg = JSON.stringify(e?.message || e); if (msg.includes("Could not establish connection. Receiving end does not exist.")) { messageQueueLogger.debug("No target audience for .publish", { msg }); @@ -92,6 +94,13 @@ export class MessageQueue implements IMessageQueue { messageQueueLogger.error("Unable to execute runtime.sendMessage for .publish", { msg }); } }); + } catch (e) { + if (e instanceof MessageSizeError) { + messageQueueLogger.error("Unable to execute runtime.sendMessage for .publish", { error: e.message }); + } else { + throw e; + } + } this.EE.emit(topic, message); //@ts-ignore messageQueueLogger.trace("publish", { topic, message }); diff --git a/packages/message/message_size.test.ts b/packages/message/message_size.test.ts new file mode 100644 index 000000000..99ed37c8c --- /dev/null +++ b/packages/message/message_size.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; +import { + MAX_EXTENSION_MESSAGE_BYTES, + MessageSizeError, + assertMessageSize, + measureMessageBytes, + measureStructuredMessageBytes, + utf8ByteLength, +} from "./message_size"; +import { ExtensionMessage } from "./extension_message"; + +describe("message size accounting", () => { + it("counts UTF-8 bytes after JSON escaping", () => { + const value = { data: '你好"\\😀' }; + const serialized = JSON.stringify(value); + + expect(utf8ByteLength(serialized)).toBe(new TextEncoder().encode(serialized).byteLength); + expect(measureMessageBytes(value)).toBe(utf8ByteLength(serialized)); + expect(measureMessageBytes(value)).toBeGreaterThan(value.data.length); + }); + + it("measures the complete envelope and accepts exact limits", () => { + const envelope = { messageId: "m1", type: "sendMessage", data: { action: "resource", data: "x" } }; + const bytes = measureMessageBytes(envelope); + + expect(() => assertMessageSize(envelope, "test-channel", bytes)).not.toThrow(); + expect(() => assertMessageSize(envelope, "test-channel", bytes - 1)).toThrow( + new MessageSizeError("test-channel", bytes, bytes - 1).message + ); + }); + + it("includes base64 expansion and reports deterministic over-limit diagnostics", () => { + const envelope = { + msgQueue: "backup", + data: { action: "message", message: { base64: "data:image/png;base64," + "A0==" } }, + }; + const bytes = measureMessageBytes(envelope); + const error = (() => { + try { + assertMessageSize(envelope, "message-queue", bytes - 1); + return undefined; + } catch (e) { + return e; + } + })(); + + expect(error).toBeInstanceOf(MessageSizeError); + expect((error as MessageSizeError).message).toBe( + `message-queue message exceeds limit: ${bytes} bytes (limit ${bytes - 1} bytes)` + ); + }); + + it("counts binary payloads on structured-clone channels", () => { + const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "application/octet-stream" }); + const envelope = { messageId: "m1", type: "sendMessage", data: { blob } }; + + expect(measureStructuredMessageBytes(envelope)).toBe(measureMessageBytes(envelope) + blob.size); + }); + + it("counts binary values nested in Map and Set structured-clone payloads", () => { + const mapBlob = new Blob([new Uint8Array([1, 2])]); + const setBlob = new Blob([new Uint8Array([3, 4, 5])]); + const envelope = { data: new Map([["blob", mapBlob]]), values: new Set([setBlob]) }; + + expect(measureStructuredMessageBytes(envelope)).toBe(measureMessageBytes(envelope) + mapBlob.size + setBlob.size); + }); + + it("rejects an oversized extension message before calling chrome.runtime", async () => { + const sendMessage = vi.spyOn(chrome.runtime, "sendMessage"); + const message = new ExtensionMessage(); + + await expect( + message.sendMessage({ action: "large", data: "x".repeat(MAX_EXTENSION_MESSAGE_BYTES) }) + ).rejects.toBeInstanceOf(MessageSizeError); + expect(sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/message/message_size.ts b/packages/message/message_size.ts new file mode 100644 index 000000000..c904f97f6 --- /dev/null +++ b/packages/message/message_size.ts @@ -0,0 +1,102 @@ +/** Chrome's documented per-message runtime limit. */ +export const MAX_EXTENSION_MESSAGE_BYTES = 64 * 1024 * 1024; + +export class MessageSizeError extends Error { + constructor( + public readonly operation: string, + public readonly actualBytes: number, + public readonly limitBytes: number, + reason?: string + ) { + super( + reason + ? `${operation} message cannot be serialized: ${reason}` + : `${operation} message exceeds limit: ${actualBytes} bytes (limit ${limitBytes} bytes)` + ); + this.name = "MessageSizeError"; + } +} + +export function utf8ByteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +export function serializeMessage(value: unknown): string { + const serialized = JSON.stringify(value); + if (serialized === undefined) { + throw new TypeError("JSON.stringify returned undefined"); + } + return serialized; +} + +export function measureMessageBytes(value: unknown): number { + return utf8ByteLength(serializeMessage(value)); +} + +export function measureStructuredMessageBytes(value: unknown): number { + return measureMessageBytes(value) + measureStructuredBinaryBytes(value); +} + +export function assertMessageSize(value: unknown, operation: string, limitBytes = MAX_EXTENSION_MESSAGE_BYTES): number { + let bytes: number; + try { + bytes = measureMessageBytes(value); + } catch (error) { + throw new MessageSizeError( + operation, + Number.NaN, + limitBytes, + error instanceof Error ? error.message : String(error) + ); + } + if (bytes > limitBytes) { + throw new MessageSizeError(operation, bytes, limitBytes); + } + return bytes; +} + +export function assertStructuredMessageSize( + value: unknown, + operation: string, + limitBytes = MAX_EXTENSION_MESSAGE_BYTES +): number { + let bytes: number; + try { + bytes = measureStructuredMessageBytes(value); + } catch (error) { + throw new MessageSizeError( + operation, + Number.NaN, + limitBytes, + error instanceof Error ? error.message : String(error) + ); + } + if (bytes > limitBytes) { + throw new MessageSizeError(operation, bytes, limitBytes); + } + return bytes; +} + +function measureStructuredBinaryBytes(value: unknown, seen = new WeakSet()): number { + if (typeof value !== "object" || value === null || seen.has(value)) return 0; + seen.add(value); + + if (typeof Blob !== "undefined" && value instanceof Blob) return value.size; + if (value instanceof ArrayBuffer) return value.byteLength; + if (ArrayBuffer.isView(value)) return Object.keys(value).length === 0 ? value.byteLength : 0; + + if (value instanceof Map) { + let total = 0; + for (const [key, child] of value) { + total += measureStructuredBinaryBytes(key, seen) + measureStructuredBinaryBytes(child, seen); + } + return total; + } + if (value instanceof Set) { + let total = 0; + for (const child of value) total += measureStructuredBinaryBytes(child, seen); + return total; + } + + return Object.values(value).reduce((total, child) => total + measureStructuredBinaryBytes(child, seen), 0); +} diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index b7b1ff007..ca9a5b2fe 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -3,6 +3,7 @@ import { GetSenderType, SenderConnect, SenderRuntime, Server, type IGetSender } import { CustomEventMessage } from "./custom_event_message"; import type { MessageConnect, RuntimeMessageSender } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; +import { MAX_EXTENSION_MESSAGE_BYTES } from "./message_size"; let inboundMessage: CustomEventMessage; let outboundMessage: CustomEventMessage; @@ -459,6 +460,18 @@ describe("Server", () => { "level1-after", ]); }); + + it("过大的响应不会再次触发传输异常,而会返回有界诊断", async () => { + server.on("oversized-response", () => "x".repeat(MAX_EXTENSION_MESSAGE_BYTES)); + + const response = await client.sendMessage({ + action: "api/oversized-response", + data: {}, + }); + + expect(response.code).toBe(-1); + expect(response.message).toContain("message-server/oversized-response message exceeds limit"); + }); }); describe("IGetSender 功能测试", () => { diff --git a/packages/message/server.ts b/packages/message/server.ts index 3df109f80..6e8679f7e 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -3,6 +3,7 @@ import LoggerCore from "@App/app/logger/core"; import { connect, sendMessage } from "./client"; import { ExtensionMessageConnect } from "./extension_message"; import Logger from "@App/app/logger/logger"; +import { assertStructuredMessageSize, MessageSizeError } from "./message_size"; export const enum GetSenderType { CONNECT = 1, @@ -170,6 +171,17 @@ export class Server { this.apiFunctionMap.set(name, func); } + private sendConnectResponse(con: MessageConnect, response: TMessage, action: string) { + try { + assertStructuredMessageSize(response, `message-server/${action}`); + con.sendMessage(response); + } catch (e) { + if (!(e instanceof MessageSizeError)) throw e; + this.logger.error("message-server response exceeded transport limit", Logger.E(e)); + con.sendMessage({ code: -1, message: e.message }); + } + } + private connectHandle(msg: string, params: any, con: MessageConnect) { const func = this.apiFunctionMap.get(msg); if (func) { @@ -178,15 +190,15 @@ export class Server { if (ret instanceof Promise) { ret .then((data) => { - data && con.sendMessage({ code: 0, data }); + data && this.sendConnectResponse(con, { code: 0, data }, msg); }) .catch((e: Error) => { - con.sendMessage({ code: -1, message: formatErrorToClient(e) }); + this.sendConnectResponse(con, { code: -1, message: formatErrorToClient(e) }, msg); this.logger.error("connectHandle error", Logger.E(e)); }); return true; } else { - con.sendMessage({ code: 0, data: ret }); + this.sendConnectResponse(con, { code: 0, data: ret }, msg); } } return true; @@ -200,6 +212,16 @@ export class Server { sender: RuntimeMessageSender ) { const func = this.apiFunctionMap.get(action); + const respond = (response: any) => { + try { + assertStructuredMessageSize(response, `message-server/${action}`); + sendResponse(response); + } catch (e) { + if (!(e instanceof MessageSizeError)) throw e; + this.logger.error("message-server response exceeded transport limit", Logger.E(e)); + sendResponse({ code: -1, message: e.message }); + } + }; if (func) { try { const ret = func(params, new SenderRuntime(sender)); @@ -207,25 +229,25 @@ export class Server { ret .then((data) => { try { - sendResponse({ code: 0, data }); + respond({ code: 0, data }); } catch (e: any) { this.logger.error("sendResponse error", Logger.E(e)); } }) .catch((e: Error) => { - sendResponse({ code: -1, message: formatErrorToClient(e) }); + respond({ code: -1, message: formatErrorToClient(e) }); this.logger.error("messageHandle error", Logger.E(e)); }); return true; } else { - sendResponse({ code: 0, data: ret }); + respond({ code: 0, data: ret }); } } catch (e: any) { - sendResponse({ code: -1, message: formatErrorToClient(e) }); + respond({ code: -1, message: formatErrorToClient(e) }); this.logger.error("messageHandle error", Logger.E(e)); } } else { - sendResponse({ code: -1, message: "no such api " + action }); + respond({ code: -1, message: "no such api " + action }); this.logger.error("no such api", { action: action }); } } diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index 9946bd089..b13d28577 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -8,6 +8,7 @@ import type { } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; import EventEmitter from "eventemitter3"; +import { assertStructuredMessageSize } from "./message_size"; const listenerMgr = new EventEmitter(); // 单一管理器 @@ -21,6 +22,7 @@ class WindowPostMessage implements PostMessage { constructor(private target: Window) {} postMessage(message: T): void { + assertStructuredMessageSize(message, "window.postMessage"); this.target.postMessage(message, "*"); } } @@ -117,6 +119,7 @@ export class WindowMessage implements Message { type: "connect", data, }; + assertStructuredMessageSize(body, "window.postMessage"); const target = this.getTarget(); target.postMessage(body, "*"); // 使用 WindowPostMessage 包装,确保后续 sendMessage 也带 "*" targetOrigin @@ -140,6 +143,7 @@ export class WindowMessage implements Message { type: "sendMessage", data, }; + assertStructuredMessageSize(body, "window.postMessage"); const eventId = `response:${messageId}`; this.EE.addListener(eventId, (body: WindowMessageBody) => { this.EE.removeAllListeners(eventId); @@ -192,6 +196,7 @@ export class WindowMessageConnect implements MessageConnect { type: "connectMessage", data, }; + assertStructuredMessageSize(body, "window.postMessage"); this.target.postMessage(body); } @@ -274,6 +279,7 @@ export class ServiceWorkerMessageSend implements Message { type: "respMessage", data: resp, }; + assertStructuredMessageSize(body, "serviceWorker.postMessage"); source.postMessage(body); }, {} as RuntimeMessageSender @@ -306,6 +312,7 @@ export class ServiceWorkerMessageSend implements Message { type: "connect", data, }; + assertStructuredMessageSize(body, "serviceWorker.postMessage"); this.target!.postMessage(body); return new WindowMessageConnect(body.messageId, this.EE, this.target!); } @@ -320,6 +327,7 @@ export class ServiceWorkerMessageSend implements Message { type: "sendMessage", data, }; + assertStructuredMessageSize(body, "serviceWorker.postMessage"); const eventId = `response:${messageId}`; this.EE.addListener(eventId, (body: WindowMessageBody) => { this.EE.removeAllListeners(eventId); @@ -363,7 +371,11 @@ export class ServiceWorkerClientMessage implements Message { this.EE.emit( "message", data.data, - (resp: any) => source.postMessage({ messageId: data.messageId, type: "respMessage", data: resp }), + (resp: any) => { + const body: WindowMessageBody = { messageId: data.messageId, type: "respMessage", data: resp }; + assertStructuredMessageSize(body, "serviceWorker.postMessage"); + source.postMessage(body); + }, {} as RuntimeMessageSender ); } else if (data.type === "connect" && source) { @@ -386,6 +398,7 @@ export class ServiceWorkerClientMessage implements Message { } private postToServiceWorker(message: any) { + assertStructuredMessageSize(message, "serviceWorker.postMessage"); if (this.sw) { this.sw.postMessage(message); } else { diff --git a/src/app/repo/resource.ts b/src/app/repo/resource.ts index c705d35c8..5a2612ec5 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 = 512 * 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/offscreen/event_page_manager.ts b/src/app/service/offscreen/event_page_manager.ts index ac019c1ea..66982c76f 100644 --- a/src/app/service/offscreen/event_page_manager.ts +++ b/src/app/service/offscreen/event_page_manager.ts @@ -12,6 +12,7 @@ import EventEmitter from "eventemitter3"; import { type IMessageQueue } from "@Packages/message/message_queue"; import { ServiceWorkerClient } from "../service_worker/client"; import { BackgroundEnvManagerBase } from "./base"; +import { assertStructuredMessageSize } from "@Packages/message/message_size"; class InProcessMessageConnect implements MessageConnect { private messages = new EventEmitter(); @@ -28,6 +29,7 @@ class InProcessMessageConnect implements MessageConnect { sendMessage(data: TMessage): void { if (!this.disconnected) { + assertStructuredMessageSize(data, "event-page.port.postMessage"); this.peer?.messages.emit("message", data); } } @@ -57,6 +59,7 @@ export class InProcessMessage implements Message, MessageSend { private events = new EventEmitter(); connect(data: TMessage): Promise { + assertStructuredMessageSize(data, "event-page.connect"); const client = new InProcessMessageConnect(); const server = new InProcessMessageConnect(); client.peer = server; @@ -68,6 +71,7 @@ export class InProcessMessage implements Message, MessageSend { } sendMessage(data: TMessage): Promise { + assertStructuredMessageSize(data, "event-page.sendMessage"); return new Promise((resolve) => { this.events.emit("message", data, resolve, {} as RuntimeMessageSender); }); diff --git a/src/app/service/offscreen/external-access-connect.test.ts b/src/app/service/offscreen/external-access-connect.test.ts index 990dc0a72..4a54fd561 100644 --- a/src/app/service/offscreen/external-access-connect.test.ts +++ b/src/app/service/offscreen/external-access-connect.test.ts @@ -405,6 +405,31 @@ describe("ExternalAccessConnect", () => { (externalAccessConnect as any).sendEnvelope({ jsonrpc: "2.0", id: "r1", result: {} }); expect(ws.sentMessages.length).toBe(0); }); + + it("出站 WebSocket frame 超过协议上限时被丢弃", async () => { + const ws = await completeSessionHandshake(); + const sentCount = ws.sentMessages.length; + (externalAccessConnect as any).sendEnvelope({ + jsonrpc: "2.0", + id: "too-large", + result: { source: "x".repeat(LIMITS.maxFrameBytes) }, + }); + + expect(ws.sentMessages.length).toBe(sentCount); + }); + + it("入站 WebSocket frame 超过协议上限时关闭连接", async () => { + const ws = await completeSessionHandshake(); + const closeSpy = vi.spyOn(ws, "close"); + ws.simulateMessage({ + jsonrpc: "2.0", + method: "scripts.list", + params: { input: "x".repeat(LIMITS.maxFrameBytes) }, + }); + + await Promise.resolve(); + expect(closeSpy).toHaveBeenCalled(); + }); }); // ──────────────────────────────────────────────── diff --git a/src/app/service/offscreen/external-access-connect.ts b/src/app/service/offscreen/external-access-connect.ts index 8203abc17..16f954145 100644 --- a/src/app/service/offscreen/external-access-connect.ts +++ b/src/app/service/offscreen/external-access-connect.ts @@ -5,6 +5,7 @@ import type { MessageSend } from "@Packages/message/types"; import { ExternalAccessConnectRelayClient } from "../service_worker/client"; import { CRYPTO, LIMITS } from "../service_worker/external_access/generated/protocol.generated"; import { decodeWireEnvelope } from "../service_worker/external_access/protocol-wire"; +import { utf8ByteLength } from "@Packages/message/message_size"; import type { AuthChallengePayload, AuthMode, @@ -250,6 +251,15 @@ export class ExternalAccessConnect { private async handleMessage(ev: MessageEvent, sessionEpoch: number): Promise { if (sessionEpoch !== this.epoch) return; + if (typeof ev.data !== "string" || utf8ByteLength(ev.data) > LIMITS.maxFrameBytes) { + this.logger.warn("Dropped inbound WebSocket frame over limit", { + bytes: typeof ev.data === "string" ? utf8ByteLength(ev.data) : undefined, + limit: LIMITS.maxFrameBytes, + }); + this.ws?.close(); + return; + } + let envelope: WSEnvelope; try { envelope = decodeWireEnvelope(ev.data as string); @@ -356,7 +366,17 @@ export class ExternalAccessConnect { private rawSend(envelope: WSEnvelope): void { if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify(envelope)); + const frame = JSON.stringify(envelope); + const bytes = utf8ByteLength(frame); + if (bytes > LIMITS.maxFrameBytes) { + this.logger.warn("Dropped outbound WebSocket frame over limit", { + method: envelope.method, + bytes, + limit: LIMITS.maxFrameBytes, + }); + return; + } + this.ws.send(frame); } } 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/external_access/source.test.ts b/src/app/service/service_worker/external_access/source.test.ts index 8ce8ee351..bedd460e1 100644 --- a/src/app/service/service_worker/external_access/source.test.ts +++ b/src/app/service/service_worker/external_access/source.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { sliceLines, grepLines, applyTextEdits } from "./source"; +import { sliceLines, grepLines, applyTextEdits, MAX_GREP_RESULT_BYTES } from "./source"; import { ExternalAccessBridgeError } from "./errors"; describe("sliceLines(scripts.source.get 的行开窗,1-based 闭区间)", () => { @@ -136,6 +136,17 @@ describe("grepLines(scripts.source.grep 的逐行匹配,不复用 stringMatc expect(result.truncated).toBe(true); }); + it("匹配结果总字节数受上限约束,超长命中不会在 service worker 中累积", () => { + const result = grepLines(`hit ${"x".repeat(MAX_GREP_RESULT_BYTES)}`, "hit"); + + expect(result.totalMatches).toBe(1); + expect(result.matches).toEqual([]); + expect(result.truncated).toBe(true); + expect(new TextEncoder().encode(JSON.stringify(result.matches)).byteLength).toBeLessThanOrEqual( + MAX_GREP_RESULT_BYTES + ); + }); + it("未触发 maxMatches 截断时 truncated 为 false", () => { const result = grepLines(code, "import"); expect(result.truncated).toBe(false); diff --git a/src/app/service/service_worker/external_access/source.ts b/src/app/service/service_worker/external_access/source.ts index 67624b5ba..7e12806b1 100644 --- a/src/app/service/service_worker/external_access/source.ts +++ b/src/app/service/service_worker/external_access/source.ts @@ -8,6 +8,10 @@ import type { ScriptSource, ScriptSourceGrepMatch, ScriptSourceGrepResult } from // slice (design §4.1), not the underlying file, so a line window can be pulled out of an // oversized script while a full read keeps behaving exactly as before. export const MAX_SOURCE_BYTES = 2 * 1024 * 1024; +// Keep grep responses well below the WebSocket frame cap after JSON-RPC envelopes and UTF-8 +// encoding are added. A bounded result also prevents a set of long matching lines from building +// an unbounded response in the service worker. +export const MAX_GREP_RESULT_BYTES = 512 * 1024; export interface SlicedLines { code: string; @@ -128,6 +132,10 @@ export interface GrepLinesResult { totalLines: number; } +function serializedBytes(value: unknown): number { + return new TextEncoder().encode(JSON.stringify(value)).byteLength; +} + // Compiling the user's pattern is itself a validation step, so acceptance-time checking and the // actual scan share it rather than each spelling out their own try/catch. function compileGrepPattern(query: string, mode: "text" | "regex", ignoreCase: boolean): RegExp | undefined { @@ -194,6 +202,7 @@ export function grepLines( const totalLines = lines.length; const matches: ScriptSourceGrepMatch[] = []; let totalMatches = 0; + let resultTruncated = false; const skippedLongLines = 0; const startedAt = clock(); @@ -211,16 +220,27 @@ export function grepLines( totalMatches++; if (matches.length < maxMatches) { - matches.push({ + const match = { lineNumber: i + 1, line, before: lines.slice(Math.max(0, i - contextLines), i), after: lines.slice(i + 1, i + 1 + contextLines), - }); + }; + if (serializedBytes([...matches, match]) <= MAX_GREP_RESULT_BYTES) { + matches.push(match); + } else { + resultTruncated = true; + } } } - return { matches, totalMatches, truncated: totalMatches > matches.length, skippedLongLines, totalLines }; + return { + matches, + totalMatches, + truncated: resultTruncated || totalMatches > matches.length, + skippedLongLines, + totalLines, + }; } /** @@ -240,19 +260,30 @@ export async function grepScriptSource( const scriptCode = await scriptCodeDAO.get(uuid); if (!scriptCode) throw new ExternalAccessBridgeError("NOT_FOUND", "script source not found"); - const result = grepLines(scriptCode.code, query, options); - return { + const grepResult = grepLines(scriptCode.code, query, options); + const result: ScriptSourceGrepResult = { uuid: script.uuid, name: script.name, version: script.metadata.version?.[0], - matches: result.matches, - totalMatches: result.totalMatches, - truncated: result.truncated, - skippedLongLines: result.skippedLongLines, - totalLines: result.totalLines, + matches: grepResult.matches, + totalMatches: grepResult.totalMatches, + truncated: grepResult.truncated, + skippedLongLines: grepResult.skippedLongLines, + totalLines: grepResult.totalLines, sha256: sha256OfText(scriptCode.code), contentTrust: "untrusted-user-script-source", }; + if (serializedBytes(result) <= MAX_GREP_RESULT_BYTES) return result; + + const matches = [...result.matches]; + while (matches.length > 0 && serializedBytes({ ...result, matches }) > MAX_GREP_RESULT_BYTES) { + matches.pop(); + } + const bounded = { ...result, matches, truncated: true }; + if (serializedBytes(bounded) > MAX_GREP_RESULT_BYTES) { + throw new ExternalAccessBridgeError("PAYLOAD_TOO_LARGE", "grep result exceeds 512 KiB"); + } + return bounded; } export interface TextEdit { diff --git a/src/app/service/service_worker/resource.test.ts b/src/app/service/service_worker/resource.test.ts index c9fc1f176..35eafa797 100644 --- a/src/app/service/service_worker/resource.test.ts +++ b/src/app/service/service_worker/resource.test.ts @@ -412,6 +412,66 @@ describe("ResourceService - getResourceByTypes", () => { }); }); +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("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..44e852a82 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,24 @@ 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("行内删除应二次确认后才调用 deleteResource 并移除该行", async () => { render(); await screen.findByText("jquery.min.js"); @@ -90,7 +118,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 +136,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 +182,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 +194,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 +203,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)} >