Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
7b371a8
Firefox MV3 Sandbox Manifest
cyfung1031 Jul 11, 2026
c5924ab
fix `runtime.sendMessage` for message broadcast (`.publish`) without …
cyfung1031 Jul 11, 2026
82dd138
🔥 移除 sandbox 就绪握手调试用的 postMessage 探针
cyfung1031 Jul 11, 2026
6753f72
Update pack.js
cyfung1031 Jul 11, 2026
bd97e1c
ScriptCat MV3 正式支持 Firefox
cyfung1031 Jul 11, 2026
df21120
Update pack.js
cyfung1031 Jul 11, 2026
c67cae3
`content_security_policy` fix
cyfung1031 Jul 11, 2026
8857adc
Update pack.js
cyfung1031 Jul 11, 2026
44c4db5
Update pack.js
cyfung1031 Jul 11, 2026
f4015e0
Firefox - incognito "split" is unsupported
cyfung1031 Jul 11, 2026
e3bb088
minor Firefox MV3 manifest fix
cyfung1031 Jul 11, 2026
b6329d4
fix event page messaging
cyfung1031 Jul 11, 2026
fc11e78
fix logger for disable
cyfung1031 Jul 11, 2026
26ec55e
startFirefoxEventPageKeepAliveLoop
cyfung1031 Jul 11, 2026
1f91237
startFirefoxEventPageKeepAliveLoop : change to nativeScheduler
cyfung1031 Jul 11, 2026
5c626c6
docs: clarify Firefox event page implementation notes
cyfung1031 Jul 11, 2026
2db3d26
`"process.env.SC_KEEP_EVENT_PAGE_ACTIVE": "'true'", // 正式 Release 需设为…
cyfung1031 Jul 11, 2026
fe9aae7
update yaml
cyfung1031 Jul 11, 2026
31c5257
fix comment
cyfung1031 Jul 11, 2026
67d1f83
✨ make Firefox keep-alive permission optional
cyfung1031 Jul 11, 2026
1e5caf8
Update RuntimeSection.tsx
cyfung1031 Jul 11, 2026
6c342aa
add success message after toggle
cyfung1031 Jul 11, 2026
cd04c67
fix
cyfung1031 Jul 11, 2026
8d2ef84
fix test
cyfung1031 Jul 11, 2026
fe66954
✨ add Chrome keep-alive setting
cyfung1031 Jul 12, 2026
fdfb04e
✨ add Chrome keep-alive setting
cyfung1031 Jul 12, 2026
c9de378
fix SC_KEEP_EVENT_PAGE_ACTIVE
cyfung1031 Jul 12, 2026
13f9864
update
cyfung1031 Jul 12, 2026
dde1238
Update keep_alive.ts
cyfung1031 Jul 12, 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
1 change: 1 addition & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
- name: Package with Node
env:
CHROME_PEM: ${{ secrets.CHROME_PEM }}
SC_KEEP_EVENT_PAGE_ACTIVE: "true"
run: |
mkdir dist
echo "$CHROME_PEM" > ./dist/scriptcat.pem
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/packageRelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ jobs:
- name: Package with Node
env:
CHROME_PEM: ${{ secrets.CHROME_PEM }}
SC_KEEP_EVENT_PAGE_ACTIVE: "true"
run: |
mkdir dist
echo "$CHROME_PEM" > ./dist/scriptcat.pem
Expand Down
23 changes: 20 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ if (hasOffscreenDocument) {
new ServiceWorkerManager(server, messageQueue, offscreen).initManager();
setupOffscreenDocument(); // chrome.offscreen.createDocument(...)
} else {
const offscreen = new EventPageOffscreenManager(message); // Firefox MV3: event page IS the DOM env
const offscreen = new EventPageOffscreenManager(message, messageQueue); // Firefox MV3: event page IS the DOM env
new ServiceWorkerManager(server, messageQueue, offscreen).initManager();
}
```
Expand All @@ -119,8 +119,25 @@ already has DOM and plays the offscreen role directly.
finds the offscreen client via `clients.matchAll()` and `postMessage`s it); Offscreen → SW replies over
`ExtensionMessage` (`chrome.runtime`).
- **Firefox:** [`EventPageOffscreenManager`](../src/app/service/offscreen/event_page_manager.ts) substitutes
for the offscreen document; `service_worker.ts` emits `preparationOffscreen` immediately because the DOM
environment is already live.
for the offscreen document; its sandbox iframe is a `sandbox` manifest page, which Firefox 154+ loads as a
cross-origin frame (`contentDocument` is `null`, `contentWindow.location` is unreadable). Only the sandbox
itself knows when it's actually ready, so the parent never polls or pings it: `SandboxManager`
([`src/app/service/sandbox/index.ts`](../src/app/service/sandbox/index.ts)) proactively posts a
`preparationSandbox` message once its own `Server` is wired up (same mechanism on both platforms), and
[`BackgroundEnvManagerBase.preparationSandbox`](../src/app/service/offscreen/base.ts) immediately tells the
service worker `preparationOffscreen` — no round trip, no waiting. Separately and non-blockingly, the
sandbox reuses its own in-flight `getExtensionEnv` request to self-check that the channel is genuinely
bidirectional, and reports the outcome via `reportSandboxChannelHealth`, which the parent logs (visible in
the parent's own console/log, since the sandbox iframe's console is far less discoverable). If the sandbox
never announces readiness at all (iframe failed to load, script error), a fallback timer in
`BackgroundEnvManagerBase` still tells the service worker `preparationOffscreen` after
`SANDBOX_READY_FALLBACK_MS`, logging a clear error instead of hanging forever.

