Skip to content

[BUG] 当前站点排除后脚本从 Popup 消失,无法在 Popup 中取消排除 #1591

Description

@cyfung1031

预检查

  • 已搜索现有 Issue;本问题从 [Feature] Add "Unmatched Scripts" section to popup #1590 中拆分,避免将既有功能故障与“显示从未匹配的脚本”新功能混在一起。
  • 已检查当前 main 分支 Popup、运行时匹配器及相关测试。
  • 这不是安全漏洞。

问题区域

  • Script execution / injection / matching rules
  • Popup / Options page / UI

问题描述

在 Popup 中对一个当前页面已匹配的脚本执行“排除当前站点”后,脚本会从 Popup 的“当前页面脚本”列表消失。此后用户无法在 Popup 中执行“取消排除”,只能进入管理页找到脚本并手工修改规则。

这应当视为 Bug,而不是缺少“未匹配脚本”功能:当前 Popup 已经实现了双向的“排除 / 取消排除”操作,ScriptRow 会根据 script.isEffective 显示对应按钮;如果排除后脚本不再出现在列表中,现有的取消排除分支就永远不可达。

关联背景:#1590

最小复现脚本

// ==UserScript==
// @name         ScriptCat Popup Exclude Reproduction
// @namespace    scriptcat-popup-exclude-repro
// @version      0.1
// @match        https://example.com/*
// @grant        none
// ==/UserScript==

console.log("ScriptCat Popup Exclude Reproduction");

