Skip to content
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