预检查
问题区域
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" ) ;
最小复现步骤
安装以上脚本并确认其处于启用状态。
打开 https://example.com/。
打开 ScriptCat Popup,确认脚本显示在“当前页面脚本”中。
展开该脚本,点击“排除 example.com”(实际写入规则应为 *://example.com/*)。
关闭并重新打开 Popup;同时建议分别测试:
不刷新页面,直接重新打开 Popup;
刷新页面后重新打开 Popup;
切换到其他标签页再切回来后重新打开 Popup。
观察脚本是否仍显示,并提供“取消排除 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,并标记:
实现位置:
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 },脚本就会消失。
建议优先检查的具体原因
稳定版尚未包含当前 main 的 ORIGINAL_URLMATCH_SUFFIX / includeNonEffective 实现
当前 main 看起来已经具备正确设计,但 [Feature] Add "Unmatched Scripts" section to popup #1590 描述的用户行为表明发布版本可能仍使用旧逻辑,或修复尚未回移到稳定渠道。
excludeUrl 写入后,运行时匹配器或 cachedPatterns 未及时失效/重建
如果主 uuid 规则被更新为排除后的规则,但 uuid{ORIGINAL} 未注册、被错误清理或保留了陈旧缓存,结果会完全不包含该 UUID。
originalUrlPatterns 构建条件存在边界分支
需要确认通过 Popup 新增 selfMetadata.exclude 时,buildScriptRunResourceBasic()、getOrBuildPatternCache() 和 compiled resource 中的 originalUrlPatterns 始终保留脚本原始 @match/@include,且不被合并后的 exclude 污染。
Service Worker 重启后的恢复路径与即时更新路径不一致
即时点击后本地 React 状态只是把 isEffective 取反;重新打开 Popup 会重新调用后端。如果 SW 重启或重新构建时没有恢复 uuid{ORIGINAL} 规则,就会出现“点击后短暂可见,重开后消失”。
当前测试只覆盖 Runtime 方法,缺少端到端的 Popup 数据回归测试
Runtime 单测正确并不能保证 excludeUrl → 失效缓存 → getPopupData 的完整链路正确。
建议修复方案
确保每个存在原始正向匹配、但被 selfMetadata.exclude 排除的普通脚本同时维护:
合并规则键 uuid;
原始正向规则键 uuid{ORIGINAL}。
修改 exclusion 后同步失效并重建:
pageLoadCaches;
cachedPatterns;
enabled matcher / disabled matcher 中的主键和 ORIGINAL 键;
如有 compiled resource 缓存,也应保证其原始规则正确刷新。
getPopupPageScriptMatchingResultByUrl() 必须继续使用 includeNonEffective=true。
PopupService.getPopupData() 对该结果保留脚本,并将 isEffective=false 传给现有 UI。
如果当前 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 各一例。
版本 / 环境
临时解决方法
进入 ScriptCat 管理页,找到对应脚本并手工删除当前站点的排除规则。
预检查
main分支 Popup、运行时匹配器及相关测试。问题区域
问题描述
在 Popup 中对一个当前页面已匹配的脚本执行“排除当前站点”后,脚本会从 Popup 的“当前页面脚本”列表消失。此后用户无法在 Popup 中执行“取消排除”,只能进入管理页找到脚本并手工修改规则。
这应当视为 Bug,而不是缺少“未匹配脚本”功能:当前 Popup 已经实现了双向的“排除 / 取消排除”操作,
ScriptRow会根据script.isEffective显示对应按钮;如果排除后脚本不再出现在列表中,现有的取消排除分支就永远不可达。关联背景:#1590
最小复现脚本
最小复现步骤
https://example.com/。*://example.com/*)。预期行为与实际行为
预期行为
script.isEffective === false。isEffective === true。实际行为
代码证据
以下分析基于当前
main快照48e96351e9bbbeefae4275d53aa5b076fc71f79b。1. Popup 已有双向操作,因此不是缺少 UI 功能
src/pages/popup/App.tsx中,页面脚本只要script.isEffective !== null,就会显示操作:isEffective === true:显示排除;isEffective === false:显示取消排除。相关代码:
scriptcat/src/pages/popup/App.tsx
Lines 150 to 161 in 48e9635
src/pages/popup/usePopupData.ts的处理器也明确支持两个方向:并在成功后把当前行的
isEffective取反:scriptcat/src/pages/popup/usePopupData.ts
Lines 264 to 270 in 48e9635
因此,只要脚本仍在
scriptList中,Popup 已经具备撤销能力。2. Popup 列表由 URL 匹配结果驱动,而不是由所有脚本驱动
PopupService.getPopupData()首先调用:然后只根据
matchingResult.keys()查询 DAO 并构造scriptList:scriptcat/src/app/service/service_worker/popup.ts
Lines 370 to 405 in 48e9635
所以,如果被排除脚本没有出现在该匹配结果中,它就不会进入 Popup;UI 中的“取消排除”分支也无法触达。
3. 当前 main 已设计
uuid{ORIGINAL}影子规则来保留被排除脚本运行时会同时维护:
uuiduuid{ORIGINAL}当 URL 只命中原始规则、但未命中合并后的规则时,应返回同一个脚本 UUID,并标记:
实现位置:
scriptcat/src/app/service/service_worker/runtime.ts
Lines 1211 to 1280 in 48e9635
关键逻辑:
Popup 查询使用
includeNonEffective=true:因此从设计意图看,被自定义排除的脚本本应仍返回,只是
effective=false。4. 已有单元测试也明确规定此行为
runtime.test.ts已测试自定义排除:includeNonEffective=true时必须返回该脚本;effective必须为false。scriptcat/src/app/service/service_worker/runtime.test.ts
Lines 155 to 190 in 48e9635
这进一步说明“Popup 中仍可见并可撤销”是既定行为,而不是新增需求。
根因分析
高置信度结论
故障发生在 Popup 数据源 / 匹配状态链路,而不是 Popup 按钮实现:
只要上述链路在排除后没有返回
{ uuid, effective: false },脚本就会消失。建议优先检查的具体原因
稳定版尚未包含当前 main 的
ORIGINAL_URLMATCH_SUFFIX/includeNonEffective实现当前 main 看起来已经具备正确设计,但 [Feature] Add "Unmatched Scripts" section to popup #1590 描述的用户行为表明发布版本可能仍使用旧逻辑,或修复尚未回移到稳定渠道。
excludeUrl写入后,运行时匹配器或cachedPatterns未及时失效/重建如果主
uuid规则被更新为排除后的规则,但uuid{ORIGINAL}未注册、被错误清理或保留了陈旧缓存,结果会完全不包含该 UUID。originalUrlPatterns构建条件存在边界分支需要确认通过 Popup 新增
selfMetadata.exclude时,buildScriptRunResourceBasic()、getOrBuildPatternCache()和 compiled resource 中的originalUrlPatterns始终保留脚本原始@match/@include,且不被合并后的 exclude 污染。Service Worker 重启后的恢复路径与即时更新路径不一致
即时点击后本地 React 状态只是把
isEffective取反;重新打开 Popup 会重新调用后端。如果 SW 重启或重新构建时没有恢复uuid{ORIGINAL}规则,就会出现“点击后短暂可见,重开后消失”。当前测试只覆盖 Runtime 方法,缺少端到端的 Popup 数据回归测试
Runtime 单测正确并不能保证
excludeUrl → 失效缓存 → getPopupData的完整链路正确。建议修复方案
selfMetadata.exclude排除的普通脚本同时维护:uuid;uuid{ORIGINAL}。pageLoadCaches;cachedPatterns;getPopupPageScriptMatchingResultByUrl()必须继续使用includeNonEffective=true。PopupService.getPopupData()对该结果保留脚本,并将isEffective=false传给现有 UI。建议新增回归测试
Runtime 层
现有测试基础上增加“通过 excludeUrl 修改后立即查询”和“模拟 SW 重启后重新构建”的用例。
PopupService 层(必要)
新增集成测试:
另增加:
isEffective === true;@match、@include、多个规则、已有手工@exclude各一例。版本 / 环境
main/48e96351e9bbbeefae4275d53aa5b076fc71f79bmain的package.json:1.5.0-beta临时解决方法
进入 ScriptCat 管理页,找到对应脚本并手工删除当前站点的排除规则。