Skip to content
Draft
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
42 changes: 30 additions & 12 deletions src/app/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,25 +267,43 @@ export function migrateChromeStorage() {
);
},
},
{
version: 2,
upgrade: async () => {
const scriptCodeDAO = new ScriptCodeDAO();
// 从 chrome.storage.local 读取所有旧代码
const scriptCodes = await scriptCodeDAO.all();
// 仅写入 OPFS,避免写放大(不通过 save() 回写 chrome.storage.local)
await Promise.all(scriptCodes.map(async (scriptCode) => scriptCodeDAO.saveToOPFS(scriptCode)));
},
},
];
const localstorageDAO = new LocalStorageDAO();
localstorageDAO.get("migrations").then(async (item) => {
const migrations = item?.value || [];
const migrations: number[] = item?.value || [];
for (let i = 0; i < migrationList.length; i++) {
const m = migrationList[i];
if (!migrations.includes(m.version)) {
try {
await m.upgrade();
migrations.push(m.version);
} catch (e) {
throw new Error(`Chrome storage migration v${m.version} failed: ${e}`);
}
if (migrations.includes(m.version)) {
continue;
}
// 保证顺序:前一个版本必须已完成
if (i > 0 && !migrations.includes(migrationList[i - 1].version)) {
throw new Error(
`Chrome storage migration v${m.version} skipped: v${migrationList[i - 1].version} not completed`
);
}
try {
await m.upgrade();
migrations.push(m.version);
// 每步成功后立即持久化,避免 SW 挂起导致进度丢失
await localstorageDAO.save({
key: "migrations",
value: migrations,
});
} catch (e) {
throw new Error(`Chrome storage migration v${m.version} failed: ${e}`);
}
}
localstorageDAO.save({
key: "migrations",
value: migrations,
});
});
}

Expand Down
69 changes: 69 additions & 0 deletions src/app/repo/scripts.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
ScriptDAO,
ScriptCodeDAO,
type Script,
SCRIPT_TYPE_NORMAL,
SCRIPT_STATUS_ENABLE,
Expand Down Expand Up @@ -152,3 +153,71 @@ describe("ScriptDAO.searchExistingScript", () => {
expect(found[0]).toBeUndefined();
});
});

describe("ScriptCodeDAO", () => {
let dao: ScriptCodeDAO;

beforeEach(async () => {
await chrome.storage.local.clear();
(globalThis as any).__clearOPFSMock?.();
dao = new ScriptCodeDAO();
});

it("save 后 get 应该从 OPFS 读取", async () => {
await dao.save({ uuid: "test-1", code: "alert('hello');" });
const result = await dao.get("test-1");
expect(result).toBeDefined();
expect(result!.code).toBe("alert('hello');");
});

it("get 不存在的脚本应返回 undefined", async () => {
const result = await dao.get("nonexistent");
expect(result).toBeUndefined();
});

it("OPFS 不存在时应 fallback 到 chrome.storage.local", async () => {
await chrome.storage.local.set({ "scriptCode:old-script": { uuid: "old-script", code: "old code" } });
const result = await dao.get("old-script");
expect(result).toBeDefined();
expect(result!.code).toBe("old code");
});

it("fallback 读取后应懒迁移到 OPFS", async () => {
await chrome.storage.local.set({ "scriptCode:lazy": { uuid: "lazy", code: "lazy code" } });
await dao.get("lazy");
// 等待懒迁移的异步写入完成
await new Promise((resolve) => setTimeout(resolve, 0));
await chrome.storage.local.clear();
const dao2 = new ScriptCodeDAO();
const result = await dao2.get("lazy");
expect(result).toBeDefined();
expect(result!.code).toBe("lazy code");
});

it("delete 应同时删除 OPFS 和 chrome.storage.local", async () => {
await dao.save({ uuid: "del-test", code: "to delete" });
await dao.delete("del-test");
const result = await dao.get("del-test");
expect(result).toBeUndefined();
});

it("gets 应批量获取", async () => {
await dao.save({ uuid: "a", code: "code-a" });
await dao.save({ uuid: "b", code: "code-b" });
const results = await dao.gets(["a", "b", "c"]);
expect(results[0]?.code).toBe("code-a");
expect(results[1]?.code).toBe("code-b");
expect(results[2]).toBeUndefined();
});

it("saveToOPFS 仅写 OPFS 不写 chrome.storage.local", async () => {
await dao.saveToOPFS({ uuid: "opfs-only", code: "opfs code" });
const storageResult = await new Promise<any>((resolve) => {
chrome.storage.local.get("scriptCode:opfs-only", resolve);
});
expect(storageResult["scriptCode:opfs-only"]).toBeUndefined();
const result = await dao.get("opfs-only");
expect(result).toBeDefined();
expect(result!.code).toBe("opfs code");
});
});
171 changes: 144 additions & 27 deletions src/app/repo/scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export interface Script {
checktime: number; // 脚本检查更新时间戳
lastruntime?: number; // 脚本最后一次运行时间戳
nextruntime?: number; // 脚本下一次运行时间戳
ignoreVersion?: string; // 忽略單一版本的更新檢查
ignoreVersion?: string; // 忽略单一版本的更新检查
}

