Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/app/repo/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 12 additions & 3 deletions src/app/service/service_worker/client.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<ResourceListPage> {
return this.doThrow("getScriptResources", { script, offset, limit });
}

getResourceChunk(params: ResourceChunkRequest): Promise<ResourceChunk> {
return this.doThrow("getResourceChunk", params);
}

deleteResource(url: string) {
Expand Down
60 changes: 60 additions & 0 deletions src/app/service/service_worker/resource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
67 changes: 64 additions & 3 deletions src/app/service/service_worker/resource.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -459,12 +469,63 @@ export class ResourceService {
return await this.resourceDAO.save(res);
}

requestGetScriptResources(script: Script): Promise<ScriptResource> {
return this.getScriptResourceValue(script);
async getScriptResourcePage(script: Script, offset = 0, limit = RESOURCE_LIST_PAGE_SIZE): Promise<ResourceListPage> {
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<ResourceChunk> {
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<ResourceListPage> {
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));

// 删除相关资源
Expand Down
62 changes: 45 additions & 17 deletions src/pages/options/routes/ScriptEditor/tabs/ResourcePane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -59,6 +69,24 @@ describe("ResourcePane 资源面板", () => {
expect(screen.getByText("application/javascript")).toBeInTheDocument();
});

it("下载应按元数据请求脚本绑定的资源块", async () => {
const downloadSpy = vi.spyOn(chrome.downloads, "download").mockResolvedValue(undefined);
render(<ResourcePane uuid="u1" />);
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(<ResourcePane uuid="u1" />);
await screen.findByText("jquery.min.js");
Expand Down Expand Up @@ -90,7 +118,7 @@ describe("ResourcePane 资源面板", () => {
});

it("无资源时应展示空状态", async () => {
getScriptResources.mockResolvedValue({});
getScriptResources.mockResolvedValue({ items: [], offset: 0, limit: 100, total: 0 });
render(<ResourcePane uuid="u1" />);
expect(await screen.findByText(t("no_data"))).toBeInTheDocument();
});
Expand All @@ -108,7 +136,7 @@ describe("ResourcePane 资源面板", () => {
it("缓存失效后应重新加载同一脚本的资源", async () => {
render(<ResourcePane uuid="u1" />);
await screen.findByText("jquery.min.js");
getScriptResources.mockResolvedValue({});
getScriptResources.mockResolvedValue({ items: [], offset: 0, limit: 100, total: 0 });

invalidateResourcePane("u1");

Expand Down Expand Up @@ -154,10 +182,10 @@ describe("ResourcePane 资源面板", () => {
});

it("预加载取消不应展示错误", async () => {
let resolveFirst!: (resources: ReturnType<typeof sampleResources>) => void;
let resolveFirst!: (resources: ReturnType<typeof samplePage>) => void;
getScriptResources
.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve)))
.mockResolvedValueOnce(sampleResources());
.mockResolvedValueOnce(samplePage());
const toastError = vi.spyOn(notify, "error");

const { rerender } = renderHook(({ uuid }) => usePreloadResourcePane(uuid), {
Expand All @@ -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();
});
Expand All @@ -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(<ResourcePane uuid="u1" />);

Expand Down
Loading
Loading