diff --git a/src/app/repo/trash_script.test.ts b/src/app/repo/trash_script.test.ts new file mode 100644 index 000000000..19d750e52 --- /dev/null +++ b/src/app/repo/trash_script.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import { TrashScriptDAO, type TrashScript } from "./trash_script"; +import { ScriptDAO, SCRIPT_TYPE_NORMAL, SCRIPT_STATUS_ENABLE, SCRIPT_RUN_STATUS_COMPLETE } from "./scripts"; + +initTestEnv(); + +const makeTrashScript = (overrides: Partial = {}): TrashScript => ({ + uuid: "uuid-trash-1", + name: "测试脚本", + namespace: "test-namespace", + type: SCRIPT_TYPE_NORMAL, + status: SCRIPT_STATUS_ENABLE, + sort: 0, + runStatus: SCRIPT_RUN_STATUS_COMPLETE, + createtime: Date.now(), + checktime: Date.now(), + metadata: {}, + deleteTime: Date.now(), + deleteBy: "user", + ...overrides, +}); + +describe("TrashScriptDAO", () => { + let dao: TrashScriptDAO; + let scriptDAO: ScriptDAO; + + beforeEach(async () => { + await chrome.storage.local.clear(); + dao = new TrashScriptDAO(); + scriptDAO = new ScriptDAO(); + }); + + it("应能保存回收站脚本并按 uuid 取回,且保留删除元数据", async () => { + await dao.save(makeTrashScript({ uuid: "u1", deleteTime: 1234, deleteBy: "sync" })); + const got = await dao.get("u1"); + expect(got?.uuid).toBe("u1"); + expect(got?.deleteTime).toBe(1234); + expect(got?.deleteBy).toBe("sync"); + }); + + it("回收站脚本不得出现在 ScriptDAO 的查询结果中(前缀隔离)", async () => { + await dao.save(makeTrashScript({ uuid: "u2", name: "只在回收站" })); + const all = await scriptDAO.all(); + expect(all.find((s) => s.uuid === "u2")).toBeUndefined(); + expect(await scriptDAO.get("u2")).toBeUndefined(); + }); + + it("应能按 name+namespace 查回收站脚本", async () => { + await dao.save(makeTrashScript({ uuid: "u3", name: "查我", namespace: "ns-a" })); + expect((await dao.findByNameAndNamespace("查我", "ns-a"))?.uuid).toBe("u3"); + expect(await dao.findByNameAndNamespace("查我", "ns-b")).toBeUndefined(); + }); + + it("应能批量删除回收站脚本", async () => { + await dao.save(makeTrashScript({ uuid: "u4" })); + await dao.save(makeTrashScript({ uuid: "u5" })); + await dao.deletes(["u4", "u5"]); + expect(await dao.all()).toHaveLength(0); + }); +}); diff --git a/src/app/repo/trash_script.ts b/src/app/repo/trash_script.ts new file mode 100644 index 000000000..7ae0ea87b --- /dev/null +++ b/src/app/repo/trash_script.ts @@ -0,0 +1,27 @@ +import { Repo } from "./repo"; +import type { Script } from "./scripts"; +import type { InstallSource } from "../service/service_worker/types"; + +/** 回收站中的脚本:原脚本 + 删除元数据 */ +export interface TrashScript extends Script { + /** 进入回收站的时间戳(毫秒) */ + deleteTime: number; + /** 删除来源 */ + deleteBy: InstallSource; +} + +export class TrashScriptDAO extends Repo { + constructor() { + super("trashScript"); + } + + public save(val: TrashScript) { + return super._save(val.uuid, val); + } + + public findByNameAndNamespace(name: string, namespace: string) { + return this.findOne((key, value) => { + return value.name === name && (!namespace || value.namespace === namespace); + }); + } +} diff --git a/src/app/service/offscreen/script.ts b/src/app/service/offscreen/script.ts index eddf363d6..9a52de4b5 100644 --- a/src/app/service/offscreen/script.ts +++ b/src/app/service/offscreen/script.ts @@ -75,7 +75,7 @@ export class ScriptService { disableScript(this.windowMessage, data.script.uuid); } }); - this.messageQueue.subscribe("deleteScripts", async (data) => { + this.messageQueue.subscribe("trashScripts", async (data) => { for (const { uuid, type } of data) { // 只发送后台脚本和定时脚本 if (type === SCRIPT_TYPE_BACKGROUND || type === SCRIPT_TYPE_CRONTAB) { diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 8e874ce95..4a83359ef 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -19,9 +19,11 @@ import type { ScriptService, TCheckScriptUpdateOption, TOpenBatchUpdatePageOption, + TRestoreResult, TScriptInstallParam, TScriptInstallReturn, } from "./script"; +import type { TrashScript } from "@App/app/repo/trash_script"; import { encodeRValue, type TKeyValuePair } from "@App/pkg/utils/message_value"; import { type TSetValuesParams } from "./value"; import type { LocalBackupExport } from "./synchronize"; @@ -60,6 +62,18 @@ export class ScriptClient extends Client { return this.do("deletes", uuids); } + restores(uuids: string[]) { + return this.do("restores", uuids); + } + + purges(uuids: string[]) { + return this.do("purges", uuids); + } + + getTrashScripts() { + return this.do("getTrashScripts"); + } + enable(uuid: string, enable: boolean) { return this.do("enable", { uuid, enable }); } diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index e06082a9c..ffecc7c75 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -187,6 +187,9 @@ export default class ServiceWorkerManager { // 避免浏览器打开时立即清除。先等tabs载入一下 setTimeout(cleanupStaleTempStorageEntries, needsWarmupDelay ? 45_000 : 100); break; + case "cleanupTrash": + script.cleanupExpiredTrash(); + break; } }); // 12小时检查一次扩展更新 @@ -246,6 +249,9 @@ export default class ServiceWorkerManager { // 定期清理过期的临时安装信息 chrome.alarms.create("cleanupTempStorage", { periodInMinutes: 30 }); + // 定期清理回收站中过期的脚本(30 天精度无需更细) + chrome.alarms.create("cleanupTrash", { periodInMinutes: 12 * 60 }); + // 一些只需启动时运行一次的任务 cacheInstance.getOrSet("extension_initialized", () => { // 启动一次云同步 diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index 3f87451e4..85b3b1721 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -146,7 +146,7 @@ describe("PopupService 删除脚本后 Popup 菜单残留清理", () => { expect(scriptDAO.gets).toHaveBeenCalledWith([deletedUuid]); }); - it("deleteScripts 事件应从所有标签页缓存(包含后台 -1)清除已删脚本,并清理待处理菜单命令", async () => { + it("trashScripts 事件应从所有标签页缓存(包含后台 -1)清除已删脚本,并清理待处理菜单命令", async () => { const deletedUuid = "deleted-script"; const liveUuid = "live-script"; await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(deletedUuid), createMenu(liveUuid)]); @@ -163,7 +163,9 @@ describe("PopupService 删除脚本后 Popup 菜单残留清理", () => { ] as never); service.dealBackgroundScriptInstall(); - const [deleteHandler] = subscriptions.get("deleteScripts") || []; + // 删除脚本现在语义为“移入回收站”,该清理逻辑随 popup.ts 的订阅一并迁移到了 trashScripts 事件 + // (事件名变更,handler 本身未变)。 + const [deleteHandler] = subscriptions.get("trashScripts") || []; expect(deleteHandler).toBeDefined(); await deleteHandler!([ { uuid: deletedUuid, storageName: "deleted-script", type: SCRIPT_TYPE_NORMAL }, diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index db4900f1d..0cc63b20a 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -601,7 +601,7 @@ export class PopupService { } }); }); - this.mq.subscribe("deleteScripts", async (data) => { + this.mq.subscribe("trashScripts", async (data) => { const changed = await this.removeDeletedScriptsFromPopupCaches(data.map(({ uuid }) => uuid)); if (changed) { this.updateBadgeIcon(); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 6c294cecd..d2edbefdd 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -593,7 +593,7 @@ export class RuntimeService { }); // 监听脚本删除 - this.mq.subscribe("deleteScripts", async (data) => { + this.mq.subscribe("trashScripts", async (data) => { // 显式提前失效,确保在任何 await 之前就清掉陈旧的 disabled 匹配器。 // 虽然下方 updateSorter() 内部也会调用 invalidateDisabledMatcher(),但此处与 installScript 保持一致: // 由于 updateSorter() 位于 await 之后,必须先在最前面显式失效一次。 diff --git a/src/app/service/service_worker/script.test.ts b/src/app/service/service_worker/script.test.ts new file mode 100644 index 000000000..b7dedc50f --- /dev/null +++ b/src/app/service/service_worker/script.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import { ScriptService } from "./script"; +import { + ScriptDAO, + ScriptCodeDAO, + SCRIPT_TYPE_NORMAL, + SCRIPT_STATUS_ENABLE, + SCRIPT_RUN_STATUS_COMPLETE, +} from "@App/app/repo/scripts"; +import { TrashScriptDAO, type TrashScript } from "@App/app/repo/trash_script"; +import type { Script } from "@App/app/repo/scripts"; +import { SubscribeDAO } from "@App/app/repo/subscribe"; +import { MessageQueue } from "@Packages/message/message_queue"; +import { MockMessage } from "@Packages/message/mock_message"; +import { Server } from "@Packages/message/server"; +import { SystemConfig } from "@App/pkg/config/config"; +import EventEmitter from "eventemitter3"; +import type { ValueService } from "./value"; +import type { ResourceService } from "./resource"; +import type { TDeleteScript, TInstallScript } from "@App/app/service/queue"; + +initTestEnv(); + +const makeScript = (overrides: Partial