Firefox builds compiled with `SC_KEEP_EVENT_PAGE_ACTIVE=true` also include an experimental event-page keep-alive
loop in `EventPageOffscreenManager`. That loop needs `webRequestBlocking`, so `scripts/pack.js` injects it only
as a Firefox `optional_permissions` entry under the same build flag. Users grant it from the install prompt or
runtime settings when they use Background Scripts or Scheduled Scripts; the event page listens for
`chrome.permissions.onAdded` and starts the loop only after the grant is present.

Services never see this difference: they receive an `IOffscreenSend` and call `.init()`.

Expand Down
2 changes: 2 additions & 0 deletions packages/chrome-extension-mock/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const chromeMock = {
action: new Action(),
init() {
this.downloads.reset();
this.permissions.reset();
this.webRequest.reset();
},
};

Expand Down
84 changes: 77 additions & 7 deletions packages/chrome-extension-mock/permissions.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,84 @@
import EventEmitter from "eventemitter3";

class PermissionsEvent {
private EE = new EventEmitter<string, any>();

addListener(callback: (permissions: chrome.permissions.Permissions) => void) {
this.EE.addListener("permission", callback);
}

removeListener(callback: (permissions: chrome.permissions.Permissions) => void) {
this.EE.removeListener("permission", callback);
}

hasListener(callback: (permissions: chrome.permissions.Permissions) => void) {
return this.EE.listeners("permission").includes(callback);
}

emit(permissions: chrome.permissions.Permissions) {
this.EE.emit("permission", permissions);
}

reset() {
this.EE.removeAllListeners();
}
}