最小复现步骤

  1. 安装以上脚本并确认其处于启用状态。
  2. 打开 https://example.com/
  3. 打开 ScriptCat Popup,确认脚本显示在“当前页面脚本”中。
  4. 展开该脚本,点击“排除 example.com”(实际写入规则应为 *://example.com/*)。
  5. 关闭并重新打开 Popup;同时建议分别测试:
    • 不刷新页面,直接重新打开 Popup;
    • 刷新页面后重新打开 Popup;
    • 切换到其他标签页再切回来后重新打开 Popup。
  6. 观察脚本是否仍显示,并提供“取消排除 example.com”操作。

预期行为与实际行为

预期行为

  • 脚本仍保留在“当前页面脚本”列表中。
  • script.isEffective === false
  • Popup 显示现有的“取消排除 example.com”操作。
  • 点击后移除该站点排除规则,脚本恢复为 isEffective === true

实际行为

  • 排除后脚本从 Popup 列表消失。
  • 用户无法使用 Popup 中已经存在的“取消排除”操作。
  • 只能进入完整管理页手工撤销规则。

代码证据

以下分析基于当前 main 快照 48e96351e9bbbeefae4275d53aa5b076fc71f79b

1. Popup 已有双向操作,因此不是缺少 UI 功能

src/pages/popup/App.tsx 中,页面脚本只要 script.isEffective !== null,就会显示操作:

  • isEffective === true:显示排除;
  • isEffective === false:显示取消排除。

相关代码:

totalCount={data.fullScriptCount}
>
{data.scriptList.map((script) => (
<ScriptRow
key={script.uuid}
script={script}
host={data.host}
isPageScript
menuExpandNum={data.menuExpandNum}
onToggle={data.handleToggleScript}
onDelete={data.handleDeleteScript}
onOpenEditor={data.handleOpenEditor}

src/pages/popup/usePopupData.ts 的处理器也明确支持两个方向:

// isEffective=true → 排除(remove=false)
// isEffective=false → 取消排除(remove=true)
await scriptClient.excludeUrl(uuid, `*://${host}/*`, !isEffective);

并在成功后把当前行的 isEffective 取反:

if (!host) return;
try {
// isEffective=true → 排除(remove=false); isEffective=false → 取消排除(remove=true)
await scriptClient.excludeUrl(uuid, `*://${host}/*`, !isEffective);
setScriptList((prev) => prev.map((s) => (s.uuid === uuid ? { ...s, isEffective: !isEffective } : s)));
} catch (e) {
console.error("Failed to toggle exclude:", e);

因此,只要脚本仍在 scriptList 中,Popup 已经具备撤销能力。

2. Popup 列表由 URL 匹配结果驱动,而不是由所有脚本驱动

PopupService.getPopupData() 首先调用:

this.runtime.getPopupPageScriptMatchingResultByUrl(url)

然后只根据 matchingResult.keys() 查询 DAO 并构造 scriptList

const [matchingResult, runScripts, backScriptList] = await Promise.all([
this.runtime.getPopupPageScriptMatchingResultByUrl(url),
this.getScriptMenu(tabId),
this.getScriptMenu(-1),
]);
const uuids = [...matchingResult.keys()];
const scripts = await this.scriptDAO.gets(uuids);
// 与运行时脚本进行合并
// 以已运行脚本建立快取(uuid→ScriptMenu),供后续合并与覆盖状态。
const runMap = new Map<string, ScriptMenu>(runScripts.map((script) => [script.uuid, script]));
// 合并后结果
const scriptMenuMap = new Map<string, ScriptMenu>();
// 合并数据
for (let idx = 0, l = uuids.length; idx < l; idx++) {
const uuid = uuids[idx];
const script = scripts[idx];
const o = matchingResult.get(uuid);
if (!script || !o) continue;
let run = runMap.get(uuid);
if (run) {
// 如果脚本已经存在,则不添加,更新信息
run.enable = script.status === SCRIPT_STATUS_ENABLE;
run.isEffective = o.effective!;
run.hasUserConfig = !!script.config;
} else {
// 由于目前没有在 Popup 显示 @match @include @exclude, 所以以下代码暂不需要
// if (script.selfMetadata) {
// script.metadata = getCombinedMeta(script.metadata, script.selfMetadata);
// }
run = scriptToMenu(script);
run.isEffective = o.effective!;

所以,如果被排除脚本没有出现在该匹配结果中,它就不会进入 Popup;UI 中的“取消排除”分支也无法触达。

3. 当前 main 已设计 uuid{ORIGINAL} 影子规则来保留被排除脚本

运行时会同时维护:

  • 合并自定义规则后的有效匹配:uuid
  • 脚本原始正向匹配:uuid{ORIGINAL}

当 URL 只命中原始规则、但未命中合并后的规则时,应返回同一个脚本 UUID,并标记:

effective = false

实现位置:

return this.getPageScriptMatchingResultByUrlInternal(url, disabledMatcher, true);
}
private getPageScriptMatchingResultByUrlInternal(
url: string,
disabledMatcher: UrlMatch<string> | undefined,
includeNonEffective: boolean
) {
// 返回当前页面匹配的uuids
// 如果有使用自定义排除,原本脚本定义的会返回 uuid{Ori}
// 因此基于自定义排除页面被排除的情况下,结果只包含 uuid{Ori} 而不包含 uuid
const matchedUuids = disabledMatcher
? [...this.scriptMatchEnable.urlMatch(url!), ...disabledMatcher.urlMatch(url!)]
: this.scriptMatchEnable.urlMatch(url!);
const ret = new Map<string, { uuid: string; effective: boolean }>();
for (const e of matchedUuids) {
const uuid = e.endsWith(ORIGINAL_URLMATCH_SUFFIX) ? e.slice(0, -ORIGINAL_URLMATCH_SUFFIX.length) : e;
if (!includeNonEffective && uuid !== e) continue;
const o = ret.get(uuid) || { uuid, effective: false };
// 只包含 uuid{Ori} 而不包含 uuid 的情况,effective = false
if (e === uuid) {
o.effective = true;
}
ret.set(uuid, o);
}
// ret 只包含 uuid 为键的 matchingResult
return ret;
}
async updateSites() {
if (this.sitesLoaded.size === 0 || this.updateSitesBusy) return;
this.updateSitesBusy = true;
const list = [...this.sitesLoaded];
this.sitesLoaded.clear();
const currentSites = (await this.localStorageDAO.getValue<ScriptSite>("sites")) || ({} as ScriptSite);
const sets: Partial<Record<string, Set<string>>> = {};
for (const str of list) {
const [uuid, domain] = str.split("|");
const s = sets[uuid] || (sets[uuid] = new Set([] as string[]));
s.add(domain);
}
for (const uuid in sets) {
const s = new Set([...sets[uuid]!, ...(currentSites[uuid] || ([] as string[]))]);
const arr = (currentSites[uuid] = [...s]);
if (arr.length > 50) arr.length = 50;
}
await this.localStorageDAO.saveValue("sites", currentSites);
this.updateSitesBusy = false;
if (this.sitesLoaded.size > 0) {
Promise.resolve().then(() => this.updateSites());
}
}
async pageLoad(_: any, sender: IGetSender): Promise<TClientPageLoadInfo> {
const chromeSender = sender.getSender();
const url = chromeSender?.url;
if (!url) {
// 异常加载
return { ok: false };
}
const tabId = chromeSender.tab?.id || -1;
const frameId = chromeSender.frameId;
const res = await this.getScriptsForTab({ url, tabId, frameId });
this.mq.emit<TPopupPageLoadInfo>("popupPageLoadUpdate", {
tabId: tabId,
frameId: frameId,
scriptmenus: res?.scriptmenus || [], // 对于 popup, resources那些不需要
});

关键逻辑:

const uuid = e.endsWith(ORIGINAL_URLMATCH_SUFFIX)
  ? e.slice(0, -ORIGINAL_URLMATCH_SUFFIX.length)
  : e;

const o = ret.get(uuid) || { uuid, effective: false };
if (e === uuid) o.effective = true;
ret.set(uuid, o);

Popup 查询使用 includeNonEffective=true

return this.getPageScriptMatchingResultByUrlInternal(url, disabledMatcher, true);

因此从设计意图看,被自定义排除的脚本本应仍返回,只是 effective=false

4. 已有单元测试也明确规定此行为

runtime.test.ts 已测试自定义排除:

  • 默认执行查询不返回被排除脚本;
  • includeNonEffective=true 时必须返回该脚本;
  • 返回项的 effective 必须为 false

const script = createMockScript({
metadata: {
match: ["https://www.example.com/*"],
},
selfMetadata: {
exclude: ["https://www.example.com/*"],
},
});
const scriptRunResource = createScriptRunResource(script);
mockScriptService.buildScriptRunResource.mockReturnValue(scriptRunResource);
// Act
const scriptMatchInfo = await runtime.applyScriptMatchInfo(scriptRunResource);
expect(scriptMatchInfo).toBeDefined();
// 测试默认查询(不包含无效匹配)
const defaultResult = runtime.getPageScriptMatchingResultByUrl("https://www.example.com/path");
// 测试包含无效匹配的查询
const allResult = runtime.getPageScriptMatchingResultByUrl("https://www.example.com/path", true);
// Assert
// expect(mockScriptService.buildScriptRunResource).toHaveBeenCalledWith(script);
// 默认查询应该不包含被排除的脚本
expect(defaultResult.has(script.uuid)).toBe(false);
// 包含无效匹配的查询应该包含被排除的脚本,但标记为无效
expect(allResult.has(script.uuid)).toBe(true);
const matchInfo = allResult.get(script.uuid);
expect(matchInfo).toBeDefined();
expect(matchInfo!.effective).toBe(false);
});
});

这进一步说明“Popup 中仍可见并可撤销”是既定行为,而不是新增需求。

根因分析

高置信度结论

故障发生在 Popup 数据源 / 匹配状态链路,而不是 Popup 按钮实现:

Popup 点击排除
  → ScriptClient.excludeUrl 写入 selfMetadata.exclude
  → 运行时匹配缓存/规则重建
  → getPopupPageScriptMatchingResultByUrl(url)
  → PopupService.getPopupData()
  → scriptList

只要上述链路在排除后没有返回 { uuid, effective: false },脚本就会消失。

建议优先检查的具体原因

  1. 稳定版尚未包含当前 main 的 ORIGINAL_URLMATCH_SUFFIX / includeNonEffective 实现
    当前 main 看起来已经具备正确设计,但 [Feature] Add "Unmatched Scripts" section to popup #1590 描述的用户行为表明发布版本可能仍使用旧逻辑,或修复尚未回移到稳定渠道。

  2. excludeUrl 写入后,运行时匹配器或 cachedPatterns 未及时失效/重建
    如果主 uuid 规则被更新为排除后的规则,但 uuid{ORIGINAL} 未注册、被错误清理或保留了陈旧缓存,结果会完全不包含该 UUID。

  3. originalUrlPatterns 构建条件存在边界分支
    需要确认通过 Popup 新增 selfMetadata.exclude 时,buildScriptRunResourceBasic()getOrBuildPatternCache() 和 compiled resource 中的 originalUrlPatterns 始终保留脚本原始 @match/@include,且不被合并后的 exclude 污染。

  4. Service Worker 重启后的恢复路径与即时更新路径不一致
    即时点击后本地 React 状态只是把 isEffective 取反;重新打开 Popup 会重新调用后端。如果 SW 重启或重新构建时没有恢复 uuid{ORIGINAL} 规则,就会出现“点击后短暂可见,重开后消失”。

  5. 当前测试只覆盖 Runtime 方法,缺少端到端的 Popup 数据回归测试
    Runtime 单测正确并不能保证 excludeUrl → 失效缓存 → getPopupData 的完整链路正确。

建议修复方案

  1. 确保每个存在原始正向匹配、但被 selfMetadata.exclude 排除的普通脚本同时维护:
    • 合并规则键 uuid
    • 原始正向规则键 uuid{ORIGINAL}
  2. 修改 exclusion 后同步失效并重建:
    • pageLoadCaches
    • cachedPatterns
    • enabled matcher / disabled matcher 中的主键和 ORIGINAL 键;
    • 如有 compiled resource 缓存,也应保证其原始规则正确刷新。
  3. getPopupPageScriptMatchingResultByUrl() 必须继续使用 includeNonEffective=true
  4. PopupService.getPopupData() 对该结果保留脚本,并将 isEffective=false 传给现有 UI。
  5. 如果当前 main 已修复,请确认首次包含修复的版本,并考虑回移至当前稳定渠道。

建议新增回归测试

Runtime 层

现有测试基础上增加“通过 excludeUrl 修改后立即查询”和“模拟 SW 重启后重新构建”的用例。

PopupService 层(必要)

新增集成测试:

it("排除当前站点后 getPopupData 仍返回脚本且 isEffective=false", async () => {
  // 1. DAO 中脚本原始 @match 命中当前 URL
  // 2. 写入 selfMetadata.exclude
  // 3. 触发与生产相同的缓存失效/规则重建
  // 4. 调用 getPopupData({ tabId, url })
  // 5. 断言 scriptList 包含 uuid
  // 6. 断言 isEffective === false
});

另增加:

  • 取消排除后 isEffective === true
  • 关闭并重新打开 Popup;
  • 模拟 Service Worker 重启;
  • 启用和禁用脚本各一例;
  • @match@include、多个规则、已有手工 @exclude 各一例。

版本 / 环境

  • 用户可见报告来源:[Feature] Add "Unmatched Scripts" section to popup #1590(2026-07-16)
  • 当前代码审查版本:main / 48e96351e9bbbeefae4275d53aa5b076fc71f79b
  • 当前 mainpackage.json1.5.0-beta
  • 报告者实际浏览器、操作系统及扩展发行渠道:需由能够稳定复现的用户补充

临时解决方法

进入 ScriptCat 管理页,找到对应脚本并手工删除当前站点的排除规则。

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinghelp wantedExtra attention is needed

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions