Skip to content

Commit 55a0376

Browse files
committed
Merge branch 'release/v1.3' into pr-version-empty-001
2 parents 6eedb5f + d1161b7 commit 55a0376

File tree

5 files changed

+189
-111
lines changed

5 files changed

+189
-111
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { ExternalWhitelist } from "@App/app/const";
2+
import { sendMessage } from "@Packages/message/client";
3+
import type { Message } from "@Packages/message/types";
4+
5+
// ================================
6+
// 对外接口:external 注入
7+
// ================================
8+
9+
// 判断当前 hostname 是否命中白名单(含子域名)
10+
const isExternalWhitelisted = (hostname: string) => {
11+
return ExternalWhitelist.some(
12+
(t) => hostname.endsWith(t) && (hostname.length === t.length || hostname.endsWith(`.${t}`))
13+
);
14+
};
15+
16+
// 生成暴露给页面的 Scriptcat 外部接口
17+
const createScriptcatExpose = (msg: Message) => {
18+
const scriptExpose: App.ExternalScriptCat = {
19+
isInstalled(name: string, namespace: string, callback: (res: App.IsInstalledResponse | undefined) => unknown) {
20+
sendMessage<App.IsInstalledResponse>(msg, "scripting/script/isInstalled", { name, namespace }).then(callback);
21+
},
22+
};
23+
return scriptExpose;
24+
};
25+
26+
// 尝试写入 external,失败则忽略
27+
const safeSetExternal = <T extends object>(external: any, key: string, value: T) => {
28+
try {
29+
external[key] = value;
30+
return true;
31+
} catch {
32+
// 无法注入到 external,忽略
33+
return false;
34+
}
35+
};
36+
37+
// 当 TM 与 SC 同时存在时的兼容处理:TM 未安装脚本时回退查询 SC
38+
const patchTampermonkeyIsInstalled = (external: any, scriptExpose: App.ExternalScriptCat) => {
39+
const exposedTM = external.Tampermonkey;
40+
const isInstalledTM = exposedTM?.isInstalled;
41+
const isInstalledSC = scriptExpose.isInstalled;
42+
43+
// 满足这些字段时,认为是较完整的 TM 对象
44+
if (isInstalledTM && exposedTM?.getVersion && exposedTM.openOptions) {
45+
try {
46+
exposedTM.isInstalled = (
47+
name: string,
48+
namespace: string,
49+
callback: (res: App.IsInstalledResponse | undefined) => unknown
50+
) => {
51+
isInstalledTM(name, namespace, (res: App.IsInstalledResponse | undefined) => {
52+
if (res?.installed) callback(res);
53+
else isInstalledSC(name, namespace, callback);
54+
});
55+
};
56+
} catch {
57+
// 忽略错误
58+
}
59+
return true;
60+
}
61+
62+
return false;
63+
};
64+
65+
// inject 环境 pageLoad 后执行:按白名单对页面注入 external 接口
66+
export const onInjectPageLoaded = (msg: Message) => {
67+
const hostname = window.location.hostname;
68+
69+
// 不在白名单则不对外暴露接口
70+
if (!isExternalWhitelisted(hostname)) return;
71+
72+
// 确保 external 存在
73+
const external: External = (window.external || (window.external = {} as External)) as External;
74+
75+
// 创建 Scriptcat 暴露对象
76+
const scriptExpose = createScriptcatExpose(msg);
77+
78+
// 尝试设置 external.Scriptcat
79+
safeSetExternal(external, "Scriptcat", scriptExpose);
80+
81+
// 如果页面已有 Tampermonkey,则做兼容补丁;否则将 Tampermonkey 也指向 Scriptcat 接口
82+
const patched = patchTampermonkeyIsInstalled(external, scriptExpose);
83+
if (!patched) {
84+
safeSetExternal(external, "Tampermonkey", scriptExpose);
85+
}
86+
};
Lines changed: 2 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { type Server } from "@Packages/message/server";
22
import type { Message } from "@Packages/message/types";
3-
import { ExternalWhitelist } from "@App/app/const";
4-
import { sendMessage } from "@Packages/message/client";
53
import { initEnvInfo, type ScriptExecutor } from "./script_executor";
64
import type { TScriptInfo } from "@App/app/repo/scripts";
75
import type { EmitEventRequest } from "../service_worker/types";
86
import type { GMInfoEnv, ValueUpdateDataEncoded } from "./types";
97
import type { ScriptEnvTag } from "@Packages/message/consts";
8+
import { onInjectPageLoaded } from "./external";
109

