diff --git a/src/app/service/content/gm_api/gm_info.ts b/src/app/service/content/gm_api/gm_info.ts index 3f7848efd..44ca42d25 100644 --- a/src/app/service/content/gm_api/gm_info.ts +++ b/src/app/service/content/gm_api/gm_info.ts @@ -34,7 +34,7 @@ export function evaluateGMInfo(envInfo: GMInfoEnv, script: TScriptInfo) { // TODO: 更多完整的信息(为了兼容Tampermonkey,后续待定) name: script.name, namespace: script.namespace, - version: script.metadata.version?.[0], + version: script.metadata.version?.[0] || "0.0", author: script.author, lastModified: script.updatetime, downloadURL: script.downloadUrl || null, diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 1a0c2ee1d..227be28d1 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -384,7 +384,7 @@ export class ScriptService { const logger = this.logger.with({ name: script.name, uuid: script.uuid, - version: script.metadata.version![0], + version: script.metadata.version?.[0] || "0.0", upsertBy, }); let update = false; @@ -803,15 +803,8 @@ export class ScriptService { logger.error("parse metadata failed"); return false; } - const newVersion = metadata.version && metadata.version[0]; - if (!newVersion) { - logger.error("parse version failed", { version: metadata.version }); - return false; - } - let oldVersion = script.metadata.version && script.metadata.version[0]; - if (!oldVersion) { - oldVersion = "0.0.0"; - } + const newVersion = metadata.version?.[0] || "0.0"; + const oldVersion = script.metadata.version?.[0] || "0.0"; // 对比版本大小 if (ltever(newVersion, oldVersion)) { return false; @@ -902,7 +895,8 @@ export class ScriptService { } shouldIgnoreUpdate(script: Script, newMeta: Partial> | null) { - return script.ignoreVersion === newMeta?.version?.[0]; + const newVersion = newMeta?.version?.[0]; + return typeof newVersion === "string" && script.ignoreVersion === newVersion; } // 用于定时自动检查脚本更新 @@ -1139,7 +1133,6 @@ export class ScriptService { } requestCheckUpdate(uuid: string) { - // src/pages/options/routes/ScriptList.tsx return this.checkUpdateAvailable(uuid).then((script) => { if (script) { // 如有更新则打开更新画面进行更新 @@ -1148,56 +1141,15 @@ export class ScriptService { } return false; }); - - // 没有空值 case - /* - if (uuid) { - return this.checkUpdateAvailable(uuid).then((script) => { - if (script) { - // 如有更新则打开更新画面进行更新 - this.openUpdatePage(script, "user"); - return true; - } - return false; - }); - } else { - // 批量检查更新 - InfoNotification("检查更新", "正在检查所有的脚本更新"); - this.scriptDAO - .all() - .then(async (scripts) => { - // 检查是否有更新 - const results = await this.checkUpdatesAvailable( - scripts.map((script) => script.uuid), - { - MIN_DELAY: 300, - MAX_DELAY: 800, - } - ); - return Promise.all( - scripts.map((script, i) => { - const result = results[i]; - if (result) { - // 如有更新则打开更新画面进行更新 - this.openUpdatePage(script, "user"); - } - }) - ); - }) - .then(() => { - InfoNotification("检查更新", "所有脚本检查完成"); - }); - return Promise.resolve(true); // 无视检查结果,立即回传true - } - */ } isInstalled({ name, namespace }: { name: string; namespace: string }): Promise { + // 用於 window.external return this.scriptDAO.findByNameAndNamespace(name, namespace).then((script) => { if (script) { return { installed: true, - version: script.metadata.version && script.metadata.version[0], + version: script.metadata.version?.[0] || "0.0", } as App.IsInstalledResponse; } return { installed: false } as App.IsInstalledResponse; diff --git a/src/app/service/service_worker/script_update_check.ts b/src/app/service/service_worker/script_update_check.ts index ae68fb0c4..f3aa02f33 100644 --- a/src/app/service/service_worker/script_update_check.ts +++ b/src/app/service/service_worker/script_update_check.ts @@ -32,7 +32,8 @@ class ScriptUpdateCheck { if (!list) return [] as string[]; const s = new Set(); for (const entry of list) { - if (entry.script?.ignoreVersion === entry.newMeta?.version?.[0]) continue; + const newVersion = entry.newMeta?.version?.[0]; + if (typeof newVersion === "string" && entry.script?.ignoreVersion === newVersion) continue; if (entry.script?.status !== 1) continue; if (!entry.script?.checkUpdate) continue; if (!entry.sites) continue; diff --git a/src/app/service/service_worker/subscribe.ts b/src/app/service/service_worker/subscribe.ts index 479e9b0c1..7062c5565 100644 --- a/src/app/service/service_worker/subscribe.ts +++ b/src/app/service/service_worker/subscribe.ts @@ -190,15 +190,8 @@ export class SubscribeService { logger.error("parse metadata failed"); return false; } - const newVersion = metadata.version && metadata.version[0]; - if (!newVersion) { - logger.error("parse version failed", { version: metadata.version }); - return false; - } - let oldVersion = subscribe.metadata.version && subscribe.metadata.version[0]; - if (!oldVersion) { - oldVersion = "0.0.0"; - } + const newVersion = metadata.version?.[0] || "0.0"; + const oldVersion = subscribe.metadata.version?.[0] || "0.0"; // 对比版本大小 if (ltever(newVersion, oldVersion)) { return false; diff --git a/src/app/types.d.ts b/src/app/types.d.ts index 52e9a3c8f..4588e6046 100644 --- a/src/app/types.d.ts +++ b/src/app/types.d.ts @@ -34,7 +34,7 @@ declare namespace App { export type IsInstalledResponse = | { installed: true; - version: string | undefined; + version: string; } | { installed: false; diff --git a/src/pages/batchupdate/App.tsx b/src/pages/batchupdate/App.tsx index 3386cf849..3d02fc5da 100644 --- a/src/pages/batchupdate/App.tsx +++ b/src/pages/batchupdate/App.tsx @@ -90,7 +90,8 @@ function App() { site.push(entry); continue; } - const isIgnored = entry.script.ignoreVersion === entry.newMeta?.version?.[0]; + const newVersion = entry.newMeta?.version?.[0]; + const isIgnored = typeof newVersion === "string" && entry.script.ignoreVersion === newVersion; const mEntry = { ...entry, }; @@ -267,12 +268,13 @@ function App() { onUpdateClick(item.uuid)}> {t("updatepage.update")} - {item.script.ignoreVersion !== item.newMeta?.version?.[0] ? ( + {typeof item.newMeta?.version?.[0] === "string" && + item.script.ignoreVersion !== item.newMeta.version[0] ? ( <> onIgnoreClick(item.uuid, item.newMeta?.version?.[0])} + onClick={() => onIgnoreClick(item.uuid, item.newMeta.version[0])} > {t("updatepage.ignore")} diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index b01cd9ed0..d27481a9c 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -200,7 +200,9 @@ function App() { await scriptClient.install({ script: newScript, code }); const metadata = newScript.metadata; setScriptInfo((prev) => (prev ? { ...prev, code, metadata } : prev)); - setOldScriptVersion(metadata!.version![0]); + const scriptVersion = metadata.version?.[0]; + const oldScriptVersion = typeof scriptVersion === "string" ? scriptVersion : "N/A"; + setOldScriptVersion(oldScriptVersion); setUpsertScript(newScript); setDiffCode(code); }; @@ -289,7 +291,8 @@ function App() { prepare = await prepareSubscribeByCode(code, url); action = prepare.subscribe; if (prepare.oldSubscribe) { - oldVersion = prepare.oldSubscribe!.metadata!.version![0] || ""; + const oldSubscribeVersion = prepare.oldSubscribe.metadata.version?.[0]; + oldVersion = typeof oldSubscribeVersion === "string" ? oldSubscribeVersion : "N/A"; } diffCode = prepare.oldSubscribe?.code; } else { @@ -297,7 +300,8 @@ function App() { prepare = await prepareScriptByCode(code, url, knownUUID, false, undefined, paramOptions); action = prepare.script; if (prepare.oldScript) { - oldVersion = prepare.oldScript!.metadata!.version![0] || ""; + const oldScriptVersion = prepare.oldScript.metadata.version?.[0]; + oldVersion = typeof oldScriptVersion === "string" ? oldScriptVersion : "N/A"; } diffCode = prepare.oldScriptCode; } @@ -793,7 +797,7 @@ function App() { {oldScriptVersion} )} - {metadataLive.version && metadataLive.version[0] !== oldScriptVersion && ( + {typeof metadataLive.version?.[0] === "string" && metadataLive.version[0] !== oldScriptVersion && ( {metadataLive.version[0]} diff --git a/src/pages/options/routes/ScriptList/ScriptCard.tsx b/src/pages/options/routes/ScriptList/ScriptCard.tsx index eec90d932..41f151e52 100644 --- a/src/pages/options/routes/ScriptList/ScriptCard.tsx +++ b/src/pages/options/routes/ScriptList/ScriptCard.tsx @@ -231,7 +231,7 @@ export const ScriptCardItem = React.memo( {/* 版本和更新时间 */}
- {item.metadata.version && ( + {item.metadata.version?.[0] && (
{t("version")} diff --git a/src/pages/options/routes/ScriptList/ScriptTable.tsx b/src/pages/options/routes/ScriptList/ScriptTable.tsx index d6313f022..d9d64a5b3 100644 --- a/src/pages/options/routes/ScriptList/ScriptTable.tsx +++ b/src/pages/options/routes/ScriptList/ScriptTable.tsx @@ -396,7 +396,7 @@ const NameCell = React.memo(({ col, item }: { col: string; item: ListType }) => NameCell.displayName = "NameCell"; const VersionCell = React.memo(({ item }: { item: ListType }) => { - return item.metadata.version && item.metadata.version[0]; + return item.metadata.version?.[0] || "0.0"; }); VersionCell.displayName = "VersionCell"; diff --git a/src/pages/options/routes/SubscribeList.tsx b/src/pages/options/routes/SubscribeList.tsx index 30d6ee0e5..d3674a3e6 100644 --- a/src/pages/options/routes/SubscribeList.tsx +++ b/src/pages/options/routes/SubscribeList.tsx @@ -158,7 +158,7 @@ function SubscribeList() { align: "center", key: "version", render(col, item: Subscribe) { - return item.metadata.version && item.metadata.version[0]; + return item.metadata.version?.[0] || "0.0"; }, }, { diff --git a/src/pkg/utils/script.test.ts b/src/pkg/utils/script.test.ts index 4640998b7..349feca84 100644 --- a/src/pkg/utils/script.test.ts +++ b/src/pkg/utils/script.test.ts @@ -1,7 +1,14 @@ import { describe, it, expect } from "vitest"; -import { parseMetadata } from "./script"; +import { parseMetadata, parseScriptFromCode } from "./script"; import { getMetadataStr, getUserConfigStr } from "./utils"; import { parseUserConfig } from "./yaml"; +import { + SCRIPT_TYPE_NORMAL, + SCRIPT_TYPE_CRONTAB, + SCRIPT_TYPE_BACKGROUND, + SCRIPT_STATUS_DISABLE, + SCRIPT_RUN_STATUS_COMPLETE, +} from "@App/app/repo/scripts"; describe.concurrent("parseMetadata", () => { it.concurrent("解析标准UserScript元数据", () => { @@ -30,6 +37,36 @@ console.log('Hello World'); expect(result?.grant).toEqual(["none"]); }); + it.concurrent("解析最少UserScript元数据", () => { + const code = ` +// ==UserScript== +// @name GM_addElement test +// @match *://*/* +// @grant GM_addElement +// ==/UserScript== + +console.log('image insertion begin'); + +await new Promise((resolve, reject) => { + GM_addElement(document.body, 'img', { + src: 'https://www.tampermonkey.net/favicon.ico', + onload: resolve, + onerror: reject + }); + + console.log('image insertion end'); +}); + +console.log('image loaded'); // never fired +`; + + const result = parseMetadata(code); + expect(result).not.toBeNull(); + expect(result?.name).toEqual(["GM_addElement test"]); + expect(result?.match).toEqual(["*://*/*"]); + expect(result?.grant).toEqual(["GM_addElement"]); + }); + it.concurrent("解析@match *", () => { const code = ` // ==UserScript== @@ -91,7 +128,7 @@ console.log('Hello World'); expect(result?.grant).toEqual(["GM_setValue", "GM_getValue"]); }); - it.concurrent("解析包含空值的元数据", () => { + it.concurrent("解析包含空值的元数据 (1)", () => { const code = ` // ==UserScript== // @name 测试脚本 @@ -113,6 +150,29 @@ console.log('Hello World'); expect(result?.author).toEqual([""]); }); + it.concurrent("解析包含空值的元数据(2)", () => { + const code = ` +// ==UserScript== +// @name 测试脚本 +// @namespace http://tampermonkey.net/ +// @version +// @description +// @author +// @match https://example.com/* +// ==/UserScript== + +console.log('Hello World'); +`; + + const result = parseMetadata(code); + expect(result).not.toBeNull(); + expect(result?.name).toEqual(["测试脚本"]); + expect(result?.namespace).toEqual(["http://tampermonkey.net/"]); + expect(result?.description).toEqual([""]); + expect(result?.author).toEqual([""]); + expect(result?.version).toEqual([""]); + }); + it.concurrent("解析元数据(分行1)", () => { const code = ` // ==UserScript== @@ -280,6 +340,41 @@ console.log('Hello World'); expect(result?.author).toEqual([""]); }); + it.concurrent("正確解析元数据(空version)", () => { + const code = ` +// ==UserScript== +// @name 测试脚本 +// @namespace http://tampermonkey.net/ +// @match https://example.org/* +// @match https://test.com/* +// @match https://demo.com/* +// @description +// @early-start +// @author +// @match https://example.com/* +// @grant + GM_setValue +// @grant GM_getValue +// ==/UserScript== +console.log('Hello World'); +`; + + const result = parseMetadata(code); + expect(result).not.toBeNull(); + expect(result?.name).toEqual(["测试脚本"]); + expect(result?.namespace).toEqual(["http://tampermonkey.net/"]); + expect(result?.match).toEqual([ + "https://example.org/*", + "https://test.com/*", + "https://demo.com/*", + "https://example.com/*", + ]); + expect(result?.["early-start"]).toEqual([""]); + expect(result?.grant).toEqual(["", "GM_getValue"]); + expect(result?.description).toEqual([""]); + expect(result?.author).toEqual([""]); + }); + it.concurrent("正確解析元数据(換行空白1)", () => { const code = ` // ==UserScript== @@ -883,3 +978,164 @@ console.log('Hello World'); expect(() => parseUserConfig(code)).toThrow('UserConfig group "name" is not a valid object.'); }); }); + +describe.concurrent("parseScriptFromCode", () => { + const normalCode = ` +// ==UserScript== +// @name 测试脚本 +// @namespace http://tampermonkey.net/ +// @version 1.0.0 +// @description 这是一个测试脚本 +// @author 测试作者 +// @match https://example.com/* +// @grant none +// ==/UserScript== + +console.log('Hello World'); +`; + + it.concurrent("解析普通脚本基本信息", () => { + const origin = "https://example.com/test.user.js"; + const script = parseScriptFromCode(normalCode, origin); + + expect(script.name).toBe("测试脚本"); + expect(script.namespace).toBe("http://tampermonkey.net/"); + expect(script.author).toBe("测试作者"); + expect(script.type).toBe(SCRIPT_TYPE_NORMAL); + expect(script.status).toBe(SCRIPT_STATUS_DISABLE); + expect(script.runStatus).toBe(SCRIPT_RUN_STATUS_COMPLETE); + expect(script.origin).toBe(origin); + expect(script.originDomain).toBe("example.com"); + expect(script.metadata.version).toEqual(["1.0.0"]); + expect(script.selfMetadata).toEqual({}); + expect(script.sort).toBe(-1); + expect(script.checkUpdate).toBe(true); + }); + + it.concurrent("使用指定的uuid", () => { + const script = parseScriptFromCode(normalCode, "https://example.com/test.user.js", "custom-uuid-123"); + expect(script.uuid).toBe("custom-uuid-123"); + }); + + it.concurrent("未指定uuid时自动生成", () => { + const script = parseScriptFromCode(normalCode, "https://example.com/test.user.js"); + expect(script.uuid).toBeTruthy(); + expect(script.uuid.length).toBeGreaterThan(0); + }); + + it.concurrent("从origin解析checkUpdateUrl和downloadUrl", () => { + const origin = "https://example.com/test.user.js"; + const script = parseScriptFromCode(normalCode, origin); + + expect(script.checkUpdateUrl).toBe("https://example.com/test.meta.js"); + expect(script.downloadUrl).toBe(origin); + }); + + it.concurrent("使用metadata中的updateurl和downloadurl", () => { + const code = ` +// ==UserScript== +// @name 测试脚本 +// @namespace http://tampermonkey.net/ +// @version 1.0.0 +// @author test +// @match *://*/* +// @updateURL https://cdn.example.com/test.meta.js +// @downloadURL https://cdn.example.com/test.user.js +// @grant none +// ==/UserScript== +`; + const script = parseScriptFromCode(code, "https://example.com/test.user.js"); + expect(script.checkUpdateUrl).toBe("https://cdn.example.com/test.meta.js"); + expect(script.downloadUrl).toBe("https://cdn.example.com/test.user.js"); + }); + + it.concurrent("解析crontab类型脚本", () => { + const code = ` +// ==UserScript== +// @name 定时脚本 +// @namespace http://tampermonkey.net/ +// @version 1.0.0 +// @author test +// @crontab * * * * * +// @grant none +// ==/UserScript== +`; + const script = parseScriptFromCode(code, "https://example.com/test.user.js"); + expect(script.type).toBe(SCRIPT_TYPE_CRONTAB); + }); + + it.concurrent("解析background类型脚本", () => { + const code = ` +// ==UserScript== +// @name 后台脚本 +// @namespace http://tampermonkey.net/ +// @version 1.0.0 +// @author test +// @background +// @grant none +// ==/UserScript== +`; + const script = parseScriptFromCode(code, "https://example.com/test.user.js"); + expect(script.type).toBe(SCRIPT_TYPE_BACKGROUND); + }); + + it.concurrent("非http origin不设置domain", () => { + const script = parseScriptFromCode(normalCode, "file:///tmp/test.user.js"); + expect(script.originDomain).toBe(""); + }); + + it.concurrent("可接受空白namespace", () => { + const code = ` +// ==UserScript== +// @name 测试脚本 +// @version 1.0.0 +// @match *://*/* +// @grant none +// ==/UserScript== +`; + const script = parseScriptFromCode(code, "https://example.com/test.user.js"); + expect(script.namespace).toBe(""); + }); + + it.concurrent("可接受空白version", () => { + const code = ` +// ==UserScript== +// @name 测试脚本 +// @namespace http://tampermonkey.net/ +// @match *://*/* +// @grant none +// ==/UserScript== +`; + const script = parseScriptFromCode(code, "https://example.com/test.user.js"); + expect(script.metadata.version).toEqual(undefined); + }); + + it.concurrent("无效metadata应抛出错误", () => { + expect(() => parseScriptFromCode("invalid code", "https://example.com/test.user.js")).toThrow(); + }); + + it.concurrent("空白name应抛出错误", () => { + const code = ` +// ==UserScript== +// @namespace http://tampermonkey.net/ +// @version 1.0.0 +// @match *://*/* +// @grant none +// ==/UserScript== +`; + expect(() => parseScriptFromCode(code, "https://example.com/test.user.js")).toThrow(); + }); + + it.concurrent("无效crontab表达式应抛出错误", () => { + const code = ` +// ==UserScript== +// @name 定时脚本 +// @namespace http://tampermonkey.net/ +// @version 1.0.0 +// @crontab invalid_cron +// @grant none +// ==/UserScript== +`; + expect(() => parseScriptFromCode(code, "https://example.com/test.user.js")).toThrow(); + }); +}); diff --git a/src/pkg/utils/script.ts b/src/pkg/utils/script.ts index ea9619593..76a5a5600 100644 --- a/src/pkg/utils/script.ts +++ b/src/pkg/utils/script.ts @@ -62,19 +62,8 @@ export async function fetchScriptBody(url: string): Promise { return body; } -// 通过代码解析出脚本信息 (Script) -export async function prepareScriptByCode( - code: string, - origin: string, - uuid?: string, - override: boolean = false, - dao?: ScriptDAO, - options?: { - byEditor?: boolean; // 是否通过编辑器导入 - byWebRequest?: boolean; // 是否通过網頁連結安裝或更新 - } -): Promise<{ script: Script; oldScript?: Script; oldScriptCode?: string }> { - dao = dao ?? new ScriptDAO(); +// 通过代码解析出脚本基本信息 (不含数据库查询) +export function parseScriptFromCode(code: string, origin: string, uuid?: string): Script { const metadata = parseMetadata(code); if (!metadata) { throw new Error(i18n_t("error_metadata_invalid")); @@ -83,14 +72,11 @@ export async function prepareScriptByCode( if (!metadata.name?.[0]) { throw new Error(i18n_t("error_script_name_required")); } - // 不接受空白version - if (!metadata.version?.[0]) { - throw new Error(i18n_t("error_script_version_required")); - } // 可接受空白namespace if (metadata.namespace === undefined) { throw new Error(i18n_t("error_script_namespace_required")); } + // 可接受空白version let type = SCRIPT_TYPE_NORMAL; if (metadata.crontab !== undefined) { type = SCRIPT_TYPE_CRONTAB; @@ -118,7 +104,7 @@ export async function prepareScriptByCode( const newUUID = uuid || uuidv4(); const config: UserConfig | undefined = parseUserConfig(code); const now = Date.now(); - const script: Script = { + return { uuid: newUUID, name: metadata.name[0], author: metadata.author && metadata.author[0], @@ -139,6 +125,22 @@ export async function prepareScriptByCode( updatetime: now, checktime: now, }; +} + +// 通过代码解析出脚本信息 (Script) +export async function prepareScriptByCode( + code: string, + origin: string, + uuid?: string, + override: boolean = false, + dao?: ScriptDAO, + options?: { + byEditor?: boolean; // 是否通过编辑器导入 + byWebRequest?: boolean; // 是否通过網頁連結安裝或更新 + } +): Promise<{ script: Script; oldScript?: Script; oldScriptCode?: string }> { + dao = dao ?? new ScriptDAO(); + const script = parseScriptFromCode(code, origin, uuid); let old: Script | undefined; let oldCode: ScriptCode | undefined; if (uuid) { @@ -209,7 +211,7 @@ export async function prepareScriptByCode( if (script.type === SCRIPT_TYPE_NORMAL) { script.status = SCRIPT_STATUS_ENABLE; } - script.checktime = now; + script.checktime = Date.now(); } return { script, oldScript: old, oldScriptCode: oldCode?.code }; }