Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
5b631fc
feat(trash): 新增 TrashScriptDAO 与 TrashScript 类型
CodFrm Jul 14, 2026
eafd990
feat(trash): 新增 purgeScripts 彻底删除入口
CodFrm Jul 14, 2026
a7bc855
feat(trash): 删除脚本改为移入回收站,4 个订阅者迁移至 trashScripts
CodFrm Jul 15, 2026
f6d4512
test(trash): 断言回收站期间 value 冻结、彻底删除后才清理
CodFrm Jul 15, 2026
0e768ea
feat(trash): 新增 restoreScripts,含同名冲突拒绝与孤儿订阅清理
CodFrm Jul 15, 2026
c055036
fix(trash): installScript 落库前清除回收站同 uuid 条目,守住两表互斥不变量
CodFrm Jul 15, 2026
b8f6108
fix(trash): value 清理需同时检查回收站,避免误删共享 storagename 的数据
CodFrm Jul 15, 2026
ab6064e
feat(trash): 保留时间配置(默认30天/0=永不)与到期自动清理 alarm
CodFrm Jul 15, 2026
f882e8a
feat(trash): 新增 restores/purges/getTrashScripts 的 client 与 store 包装
CodFrm Jul 15, 2026
8b2263a
i18n(trash): 新增回收站相关文案 key(8 个 locale)
CodFrm Jul 15, 2026
9852f84
i18n(trash): 补充撤销 toast 与安装页还原态文案(script/install,8 个 locale)
CodFrm Jul 15, 2026
2ed47b5
feat(trash): 回收站 UI —— 脚本列表新增回收站 tab 与 TrashTable
CodFrm Jul 15, 2026
0e127df
feat(trash): 删除后提供撤销 toast
CodFrm Jul 15, 2026
c593bbb
feat(trash): 回收站支持按名称/命名空间搜索,补齐设计稿的搜索框
CodFrm Jul 15, 2026
2a5a343
feat(trash): 移动端回收站 —— TrashCardGrid 与移动端 tab
CodFrm Jul 15, 2026
36d5b1d
refactor(trash): 顶栏按设计稿对齐 —— tabs 取代标题槽位,数量改由 tab 角标承载
CodFrm Jul 15, 2026
32fadff
feat(trash): 安装页复用回收站脚本身份,按钮改为还原/还原并更新
CodFrm Jul 15, 2026
58c7704
i18n(trash): 新增回收站保留时间设置项文案(settings,8 个 locale)
CodFrm Jul 15, 2026
0909f66
feat(trash): 设置页新增回收站保留时间选项
CodFrm Jul 15, 2026
2623618
i18n(trash): 修正保留时间文案两处
CodFrm Jul 15, 2026
0650313
fix(trash): 页面侧订阅者漏迁移导致删除后列表不刷新
CodFrm Jul 15, 2026
60894e9
feat(trash): 移动端 tab 补上数量角标,与桌面端及设计稿一致
CodFrm Jul 15, 2026
21afc87
✨ 完善回收站清理设置与空状态交互
CodFrm Jul 15, 2026
d042192
perf(trash): 回收站 DAO 开启缓存,消除每次读取的全量存储扫描
CodFrm Jul 15, 2026
08a54da
feat(trash): 新增 trash_enabled 配置,关闭时删除直接销毁并发停用/销毁双事件
CodFrm Jul 15, 2026
83dc0cb
feat(trash): 回收站关闭时不再自动清理残留条目
CodFrm Jul 15, 2026
b5e38c5
fix(test): 补清 sync storage,消除 trash_enabled 跨用例泄漏
CodFrm Jul 15, 2026
c951ce3
i18n(trash): 新增回收站启用开关与自定义天数文案(8 个 locale)
CodFrm Jul 15, 2026
19372e8
feat(trash): 设置页新增回收站启用开关与自定义保留天数
CodFrm Jul 15, 2026
93f29d5
perf(trash): 角标计数改为订阅回收站相关事件,排序/开关不再重拉回收站
CodFrm Jul 15, 2026
b792070
test(trash): 覆盖 useTrashCount 的选择性订阅,钉住排序/开关不重拉回收站
CodFrm Jul 15, 2026
0a2d407
feat(trash): 回收站关闭且清空后隐藏回收站 tab,并回落到已安装
CodFrm Jul 15, 2026
14f4364
test(trash): 钉住回收站 tab 消失时回落到已安装
CodFrm Jul 15, 2026
bcc8e9c
test(trash): 护栏登记 useTrashCount 对 deleteScripts 的只读订阅
CodFrm Jul 15, 2026
432bd96
fix(trash): 关闭回收站后删除提示求真,兜底撤销失败的静默 rejection
CodFrm Jul 15, 2026
3b9ee34
test(trash): 补齐 DAO 缓存反向断言 + buildService 实例共享;fix(settings): a11y 重名…
CodFrm Jul 15, 2026
ac09779
refactor(trash): 抽取共享 SelectionBar,回收站与批量栏复用同一多选态外壳
CodFrm Jul 15, 2026
dd001a3
i18n(trash): 新增关闭态提示与开启态删除确认文案(8 个 locale)
CodFrm Jul 15, 2026
eec2f58
refactor(trash): 移除回收站内的保留时间弹窗,设置入口改为跳转设置页
CodFrm Jul 15, 2026
06e135f
🐛 完善回收站脚本信息与空标签显隐
CodFrm Jul 15, 2026
c5ee96e
🐛 空回收站恢复脚本列表原布局
CodFrm Jul 15, 2026
94f6e72
feat(trash): 关闭态删除文案不再承诺可还原,并修正开启态「无法撤销」的错误措辞
CodFrm Jul 15, 2026
03ee478
fix(settings): 自定义保留天数改为失焦提交,不再逐键写 sync 存储
CodFrm Jul 15, 2026
2c63f0a
🐛 cleanupTrash 闹钟先 get 再 create,避免 SW 冷启动重置 12 小时倒计时
cyfung1031 Jul 15, 2026
96d80e7
🐛 订阅链路删除脚本时标记 deleteBy=subscribe
cyfung1031 Jul 15, 2026
1dc0dd4
🐛 回收站视图:操作失败不再静默,空状态不再显示「保留 0 天」
cyfung1031 Jul 15, 2026
58f96c2
⚡️ prepareScriptByCode 的回收站查找继承 dao 缓存开关
cyfung1031 Jul 15, 2026
0367c03
⚡️ 回收站数据改用 OPFS 存储
CodFrm Jul 16, 2026
eb8507a
💚 修复 OPFS 目录类型的 CI 兼容
CodFrm Jul 16, 2026
0552034
✨ 编辑器命中回收站脚本时提示恢复
CodFrm Jul 16, 2026
64c7b66
🐛 统一恢复订阅校验并让自动清理幂等
CodFrm Jul 16, 2026
8f0fc20
✨ 回收站列表响应其他窗口变更
CodFrm Jul 16, 2026
53ba91e
✨ 移动端列表分别保留搜索条件
CodFrm Jul 16, 2026
c13681c
♻️ 合并 main 并解决回收站测试冲突
CodFrm Jul 16, 2026
fceb870
♻️ 清理回收站过期注释与测试类型逃逸
CodFrm Jul 16, 2026
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
13 changes: 11 additions & 2 deletions src/app/repo/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ export function createMockOPFS() {
if (opts?.create) {
store.set("__dir__" + name, new Map());
} else {
throw new Error("Not found");
throw new DOMException("Not found", "NotFoundError");
}
}
return createMockDirHandle(store.get("__dir__" + name));
}),
getFileHandle: vi.fn(async (name: string, opts?: { create?: boolean }) => {
if (!store.has(name) && !opts?.create) {
throw new Error("Not found");
throw new DOMException("Not found", "NotFoundError");
}
if (!store.has(name)) {
store.set(name, "");
Expand All @@ -62,6 +62,15 @@ export function createMockOPFS() {
store.delete(name);
store.delete("__dir__" + name);
}),
async *entries() {
for (const [name, value] of store) {
if (name.startsWith("__dir__")) {
yield [name.slice(7), createMockDirHandle(value)];
} else {
yield [name, createMockFileHandle(name, store)];
}
}
},
};
}