// 分开存储脚本代码
Expand Down Expand Up @@ -147,33 +147,18 @@ export type TClientPageLoadInfo =
| { ok: false };

export class ScriptDAO extends Repo<Script> {
scriptCodeDAO: ScriptCodeDAO = new ScriptCodeDAO();

constructor() {
super("script");
}

enableCache(): void {
super.enableCache();
this.scriptCodeDAO.enableCache();
}

public save(val: Script) {
return super._save(val.uuid, val);
}

findByUUID(uuid: string) {
return this.get(uuid);
}

async getAndCode(uuid: string): Promise<ScriptAndCode | undefined> {
const [script, code] = await Promise.all([this.get(uuid), this.scriptCodeDAO.get(uuid)]);
if (!script || !code) {
return undefined;
}
return Object.assign(script, code);
}

public findByName(name: string) {
return this.findOne((key, value) => {
return value.name === name;
Expand Down Expand Up @@ -235,22 +220,22 @@ export class ScriptDAO extends Repo<Script> {
if (val1.length < 2) {
return val1[0] === val2[0];
}
// 無視次序
// 无视次序
const s = new Set([...val1, ...val2]);
if (s.size !== val1.length) return false;
return true;
}
return val1 === val2;
};
const isScriptInfoEqual = (script1: Script, script2: Script) => {
// @author, @copyright, @license 應該不會改
// @author, @copyright, @license 应该不会改
if (!valEqual(script1.metadata.author, script2.metadata.author)) return false;
if (!valEqual(script1.metadata.copyright, script2.metadata.copyright)) return false;
if (!valEqual(script1.metadata.license, script2.metadata.license)) return false;
// @grant, @connect 應該不會改
// @grant, @connect 应该不会改
if (!valEqual(script1.metadata.grant, script2.metadata.grant)) return false;
if (!valEqual(script1.metadata.connect, script2.metadata.connect)) return false;
// @match @include 應該不會改
// @match @include 应该不会改
if (!valEqual(script1.metadata.match, script2.metadata.match)) return false;
if (!valEqual(script1.metadata.include, script2.metadata.include)) return false;
return true;
Expand Down Expand Up @@ -293,16 +278,148 @@ export class ScriptDAO extends Repo<Script> {
}

// 为了防止脚本代码数据量过大,单独存储脚本代码
export class ScriptCodeDAO extends Repo<ScriptCode> {
constructor() {
super("scriptCode");
// 内部使用 OPFS 优先存储,fallback 到 chrome.storage.local(过渡期间)
export class ScriptCodeDAO {
private static readonly LEGACY_PREFIX = "scriptCode:";
private _dirHandlePromise: Promise<FileSystemDirectoryHandle> | null = null;

private getDirHandle(): Promise<FileSystemDirectoryHandle> {
if (!this._dirHandlePromise) {
this._dirHandlePromise = navigator.storage
.getDirectory()
.then((opfsRoot) => opfsRoot.getDirectoryHandle("script_codes", { create: true }));
}
return this._dirHandlePromise;
}

findByUUID(uuid: string) {
return this.get(uuid);
// 仅写入 OPFS,供迁移使用,避免写放大
public async saveToOPFS(val: ScriptCode): Promise<void> {
const folder = await this.getDirHandle();
const handle = await folder.getFileHandle(`${val.uuid}.user.js`, { create: true });
const writable = await handle.createWritable({ keepExistingData: false });
await writable.write(val.code);
await writable.close();
}

public save(val: ScriptCode) {
return super._save(val.uuid, val);
public async save(val: ScriptCode): Promise<ScriptCode> {
// 写入 OPFS(失败不影响 chrome.storage.local)
try {
await this.saveToOPFS(val);
} catch {
// OPFS 写入失败,忽略
}
// 过渡期间同步写入 chrome.storage.local
await this.legacySave(val);
return val;
}

public async get(key: string): Promise<ScriptCode | undefined> {
// 优先从 OPFS 读取
try {
const folder = await this.getDirHandle();
const handle = await folder.getFileHandle(`${key}.user.js`, { create: false });
const code = await handle.getFile().then((f) => f.text());
return { uuid: key, code };
} catch {
// OPFS 没有,fallback 到 chrome.storage.local
}
const result = await this.legacyGet(key);
if (result) {
// 懒迁移:写入 OPFS
this.saveToOPFS(result).catch(() => {});
}
return result;
}

public async gets(keys: string[]): Promise<(ScriptCode | undefined)[]> {
return Promise.all(keys.map((key) => this.get(key)));
}

public async delete(key: string): Promise<void> {
// 删除 OPFS
try {
const folder = await this.getDirHandle();
await folder.removeEntry(`${key}.user.js`);
} catch {
// 忽略删除失败
}
// 过渡期间同步删除 chrome.storage.local
await this.legacyDelete([key]);
}

public async deletes(keys: string[]): Promise<void> {
// 删除 OPFS
try {
const folder = await this.getDirHandle();
await Promise.all(
keys.map(async (key) => {
try {
await folder.removeEntry(`${key}.user.js`);
} catch {
// 忽略
}
})
);
} catch {
// 忽略
}
// 过渡期间同步删除 chrome.storage.local
await this.legacyDelete(keys);
}

// --- 过渡期间 chrome.storage.local 操作,过渡结束后删除 ---

// 从 chrome.storage.local 读取所有脚本代码(仅迁移使用)
public all(): Promise<ScriptCode[]> {
return new Promise((resolve) => {
chrome.storage.local.get(null, (items) => {
if (chrome.runtime.lastError) {
console.error("chrome.storage.local.get error:", chrome.runtime.lastError);
}
const result: ScriptCode[] = [];
for (const key in items) {
if (key.startsWith(ScriptCodeDAO.LEGACY_PREFIX)) {
result.push(items[key]);
}
}
resolve(result);
});
});
}

private legacySave(val: ScriptCode): Promise<void> {
const key = ScriptCodeDAO.LEGACY_PREFIX + val.uuid;
return new Promise((resolve) => {
chrome.storage.local.set({ [key]: val }, () => {
if (chrome.runtime.lastError) {
console.error("chrome.storage.local.set error:", chrome.runtime.lastError);
}
resolve();
});
});
}

private legacyGet(key: string): Promise<ScriptCode | undefined> {
const storageKey = ScriptCodeDAO.LEGACY_PREFIX + key;
return new Promise((resolve) => {
chrome.storage.local.get(storageKey, (items) => {
if (chrome.runtime.lastError) {
console.error("chrome.storage.local.get error:", chrome.runtime.lastError);
}
resolve(items[storageKey]);
});
});
}

private legacyDelete(keys: string[]): Promise<void> {
const storageKeys = keys.map((key) => ScriptCodeDAO.LEGACY_PREFIX + key);
return new Promise((resolve) => {
chrome.storage.local.remove(storageKeys, () => {
if (chrome.runtime.lastError) {
console.error("chrome.storage.local.remove error:", chrome.runtime.lastError);
}
resolve();
});
});
}
}
7 changes: 4 additions & 3 deletions src/app/service/service_worker/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { EmitEventRequest, ScriptLoadInfo, ScriptMatchInfo, ScriptMenu } fr
import type { IMessageQueue } from "@Packages/message/message_queue";
import type { Group, IGetSender } from "@Packages/message/server";
import type { ExtMessageSender, MessageSend } from "@Packages/message/types";
import type { TClientPageLoadInfo } from "@App/app/repo/scripts";
import { ScriptCodeDAO, type TClientPageLoadInfo } from "@App/app/repo/scripts";
import type { Script, ScriptDAO, ScriptRunResource, ScriptSite, TScriptInfo } from "@App/app/repo/scripts";
import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts";
import { type ValueService } from "./value";
Expand Down Expand Up @@ -128,6 +128,7 @@ export class RuntimeService {
initialCompiledResourcePromise: Promise<any> | undefined;

compiledResourceDAO: CompiledResourceDAO = new CompiledResourceDAO();
private readonly scriptCodeDAO: ScriptCodeDAO = new ScriptCodeDAO();

constructor(
private systemConfig: SystemConfig,
Expand Down Expand Up @@ -1245,7 +1246,7 @@ export class RuntimeService {
}
}

const { value, resource, scriptDAO } = this;
const { value, resource, scriptCodeDAO } = this;
await Promise.all(
enableScriptList.flatMap((script) => [
// 加载value
Expand All @@ -1265,7 +1266,7 @@ export class RuntimeService {
}
}),
// 加载code相关的信息
scriptDAO.scriptCodeDAO.get(script.uuid).then((code) => {
scriptCodeDAO.get(script.uuid).then((code) => {
if (code) {
const metadataStr = getMetadataStr(code.code) || "";
const userConfigStr = getUserConfigStr(code.code) || "";
Expand Down
Loading
Loading