背景问题
本issue 在原沙盒基礎上修正Firefox Spec問題
考慮但不一定 revert #1692
PR #1692 沒有實際能重視問題的針對性腳本,也沒有TDD的測試代碼
這樣對程式員或agent來說都很危險
#1697 給出了更進一步的問題,也有了針對性腳本
可是PR的代碼更動不一定是最理想的
agent 或程式員應參考以下分析進一步在代碼層面深入考慮修改部份,以相容於Firefox Spec但又不影響Chrome Spec.
以下分析或具体方案僅用於參考。不能視為必須遵循。Agent 或程式員應制定詳細方案包括代碼測試驗收標準等才能正式提交PR。
具体方案
我把你上傳的 scriptcat-1.5.0-beta.2.zip 解開後,核心執行鏈已經追清楚了。這次 Firefox 的 10 個失敗不是單點 bug,而是 ScriptCat 現有 sandbox 建模隱含假設了 global === window;這個假設在 Chrome USER_SCRIPT world 大致成立,在 Firefox 明確不成立。
Mozilla 對這件事其實寫得很直接:Firefox content script 中 globalThis 是一個不同於 window、並繼承自 window 的物件。(MDN Web Docs) Firefox USER_SCRIPT 的實作也是建立 Cu.Sandbox,並指定 sandboxPrototype: contentWindow、wantXrays: true。(Searchfox)
所以目標不應是「讓 Firefox 自己變成 Chrome」,而應是讓 ScriptCat 明確處理三個不同物件:
realmGlobal = globalThis
Firefox: Cu.Sandbox global
Chrome: 通常近似 Window global
hostWindow = window
真正提供 DOM / Window / EventTarget 的 Window
scriptGlobal = mySandbox
ScriptCat 每個 userscript 自己看到的 synthetic global
userscript 最終應看到:
window ──────┐
self ────────┼──> scriptGlobal
globalThis ──┘
unsafeWindow ───> hostWindow
而不是現在 Firefox 下的混合狀態。
一、你這份 beta.2 的真正問題在哪裡
最重要的是 src/app/service/content/create_context.ts。
問題 1:整套 descriptor 系統從 global 開始
beta.2 約 172 行:
const initOwnDescs = Object.getOwnPropertyDescriptors(global);
接著約 186 行:
getAllPropertyDescriptors(global, ([key, desc]) => {
這在 Chrome 比較碰巧,因為 global/window 的差異小。
Firefox USER_SCRIPT 下:
所以這裡其實正在遍歷:
Cu.Sandbox global
↓ prototype
Xray Window
↓
...
但後面的程式把整條鏈都當成「同一個 Window global」。
這是根本錯誤。
二、10 個測試為什麼逐項失敗
你現在 alias 處理在約 360 行:
for (const key of ["window", "self", "globalThis", "top", "parent", "frames"]) {
const desc = ownDescs[key];
if (desc?.value === global) {
desc.get = function () {
return mySandbox;
};
這段實際只對「值剛好等於 Firefox sandbox global」的屬性有效。
Firefox 中大致是:
globalThis -> sandbox global
window -> contentWindow
self -> contentWindow
因此:
globalThis -> mySandbox
window -> host Window
self -> host Window
於是直接造成:
| 測試 |
根因 |
window === globalThis |
window 沒被映射到 mySandbox |
self === globalThis |
同上 |
window.GM_getValue |
GM API 在 mySandbox,但 window 指到 hostWindow |
self.x → globalThis.x |
寫在 hostWindow,讀的是 mySandbox |
所以 ① 的四個失敗,本質上是同一件事。
三、Node / NodeFilter / XMLHttpRequest 的 bug 更具體
你們其實已經知道 constructor 不能亂 bind。
shouldFnBind() 有:
if ("prototype" in f) return false;
而且還有:
// 要求函数名字小写字头 能筛选掉 NodeFilter 之类 Interface
這個方向是對的。
但是後面約 205–214 行又把保護完全繞掉了:
} else if (!(key in initOwnDescs) && !Object.hasOwn(global, key)) {
if (!protoBaseDescs[key]) {
if (typeof value === "function") {
const boundValue = value.bind(global);
protoBaseDescs[key] = {
...desc,
value: boundValue,
};
也就是:
shouldFnBind(NodeFilter) -> false
↓
fallback
typeof NodeFilter === function
↓
NodeFilter.bind(global)
結果還是 bind。
而:
const B = NodeFilter.bind(...);
B.SHOW_TEXT
// undefined
同樣:
XMLHttpRequest.bind(...).DONE
// undefined
Node.bind(...).ELEMENT_NODE
// undefined
HTMLBodyElement.bind(...).prototype
// undefined
這就是你的 ②。
所以這裡不是抽象性的「可能有問題」,而是我會直接判斷:
protoBaseDescs 的 typeof value === "function" => bind() 必須取消。
四、EventTarget 也綁錯 receiver 了
現在很多地方用:
例如約 199 行。
Firefox 的 global 是 Cu.Sandbox object。
但是:
EventTarget.prototype.addEventListener
EventTarget.prototype.removeEventListener
EventTarget.prototype.dispatchEvent
需要作用的 EventTarget 是:
不是 Cu.Sandbox global。
所以理想操作是:
Reflect.apply(window.addEventListener, window, args)
而不是:
window.addEventListener.bind(global)
這也解釋了你的:
globalThis.addEventListener(...)
globalThis.dispatchEvent(...)
globalThis.removeEventListener(...)
為什麼整個 lifecycle 壞掉。
五、還有幾個 beta.2 裡很值得一起修的點
createFuncWrapper() 現在是:
const ret = f.call(global);
if (ret === global) return mySandbox;
Firefox 應該用 window 作 Window getter receiver,而且應判斷:
而不是:
否則 self/parent/top/frames 都會出問題。
createEventProp() 也全部在:
global.addEventListener(...)
global.removeEventListener(...)
應該改成 hostWindow。
PseudoWindowPrototype 現在又取:
global[Symbol.toStringTag]
global.constructor
global.__proto__
如果想模擬 Window,來源也應該是 hostWindow,不是 Firefox Cu.Sandbox。
六、不能簡單做 global -> window 全域替換
這點非常重要。
直覺 patch 可能是:
或把所有:
改成:
我不建議。
原因是 Firefox sandbox global 裡還有屬於 userscript JS realm 自己的 ECMAScript intrinsics。
例如:
Object
Array
Promise
RegExp
Number
Math
Proxy
Error
這些最好保留 Firefox sandbox realm 自己的版本。
尤其 ScriptCat 現在已經有:
相關相容性測試。
如果全部改成 Xray window.RegExp / window.Number / window.Math,會引入另一批跨 realm / Xray 問題。
有意思的是,我也看了目前 ScriptCat main。在你這份 beta.2 之後,上游已經開始嘗試做兩趟:
collectPropertyDescriptors(global);
window !== global && collectPropertyDescriptors(window);
程式註解也已明確認知 Firefox global/window 是兩個 realm。
方向是對的,但我認為現在 main 的實作仍然沒有徹底解決問題,因為它依舊:
getAllPropertyDescriptors(global)
也就是第一趟會沿 global 的 prototype 鑽進 window,然後卻把找到的方法:
綁到 global。
而且 fallback 仍然:
typeof value === "function"
? value.bind(root)
最後 alias 還是:
所以「兩趟掃描」本身還不夠。
七、方案 A:保留現在架構,改成真正的 dual-source descriptor model
這是我最推薦的近期修法。
不要重寫整個 sandbox,保留現在:
sharedInitCopy
→ ownDescs
→ mySandbox
→ with(this.$)
但把 globals 收集重構為兩個來源。
核心應先明確定義:
const realmGlobal = globalThis as typeof globalThis & Record<PropertyKey, any>;
const hostWindow = window as Window & Record<PropertyKey, any>;
const splitGlobal = realmGlobal !== hostWindow;
不要判 Firefox:
而是 feature detection:
realmGlobal 只取 own properties
這是很關鍵的細節。
不要:
getAllPropertyDescriptors(realmGlobal)
應該:
const realmOwnDescs =
Object.getOwnPropertyDescriptors(realmGlobal);
只取 own。
因為 Firefox 的:
realmGlobal
prototype -> hostWindow
如果你遍歷 prototype chain,你馬上又把兩個 realm 混回一起。
realm own 用來取得:
Object
Array
Promise
RegExp
Proxy
Error
structuredClone
Firefox USER_SCRIPT own globals
然後另外走 hostWindow prototype chain
collectWindowDescriptors(hostWindow);
這一趟專門取得:
Node
NodeFilter
XMLHttpRequest
HTMLElement
HTMLBodyElement
document/location accessors
setTimeout
addEventListener
dispatchEvent
...
而且 callback 要把 descriptor owner 傳出來。
現在:
不夠。
至少應變成:
callback({
key,
desc,
owner,
root,
});
因為你必須知道:
這個 function 到底是哪個物件的 method?
而不是只知道「這次是從 global/root 開始掃」。
八、方案 A 的 function 分類我會這樣改
目前最大的錯是:
我建議拆成:
function isInterfaceLike(key: string, value: unknown) {
if (typeof value !== "function") return false;
// Node / XMLHttpRequest / HTMLBodyElement...
if ("prototype" in value) return true;
// NodeFilter 等瀏覽器 interface
const c = key.charCodeAt(0);
if (c >= 65 && c <= 90) return true;
// 有靜態成員的 callable interface 也盡量不要 bind
const extraKeys = Reflect.ownKeys(value).filter(
k => k !== "length" && k !== "name" &&
k !== "caller" && k !== "arguments"
);
return extraKeys.length > 0;
}
然後:
if (typeof value === "function") {
if (isInterfaceLike(key, value)) {
// Node、XHR、HTMLBodyElement:原封不動
result[key] = { ...desc };
} else if (shouldBindWindowMethod(key, value, owner)) {
result[key] = {
...desc,
value: bindWindowMethod(value, key),
};
} else {
result[key] = { ...desc };
}
}
最重要的是:
protoBaseDescs[key] = { ...desc };
不能再有:
的 unconditional fallback。
九、EventTarget 我甚至更偏好 forwarding,而不是 bind
最低改動當然可以:
但跨 Firefox compartment/Xray,我會更傾向:
const makeWindowMethod = (fn: Function, name: string) => {
const wrapped = function (this: unknown, ...args: unknown[]) {
return Reflect.apply(fn, hostWindow, args);
};
Object.defineProperty(wrapped, "name", {
value: name,
configurable: true,
});
return wrapped;
};
於是:
sandbox.addEventListener(...)
實際:
Reflect.apply(
EventTarget.prototype.addEventListener,
hostWindow,
args
)
removeEventListener 和 dispatchEvent 同理。
這能確保三個 API 永遠作用在:
同一個 hostWindow EventTarget
你的第 10 個 lifecycle test 就自然通過。
.bind(hostWindow) 的優點
修改小、效能好、現有測試可能已依賴 "bound xxx" name。
forwarding wrapper 的優點
receiver 完全可控,跨 realm 行為更清楚,也比較容易日後特殊處理。
所以近期 backport 我可以接受 bind(hostWindow);長期我偏 forwarding。
十、alias 不應再靠 descriptor value 猜
現在:
if (desc.value === global)
我建議整段刪掉,直接明確定義。
window/self/globalThis:
for (const key of ["window", "self", "globalThis"] as const) {
ownDescs[key] = {
configurable: true,
enumerable: ownDescs[key]?.enumerable ?? true,
get() {
return mySandbox;
},
};
}
這樣無論 Firefox host 怎麼設計:
window === globalThis
self === globalThis
在 userscript observable semantics 中永遠成立。
這不是修改 Firefox global。
只是 ScriptCat 的 userscript global façade。
十一、top / parent / frames 不要無腦等於 sandbox
它們稍微不同。
top-level page:
window.top === window
window.parent === window
window.frames === window
所以 userscript sandbox 應該得到:
sandbox.top === sandbox
sandbox.parent === sandbox
sandbox.frames === sandbox
但是 iframe 中:
不能把 parent 永遠改成 sandbox。
我會做:
const wrapWindowReference = (key: "top" | "parent" | "frames") => ({
configurable: true,
enumerable: ownDescs[key]?.enumerable ?? true,
get() {
const value = Reflect.get(hostWindow, key, hostWindow);
return value === hostWindow
? mySandbox
: value;
},
});
這才是真正跨瀏覽器一致。
十二、createEventProp 也要直接換 receiver
beta.2:
global.removeEventListener(...)
global.addEventListener(...)
應改:
hostWindow.removeEventListener(...)
hostWindow.addEventListener(...)
createFuncWrapper():
const ret = f.call(global);
if (ret === global) return mySandbox;
應改成:
const ret = Reflect.apply(f, hostWindow, []);
if (ret === hostWindow) {
return mySandbox;
}
return ret;
這兩個屬於低風險、高收益修改。
十三、方案 A 對你的 10 個測試
| 測試 |
修復機制 |
window === globalThis |
三個 alias 明確回傳 mySandbox |
self === globalThis |
同上 |
window.GM_getValue |
window → mySandbox,GM API 已複製到 mySandbox |
self.x → globalThis.x |
兩者同一物件 |
Node.ELEMENT_NODE |
Node 原 interface,不 bind |
NodeFilter.SHOW_TEXT |
NodeFilter 原 interface,不 bind |
XMLHttpRequest.DONE |
XHR constructor 不 bind |
HTMLBodyElement.prototype |
constructor identity 保留 |
globalThis.addEventListener |
sandbox method forwarding 到 hostWindow |
| add/dispatch/remove |
全部使用同一 hostWindow |
這套不是為 10 個 case 寫 allowlist,而是修正 underlying object model。
十四、方案 B:直接改成 Violentmonkey 式 lazy Proxy
這是我認為長期最乾淨的方向。
現在 ScriptCat 是:
啟動時把大量 Window descriptors 複製一份
↓
sharedInitCopy
↓
mySandbox
可以改成:
local GM/global properties
↓
Proxy wrapper
↙ ↘
realmGlobal hostWindow
簡化概念:
const local = Object.create(null);
Object.assign(local, grantedGMAPIs);
let wrapper: any;
wrapper = new Proxy(local, {
get(_, key) {
if (
key === "window" ||
key === "self" ||
key === "globalThis"
) {
return wrapper;
}
if (Reflect.has(local, key)) {
return Reflect.get(local, key);
}
const desc = resolveGlobalDescriptor(key);
if (!desc) return undefined;
return materializeDescriptorValue(
key,
desc,
realmGlobal,
hostWindow
);
},
set(_, key, value) {
local[key] = value;
return true;
},
has(_, key) {
return key in local ||
key in realmGlobal ||
key in hostWindow;
},
ownKeys() {
return unionKeys(local, realmGlobal, hostWindow);
},
});
這基本就是 VM 的方向。
優勢
不用在頁面初始化時複製幾百個 property。
Firefox global/window 分裂可以自然表示。
未來瀏覽器新增 API,不需要 ScriptCat 更新 snapshot。
可以 lazy cache descriptor。
更容易明確實現:
ES intrinsic -> realmGlobal
DOM interface -> hostWindow
window alias -> wrapper
GM -> local
也能處理頁面 frame index、late-added globals 等。
缺點
這不是小 patch。
Proxy 必須處理:
get
set
has
ownKeys
getOwnPropertyDescriptor
defineProperty
deleteProperty
否則:
Object.keys(window)
Object.getOwnPropertyNames(window)
"x" in window
delete window.x
Object.defineProperty(window, ...)
可能跟 TM/VM 不一致。
還會碰 Proxy invariant。
另外很多老 userscript/library 對 window 很敏感,所以要跑大量 regression。
我的判斷
不要拿這個當 beta.2 緊急修。
但如果 ScriptCat 的目標真的是:
Chrome / Firefox / 未來其他 browser 的 userscript sandbox 長期完全一致
那最終應該往這個方向。
十五、方案 C:只做 compatibility overlay,最快能發布
如果現在目標是很快出一個 Firefox 修復版,可以不動 descriptor engine 主體。
直接在 createProxyContext() 建好 mySandbox 之前/之後覆蓋:
window
self
globalThis
Node
NodeFilter
XMLHttpRequest
HTMLBodyElement
addEventListener
removeEventListener
dispatchEvent
例如:
defineSelfAlias("window");
defineSelfAlias("self");
defineSelfAlias("globalThis");
for (const key of [
"Node",
"NodeFilter",
"XMLHttpRequest",
"HTMLBodyElement",
]) {
ownDescs[key] =
Object.getOwnPropertyDescriptor(hostWindow, key) ??
{ value: hostWindow[key], writable: true, configurable: true };
}
for (const key of [
"addEventListener",
"removeEventListener",
"dispatchEvent",
]) {
ownDescs[key] = {
configurable: true,
value: hostWindow[key].bind(hostWindow),
};
}
優點
改動非常小。
可以快速驗證你的 10 個 testcase。
風險集中。
適合作為 beta hotfix。
缺點
它只是補表面。
下一個 userscript 可能馬上撞:
requestAnimationFrame
postMessage
matchMedia
getComputedStyle
queueMicrotask
CSS
DOMParser
MutationObserver
FileReader
...
然後繼續加 allowlist。
所以如果你的要求是:
「所有 userscript Chrome / Firefox 一致」
我不會把 C 當最終方案。
十六、方案 D:直接修改 Firefox 的 globalThis sandbox object
另一個看起來很誘人的方案:
if (globalThis !== window) {
Object.defineProperty(globalThis, "window", {
value: globalThis,
});
Object.defineProperty(globalThis, "self", {
value: globalThis,
});
}
甚至把:
addEventListener
removeEventListener
dispatchEvent
也掛到 Firefox sandbox global。
這樣真正的:
可能就能被 shadow 成 true。
優點
@grant none 都可能一起得到正常 alias。
new Function() 產生的真正 global scope 也比較接近 Chrome。
wrapper 的複雜度降低。
缺點很嚴重
Firefox USER_SCRIPT world 是 ScriptCat runtime 自己也在使用的 global。
你是在修改 browser-created execution realm,不是單一 userscript sandbox。
多個 ScriptCat userscript 可能共享這個 USER_SCRIPT world。
任何 global patch 都可能造成 scripts 間互相污染。
globalThis 本體仍不是 EventTarget/Window,只是偽裝。
getter、brand check、prototype、instanceof 等還是會暴露真相。
未來 Firefox 改 sandbox implementation,風險很大。
而且你們現在本來就有 mySandbox 作 per-script isolation,繞過它反而退步。
我的評價
不建議。
除非只作非常小的 runtime internal shim,而且絕對不讓 userscript直接修改。
十七、方案 E:Firefox content 全部改跑 MAIN world
這個會讓:
瞬間「正常」。
但我會直接排除。
runtime.ts 現在很清楚:
scriptcat-inject -> world: "MAIN"
scriptcat-content -> world: "USER_SCRIPT"
這個架構是合理的。
Firefox USER_SCRIPT 是 sandbox world;Firefox 本身也是這麼定義的。(Searchfox)
如果 @inject-into content 改 MAIN:
頁面可以污染 prototype
頁面可以窺探 globals
GM API bridge 安全邊界惡化
@inject-into content 名存實亡
unsafeWindow 語義消失
相當於為解 compatibility 問題把 isolation 拆掉。
不值得。
十八、還有一件事:@grant none
這部分需要單獨決策。
現在 exec_script.ts 約 90 行:
this.execContext =
sandboxContext
? createProxyContext(sandboxContext)
: global;
所以 @grant none 不建立 ScriptCat sandbox。
在 Firefox USER_SCRIPT world 裡,即使 ScriptCat 完全不介入:
仍是 Firefox host semantics。MDN 也明確指出這件事。(MDN Web Docs)
因此如果你們的產品目標真的定義成:
任何 @inject-into content userscript,包括 @grant none,都必須看到 Chrome-like window === self === globalThis
那方案 A 只修 granted scripts 還不夠。
你們需要讓 @grant none 也經過一層沒有 GM 權限、但有 global compatibility 的 scope façade。
不是完整 sandbox,只處理 browser-global semantics。
例如概念上:
const compatScope = createContentCompatScope({
realmGlobal,
hostWindow,
grants: null,
isolateWrites: false, // 這個語義需你們決定
});
再:
scriptFunc.call(compatScope, compatScope, scriptName);
這裡最大的產品決策是:
foo = 1;
window.foo = 1;
globalThis.foo = 1;
在 @grant none + content 下究竟應寫到哪個 realm。
這不能靠 patch 猜。
要先拿 Chrome ScriptCat 現有行為作 reference,再要求 Firefox完全 replicate。
十九、我最推薦的落地順序
| 方案 |
改動量 |
10 tests |
長期通用性 |
風險 |
建議 |
| C:10-case overlay |
很小 |
✅ |
低 |
低 |
緊急 hotfix |
| A:dual-source descriptor |
中 |
✅ |
高 |
中低 |
現在最值得做 |
| B:VM-style Proxy |
大 |
✅ |
最高 |
中高 |
長期重構 |
| D:patch Firefox global |
中 |
部分 |
低 |
高 |
不建議 |
| E:MAIN world |
大 |
✅表面上 |
很低 |
很高 |
排除 |
如果是我負責這個版本,我會:
beta.2 fix
↓
實作 A
↓
保留現有 sharedInitCopy 架構
↓
所有 browser 都走 realmGlobal + hostWindow 雙來源
↓
Firefox 用 globalThis !== window 自動啟用 split-global path
↓
真 Firefox E2E
↓
之後再考慮 B
二十、方案 A 我會具體改哪些地方
這份 beta.2,我會集中改 create_context.ts,而不是到處 patch:
172 initOwnDescs
186 getAllPropertyDescriptors(global)
199 value.bind(global)
205 protoBaseDescs fallback
229 getter.bind(global)
230 setter.bind(global)
250 Symbol.toStringTag
256 constructor
263 __proto__
301 f.call(global)
302 ret === global
317 global.removeEventListener
339 global.removeEventListener
343 global.addEventListener
360 alias loop
另外 exec_script.ts:90 要單獨決定 @grant none 的跨瀏覽器 policy。
unsafeWindow = window 這一行反而是對的,不應改。
二十一、測試層目前也有盲點
現在 create_context.test.ts 已經有:
expect(sandbox.window).toBe(sandbox);
expect(sandbox.self).toBe(sandbox);
expect(sandbox.globalThis).toBe(sandbox);
以及 NodeFilter、Illegal invocation 等測試。
問題是這些測試跑在 happy-dom / Node test global 中。
它沒有真的模擬:
Firefox:
globalThis !== window
Object.getPrototypeOf(globalThis) -> Xray-ish Window
所以測試 green 並不代表 Firefox green。
而且目前 E2E fixtures 從你這份 source 看都是:
import { chromium } from "@playwright/test";
基本沒有真正的 Firefox sandbox E2E。
這種 bug 靠 happy-dom 很難可靠抓到。
我會把你現在這 10 個 case 直接升格成一份 browser conformance userscript,最少建立這個矩陣:
| Case |
Chrome USER_SCRIPT |
Firefox USER_SCRIPT |
window===globalThis |
pass |
pass |
self===globalThis |
pass |
pass |
| GM on window |
pass |
pass |
| self/globalThis write |
pass |
pass |
| Node constants |
pass |
pass |
| NodeFilter constants |
pass |
pass |
| XHR constants |
pass |
pass |
| DOM prototype identity |
pass |
pass |
| EventTarget method |
pass |
pass |
| Event lifecycle |
pass |
pass |
RegExp.$1 |
pass |
pass |
Object.freeze |
pass |
pass |
Number.isNaN |
pass |
pass |
top/parent/frames top frame |
pass |
pass |
top/parent iframe |
pass |
pass |
onload this === sandbox |
pass |
pass |
| sandbox A/B globals isolation |
pass |
pass |
最後幾項很重要,因為它們可以阻止「修 Firefox 10 tests,但把 ScriptCat 舊相容性修壞」。
結論
我不建議把問題定義成「Firefox 的 globalThis 不符合 Chrome spec」。
更準確的是:
Firefox USER_SCRIPT world 本來就是 Cu.Sandbox + sandboxPrototype: contentWindow;ScriptCat 現有 sandbox abstraction 把「JS realm global」和「DOM Window」當成一個物件,因此在 Firefox 上崩掉。(Searchfox)
真正值得做的修復是:
realmGlobal
│
├─ ES intrinsics
│
hostWindow
│
├─ DOM/WebIDL interfaces
├─ Window accessors
└─ EventTarget methods
│
▼
scriptGlobal / mySandbox
├─ window ─────┐
├─ self ───────┼─ self-reference
├─ globalThis ─┘
└─ GM APIs
其中 「realmGlobal 只取 own properties;hostWindow 另外遍歷 prototype chain」 是我認為最關鍵的一刀。這比目前 main 的「先完整遍歷 global,再完整遍歷 window」更可靠,因為 Firefox 的 global prototype 本身就是 window;不把兩者來源拆開,receiver 永遠有機會綁錯。
如果下一步是直接修 code,我可以基於你這份 1.5.0-beta.2 給你做一版實際 diff,只改 create_context.ts + exec_script.ts + tests,按方案 A 實現,並把你這 10 個 testcase 加成 regression tests。
兼容性 / 迁移 / 测试
No response
ScriptCat 版本
No response
背景问题
本issue 在原沙盒基礎上修正Firefox Spec問題
考慮但不一定 revert #1692
PR #1692 沒有實際能重視問題的針對性腳本,也沒有TDD的測試代碼
這樣對程式員或agent來說都很危險
#1697 給出了更進一步的問題,也有了針對性腳本
可是PR的代碼更動不一定是最理想的
agent 或程式員應參考以下分析進一步在代碼層面深入考慮修改部份,以相容於Firefox Spec但又不影響Chrome Spec.
以下分析或具体方案僅用於參考。不能視為必須遵循。Agent 或程式員應制定詳細方案包括代碼測試驗收標準等才能正式提交PR。
具体方案
我把你上傳的
scriptcat-1.5.0-beta.2.zip解開後,核心執行鏈已經追清楚了。這次 Firefox 的 10 個失敗不是單點 bug,而是 ScriptCat 現有 sandbox 建模隱含假設了global === window;這個假設在 Chrome USER_SCRIPT world 大致成立,在 Firefox 明確不成立。Mozilla 對這件事其實寫得很直接:Firefox content script 中
globalThis是一個不同於window、並繼承自window的物件。(MDN Web Docs) Firefox USER_SCRIPT 的實作也是建立Cu.Sandbox,並指定sandboxPrototype: contentWindow、wantXrays: true。(Searchfox)所以目標不應是「讓 Firefox 自己變成 Chrome」,而應是讓 ScriptCat 明確處理三個不同物件:
userscript 最終應看到:
而不是現在 Firefox 下的混合狀態。
一、你這份 beta.2 的真正問題在哪裡
最重要的是
src/app/service/content/create_context.ts。問題 1:整套 descriptor 系統從
global開始beta.2 約 172 行:
接著約 186 行:
這在 Chrome 比較碰巧,因為
global/window的差異小。Firefox USER_SCRIPT 下:
所以這裡其實正在遍歷:
但後面的程式把整條鏈都當成「同一個 Window global」。
這是根本錯誤。
二、10 個測試為什麼逐項失敗
你現在 alias 處理在約 360 行:
這段實際只對「值剛好等於 Firefox sandbox global」的屬性有效。
Firefox 中大致是:
因此:
於是直接造成:
window === globalThiswindow沒被映射到 mySandboxself === globalThiswindow.GM_getValuewindow指到 hostWindowself.x→globalThis.x所以 ① 的四個失敗,本質上是同一件事。
三、Node / NodeFilter / XMLHttpRequest 的 bug 更具體
你們其實已經知道 constructor 不能亂 bind。
shouldFnBind()有:而且還有:
// 要求函数名字小写字头 能筛选掉 NodeFilter 之类 Interface這個方向是對的。
但是後面約 205–214 行又把保護完全繞掉了:
也就是:
結果還是 bind。
而:
同樣:
這就是你的 ②。
所以這裡不是抽象性的「可能有問題」,而是我會直接判斷:
四、EventTarget 也綁錯 receiver 了
現在很多地方用:
例如約 199 行。
Firefox 的
global是 Cu.Sandbox object。但是:
需要作用的 EventTarget 是:
window不是 Cu.Sandbox global。
所以理想操作是:
而不是:
這也解釋了你的:
為什麼整個 lifecycle 壞掉。
五、還有幾個 beta.2 裡很值得一起修的點
createFuncWrapper()現在是:Firefox 應該用
window作 Window getter receiver,而且應判斷:而不是:
否則
self/parent/top/frames都會出問題。createEventProp()也全部在:應該改成
hostWindow。PseudoWindowPrototype現在又取:如果想模擬 Window,來源也應該是
hostWindow,不是 Firefox Cu.Sandbox。六、不能簡單做
global -> window全域替換這點非常重要。
直覺 patch 可能是:
或把所有:
global改成:
window我不建議。
原因是 Firefox sandbox global 裡還有屬於 userscript JS realm 自己的 ECMAScript intrinsics。
例如:
這些最好保留 Firefox sandbox realm 自己的版本。
尤其 ScriptCat 現在已經有:
相關相容性測試。
如果全部改成 Xray
window.RegExp/window.Number/window.Math,會引入另一批跨 realm / Xray 問題。有意思的是,我也看了目前 ScriptCat
main。在你這份 beta.2 之後,上游已經開始嘗試做兩趟:程式註解也已明確認知 Firefox global/window 是兩個 realm。
方向是對的,但我認為現在 main 的實作仍然沒有徹底解決問題,因為它依舊:
也就是第一趟會沿
global的 prototype 鑽進window,然後卻把找到的方法:綁到
global。而且 fallback 仍然:
最後 alias 還是:
所以「兩趟掃描」本身還不夠。
七、方案 A:保留現在架構,改成真正的 dual-source descriptor model
這是我最推薦的近期修法。
不要重寫整個 sandbox,保留現在:
但把 globals 收集重構為兩個來源。
核心應先明確定義:
不要判 Firefox:
而是 feature detection:
realmGlobal 只取 own properties
這是很關鍵的細節。
不要:
應該:
只取 own。
因為 Firefox 的:
如果你遍歷 prototype chain,你馬上又把兩個 realm 混回一起。
realm own 用來取得:
然後另外走 hostWindow prototype chain
這一趟專門取得:
而且 callback 要把 descriptor owner 傳出來。
現在:
不夠。
至少應變成:
因為你必須知道:
而不是只知道「這次是從 global/root 開始掃」。
八、方案 A 的 function 分類我會這樣改
目前最大的錯是:
我建議拆成:
然後:
最重要的是:
不能再有:
的 unconditional fallback。
九、EventTarget 我甚至更偏好 forwarding,而不是 bind
最低改動當然可以:
但跨 Firefox compartment/Xray,我會更傾向:
於是:
實際:
removeEventListener和dispatchEvent同理。這能確保三個 API 永遠作用在:
你的第 10 個 lifecycle test 就自然通過。
.bind(hostWindow)的優點修改小、效能好、現有測試可能已依賴
"bound xxx"name。forwarding wrapper 的優點
receiver 完全可控,跨 realm 行為更清楚,也比較容易日後特殊處理。
所以近期 backport 我可以接受
bind(hostWindow);長期我偏 forwarding。十、alias 不應再靠 descriptor value 猜
現在:
我建議整段刪掉,直接明確定義。
window/self/globalThis:這樣無論 Firefox host 怎麼設計:
在 userscript observable semantics 中永遠成立。
這不是修改 Firefox global。
只是 ScriptCat 的 userscript global façade。
十一、
top / parent / frames不要無腦等於 sandbox它們稍微不同。
top-level page:
所以 userscript sandbox 應該得到:
但是 iframe 中:
不能把 parent 永遠改成 sandbox。
我會做:
這才是真正跨瀏覽器一致。
十二、
createEventProp也要直接換 receiverbeta.2:
應改:
createFuncWrapper():應改成:
這兩個屬於低風險、高收益修改。
十三、方案 A 對你的 10 個測試
window === globalThismySandboxself === globalThiswindow.GM_getValuewindow → mySandbox,GM API 已複製到 mySandboxself.x→globalThis.xNode.ELEMENT_NODENodeFilter.SHOW_TEXTXMLHttpRequest.DONEHTMLBodyElement.prototypeglobalThis.addEventListenerhostWindow這套不是為 10 個 case 寫 allowlist,而是修正 underlying object model。
十四、方案 B:直接改成 Violentmonkey 式 lazy Proxy
這是我認為長期最乾淨的方向。
現在 ScriptCat 是:
可以改成:
簡化概念:
這基本就是 VM 的方向。
優勢
不用在頁面初始化時複製幾百個 property。
Firefox
global/window分裂可以自然表示。未來瀏覽器新增 API,不需要 ScriptCat 更新 snapshot。
可以 lazy cache descriptor。
更容易明確實現:
也能處理頁面 frame index、late-added globals 等。
缺點
這不是小 patch。
Proxy必須處理:否則:
可能跟 TM/VM 不一致。
還會碰 Proxy invariant。
另外很多老 userscript/library 對
window很敏感,所以要跑大量 regression。我的判斷
不要拿這個當 beta.2 緊急修。
但如果 ScriptCat 的目標真的是:
那最終應該往這個方向。
十五、方案 C:只做 compatibility overlay,最快能發布
如果現在目標是很快出一個 Firefox 修復版,可以不動 descriptor engine 主體。
直接在
createProxyContext()建好mySandbox之前/之後覆蓋:例如:
優點
改動非常小。
可以快速驗證你的 10 個 testcase。
風險集中。
適合作為 beta hotfix。
缺點
它只是補表面。
下一個 userscript 可能馬上撞:
然後繼續加 allowlist。
所以如果你的要求是:
我不會把 C 當最終方案。
十六、方案 D:直接修改 Firefox 的
globalThissandbox object另一個看起來很誘人的方案:
甚至把:
也掛到 Firefox sandbox global。
這樣真正的:
可能就能被 shadow 成 true。
優點
@grant none都可能一起得到正常 alias。new Function()產生的真正 global scope 也比較接近 Chrome。wrapper 的複雜度降低。
缺點很嚴重
Firefox USER_SCRIPT world 是 ScriptCat runtime 自己也在使用的 global。
你是在修改 browser-created execution realm,不是單一 userscript sandbox。
多個 ScriptCat userscript 可能共享這個 USER_SCRIPT world。
任何 global patch 都可能造成 scripts 間互相污染。
globalThis本體仍不是 EventTarget/Window,只是偽裝。getter、brand check、prototype、
instanceof等還是會暴露真相。未來 Firefox 改 sandbox implementation,風險很大。
而且你們現在本來就有
mySandbox作 per-script isolation,繞過它反而退步。我的評價
不建議。
除非只作非常小的 runtime internal shim,而且絕對不讓 userscript直接修改。
十七、方案 E:Firefox content 全部改跑 MAIN world
這個會讓:
瞬間「正常」。
但我會直接排除。
runtime.ts現在很清楚:這個架構是合理的。
Firefox USER_SCRIPT 是 sandbox world;Firefox 本身也是這麼定義的。(Searchfox)
如果
@inject-into content改 MAIN:相當於為解 compatibility 問題把 isolation 拆掉。
不值得。
十八、還有一件事:
@grant none這部分需要單獨決策。
現在
exec_script.ts約 90 行:所以
@grant none不建立 ScriptCat sandbox。在 Firefox USER_SCRIPT world 裡,即使 ScriptCat 完全不介入:
仍是 Firefox host semantics。MDN 也明確指出這件事。(MDN Web Docs)
因此如果你們的產品目標真的定義成:
那方案 A 只修 granted scripts 還不夠。
你們需要讓
@grant none也經過一層沒有 GM 權限、但有 global compatibility 的 scope façade。不是完整 sandbox,只處理 browser-global semantics。
例如概念上:
再:
這裡最大的產品決策是:
在
@grant none + content下究竟應寫到哪個 realm。這不能靠 patch 猜。
要先拿 Chrome ScriptCat 現有行為作 reference,再要求 Firefox完全 replicate。
十九、我最推薦的落地順序
如果是我負責這個版本,我會:
二十、方案 A 我會具體改哪些地方
這份 beta.2,我會集中改
create_context.ts,而不是到處 patch:另外
exec_script.ts:90要單獨決定@grant none的跨瀏覽器 policy。unsafeWindow = window這一行反而是對的,不應改。二十一、測試層目前也有盲點
現在
create_context.test.ts已經有:以及 NodeFilter、Illegal invocation 等測試。
問題是這些測試跑在 happy-dom / Node test global 中。
它沒有真的模擬:
所以測試 green 並不代表 Firefox green。
而且目前 E2E fixtures 從你這份 source 看都是:
基本沒有真正的 Firefox sandbox E2E。
這種 bug 靠 happy-dom 很難可靠抓到。
我會把你現在這 10 個 case 直接升格成一份 browser conformance userscript,最少建立這個矩陣:
window===globalThisself===globalThisRegExp.$1Object.freezeNumber.isNaNtop/parent/framestop frametop/parentiframeonload this === sandbox最後幾項很重要,因為它們可以阻止「修 Firefox 10 tests,但把 ScriptCat 舊相容性修壞」。
結論
我不建議把問題定義成「Firefox 的
globalThis不符合 Chrome spec」。更準確的是:
真正值得做的修復是:
其中 「realmGlobal 只取 own properties;hostWindow 另外遍歷 prototype chain」 是我認為最關鍵的一刀。這比目前 main 的「先完整遍歷 global,再完整遍歷 window」更可靠,因為 Firefox 的 global prototype 本身就是 window;不把兩者來源拆開,receiver 永遠有機會綁錯。
如果下一步是直接修 code,我可以基於你這份
1.5.0-beta.2給你做一版實際 diff,只改create_context.ts + exec_script.ts + tests,按方案 A 實現,並把你這 10 個 testcase 加成 regression tests。兼容性 / 迁移 / 测试
No response
ScriptCat 版本
No response