Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
23 changes: 11 additions & 12 deletions src/app/repo/subscribe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,26 @@ import type { SCMetadata } from "./metadata";

export { SCMetadata };

export type SUBSCRIBE_STATUS = 1 | 2 | 3 | 4;
export const SUBSCRIBE_STATUS_ENABLE: SUBSCRIBE_STATUS = 1;
export const SUBSCRIBE_STATUS_DISABLE: SUBSCRIBE_STATUS = 2;
export const SubscribeStatusType = {
enable: 1,
disable: 2,
} as const;
Comment on lines +6 to +9

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SubscribeStatusType 里引入 unknown3/unknown4 且注释为“3 是什么?/4 是什么?”会把不确定的语义暴露为公共 API,后续维护/排错会非常困难。建议:如果 3/4 只是历史遗留或保留值,用更明确的命名(如 reserved3/reserved4)并说明来源/用途;如果不会出现则不要纳入类型范围,避免代码误用。

Copilot uses AI. Check for mistakes.

export type SubscribeStatusType = ValueOf<typeof SubscribeStatusType>;

export interface SubscribeScript {
uuid: string;
url: string;
url: string; // url of the user.js
}

export interface Subscribe {
url: string;
url: string; // url of the user.sub.js; 作为唯一键。暂时只支持网址。( 如需要支持 手动生成 Subscribe,日后可升级成 url / uuid )
name: string;
code: string;
code: string; // (meta) code of the user.sub.js
author: string;
scripts: { [key: string]: SubscribeScript };
scripts: Record<string, SubscribeScript>; // 这里只储存脚本的 uuid 和 url 等资讯,而不是实际的代码
metadata: SCMetadata;
status: SUBSCRIBE_STATUS;
status: SubscribeStatusType;
createtime: number;
updatetime?: number;
checktime: number;
Expand All @@ -30,10 +33,6 @@ export class SubscribeDAO extends Repo<Subscribe> {
super("subscribe");
}

public findByUrl(url: string) {
return this.get(url);
}

public save(val: Subscribe) {
return super._save(val.url, val);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach } from "vitest";
import { ScriptDAO, SCRIPT_TYPE_NORMAL, SCRIPT_STATUS_ENABLE, SCRIPT_RUN_STATUS_COMPLETE } from "@App/app/repo/scripts";
import type { Script } from "@App/app/repo/scripts";
import { SubscribeDAO, SUBSCRIBE_STATUS_ENABLE } from "@App/app/repo/subscribe";
import { SubscribeDAO, SubscribeStatusType } from "@App/app/repo/subscribe";
import type { Subscribe } from "@App/app/repo/subscribe";
import type { SCMetadata } from "@App/app/repo/metadata";
import GMApi, { MockGMExternalDependencies } from "./gm_api";
Expand Down Expand Up @@ -43,7 +43,7 @@ function makeSubscribe(url: string, connect?: string[]): Subscribe {
author: "test",
scripts: {},
metadata,
status: SUBSCRIBE_STATUS_ENABLE,
status: SubscribeStatusType.enable,
createtime: Date.now(),
checktime: Date.now(),
};
Expand Down
143 changes: 95 additions & 48 deletions src/app/service/service_worker/subscribe.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import LoggerCore from "@App/app/logger/core";
import Logger from "@App/app/logger/logger";
import { ScriptDAO } from "@App/app/repo/scripts";
import { ScriptDAO, type Script } from "@App/app/repo/scripts";
import type { SCMetadata, Subscribe, SubscribeScript } from "@App/app/repo/subscribe";
import { SUBSCRIBE_STATUS_DISABLE, SUBSCRIBE_STATUS_ENABLE, SubscribeDAO } from "@App/app/repo/subscribe";
import { SubscribeDAO, SubscribeStatusType } from "@App/app/repo/subscribe";
import { type IMessageQueue } from "@Packages/message/message_queue";
import { type Group } from "@Packages/message/server";
import { type ScriptService } from "./script";
Expand Down Expand Up @@ -31,13 +31,17 @@ export class SubscribeService {
}

async install(param: { subscribe: Subscribe }) {
// 1)由安装页呼叫,进行 user.sub.js 的安装
// 2)静默更新启动状态下,Subscribe 列表自动更新
const logger = this.logger.with({
subscribeUrl: param.subscribe.url,
name: param.subscribe.name,
});
try {
await this.subscribeDAO.save(param.subscribe);
await this.subscribeDAO.save(param.subscribe); // 所谓的安装,仅储存脚本资源。
logger.info("upsert subscribe success");
// 广播后才会根据 subscribe.scripts 的 url 取得/更新脚本
// 注:installSubscribe 的广播是自己和自己对话。(不等待回应)
this.mq.publish<TInstallSubscribe>("installSubscribe", {
subscribe: param.subscribe,
});
Expand Down Expand Up @@ -83,85 +87,121 @@ export class SubscribeService {
}
}

// 更新订阅的脚本
// 更新订阅的脚本( installSubscribe )
// 已订阅的脚本则根据 Script脚本 本身的更新逻辑更新,与 Subscribe脚本 的更新无关
async upsertScript(url: string) {
const subscribe = await this.subscribeDAO.get(url);
if (!subscribe) return;
if (!subscribe || !subscribe.metadata.usersubscribe) return; // 有效的 Subscribe 必定有 usersubscribe
const logger = this.logger.with({
url: subscribe.url,
name: subscribe.name,
});
// 对比脚本是否有变化
const addScript: string[] = [];
const removeScript: SubscribeScript[] = [];
const scriptUrl = subscribe.metadata.scripturl || [];
const scripts = Object.keys(subscribe.scripts);
for (const url of scriptUrl) {
const addedScripts: string[] = [];
const removedScripts: SubscribeScript[] = [];
const metaScriptUrlSet = new Set(subscribe.metadata.scripturl || []); // 订阅列表
const subscribeScripts = new Set(Object.keys(subscribe.scripts)); // 已关联 uuid 的列表
// 注:首次安装时, subscribeScripts 是空的。
for (const url of metaScriptUrlSet) {
// 不存在于已安装的脚本中, 则添加
if (!scripts.includes(url)) {
addScript.push(url);
if (!subscribeScripts.has(url)) {
addedScripts.push(url);
}
}
for (const url of scripts) {
for (const url of subscribeScripts) {
// 不存在于订阅的脚本中, 则删除
if (!scriptUrl.includes(url)) {
removeScript.push(subscribe.scripts[url]);
if (!metaScriptUrlSet.has(url)) {
removedScripts.push(subscribe.scripts[url]);
}
}

const notification: string[][] = [[], []];
const result: Promise<boolean>[] = [];
// 添加脚本
addScript.forEach((url) => {
result.push(
// 一次性取出所有已安装脚本,建立 URL → Script 的索引,避免每个 addedScript 都全表扫描
const allScripts = await this.scriptDAO.find();
const scriptByUrl = new Map<string, Script>();
for (const script of allScripts) {
if (script.downloadUrl) scriptByUrl.set(script.downloadUrl, script);
if (script.origin) scriptByUrl.set(script.origin, script);
}

const addedScriptNames: string[] = [];
const removedScriptNames: string[] = [];
const promises: Promise<void>[] = [];
// 添加脚本: 根据 订阅列表 的 Script脚本URLs 进行安装
addedScripts.forEach((url) => {
promises.push(
(async () => {
const script = await this.scriptService.installByUrl(url, "subscribe", subscribe.url);
subscribe.scripts[url] = {
url,
uuid: script.uuid,
};
notification[0].push(i18nName(script));
return true;
const existingScript = scriptByUrl.get(url);
if (existingScript) {
// 仅关联至 已安装脚本的 uuid
// 注:1)已安装的脚本可能是用户用直接下载方式安装
// 2)已安装的脚本可能是用户用其他 Subscribe 安装
// 这里的 existingScript 的 subscribeUrl 值不一定是这个 Subscribe 的 url
subscribe.scripts[url] = {
url,
uuid: existingScript.uuid,
};
} else {
// 安装Script脚本 ( script.subscribeUrl 会指定为这个 Subscribe. 当移除 Subscribe 时会一并移除 )
const script = await this.scriptService.installByUrl(url, "subscribe", subscribe.url);
const name = i18nName(script);
// 把Script脚本关联至Subscribe
subscribe.scripts[url] = {
url,
uuid: script.uuid,
};
addedScriptNames.push(name);
}
})().catch((e) => {
logger.error("install script failed", Logger.E(e));
return false;
})
);
});
// 删除脚本
removeScript.forEach((item) => {
// 删除脚本: 根据 subscribeScripts 的 Script脚本UUIDs 进行反安装
removedScripts.forEach((item) => {
// 通过uuid查询脚本id
result.push(
promises.push(
(async () => {
const script = await this.scriptDAO.findByUUID(item.uuid);
// 以 uuid 找出已安装的Script脚本资讯
const script = await this.scriptDAO.get(item.uuid);
const url = item.url;
// 无论是否删除脚本,都需要清理 subscribe.scripts 中的关联
delete subscribe.scripts[url];
if (script) {
notification[1].push(i18nName(script));
// 删除脚本
this.scriptService.deleteScript(script.uuid);
const name = i18nName(script);
// 如果不是以此 Subscribe 安装的话则略过删除(例如其他 Subscribe、直接安装、本地安装等)
if (script.subscribeUrl === subscribe.url) {
await this.scriptService.deleteScript(script.uuid);
removedScriptNames.push(name);
} else {
logger.warn("Subscribe Update: skip deletion", {
scriptUUID: script.uuid,
scriptUrl: url,
scriptName: name,
});
}
Comment on lines +172 to +181

Copilot AI Mar 29, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

在 upsertScript 的移除逻辑里,当脚本不是由当前 Subscribe 安装(script.subscribeUrl !== subscribe.url)时会跳过删除,但同时也没有从 subscribe.scripts 中移除该 url 关联;这会导致 subscribe.scripts 与 metadata.scripturl 长期不一致,并在后续更新中反复进入 removedScripts 分支/重复告警。建议无论是否删除脚本本体,都先删除 subscribe.scripts[url](若脚本确实由该 Subscribe 安装再额外执行 deleteScript)。另外当 scriptDAO.get(item.uuid) 返回空时也应清理该映射。

Copilot uses AI. Check for mistakes.
}
return true;
})().catch((e) => {
logger.error("delete script failed", Logger.E(e));
return false;
})
);
});

await Promise.allSettled(result);
await Promise.allSettled(promises);

// 把 subscribe.scripts 的新资讯储存到 subscribeDAO
await this.subscribeDAO.update(subscribe.url, subscribe);

InfoNotification(
i18n.t("notification.subscribe_update", { subscribeName: subscribe.name }),
i18n.t("notification.subscribe_update_desc", {
newScripts: notification[0].join(","),
deletedScripts: notification[1].join(","),
newScripts: addedScriptNames.join(","),
deletedScripts: removedScriptNames.join(","),
})
);

logger.info("subscribe update", {
install: notification[0],
update: notification[1],
logger.info("subscribe list update", {
installed: addedScriptNames,
deleted: removedScriptNames,
});

return true;
Expand All @@ -183,9 +223,9 @@ export class SubscribeService {
});
try {
if (delayFn) await delayFn();
const code = await fetchScriptBody(url);
const metadata = parseMetadata(code);
if (!metadata) {
const code = await fetchScriptBody(url); // user.sub.js 的 代码
const metadata = parseMetadata(code); // user.sub.js 的 metadata = 代码内容分析; metadata.usersubscribe 是 空阵列
if (!metadata || !metadata.usersubscribe) {
logger.error("parse metadata failed");
return false;
}
Expand All @@ -203,11 +243,17 @@ export class SubscribeService {
}

// 检查更新
/**
* @param url Subscribe脚本 的 url
* @param source 系统自动检查: "system"; subscribeClient.checkUpdate(subscribe.url) 的时候: "user"
* @returns
*/
async checkUpdate(url: string, source: InstallSource) {
const subscribe = await this.subscribeDAO.get(url);
if (!subscribe) {
return false;
}
// 先写入更新触发时间
await this.subscribeDAO.update(url, { checktime: Date.now() });
const logger = this.logger.with({
url: subscribe.url,
Expand Down Expand Up @@ -263,7 +309,8 @@ export class SubscribeService {
});

for (const subscribe of list) {
if (!checkDisable && subscribe.status === SUBSCRIBE_STATUS_ENABLE) {
if (!checkDisable && subscribe.status === SubscribeStatusType.disable) {
// 旧代码一直写反了这个 enable disable
continue;
}
this.checkUpdate(subscribe.url, "system");
Expand All @@ -280,7 +327,7 @@ export class SubscribeService {
});
try {
await this.subscribeDAO.update(param.url, {
status: param.enable ? SUBSCRIBE_STATUS_ENABLE : SUBSCRIBE_STATUS_DISABLE,
status: param.enable ? SubscribeStatusType.enable : SubscribeStatusType.disable,
});
logger.info("enable subscribe success");
return true;
Expand Down
2 changes: 1 addition & 1 deletion src/pages/install/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@

useEffect(() => {
!loaded && initAsync();
}, [searchParams, loaded]);

Check warning on line 329 in src/pages/install/App.tsx

View workflow job for this annotation

GitHub Actions / Run tests

React Hook useEffect has a missing dependency: 'initAsync'. Either include it or remove the dependency array

const [watchFile, setWatchFile] = useState(false);
const metadataLive = useMemo(() => (scriptInfo?.metadata || {}) as SCMetadata, [scriptInfo]);
Expand Down Expand Up @@ -474,7 +474,7 @@

try {
if (scriptInfo?.userSubscribe) {
await subscribeClient.install(upsertScript as Subscribe);
await subscribeClient.install(upsertScript as Subscribe); // 首次安装时,upsertScript 里的 scripts 为空物件
Message.success(t("subscribe_success")!);
setBtnText(t("subscribe_success")!);
} else {
Expand Down Expand Up @@ -640,7 +640,7 @@
return () => {
unmountFileTrack(handle);
};
}, [memoWatchFile]);

Check warning on line 643 in src/pages/install/App.tsx

View workflow job for this annotation

GitHub Actions / Run tests

React Hook useEffect has missing dependencies: 'localFileHandle', 'scriptInfo?.uuid', 'setupWatchFile', and 'watchFile'. Either include them or remove the dependency array

// 检查是否有 uuid 或 file
const searchParamUrl = searchParams.get("url");
Expand Down
10 changes: 5 additions & 5 deletions src/pages/options/routes/SubscribeList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
Typography,
} from "@arco-design/web-react";
import type { Subscribe } from "@App/app/repo/subscribe";
import { SUBSCRIBE_STATUS_DISABLE, SUBSCRIBE_STATUS_ENABLE, SubscribeDAO } from "@App/app/repo/subscribe";
import { SubscribeStatusType, SubscribeDAO } from "@App/app/repo/subscribe";
import type { ColumnProps } from "@arco-design/web-react/es/Table";
import { IconSearch, IconUserAdd } from "@arco-design/web-react/icon";
import { semTime } from "@App/pkg/utils/dayjs";
Expand Down Expand Up @@ -69,18 +69,18 @@ function SubscribeList() {
filters: [
{
text: t("enable"),
value: SUBSCRIBE_STATUS_ENABLE,
value: SubscribeStatusType.enable,
},
{
text: t("disable"),
value: SUBSCRIBE_STATUS_DISABLE,
value: SubscribeStatusType.disable,
},
],
onFilter: (value, row) => row.status === value,
render: (col, item: ListType, index) => {
return (
<Switch
checked={item.status === SUBSCRIBE_STATUS_ENABLE}
checked={item.status === SubscribeStatusType.enable}
loading={item.loading}
disabled={item.loading}
onChange={(checked) => {
Expand All @@ -98,7 +98,7 @@ function SubscribeList() {
setListEntry(index, {
loading: false,
...(statusChange && {
status: checked ? SUBSCRIBE_STATUS_ENABLE : SUBSCRIBE_STATUS_DISABLE,
status: checked ? SubscribeStatusType.enable : SubscribeStatusType.disable,
}),
});
});
Expand Down
2 changes: 1 addition & 1 deletion src/pkg/backup/struct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ export type SubscribeMeta = {

export type SubscribeOptionsFile = {
settings: { enabled: boolean };
scripts: { [key: string]: SubscribeScript };
scripts: Record<string, SubscribeScript>;
meta: SubscribeMeta;
};

Expand Down
Loading
Loading