export default class Permissions {
onAdded = new PermissionsEvent();
onRemoved = new PermissionsEvent();

private grantedPermissions: Set<string> | null = null;
private grantedOrigins: Set<string> | null = null;

__setGrantedPermissions(permissions: string[], origins: string[] = []) {
this.grantedPermissions = new Set(permissions);
this.grantedOrigins = new Set(origins);
}

reset() {
this.grantedPermissions = null;
this.grantedOrigins = null;
this.onAdded.reset();
this.onRemoved.reset();
}

request(permissions: chrome.permissions.Permissions, callback?: (granted: boolean) => void) {
// Mock implementation - always grant permissions for testing
if (this.grantedPermissions) {
for (const permission of permissions.permissions || []) {
this.grantedPermissions.add(permission);
}
}
if (this.grantedOrigins) {
for (const origin of permissions.origins || []) {
this.grantedOrigins.add(origin);
}
}
this.onAdded.emit(permissions);
if (callback) {
callback(true);
}
return Promise.resolve(true);
}

contains(permissions: chrome.permissions.Permissions, callback?: (result: boolean) => void) {
// Mock implementation - always return true for testing
const result =
this.grantedPermissions === null && this.grantedOrigins === null
? true
: (permissions.permissions || []).every((permission) => this.grantedPermissions?.has(permission)) &&
(permissions.origins || []).every((origin) => this.grantedOrigins?.has(origin));
if (callback) {
callback(true);
callback(result);
}
return Promise.resolve(true);
return Promise.resolve(result);
}

getAll(callback?: (permissions: chrome.permissions.Permissions) => void) {
const mockPermissions = {
permissions: ["activeTab" as chrome.runtime.ManifestPermissions, "storage" as chrome.runtime.ManifestPermissions],
origins: ["<all_urls>"],
permissions: (this.grantedPermissions
? [...this.grantedPermissions]
: ["activeTab", "storage"]) as chrome.runtime.ManifestPermissions[],
origins: this.grantedOrigins ? [...this.grantedOrigins] : ["<all_urls>"],
};
if (callback) {
callback(mockPermissions);
Expand All @@ -27,7 +87,17 @@ export default class Permissions {
}

remove(permissions: chrome.permissions.Permissions, callback?: (removed: boolean) => void) {
// Mock implementation - always succeed for testing
if (this.grantedPermissions) {
for (const permission of permissions.permissions || []) {
this.grantedPermissions.delete(permission);
}
}
if (this.grantedOrigins) {
for (const origin of permissions.origins || []) {
this.grantedOrigins.delete(origin);
}
}
this.onRemoved.emit(permissions);
if (callback) {
callback(true);
}
Expand Down
32 changes: 31 additions & 1 deletion packages/chrome-extension-mock/web_reqeuest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,34 @@ export default class WebRequest {
onBeforeRequest = {
counter: 0,
EE: new EventEmitter<string, any>(),
addListener: function (callback: (...args: any[]) => any) {
listeners: [] as {
callback: (...args: any[]) => any;
filter?: chrome.webRequest.RequestFilter;
extraInfoSpec?: string[];
}[],
addListener: function (
callback: (...args: any[]) => any,
filter?: chrome.webRequest.RequestFilter,
extraInfoSpec?: string[]
) {
this.listeners.push({ callback, filter, extraInfoSpec });
this.EE.addListener("onBeforeRequest", (params) => {
callback(params);
});
// TODO
},
removeListener: function (callback: (...args: any[]) => any) {
this.listeners = this.listeners.filter((listener) => listener.callback !== callback);
this.EE.removeAllListeners("onBeforeRequest");
for (const listener of this.listeners) {
this.EE.addListener("onBeforeRequest", (params) => {
listener.callback(params);
});
}
},
hasListener: function (callback: (...args: any[]) => any) {
return this.listeners.some((listener) => listener.callback === callback);
},
};

onBeforeRedirect = {
Expand All @@ -50,4 +72,12 @@ export default class WebRequest {
// TODO
},
};

reset() {
this.sendHeader = undefined;
this.responseStarted = undefined;
this.onBeforeRequest.counter = 0;
this.onBeforeRequest.listeners = [];
this.onBeforeRequest.EE.removeAllListeners();
}
}
24 changes: 24 additions & 0 deletions packages/message/message_queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,30 @@ describe("MessageQueueGroup", () => {
});
});

it("publish 在没有接收方时(Firefox 下 sendMessage 返回的 Promise 会 reject)会主动 catch 住该 rejection", () => {
// chrome.runtime.sendMessage() 不带回调时返回 Promise。Chrome 在没有其它监听方时该 Promise
// 不会 reject,但 Firefox 会 reject 并抛出 "Could not establish connection. Receiving end
// does not exist."。publish 广播给"任何在监听的人",没人监听是正常情况,不应表现为报错,
// 也不应留下未处理的 Promise rejection。
// 直接断言 .catch() 是否被调用,而不是依赖 process 的 unhandledRejection 事件——
// 后者的触发时机取决于 Node 事件循环细节,在测试环境下并不可靠。
const group = messageQueue.group("api-publishNoReceiver");
const rejectedPromise = Promise.reject(
new Error("Could not establish connection. Receiving end does not exist.")
);
const originalCatch = rejectedPromise.catch.bind(rejectedPromise);
let caughtCalled = false;
rejectedPromise.catch = (onRejected: any) => {
caughtCalled = true;
return originalCatch(onRejected);
};
vi.spyOn(chrome.runtime, "sendMessage").mockImplementation(() => rejectedPromise as unknown as void);

expect(() => group.publish("test-publishNoReceiver", { data: 1 })).not.toThrow();

expect(caughtCalled).toBe(true);
});

it("emit 方法应该只在本地发布", () => {
const group = messageQueue.group("api-emitLocal");
const handler = vi.fn();
Expand Down
25 changes: 20 additions & 5 deletions packages/message/message_queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,28 @@ export class MessageQueue implements IMessageQueue {
}

publish<T>(topic: string, message: NonNullable<T>) {
chrome.runtime.sendMessage({
msgQueue: topic,
data: { action: "message", message },
});
// chrome.runtime.sendMessage() 不带回调时返回 Promise。没有其它上下文在监听(例如尚未打开
// popup/options,或没有已注入的 content script)是完全正常的广播场景,不代表出错。
// Chrome 在这种情况下该 Promise 不会 reject;但 Firefox 会 reject 并抛出
// "Could not establish connection. Receiving end does not exist."——不接住就会变成
// 未处理的 Promise rejection。publish 本身是"广播给任何在监听的人",无人监听应静默忽略。
const messageQueueLogger = LoggerCore.getInstance().logger({ service: "messageQueue" });
chrome.runtime
.sendMessage({
msgQueue: topic,
data: { action: "message", message },
})
.catch((e) => {
const msg = JSON.stringify(e?.message || e);
if (msg.includes("Could not establish connection. Receiving end does not exist.")) {
messageQueueLogger.debug("No target audience for .publish", { msg });
} else {
messageQueueLogger.debug("Unable to execute runtime.sendMessage for .publish", { msg });
}
});
this.EE.emit(topic, message);
//@ts-ignore
LoggerCore.getInstance().logger({ service: "messageQueue" }).trace("publish", { topic, message });
messageQueueLogger.trace("publish", { topic, message });
}

// 只发布给当前环境
Expand Down
Loading
Loading