Expand Down
54 changes: 54 additions & 0 deletions src/app/repo/trash_script.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach } from "vitest";
import { initTestEnv } from "@Tests/utils";
import { TrashScriptDAO, type TrashScript } from "./trash_script";
import { SCRIPT_TYPE_NORMAL, SCRIPT_STATUS_ENABLE, SCRIPT_RUN_STATUS_COMPLETE } from "./scripts";
import { createMockOPFS } from "./test-helpers";

initTestEnv();

beforeEach(() => createMockOPFS());

const makeTrashScript = (overrides: Partial<TrashScript> = {}): 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;

beforeEach(() => {
dao = new TrashScriptDAO();
});

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("应能按 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);
});
});
155 changes: 155 additions & 0 deletions src/app/repo/trash_script.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import type { Script } from "./scripts";
import type { InstallSource } from "../service/service_worker/types";

const TRASH_DIR = "trash/scripts";

/** 回收站中的脚本:原脚本 + 删除元数据 */
export interface TrashScript extends Script {
/** 进入回收站的时间戳(毫秒) */
deleteTime: number;
/** 删除来源 */
deleteBy: InstallSource;
}

interface TrashScriptFile {
script: TrashScript;
code?: string;
}

interface IterableDirectoryHandle {
entries(): AsyncIterableIterator<[string, FileSystemDirectoryHandle | FileSystemFileHandle]>;
}