1110
export class ScriptRuntime {
1211
constructor(
@@ -40,59 +39,6 @@ export class ScriptRuntime {
4039
}
4140

4241
externalMessage() {
43-
// 对外接口白名单
44-
const hostname = window.location.hostname;
45-
if (
46-
ExternalWhitelist.some(
47-
// 如果当前页面的 hostname 是白名单的网域或其子网域
48-
(t) => hostname.endsWith(t) && (hostname.length === t.length || hostname.endsWith(`.${t}`))
49-
)
50-
) {
51-
const msg = this.msg;
52-
// 注入
53-
const external: External = window.external || (window.external = {} as External);
54-
const scriptExpose: App.ExternalScriptCat = {
55-
isInstalled(name: string, namespace: string, callback: (res: App.IsInstalledResponse | undefined) => unknown) {
56-
sendMessage<App.IsInstalledResponse>(msg, "content/script/isInstalled", {
57-
name,
58-
namespace,
59-
}).then(callback);
60-
},
61-
};
62-
try {
63-
external.Scriptcat = scriptExpose;
64-
} catch {
65-
// 无法注入到 external,忽略
66-
}
67-
const exposedTM = external.Tampermonkey;
68-
const isInstalledTM = exposedTM?.isInstalled;
69-
const isInstalledSC = scriptExpose.isInstalled;
70-
if (isInstalledTM && exposedTM?.getVersion && exposedTM.openOptions) {
71-
// 当TM和SC同时启动的特殊处理:如TM没有安装,则查SC的安装状态
72-
try {
73-
exposedTM.isInstalled = (
74-
name: string,
75-
namespace: string,
76-
callback: (res: App.IsInstalledResponse | undefined) => unknown
77-
) => {
78-
isInstalledTM(name, namespace, (res) => {
79-
if (res?.installed) callback(res);
80-
else
81-
isInstalledSC(name, namespace, (res) => {
82-
callback(res);
83-
});
84-
});
85-
};
86-
} catch {
87-
// 忽略错误
88-
}
89-
} else {
90-
try {
91-
external.Tampermonkey = scriptExpose;
92-
} catch {
93-
// 无法注入到 external,忽略
94-
}
95-
}
96-
}
42+
onInjectPageLoaded(this.msg);
9743
}
9844
}

src/pages/components/ScriptMenuList/index.tsx

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -324,11 +324,45 @@ ListMenuItem.displayName = "ListMenuItem";
324324

325325
type TGrouppedMenus = Record<string, GroupScriptMenuItemsProp> & { __length__?: number };
326326

327-
type ScriptMenuEntry = ScriptMenu & {
327+
type ScriptMenuEntryBase = ScriptMenu & {
328328
menuUpdated?: number;
329+
};
330+
331+
// ScriptMenuEntryBase 加了 metadata 后变成 ScriptMenuEntry
332+
type ScriptMenuEntry = ScriptMenuEntryBase & {
329333
metadata: SCMetadata;
330334
};
331335

336+
const cacheMetadata = new Map<string, SCMetadata | undefined>();
337+
// 使用 WeakMap:当 ScriptMenuEntryBase 替换后,ScriptMenuEntryBase的引用会失去,ScriptMenuEntry能被自动回收。
338+
const cacheMergedItem = new WeakMap<ScriptMenuEntryBase, ScriptMenuEntry>();
339+
// scriptList 更新后会合并 从异步取得的 metadata 至 mergedList
340+
const fetchMergedList = async (item: ScriptMenuEntryBase) => {
341+
const uuid = item.uuid;
342+
// 检查 cacheMetadata 有没有记录
343+
let metadata = cacheMetadata.get(uuid);
344+
if (!metadata) {
345+
// 如没有记录,对 scriptDAO 发出请求 (通常在首次React元件绘画时进行)
346+
const script = await scriptDAO.get(uuid);
347+
metadata = script?.metadata || {}; // 即使 scriptDAO 返回失败也 fallback 一个空物件
348+
cacheMetadata.set(uuid, metadata);
349+
}
350+
// 检查 cacheMergedItem 有没有记录
351+
let merged = cacheMergedItem.get(item);
352+
if (!merged || merged.uuid !== item.uuid) {
353+
// 如没有记录或记录不正确,则重新生成记录 (新物件参考)
354+
merged = { ...item, metadata };
355+
cacheMergedItem.set(item, merged);
356+
}
357+
// 如 cacheMergedItem 的记录中的 metadata 跟 (新)metadata 物件参考不一致,则更新 merged
358+
if (merged.metadata !== metadata) {
359+
// 新物件参考触发 React UI 重绘
360+
merged = { ...merged, metadata: metadata };
361+
cacheMergedItem.set(item, merged);
362+
}
363+
return merged;
364+
};
365+
332366
// Popup 页面使用的脚本/选单清单元件:只负责渲染与互动,状态与持久化交由外部 client 处理。
333367
const ScriptMenuList = React.memo(
334368
({
@@ -337,9 +371,7 @@ const ScriptMenuList = React.memo(
337371
currentUrl,
338372
menuExpandNum,
339373
}: {
340-
script: (ScriptMenu & {
341-
menuUpdated?: number;
342-
})[];
374+
script: ScriptMenuEntryBase[];
343375
isBackscript: boolean;
344376
currentUrl: string;
345377
menuExpandNum: number;
@@ -406,34 +438,18 @@ const ScriptMenuList = React.memo(
406438
return url;
407439
}, [currentUrl]);
408440

409-
const cache = useMemo(() => new Map<string, SCMetadata | undefined>(), []);
410-
// 以 异步方式 取得 metadata 放入 extraData
411-
// script 或 extraData 的更新时都会再次执行
412441
useEffect(() => {
413442
let isMounted = true;
414-
// 先从 cache 读取,避免重复请求相同 uuid 的 metadata
415-
Promise.all(
416-
script.map(async (item) => {
417-
let metadata = cache.get(item.uuid);
418-
if (!metadata) {
419-
const script = await scriptDAO.get(item.uuid);
420-
if (script) {
421-
metadata = script.metadata || {};
422-
}
423-
cache.set(item.uuid, metadata);
424-
}
425-
return { ...item, metadata: metadata || {} };
426-
})
427-
).then((newScriptMenuList) => {
443+
Promise.all(script.map(fetchMergedList)).then((newList) => {
428444
if (!isMounted) {
429445
return;
430446
}
431-
updateScriptMenuList(newScriptMenuList);
447+
updateScriptMenuList(newList);
432448
});
433449
return () => {
434450
isMounted = false;
435451
};
436-
}, [cache, script]);
452+
}, [script]);
437453

438454
useEffect(() => {
439455
// 注册菜单快速键(accessKey):以各分组第一个项目的 accessKey 作为触发条件。

src/pages/components/ScriptSetting/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ const ScriptSetting: React.FC<{
9494
},
9595
];
9696
return ret;
97-
}, [script, scriptRunEnv, scriptRunAt, t]);
97+
}, [script.uuid, scriptRunEnv, scriptRunAt, t]);
9898

9999
useEffect(() => {
100100
const scriptDAO = new ScriptDAO();

src/pages/popup/App.tsx

Lines changed: 61 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,32 @@ const scriptListSorter = (a: ScriptMenu, b: ScriptMenu) =>
4141
b.runNum - a.runNum ||
4242
b.updatetime - a.updatetime;
4343

44+
type TUpdateEntryFn = (item: ScriptMenu) => ScriptMenu | undefined;
45+
46+
type TUpdateListOption = { sort?: boolean };
47+
48+
const updateList = (list: ScriptMenu[], update: TUpdateEntryFn, options: TUpdateListOption | undefined) => {
49+
// 如果更新跟当前 list 的子项无关,则不用更改 list 的物件参考
50+
const newList: ScriptMenu[] = [];
51+
let changed = false;
52+
for (let i = 0; i < list.length; i++) {
53+
const oldItem = list[i];
54+
const newItem = update(oldItem); // 如没有更改,物件参考会保持一致
55+
if (newItem !== oldItem) changed = true;
56+
if (newItem) {
57+
newList.push(newItem);
58+
}
59+
}
60+
if (options?.sort) {
61+
newList.sort(scriptListSorter);
62+
}
63+
if (!changed && list.map((e) => e.uuid).join(",") !== newList.map((e) => e.uuid).join(",")) {
64+
// 单一项未有改变,但因为 sort值改变 而改变了次序
65+
changed = true;
66+
}
67+
return changed ? newList : list; // 如子项没任何变化,则返回原list参考
68+
};
69+
4470
function App() {
4571
const [loading, setLoading] = useState(true);
4672
const [scriptList, setScriptList] = useState<(ScriptMenu & { menuUpdated?: number })[]>([]);
@@ -59,23 +85,48 @@ function App() {
5985
const { t } = useTranslation();
6086
const pageTabIdRef = useRef(0);
6187

88+
// ------------------------------ 重要! 不要隨便更改 ------------------------------
89+
// > scriptList 會隨著 (( 任何 )) 子項狀態更新而進行物件參考更新
90+
// > (( 必須 )) 把物件參考更新切換成 原始类型(例如字串)
91+
92+
// normalEnables: 只随 script 数量和启动状态而改变的state
93+
// 故意生成一个字串 memo 避免因 scriptList 的参考频繁改动而导致 normalScriptCounts 的物件参考出现非预期更改。
94+
const normalEnables = useMemo(() => {
95+
// 返回字串让 React 比对 state 有否改动
96+
return scriptList.map((script) => (script.enable ? 1 : 0)).join(",");
97+
}, [scriptList]);
98+
99+
// backEnables: 只随 script 数量和启动状态而改变的state
100+
// 故意生成一个字串 memo 避免因 scriptList 的参考频繁改动而导致 backScriptCounts 的物件参考出现非预期更改。
101+
const backEnables = useMemo(() => {
102+
// 返回字串让 React 比对 state 有否改动
103+
return backScriptList.map((script) => (script.enable ? 1 : 0)).join(",");
104+
}, [backScriptList]);
105+
// ------------------------------ 重要! 不要隨便更改 ------------------------------
106+
107+
// normalScriptCounts 的物件參考只會隨 原始类型(字串)的 normalEnables 狀態更新而重新生成
62108
const normalScriptCounts = useMemo(() => {
109+
// 拆回array
110+
const enables = normalEnables.split(",").filter(Boolean);
63111
// 计算已开启了的数量
64-
const running = scriptList.reduce((p, c) => p + (c.enable ? 1 : 0), 0);
112+
const running = enables.reduce((p, c) => p + (+c ? 1 : 0), 0);
65113
return {
66114
running,
67-
total: scriptList.length, // 总数
115+
total: enables.length, // 总数
68116
};
69-
}, [scriptList]);
117+
}, [normalEnables]);
70118

119+
// backScriptCounts 的物件參考只會隨 原始类型(字串)的 backEnables 狀態更新而重新生成
71120
const backScriptCounts = useMemo(() => {
121+
// 拆回array
122+
const enables = backEnables.split(",").filter(Boolean);
72123
// 计算已开启了的数量
73-
const running = backScriptList.reduce((p, c) => p + (c.enable ? 1 : 0), 0);
124+
const running = enables.reduce((p, c) => p + (+c ? 1 : 0), 0);
74125
return {
75126
running,
76-
total: backScriptList.length, // 总数
127+
total: enables.length, // 总数
77128
};
78-
}, [backScriptList]);
129+
}, [backEnables]);
79130

80131
const urlHost = useMemo(() => {
81132
let url: URL | undefined;
@@ -91,31 +142,10 @@ function App() {
91142
useEffect(() => {
92143
let isMounted = true;
93144

94-
const updateScriptList = (
95-
update: (item: ScriptMenu) => ScriptMenu | undefined,
96-
options?: {
97-
sort?: boolean;
98-
}
99-
) => {
100-
const updateList = (list: ScriptMenu[], update: (item: ScriptMenu) => ScriptMenu | undefined) => {
101-
const newList = [];
102-
for (let i = 0; i < list.length; i++) {
103-
const newItem = update(list[i]);
104-
if (newItem) {
105-
newList.push(newItem);
106-
}
107-
}
108-
if (options?.sort) {
109-
newList.sort(scriptListSorter);
110-
}
111-
return newList;
112-
};
113-
setScriptList((prev) => {
114-
return updateList(prev, update);
115-
});
116-
setBackScriptList((prev) => {
117-
return updateList(prev, update);
118-
});
145+
const updateScriptList = (update: TUpdateEntryFn, options?: TUpdateListOption) => {
146+
// 当 启用/禁用/菜单改变 时,如有必要则更新 list 参考
147+
setScriptList((prev) => updateList(prev, update, options));
148+
setBackScriptList((prev) => updateList(prev, update, options));
119149
};
120150

121151
const unhooks = [

0 commit comments

Comments
 (0)