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
1 change: 1 addition & 0 deletions packages/chrome-extension-mock/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export default class Runtime {
updatetime: Date.now() - 1000,
},
],
optInScriptList: [],
backScriptList: [],
isBlacklist: false,
};
Expand Down
45 changes: 45 additions & 0 deletions packages/message/message_queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,51 @@ describe("MessageQueueGroup", () => {
});

describe("发布方法测试", () => {
it("publishAndWait 应等待本地异步订阅者完成", async () => {
const group = messageQueue.group("api-publishAndWait");
let resolveRegistration!: () => void;
const registration = new Promise<void>((resolve) => {
resolveRegistration = resolve;
});
const handler = vi.fn(async () => registration);
group.subscribe("test-await", handler);

let settled = false;
const publishPromise = group.publishAndWait("test-await", { data: "test-await" }).then(() => {
settled = true;
});

await nextTick();
expect(handler).toHaveBeenCalledWith({ data: "test-await" });
expect(settled).toBe(false);

resolveRegistration();
await publishPromise;
expect(settled).toBe(true);
});

it("publishAndWait 应等待带中间件的异步订阅者完成", async () => {
const group = messageQueue.group("api-publishAndWaitMiddleware", (_topic, _message, next) => next());
let resolveRegistration!: () => void;
const registration = new Promise<void>((resolve) => {
resolveRegistration = resolve;
});
group.subscribe("test-await", async () => registration);

const publishPromise = group.publishAndWait("test-await", { data: "test-await" });
await nextTick();
let settled = false;
void publishPromise.then(() => {
settled = true;
});
await nextTick();
expect(settled).toBe(false);

resolveRegistration();
await publishPromise;
expect(settled).toBe(true);
});

it("publish 方法应该使用 chrome.runtime.sendMessage", () => {
const group = messageQueue.group("api-sendChromeMessage");

Expand Down
25 changes: 22 additions & 3 deletions packages/message/message_queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type TKeyValue<T extends SystemConfigKey> = {
type MiddlewareFunction<T = any> = (topic: string, message: T, next: () => void) => void | Promise<void>;

// 消息处理函数类型
type MessageHandler<T = any> = (message: T) => void;
type MessageHandler<T = any> = (message: T) => void | Promise<void>;

export interface IMessageQueue {
// 创建子分组
Expand All @@ -25,6 +25,9 @@ export interface IMessageQueue {
// 发布消息
publish<T>(topic: string, message: NonNullable<T>): void;

// 发布消息并等待当前环境的订阅者完成
publishAndWait<T>(topic: string, message: NonNullable<T>): Promise<void>;

// 只发布给当前环境
emit<T>(topic: string, message: NonNullable<T>): void;
}
Expand Down Expand Up @@ -82,6 +85,16 @@ export class MessageQueue implements IMessageQueue {
LoggerCore.getInstance().logger({ service: "messageQueue" }).trace("publish", { topic, message });
}

async publishAndWait<T>(topic: string, message: NonNullable<T>) {
chrome.runtime.sendMessage({
msgQueue: topic,
data: { action: "message", message },
});
await Promise.all(this.EE.listeners(topic).map((handler) => Promise.resolve(handler(message))));
//@ts-ignore
LoggerCore.getInstance().logger({ service: "messageQueue" }).trace("publishAndWait", { topic, message });
}

// 只发布给当前环境
emit<T>(topic: string, message: NonNullable<T>) {
this.EE.emit(topic, message);
Expand Down Expand Up @@ -144,8 +157,8 @@ export class MessageQueueGroup implements IMessageQueue {
await result;
}
} else {
// 所有中间件都执行完毕,执行最终的处理函数
handler(message);
// 所有中间件都执行完毕,等待最终处理函数完成
await handler(message);
}
};

Expand All @@ -162,6 +175,12 @@ export class MessageQueueGroup implements IMessageQueue {
this.messageQueue.publish(fullTopic, message);
}

// 发布消息并等待当前环境的订阅者完成
publishAndWait<T>(topic: string, message: NonNullable<T>) {
const fullTopic = `${this.name}${topic}`;
return this.messageQueue.publishAndWait(fullTopic, message);
}

// 只发布给当前环境
emit<T>(topic: string, message: NonNullable<T>) {
const fullTopic = `${this.name}${topic}`;
Expand Down
2 changes: 1 addition & 1 deletion src/app/repo/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class ResourceDAO extends Repo<Resource> {
}

// CompiledResource结构变更时,建议修改 CompiledResourceNamespace 以删除旧Cache
export const CompiledResourceNamespace = "57d79c56-231a-42d3-b6e3-d2004ba0866f";
export const CompiledResourceNamespace = "a8c4e0e7-fb12-4a33-a9d6-4f7a0aa2d0f1";

export class CompiledResourceDAO extends Repo<CompiledResource> {
constructor() {
Expand Down
26 changes: 16 additions & 10 deletions src/app/service/content/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ export function getScriptRequire(scriptRes: ScriptRunResource): CompileScriptCod
export function compileScriptletCode(
scriptRes: ScriptRunResource,
scriptCode: string,
scriptUrlPatterns: URLRuleEntry[]
scriptUrlPatterns: URLRuleEntry[],
executionCondition?: string
): string {
scriptCode = scriptCode ?? scriptRes.code;
const requireArray = getScriptRequire(scriptRes);
Expand All @@ -89,7 +90,8 @@ export function compileScriptletCode(
ruleContent,
})) satisfies EmbeddedURLRuleEntry[];
const urlCondition = embeddedPatternCheckerString("location.href", JSON.stringify(reducedPatterns));
const codeBody = `if(${urlCondition}){\n${requireCode}\n${scriptCode}\nwindow['${scriptRes.flag}']=function(){};\n}`;
const condition = executionCondition ? `&&(${executionCondition})` : "";
const codeBody = `if(${urlCondition}${condition}){\n${requireCode}\n${scriptCode}\nwindow['${scriptRes.flag}']=function(){};\n}`;
return `${codeBody}${sourceMapTo(`${scriptRes.name}.user.js`)}\n`;
}

Expand Down Expand Up @@ -171,18 +173,21 @@ export function compileScript(code: string): ScriptFunc {
export function compileInjectScript(
script: ScriptRunResource,
scriptCode: string,
autoDeleteMountFunction: boolean = false
autoDeleteMountFunction: boolean = false,
executionCondition?: string
): string {
return compileInjectScriptByFlag(script.flag, scriptCode, autoDeleteMountFunction);
return compileInjectScriptByFlag(script.flag, scriptCode, autoDeleteMountFunction, executionCondition);
}

export function compileInjectScriptByFlag(
flag: string,
scriptCode: string,
autoDeleteMountFunction: boolean = false
autoDeleteMountFunction: boolean = false,
executionCondition?: string
): string {
const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : "";
return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}`;
const code = `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}`;
return executionCondition ? `if(${executionCondition}){${code}}` : code;
}

/**
Expand Down Expand Up @@ -224,7 +229,8 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => {
export function compilePreInjectScript(
script: ScriptLoadInfo,
scriptCode: string,
autoDeleteMountFunction: boolean = false
autoDeleteMountFunction: boolean = false,
executionCondition?: string
): string {
const scriptEnvTag = isInjectIntoContent(script.metadata) ? ScriptEnvTag.content : ScriptEnvTag.inject;
const eventNamePrefix = `evt${process.env.SC_RANDOM_KEY}.${scriptEnvTag}`; // 仅用于early-start初始化
Expand All @@ -234,15 +240,15 @@ export function compilePreInjectScript(
const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : "";
const evScriptLoad = `${eventNamePrefix}${DefinedFlags.scriptLoadComplete}`;
const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`;
return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}};
const code = `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}};
{
let o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } },
c = typeof cloneInto === "function" ? cloneInto(o, performance) : o,
f = () => performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)),
needWait = f();
if (needWait) performance.addEventListener('${evEnvLoad}', f, { once: true });
}
`;
}`;
return executionCondition ? `if(${executionCondition}){\n${code}\n}\n` : `${code}\n`;
}

export function addStyle(css: string): HTMLStyleElement {
Expand Down
9 changes: 9 additions & 0 deletions src/app/service/service_worker/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ export class ScriptClient extends Client {
return this.do("excludeUrl", { uuid, excludePattern, remove });
}

includeUrl(uuid: string, includePattern: string) {
return this.do<boolean>("includeUrl", { uuid, includePattern });
}

excludeSiteAccessUrl(uuid: string, includePattern: string) {
return this.do<boolean>("excludeSiteAccessUrl", { uuid, includePattern });
}

// 重置匹配项
resetMatch(uuid: string, match: string[] | undefined) {
return this.do("resetMatch", { uuid, match });
Expand Down Expand Up @@ -229,6 +237,7 @@ export type GetPopupDataRes = {
// 在黑名单
isBlacklist: boolean;
scriptList: ScriptMenu[];
optInScriptList: ScriptMenu[];
backScriptList: ScriptMenu[];
};

Expand Down
98 changes: 98 additions & 0 deletions src/app/service/service_worker/popup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,104 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => {
expect(result.isBlacklist).toBe(false);
});

it("匹配但未加入当前网址白名单的 opt-in 脚本应单独返回,且不进入当前页脚本列表", async () => {
const uuid = "opt-in-uuid";
const matchMap = new Map([[uuid, { uuid, effective: true }]]);

const { service } = createService({
runtime: {
getPopupPageScriptMatchingResultByUrl: vi.fn().mockResolvedValue(matchMap),
isUrlBlacklist: vi.fn().mockReturnValue(false),
},
scriptDAO: {
gets: vi.fn().mockResolvedValue([
createScript(uuid, {
metadata: { match: ["*://*/*"], "site-access": ["opt-in"] },
}),
]),
},
});

const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" });

expect(result.scriptList).toEqual([]);
expect(result.optInScriptList).toHaveLength(1);
expect(result.optInScriptList[0]?.uuid).toBe(uuid);
expect(result.optInScriptList[0]?.isEffective).toBe(false);
});

it("作者声明的 site-access 默认站点应进入当前页脚本列表", async () => {
const uuid = "opt-in-default-uuid";
const matchMap = new Map([[uuid, { uuid, effective: true }]]);

const { service } = createService({
runtime: {
getPopupPageScriptMatchingResultByUrl: vi.fn().mockResolvedValue(matchMap),
isUrlBlacklist: vi.fn().mockReturnValue(false),
},
scriptDAO: {
gets: vi.fn().mockResolvedValue([
createScript(uuid, {
metadata: { match: ["*://*/*"], "site-access": ["opt-in", "+*://example.com/*"] },
}),
]),
},
});

const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" });

expect(result.scriptList).toHaveLength(1);
expect(result.optInScriptList).toEqual([]);
});

it("宽泛的用户 site-access 规则不应显示精确当前站点的移除操作", async () => {
const uuid = "opt-in-broad-user-uuid";
const matchMap = new Map([[uuid, { uuid, effective: true }]]);

const { service } = createService({
runtime: {
getPopupPageScriptMatchingResultByUrl: vi.fn().mockResolvedValue(matchMap),
isUrlBlacklist: vi.fn().mockReturnValue(false),
},
scriptDAO: {
gets: vi.fn().mockResolvedValue([
createScript(uuid, {
metadata: { match: ["*://*/*"], "site-access": ["opt-in"] },
selfMetadata: { "site-access": ["+*://*.example.com/*"] },
}),
]),
},
});

const result = await service.getPopupData({ tabId: 1, url: "https://www.example.com/" });

expect(result.scriptList[0]?.siteAccessUser).toBe(false);
});

it("作者默认允许当前站点时不应显示移除用户规则的操作", async () => {
const uuid = "opt-in-author-default-uuid";
const matchMap = new Map([[uuid, { uuid, effective: true }]]);

const { service } = createService({
runtime: {
getPopupPageScriptMatchingResultByUrl: vi.fn().mockResolvedValue(matchMap),
isUrlBlacklist: vi.fn().mockReturnValue(false),
},
scriptDAO: {
gets: vi.fn().mockResolvedValue([
createScript(uuid, {
metadata: { match: ["*://*/*"], "site-access": ["opt-in", "+*://example.com/*"] },
selfMetadata: { "site-access": ["+*://example.com/*"] },
}),
]),
},
});

const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" });

expect(result.scriptList[0]?.siteAccessUser).toBe(false);
});

it("脚本同时在匹配结果与运行缓存中,应复用缓存记录(保留 runNum)并更新 enable/isEffective/hasUserConfig", async () => {
const uuid = "run-uuid";
const matchMap = new Map([[uuid, { uuid, effective: false }]]);
Expand Down
29 changes: 25 additions & 4 deletions src/app/service/service_worker/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { GetPopupDataReq, GetPopupDataRes, MenuClickParams } from "./client
import { cacheInstance } from "@App/app/cache";
import type { ScriptDAO } from "@App/app/repo/scripts";
import { applyScriptDisplayInfo, scriptToMenu, type TPopupPageLoadInfo } from "./popup_scriptmenu";
import { hasExactUserSiteAccess, isSiteAccessAllowed, isSiteAccessAllowedByAuthor, isSiteAccessOptIn } from "./utils";
import { SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL, SCRIPT_RUN_STATUS_RUNNING } from "@App/app/repo/scripts";
import type {
TDeleteScript,
Expand Down Expand Up @@ -382,6 +383,8 @@ export class PopupService {
const runMap = new Map<string, ScriptMenu>(runScripts.map((script) => [script.uuid, script]));
// 合并后结果
const scriptMenuMap = new Map<string, ScriptMenu>();
const optInScriptList: ScriptMenu[] = [];
const optInScriptUuids = new Set<string>();
// 合并数据
for (let idx = 0, l = uuids.length; idx < l; idx++) {
const uuid = uuids[idx];
Expand All @@ -404,13 +407,25 @@ export class PopupService {
run = scriptToMenu(script);
run.isEffective = o.effective!;
}
scriptMenuMap.set(uuid, run);
const siteAccessOptIn = isSiteAccessOptIn(script.metadata);
run.siteAccess = siteAccessOptIn ? "opt-in" : undefined;
run.siteAccessUser =
siteAccessOptIn && !isSiteAccessAllowedByAuthor(script, url) && hasExactUserSiteAccess(script, url);
if (siteAccessOptIn && !isSiteAccessAllowed(script, url)) {
run.isEffective = false;
optInScriptList.push(run);
optInScriptUuids.add(uuid);
} else {
scriptMenuMap.set(uuid, run);
}
}

// 将未匹配当前 url 但仍在运行的脚本,附加到清单末端,避免使用者找不到其菜单。
// 这些记录来自 tabScript:<tabId> session cache;脚本删除事件与 Popup 读取可能交错,
// 因此这里必须用 DAO 结果做读侧防护,避免已删除脚本残留在 Popup 清单。
const unmatchedRunScripts = runScripts.filter((script) => !scriptMenuMap.has(script.uuid));
const unmatchedRunScripts = runScripts.filter(
(script) => !scriptMenuMap.has(script.uuid) && !optInScriptUuids.has(script.uuid)
);
if (unmatchedRunScripts.length) {
const existingRunScripts = await this.scriptDAO.gets(unmatchedRunScripts.map((script) => script.uuid));
for (let idx = 0, l = unmatchedRunScripts.length; idx < l; idx++) {
Expand All @@ -424,12 +439,18 @@ export class PopupService {
// 检查是否在黑名单中
const isBlacklist = this.runtime.isUrlBlacklist(url);
// 即时附加图标与本地化脚本名(仅写入响应,不回写 session 缓存,避免 icon64 等占用过大)
const [scriptListWithInfo, backScriptListWithInfo] = await Promise.all([
const [scriptListWithInfo, optInScriptListWithInfo, backScriptListWithInfo] = await Promise.all([
this.attachScriptDisplayInfo(scriptMenu),
this.attachScriptDisplayInfo(optInScriptList),
this.attachScriptDisplayInfo(backScriptList),
]);
// 后台脚本只显示开启或者运行中的脚本
return { isBlacklist, scriptList: scriptListWithInfo, backScriptList: backScriptListWithInfo };
return {
isBlacklist,
scriptList: scriptListWithInfo,
optInScriptList: optInScriptListWithInfo,
backScriptList: backScriptListWithInfo,
};
}

/** 为 ScriptMenu 列表即时附加图标 URL 与本地化脚本名(返回浅拷贝,不修改缓存中的原对象) */
Expand Down
Loading
Loading