Skip to content
19 changes: 19 additions & 0 deletions example/run-in/run-in_bg.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// ==UserScript==
// @name Test @run-in background
// @namespace https://bbs.tampermonkey.net.cn/
// @version 0.1.0
// @description 后台脚本支持 @run-in 区分正常窗口与隐身窗口;同时可透过 GM_info.isIncognito 与 GM_info.userAgentData 取得运行环境
// @author You
// @background
// @run-in incognito-tabs
// @grant GM_log
// ==/UserScript==

return new Promise((resolve) => {
// 后台脚本指定 @run-in incognito-tabs 后,仅在隐身窗口对应的扩展环境中执行
// 若改为 normal-tabs 则仅在正常窗口环境执行;不写或写 @run-in normal-tabs 与 @run-in incognito-tabs 时两者皆执行
GM_log(`run-in: ${GM_info.script["run-in"]}`);
GM_log(`isIncognito: ${GM_info.isIncognito}`);
GM_log(`userAgentData: ${JSON.stringify(GM_info.userAgentData)}`);
resolve();
});
21 changes: 16 additions & 5 deletions packages/message/common.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type TExtensionEnv } from "@App/app/service/extension/extension_env";
import { randomMessageFlag } from "@App/pkg/utils/utils";

// 避免页面载入后改动全域物件导致消息传递失败
Expand All @@ -21,7 +22,12 @@ export const pageDispatchCustomEvent = <T = any>(eventType: string, detail: T) =
};

// flag协商
export function negotiateEventFlag(messageFlag: string, readyCount: number, onInit: (eventFlag: string) => void): void {
export function negotiateEventFlag(
messageFlag: string,
extensionEnv: TExtensionEnv,
readyCount: number,
onInit: (eventFlag: string) => void
): void {
const eventFlag = randomMessageFlag();
onInit(eventFlag);
// 监听 inject/content 发来的请求 eventFlag 的消息
Expand All @@ -40,27 +46,32 @@ export function negotiateEventFlag(messageFlag: string, readyCount: number, onIn
break;
case "requestEventFlag":
// 广播通信 flag 给 inject/content
pageDispatchCustomEvent(messageFlag, { action: "broadcastEventFlag", eventFlag: eventFlag });
pageDispatchCustomEvent(messageFlag, { action: "broadcastEventFlag", eventFlag: eventFlag, extensionEnv });
break;
}
};

// 设置事件,然后广播通信 flag 给 inject/content
pageAddEventListener(messageFlag, fnEventFlagRequestHandler);
pageDispatchCustomEvent(messageFlag, { action: "broadcastEventFlag", eventFlag: eventFlag });
pageDispatchCustomEvent(messageFlag, { action: "broadcastEventFlag", eventFlag: eventFlag, extensionEnv });
}