async function getTrashDir(): Promise<FileSystemDirectoryHandle> {
let dir = await navigator.storage.getDirectory();
for (const part of TRASH_DIR.split("/")) {
dir = await dir.getDirectoryHandle(part, { create: true });
}
return dir;
}

function filename(uuid: string): string {
return `${encodeURIComponent(uuid)}.json`;
}

function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}

function isNotFoundError(error: unknown): boolean {
return error instanceof DOMException && error.name === "NotFoundError";
}

/** 回收站专用 OPFS DAO。 */
export class TrashScriptDAO {
public useCache = false;

private cache?: Map<string, TrashScriptFile>;

private cacheLoaded = false;

public enableCache(): void {
this.useCache = true;
}

private async readFile(uuid: string): Promise<TrashScriptFile | undefined> {
const cached = this.cache?.get(uuid);
if (this.useCache && cached) return clone(cached);
try {
const dir = await getTrashDir();
const handle = await dir.getFileHandle(filename(uuid));
const data = JSON.parse(await (await handle.getFile()).text()) as TrashScriptFile;
if (this.useCache) {
this.cache ??= new Map();
this.cache.set(uuid, data);
}
return clone(data);
} catch (error) {
if (isNotFoundError(error)) return undefined;
throw error;
}
}

private async writeFile(uuid: string, data: TrashScriptFile): Promise<void> {
const dir = await getTrashDir();
const handle = await dir.getFileHandle(filename(uuid), { create: true });
const writable = await handle.createWritable();
await writable.write(JSON.stringify(data));
await writable.close();
if (this.useCache) {
this.cache ??= new Map();
this.cache.set(uuid, clone(data));
}
}

private async loadAll(): Promise<Map<string, TrashScriptFile>> {
if (this.useCache && this.cacheLoaded && this.cache) {
return this.cache;
}
const result = new Map<string, TrashScriptFile>();
const dir = await getTrashDir();
for await (const [name, handle] of (dir as unknown as IterableDirectoryHandle).entries()) {
if (handle.kind !== "file" || !name.endsWith(".json")) continue;
const fileHandle = handle as FileSystemFileHandle;
const data = JSON.parse(await (await fileHandle.getFile()).text()) as TrashScriptFile;
result.set(data.script.uuid, data);
}
if (this.useCache) {
this.cache = result;
this.cacheLoaded = true;
}
return result;
}

public async save(val: TrashScript, code?: string): Promise<TrashScript> {
const old = code === undefined ? await this.readFile(val.uuid) : undefined;
await this.writeFile(val.uuid, { script: val, code: code ?? old?.code });
return val;
}

public async get(key: string): Promise<TrashScript | undefined> {
return (await this.readFile(key))?.script;
}

public async getCode(key: string): Promise<string | undefined> {
return (await this.readFile(key))?.code;
}

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

public async find(filters?: (key: string, value: TrashScript) => boolean): Promise<TrashScript[]> {
const result: TrashScript[] = [];
for (const [key, data] of await this.loadAll()) {
if (!filters || filters(key, data.script)) result.push(clone(data.script));
}
return result;
}

public async all(): Promise<TrashScript[]> {
return this.find();
}

public async findOne(filters?: (key: string, value: TrashScript) => boolean): Promise<TrashScript | undefined> {
return (await this.find(filters))[0];
}

public findByNameAndNamespace(name: string, namespace: string): Promise<TrashScript | undefined> {
return this.findOne((_key, value) => value.name === name && (!namespace || value.namespace === namespace));
}

public async delete(key: string): Promise<void> {
const dir = await getTrashDir();
try {
await dir.removeEntry(filename(key));
} catch (error) {
if (!isNotFoundError(error)) throw error;
}
this.cache?.delete(key);
}

public async deletes(keys: string[]): Promise<void> {
await Promise.all(keys.map((key) => this.delete(key)));
}
}
2 changes: 1 addition & 1 deletion src/app/service/offscreen/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export class ScriptService {
disableScript(this.windowMessage, data.script.uuid);
}
});
this.messageQueue.subscribe<TDeleteScript[]>("deleteScripts", async (data) => {
this.messageQueue.subscribe<TDeleteScript[]>("trashScripts", async (data) => {
for (const { uuid, type } of data) {
// 只发送后台脚本和定时脚本
if (type === SCRIPT_TYPE_BACKGROUND || type === SCRIPT_TYPE_CRONTAB) {
Expand Down
14 changes: 14 additions & 0 deletions src/app/service/service_worker/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -60,6 +62,18 @@ export class ScriptClient extends Client {
return this.do("deletes", uuids);
}

restores(uuids: string[]) {
return this.do<TRestoreResult>("restores", uuids);
}

purges(uuids: string[]) {
return this.do<boolean>("purges", uuids);
}

getTrashScripts() {
return this.do<TrashScript[]>("getTrashScripts");
}

enable(uuid: string, enable: boolean) {
return this.do("enable", { uuid, enable });
}
Expand Down
23 changes: 23 additions & 0 deletions src/app/service/service_worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ export default class ServiceWorkerManager {
// 避免浏览器打开时立即清除。先等tabs载入一下
setTimeout(cleanupStaleTempStorageEntries, needsWarmupDelay ? 45_000 : 100);
break;
case "cleanupTrash":
script.cleanupExpiredTrash();
break;
}
});
// 12小时检查一次扩展更新
Expand Down Expand Up @@ -246,6 +249,26 @@ export default class ServiceWorkerManager {
// 定期清理过期的临时安装信息
chrome.alarms.create("cleanupTempStorage", { periodInMinutes: 30 });

// 定期清理回收站中过期的脚本(30 天精度无需更细)。
// 必须先 get 再 create(同 checkUpdate/agentTaskScheduler):create 同名 alarm 会重置倒计时,
// 活跃使用时 SW 频繁冷启动,无条件 create 会让 12 小时的闹钟永远数不满、清理永远不触发。
chrome.alarms.get("cleanupTrash", (alarm) => {
const lastError = chrome.runtime.lastError;
if (lastError) {
console.error("chrome.runtime.lastError in chrome.alarms.get:", lastError);
// 非预期的异常API错误,停止处理
}
if (!alarm) {
chrome.alarms.create("cleanupTrash", { periodInMinutes: 12 * 60 }, () => {
const lastError = chrome.runtime.lastError;
if (lastError) {
console.error("chrome.runtime.lastError in chrome.alarms.create:", lastError);
console.error("Chrome alarm is unable to create. Please check whether limit is reached.");
}
});
}
});

// 一些只需启动时运行一次的任务
cacheInstance.getOrSet("extension_initialized", () => {
// 启动一次云同步
Expand Down
6 changes: 4 additions & 2 deletions src/app/service/service_worker/popup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)]);
Expand All @@ -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 },
Expand Down
2 changes: 1 addition & 1 deletion src/app/service/service_worker/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,7 @@ export class PopupService {
}
});
});
this.mq.subscribe<TDeleteScript[]>("deleteScripts", async (data) => {
this.mq.subscribe<TDeleteScript[]>("trashScripts", async (data) => {
const changed = await this.removeDeletedScriptsFromPopupCaches(data.map(({ uuid }) => uuid));
if (changed) {
this.updateBadgeIcon();
Expand Down
2 changes: 1 addition & 1 deletion src/app/service/service_worker/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ export class RuntimeService {
});

// 监听脚本删除
this.mq.subscribe<TDeleteScript[]>("deleteScripts", async (data) => {
this.mq.subscribe<TDeleteScript[]>("trashScripts", async (data) => {
// 显式提前失效,确保在任何 await 之前就清掉陈旧的 disabled 匹配器。
// 虽然下方 updateSorter() 内部也会调用 invalidateDisabledMatcher(),但此处与 installScript 保持一致:
// 由于 updateSorter() 位于 await 之后,必须先在最前面显式失效一次。
Expand Down
Loading
Loading