// 获取协商后的 eventFlag
export function getEventFlag(messageFlag: string, onReady: (eventFlag: string) => void) {
export function getEventFlag(
messageFlag: string,
onReady: (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => void
) {
let eventFlag = "";
let extensionEnv: TExtensionEnv | undefined = undefined;
const fnEventFlagListener: EventListener = (ev: Event) => {
if (!(ev instanceof CustomEvent)) return;
if (ev.detail?.action != "broadcastEventFlag") return;
eventFlag = ev.detail.eventFlag;
extensionEnv = ev.detail.extensionEnv;
pageRemoveEventListener(messageFlag, fnEventFlagListener);
// 告知对方已收到 eventFlag
pageDispatchCustomEvent(messageFlag, { action: "receivedEventFlag" });
onReady(eventFlag);
onReady(eventFlag, extensionEnv);
};

// 设置事件,然后对 scripting 请求 flag
Expand Down
87 changes: 87 additions & 0 deletions src/app/service/content/exec_warp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { BgExecScriptWarp } from "./exec_warp";
import type { ScriptLoadInfo } from "../service_worker/types";
import type { Message } from "@Packages/message/types";
import type { TExtensionEnv } from "../extension/extension_env";

const buildScriptRes = () =>
({
uuid: "uuid-bg",
name: "bg-script",
metadata: {
grant: ["none"],
version: ["1.0.0"],
},
code: "return GM_info;",
sourceCode: "return GM_info;",
value: {},
}) as unknown as ScriptLoadInfo;

const fakeMessage = undefined as unknown as Message;

const getGMInfo = (exec: BgExecScriptWarp) => (exec as any).named?.GM_info;

describe("BgExecScriptWarp envInfo 注入", () => {
it("extensionEnv 为 undefined 时使用默认值", () => {
const exec = new BgExecScriptWarp(buildScriptRes(), fakeMessage, undefined);
const gmInfo = getGMInfo(exec);
expect(gmInfo.isIncognito).toBe(false);
expect(gmInfo.userAgentData).toEqual({
brands: [],
mobile: false,
platform: "",
});
expect(gmInfo.sandboxMode).toBe("raw");
});

it("inIncognitoContext=true 时覆盖 isIncognito 为 true", () => {
const extensionEnv: TExtensionEnv = { inIncognitoContext: true };
const exec = new BgExecScriptWarp(buildScriptRes(), fakeMessage, extensionEnv);
const gmInfo = getGMInfo(exec);
expect(gmInfo.isIncognito).toBe(true);
// 没传 userAgentData,沿用默认空值
expect(gmInfo.userAgentData).toEqual({
brands: [],
mobile: false,
platform: "",
});
});

it("传入完整 userAgentData 时整体替换默认值", () => {
const extensionEnv: TExtensionEnv = {
inIncognitoContext: false,
userAgentData: {
brands: [{ brand: "Chromium", version: "120" }],
mobile: true,
platform: "Android",
architecture: "arm",
bitness: "64",
} as any,
};
const exec = new BgExecScriptWarp(buildScriptRes(), fakeMessage, extensionEnv);
const gmInfo = getGMInfo(exec);
expect(gmInfo.isIncognito).toBe(false);
expect(gmInfo.userAgentData).toEqual({
brands: [{ brand: "Chromium", version: "120" }],
mobile: true,
platform: "Android",
architecture: "arm",
bitness: "64",
});
});

it("userAgentData 为 null 时保留默认值", () => {
const extensionEnv: TExtensionEnv = {
inIncognitoContext: true,
userAgentData: null,
};
const exec = new BgExecScriptWarp(buildScriptRes(), fakeMessage, extensionEnv);
const gmInfo = getGMInfo(exec);
expect(gmInfo.isIncognito).toBe(true);
expect(gmInfo.userAgentData).toEqual({
brands: [],
mobile: false,
platform: "",
});
});
});
6 changes: 5 additions & 1 deletion src/app/service/content/exec_warp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ExecScript from "./exec_script";
import type { Message } from "@Packages/message/types";
import type { ScriptLoadInfo } from "../service_worker/types";
import type { GMInfoEnv } from "./types";
import { type TExtensionEnv } from "../extension/extension_env";

export class CATRetryError {
msg: string;
Expand All @@ -23,7 +24,7 @@ export class BgExecScriptWarp extends ExecScript {

setInterval: Map<number, boolean>;

constructor(scriptRes: ScriptLoadInfo, message: Message) {
constructor(scriptRes: ScriptLoadInfo, message: Message, extensionEnv: TExtensionEnv | undefined) {
const thisContext: { [key: string]: any } = {};
const setTimeout = new Map<number, any>();
const setInterval = new Map<number, any>();
Expand Down Expand Up @@ -73,6 +74,9 @@ export class BgExecScriptWarp extends ExecScript {
},
isIncognito: false,
};
const { inIncognitoContext, userAgentData } = extensionEnv || {};
if (typeof inIncognitoContext === "boolean") envInfo.isIncognito = inIncognitoContext;
if (userAgentData) envInfo.userAgentData = userAgentData;
super(scriptRes, {
envPrefix: "offscreen",
message: message,
Expand Down
4 changes: 2 additions & 2 deletions src/app/service/content/script_executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ export type ExecScriptEntry = {
scriptFunc: any;
};

export const initEnvInfo = {
export const initEnvInfo: GMInfoEnv = {
/** userAgentData - 从全局变量获取 */
userAgentData: typeof UserAgentData === "object" ? UserAgentData : {},
/** sandboxMode - 预留字段,当前固定为 raw */
sandboxMode: "raw",
/** isIncognito - inject/content 环境下无法判断,固定为 false */
/** isIncognito - inject/content 环境下透過 scripting 环境判断 */
/** 使用者可透过 「 await navigator.storage.persisted() 」来判断,但ScriptCat不会主动执行此代码来判断 */
isIncognito: false,
} satisfies GMInfoEnv;
Expand Down
11 changes: 9 additions & 2 deletions src/app/service/content/script_runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import type { GMInfoEnv, ValueUpdateDataEncoded } from "./types";
import type { ScriptEnvTag } from "@Packages/message/consts";
import { onInjectPageLoaded } from "./external";
import type { CustomEventMessage } from "@Packages/message/custom_event_message";
import { type TExtensionEnv } from "../extension/extension_env";

export class ScriptRuntime {
constructor(
private readonly scripEnvTag: ScriptEnvTag,
private readonly server: Server,
private readonly msg: Message,
private readonly scriptExecutor: ScriptExecutor
private readonly scriptExecutor: ScriptExecutor,
private readonly extensionEnv: TExtensionEnv | undefined
) {}

// content环境的特殊初始化
Expand Down Expand Up @@ -66,8 +68,13 @@ export class ScriptRuntime {
this.startScripts(data.scripts, data.envInfo);
});

// 用于 early-start 的扩充参数
const { inIncognitoContext } = this.extensionEnv || {};
const initialEnvInfo = { ...initEnvInfo };
if (typeof inIncognitoContext === "boolean") initialEnvInfo.isIncognito = inIncognitoContext;

// 检查early-start的脚本
this.scriptExecutor.checkEarlyStartScript(this.scripEnvTag, initEnvInfo);
this.scriptExecutor.checkEarlyStartScript(this.scripEnvTag, initialEnvInfo);
}

startScripts(scripts: TScriptInfo[], envInfo: GMInfoEnv) {
Expand Down
113 changes: 113 additions & 0 deletions src/app/service/extension/extension_env.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { extensionEnv, getExtensionUserAgentData } from "./extension_env";

describe("extensionEnv 常量", () => {
it("从 chrome.extension.inIncognitoContext 读取 incognito 状态", () => {
// mock 默认 inIncognitoContext = false
expect(extensionEnv.inIncognitoContext).toBe(false);
// userAgentData 为可选字段,常量初始化时不应填充
expect(extensionEnv.userAgentData).toBeUndefined();
});
});

describe("getExtensionUserAgentData", () => {
const originalUserAgentData = (navigator as any).userAgentData;
const originalGetPlatformInfo = chrome.runtime.getPlatformInfo;

const setNavigatorUserAgentData = (value: any) => {
Object.defineProperty(navigator, "userAgentData", {
configurable: true,
get: () => value,
});
};

afterEach(() => {
setNavigatorUserAgentData(originalUserAgentData);
if (originalGetPlatformInfo === undefined) {
// @ts-ignore
delete chrome.runtime.getPlatformInfo;
} else {
chrome.runtime.getPlatformInfo = originalGetPlatformInfo;
}
vi.restoreAllMocks();
});

it("navigator.userAgentData 缺失时返回 null", async () => {
setNavigatorUserAgentData(undefined);
const result = await getExtensionUserAgentData();
expect(result).toBeNull();
});

it("没有 chrome.runtime.getPlatformInfo 时只返回基础字段", async () => {
setNavigatorUserAgentData({
brands: [{ brand: "Chromium", version: "120" }],
mobile: false,
platform: "macOS",
});
// @ts-ignore
delete chrome.runtime.getPlatformInfo;

const result = await getExtensionUserAgentData();
expect(result).toEqual({
brands: [{ brand: "Chromium", version: "120" }],
mobile: false,
platform: "macOS",
});
expect((result as any).architecture).toBeUndefined();
expect((result as any).bitness).toBeUndefined();
});

it("getPlatformInfo 返回 x86-64 时 bitness 为 64", async () => {
setNavigatorUserAgentData({
brands: [],
mobile: false,
platform: "Linux",
});
chrome.runtime.getPlatformInfo = vi.fn().mockResolvedValue({
os: "linux",
arch: "x86-64",
nacl_arch: "x86-64",
}) as any;

const result = await getExtensionUserAgentData();
expect(result?.architecture).toBe("x86-64");
expect(result?.bitness).toBe("64");
});

it("getPlatformInfo 返回 x86-32 时 bitness 为 32", async () => {
setNavigatorUserAgentData({
brands: [],
mobile: false,
platform: "Windows",
});
chrome.runtime.getPlatformInfo = vi.fn().mockResolvedValue({
os: "win",
arch: "x86-32",
nacl_arch: "x86-32",
}) as any;

const result = await getExtensionUserAgentData();
expect(result?.architecture).toBe("x86-32");
expect(result?.bitness).toBe("32");
});

it("getPlatformInfo 抛异常时降级返回基础字段", async () => {
setNavigatorUserAgentData({
brands: [{ brand: "Chromium", version: "120" }],
mobile: false,
platform: "Android",
});
chrome.runtime.getPlatformInfo = vi.fn().mockRejectedValue(new Error("API not available")) as any;
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

const result = await getExtensionUserAgentData();
expect(result).toEqual({
brands: [{ brand: "Chromium", version: "120" }],
mobile: false,
platform: "Android",
});
expect((result as any).architecture).toBeUndefined();
expect((result as any).bitness).toBeUndefined();
expect(warnSpy).toHaveBeenCalled();
});
});
35 changes: 35 additions & 0 deletions src/app/service/extension/extension_env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export type TExtensionEnv = {
inIncognitoContext: boolean;
userAgentData?: GMUserAgentData | null;
};

type GMUserAgentData = typeof GM_info.userAgentData;

export const extensionEnv: TExtensionEnv = {
inIncognitoContext: chrome.extension.inIncognitoContext,
} satisfies TExtensionEnv;

export const getExtensionUserAgentData = async (): Promise<GMUserAgentData | null> => {
// @ts-ignore
const userAgentData = navigator.userAgentData;
if (userAgentData) {
const resultData: GMUserAgentData = {
brands: userAgentData.brands,
mobile: userAgentData.mobile,
platform: userAgentData.platform,
} satisfies GMUserAgentData;
// 处理architecture和bitness
if (chrome.runtime.getPlatformInfo) {
try {
const platformInfo = await chrome.runtime.getPlatformInfo();
resultData.architecture = platformInfo.nacl_arch;
resultData.bitness = platformInfo.arch.includes("64") ? "64" : "32";
} catch (e) {
// 避免 API 无法执行的问题。不影响整体运作
console.warn(e);
}
}
return resultData;
}
return null;
};
4 changes: 4 additions & 0 deletions src/app/service/offscreen/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export function preparationSandbox(windowMessage: WindowMessage) {
return sendMessage(windowMessage, "offscreen/preparationSandbox");
}

export function getExtensionEnv(windowMessage: WindowMessage) {
return sendMessage(windowMessage, "offscreen/getExtensionEnv", { requireUAD: true });
}

// 代理发送消息到ServiceWorker
export function sendMessageToServiceWorker(windowMessage: WindowMessage, action: string, data?: any) {
return sendMessage(windowMessage, "offscreen/sendMessageToServiceWorker", { action, data });
Expand Down
Loading
Loading