diff --git a/packages/eslint/compat-headers.d.ts b/packages/eslint/compat-headers.d.ts new file mode 100644 index 000000000..1363abd86 --- /dev/null +++ b/packages/eslint/compat-headers.d.ts @@ -0,0 +1,7 @@ +// compat-headers.js 是给 rspack alias 用的 CommonJS 覆盖文件(见 rspack.config.ts), +// 这里只声明测试与类型检查需要的形状。 +export declare const compatMap: { + localized: Record; + unlocalized: Record; + nonFunctional: Record; +}; diff --git a/packages/eslint/compat-headers.js b/packages/eslint/compat-headers.js index 2a674d4e7..53c0d0deb 100644 --- a/packages/eslint/compat-headers.js +++ b/packages/eslint/compat-headers.js @@ -17,6 +17,7 @@ const compatMap = { storageName: [], "early-start": [], "require-css": [], + definition: [], allFrames: [], }, }; diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index 25bfaa109..f127a99b8 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -7,6 +7,12 @@ export function GMContextApiGet(name: string): ApiValue[] | undefined { return apis.get(name); } +// 已注册的全部 @grant 名。注册表由装饰器在模块载入时填充,无法静态推导, +// 供 script_compat.ts 的静态支持表做一致性守卫(新增 GM API 若漏进表会被测出来)。 +export function GMContextApiNames(): string[] { + return [...apis.keys()]; +} + function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam): void { // 一个 @grant 可以扩充多个 API 函数 let m: ApiValue[] | undefined = apis.get(grant); diff --git a/src/locales/de-DE/install.json b/src/locales/de-DE/install.json index 62d65807d..81504780d 100644 --- a/src/locales/de-DE/install.json +++ b/src/locales/de-DE/install.json @@ -255,5 +255,15 @@ "expired_title": "Update-Inhalt ist abgelaufen", "expired_desc": "Der für dieses Update vorbereitete Code wurde bereinigt. Bitte erneut nach Updates suchen.", "expired_recheck": "Erneut nach Updates suchen", - "code_loading": "Code wird geladen" + "code_loading": "Code wird geladen", + "compat_ineffective": "ohne Wirkung", + "compat_tag_desc": "ScriptCat unterstützt diese Deklaration nicht; sie wird nach der Installation ignoriert.", + "compat_value_desc": "ScriptCat unterstützt diesen Wert nicht; die Deklaration wirkt nicht wie angegeben.", + "compat_grant_desc": "ScriptCat implementiert diese API nicht; Aufrufe schlagen fehl und darauf aufbauende Funktionen arbeiten nicht.", + "compat_jump": "Zu Zeile {{line}} springen", + "compat_docs": "Metadaten-Dokumentation", + "compat_count": "{{count}} ohne Wirkung", + "compat_scriptcat_only": "nur ScriptCat", + "perm_other_label": "Weitere Deklarationen", + "perm_other_summary": "Verhält sich anders als in anderen Skriptmanagern" } diff --git a/src/locales/en-US/install.json b/src/locales/en-US/install.json index cdd6f1a0f..5a3fda45d 100644 --- a/src/locales/en-US/install.json +++ b/src/locales/en-US/install.json @@ -255,5 +255,15 @@ "expired_title": "Update content has expired", "expired_desc": "The code prepared for this update has been cleaned up. Please check for updates again.", "expired_recheck": "Check for updates again", - "code_loading": "Loading code" + "code_loading": "Loading code", + "compat_ineffective": "no effect", + "compat_tag_desc": "ScriptCat does not support this declaration; it is ignored after installation.", + "compat_value_desc": "ScriptCat does not support this value; the declaration won't take effect as written.", + "compat_grant_desc": "ScriptCat has not implemented this API; calls to it fail and features relying on it won't work.", + "compat_jump": "Go to line {{line}}", + "compat_docs": "Metadata docs", + "compat_count": "{{count}} with no effect", + "compat_scriptcat_only": "ScriptCat only", + "perm_other_label": "Other declarations", + "perm_other_summary": "Behaves differently from other script managers" } diff --git a/src/locales/ja-JP/install.json b/src/locales/ja-JP/install.json index 1f1fe6a6c..50ba190f0 100644 --- a/src/locales/ja-JP/install.json +++ b/src/locales/ja-JP/install.json @@ -255,5 +255,15 @@ "expired_title": "更新内容の有効期限が切れました", "expired_desc": "この更新のために用意されたコードは削除されました。もう一度更新を確認してください。", "expired_recheck": "更新を再確認", - "code_loading": "コードを読み込み中" + "code_loading": "コードを読み込み中", + "compat_ineffective": "無効", + "compat_tag_desc": "ScriptCat はこの宣言に対応していないため、インストール後は無視されます。", + "compat_value_desc": "ScriptCat はこの値に対応していないため、宣言は記述どおりには機能しません。", + "compat_grant_desc": "ScriptCat はこの API を実装していないため、スクリプトから呼び出すとエラーになり、これに依存する機能は動作しません。", + "compat_jump": "{{line}} 行目へ移動", + "compat_docs": "メタデータのドキュメント", + "compat_count": "無効な項目 {{count}} 件", + "compat_scriptcat_only": "ScriptCat 専用", + "perm_other_label": "その他の宣言", + "perm_other_summary": "他のスクリプトマネージャーとは互換性が異なります" } diff --git a/src/locales/ko-KR/install.json b/src/locales/ko-KR/install.json index ab16e9395..cd6dc2c29 100644 --- a/src/locales/ko-KR/install.json +++ b/src/locales/ko-KR/install.json @@ -255,5 +255,15 @@ "expired_title": "업데이트 내용이 만료되었습니다", "expired_desc": "이번 업데이트를 위해 준비한 코드가 정리되었습니다. 업데이트를 다시 확인해 주세요.", "expired_recheck": "업데이트 다시 확인", - "code_loading": "코드를 불러오는 중" + "code_loading": "코드를 불러오는 중", + "compat_ineffective": "적용되지 않음", + "compat_tag_desc": "ScriptCat은 이 선언을 지원하지 않으므로 설치 후 무시됩니다.", + "compat_value_desc": "ScriptCat은 이 값을 지원하지 않으므로 선언이 작성한 대로 적용되지 않습니다.", + "compat_grant_desc": "ScriptCat은 이 API를 구현하지 않았습니다. 스크립트에서 호출하면 오류가 발생하고 이에 의존하는 기능은 동작하지 않습니다.", + "compat_jump": "{{line}}번째 줄로 이동", + "compat_docs": "메타데이터 문서", + "compat_count": "적용되지 않는 항목 {{count}}개", + "compat_scriptcat_only": "ScriptCat 전용", + "perm_other_label": "기타 선언", + "perm_other_summary": "다른 스크립트 관리자와 호환성이 다릅니다" } diff --git a/src/locales/pt-BR/install.json b/src/locales/pt-BR/install.json index f43b03ced..3c12fe1fc 100644 --- a/src/locales/pt-BR/install.json +++ b/src/locales/pt-BR/install.json @@ -255,5 +255,15 @@ "expired_title": "O conteúdo da atualização expirou", "expired_desc": "O código preparado para esta atualização foi removido. Verifique as atualizações novamente.", "expired_recheck": "Verificar atualizações novamente", - "code_loading": "Carregando código" + "code_loading": "Carregando código", + "compat_ineffective": "sem efeito", + "compat_tag_desc": "O ScriptCat não oferece suporte a esta declaração; ela é ignorada após a instalação.", + "compat_value_desc": "O ScriptCat não oferece suporte a este valor; a declaração não terá o efeito escrito.", + "compat_grant_desc": "O ScriptCat não implementou esta API; as chamadas falham e os recursos que dependem dela não funcionam.", + "compat_jump": "Ir para a linha {{line}}", + "compat_docs": "Documentação de metadados", + "compat_count": "{{count}} sem efeito", + "compat_scriptcat_only": "Somente ScriptCat", + "perm_other_label": "Outras declarações", + "perm_other_summary": "Comportamento diferente de outros gerenciadores de scripts" } diff --git a/src/locales/ru-RU/install.json b/src/locales/ru-RU/install.json index 478cba003..034f63a5c 100644 --- a/src/locales/ru-RU/install.json +++ b/src/locales/ru-RU/install.json @@ -255,5 +255,15 @@ "expired_title": "Содержимое обновления устарело", "expired_desc": "Код, подготовленный для этого обновления, был очищен. Проверьте обновления ещё раз.", "expired_recheck": "Проверить обновления ещё раз", - "code_loading": "Загрузка кода" + "code_loading": "Загрузка кода", + "compat_ineffective": "не действует", + "compat_tag_desc": "ScriptCat не поддерживает это объявление — после установки оно игнорируется.", + "compat_value_desc": "ScriptCat не поддерживает это значение — объявление не сработает так, как написано.", + "compat_grant_desc": "ScriptCat не реализует этот API: вызов из скрипта завершится ошибкой, а зависящие от него функции работать не будут.", + "compat_jump": "Перейти к строке {{line}}", + "compat_docs": "Документация по метаданным", + "compat_count": "Не действует: {{count}}", + "compat_scriptcat_only": "только ScriptCat", + "perm_other_label": "Прочие объявления", + "perm_other_summary": "Работает не так, как в других менеджерах скриптов" } diff --git a/src/locales/tr-TR/install.json b/src/locales/tr-TR/install.json index 6de4ce731..473db1796 100644 --- a/src/locales/tr-TR/install.json +++ b/src/locales/tr-TR/install.json @@ -255,5 +255,15 @@ "expired_title": "Güncelleme içeriğinin süresi doldu", "expired_desc": "Bu güncelleme için hazırlanan kod temizlendi. Lütfen güncellemeleri yeniden denetleyin.", "expired_recheck": "Güncellemeleri yeniden denetle", - "code_loading": "Kod yükleniyor" + "code_loading": "Kod yükleniyor", + "compat_ineffective": "etkisiz", + "compat_tag_desc": "ScriptCat bu bildirimi desteklemiyor; kurulumdan sonra yok sayılır.", + "compat_value_desc": "ScriptCat bu değeri desteklemiyor; bildirim yazıldığı gibi uygulanmaz.", + "compat_grant_desc": "ScriptCat bu API'yi uygulamadı; betik çağırdığında hata verir ve buna dayanan özellikler çalışmaz.", + "compat_jump": "{{line}}. satıra git", + "compat_docs": "Meta veri belgeleri", + "compat_count": "{{count}} etkisiz", + "compat_scriptcat_only": "Yalnızca ScriptCat", + "perm_other_label": "Diğer bildirimler", + "perm_other_summary": "Diğer betik yöneticilerinden farklı davranır" } diff --git a/src/locales/vi-VN/install.json b/src/locales/vi-VN/install.json index 45ed235ce..c0cb5e3ef 100644 --- a/src/locales/vi-VN/install.json +++ b/src/locales/vi-VN/install.json @@ -255,5 +255,15 @@ "expired_title": "Nội dung cập nhật đã hết hạn", "expired_desc": "Mã đã chuẩn bị cho lần cập nhật này đã bị dọn dẹp. Vui lòng kiểm tra cập nhật lại.", "expired_recheck": "Kiểm tra cập nhật lại", - "code_loading": "Đang tải mã" + "code_loading": "Đang tải mã", + "compat_ineffective": "không có tác dụng", + "compat_tag_desc": "ScriptCat không hỗ trợ khai báo này; nó sẽ bị bỏ qua sau khi cài đặt.", + "compat_value_desc": "ScriptCat không hỗ trợ giá trị này; khai báo sẽ không có hiệu lực như đã viết.", + "compat_grant_desc": "ScriptCat chưa triển khai API này; script gọi đến sẽ báo lỗi và các tính năng phụ thuộc vào nó sẽ không hoạt động.", + "compat_jump": "Đến dòng {{line}}", + "compat_docs": "Tài liệu metadata", + "compat_count": "{{count}} mục không có tác dụng", + "compat_scriptcat_only": "Chỉ ScriptCat", + "perm_other_label": "Khai báo khác", + "perm_other_summary": "Hoạt động khác với các trình quản lý script khác" } diff --git a/src/locales/zh-CN/install.json b/src/locales/zh-CN/install.json index 1d25ea2df..7e8d3a0c9 100644 --- a/src/locales/zh-CN/install.json +++ b/src/locales/zh-CN/install.json @@ -255,5 +255,15 @@ "expired_title": "更新内容已过期", "expired_desc": "这次更新准备好的代码已被清理,需要重新检查一次更新。", "expired_recheck": "重新检查更新", - "code_loading": "正在加载代码" + "code_loading": "正在加载代码", + "compat_ineffective": "不生效", + "compat_tag_desc": "脚本猫不支持该声明,安装后会被忽略。", + "compat_value_desc": "脚本猫不支持这个取值,该声明不会按写法生效。", + "compat_grant_desc": "脚本猫未实现该 API,脚本调用时会报错,依赖它的功能不可用。", + "compat_jump": "跳到第 {{line}} 行", + "compat_docs": "描述文档", + "compat_count": "{{count}} 项不生效", + "compat_scriptcat_only": "仅限脚本猫", + "perm_other_label": "其他声明", + "perm_other_summary": "兼容性与其他脚本管理器不同" } diff --git a/src/locales/zh-TW/install.json b/src/locales/zh-TW/install.json index b86ebdf8c..bb60ef0d5 100644 --- a/src/locales/zh-TW/install.json +++ b/src/locales/zh-TW/install.json @@ -255,5 +255,15 @@ "expired_title": "更新內容已過期", "expired_desc": "這次更新準備好的程式碼已被清理,需要重新檢查一次更新。", "expired_recheck": "重新檢查更新", - "code_loading": "正在載入程式碼" + "code_loading": "正在載入程式碼", + "compat_ineffective": "不生效", + "compat_tag_desc": "腳本貓不支援此宣告,安裝後會被忽略。", + "compat_value_desc": "腳本貓不支援此取值,這項宣告不會照寫法生效。", + "compat_grant_desc": "腳本貓尚未實作此 API,腳本呼叫時會出錯,依賴它的功能無法使用。", + "compat_jump": "跳至第 {{line}} 行", + "compat_docs": "描述文件", + "compat_count": "{{count}} 項不生效", + "compat_scriptcat_only": "僅限腳本貓", + "perm_other_label": "其他宣告", + "perm_other_summary": "相容性與其他腳本管理器不同" } diff --git a/src/pages/components/CodeEditor/index.test.tsx b/src/pages/components/CodeEditor/index.test.tsx index 1d0fb8fc9..09e9b60e0 100644 --- a/src/pages/components/CodeEditor/index.test.tsx +++ b/src/pages/components/CodeEditor/index.test.tsx @@ -5,6 +5,8 @@ import { render, cleanup, act, waitFor } from "@testing-library/react"; // 用 hoisted 持有可在测试内变更的主题与 monaco 桩,供被提升的 vi.mock 工厂引用 const h = vi.hoisted(() => { const makeEditor = () => ({ + revealLineInCenter: vi.fn(), + setSelection: vi.fn(), setModel: vi.fn(), setValue: vi.fn(), updateOptions: vi.fn(), @@ -15,16 +17,13 @@ const h = vi.hoisted(() => { removeDecorations: vi.fn(), createDecorationsCollection: vi.fn(), }); - const diffModifiedEditor = makeEditor(); - const diffEditor = { - ...makeEditor(), - getModifiedEditor: vi.fn(() => diffModifiedEditor), - }; return { resolvedTheme: "light" as string, setTheme: vi.fn(), - diffModifiedEditor, - createDiffEditor: vi.fn((_container?: unknown, _options?: any) => diffEditor), + createDiffEditor: vi.fn((_container?: unknown, _options?: any) => { + const modified = makeEditor(); + return { ...makeEditor(), getModifiedEditor: () => modified, __modified: modified }; + }), create: vi.fn((_container?: unknown, _options?: any) => makeEditor()), createModel: vi.fn(() => ({ dispose: vi.fn(), @@ -42,7 +41,14 @@ vi.mock("monaco-editor", () => ({ setTheme: h.setTheme, setModelMarkers: vi.fn(), }, - Range: class {}, + Range: class { + constructor( + public startLineNumber: number, + public startColumn: number, + public endLineNumber: number, + public endColumn: number + ) {} + }, })); vi.mock("./theme", () => ({ resolveMonacoTheme: (t: string) => t })); vi.mock("@App/pkg/utils/monaco-editor", () => ({ @@ -131,16 +137,41 @@ describe("CodeEditor 可访问性与主题", () => { }); expect(h.setTheme).toHaveBeenCalledWith("dark"); }); +}); - it("diff 编辑器创建后也应回调 onEditorMount", async () => { - const onEditorMount = vi.fn(); +describe("CodeEditor 就绪信号与定位", () => { + it("内联 diff 也报告就绪——否则加载占位会一直留在无障碍树里说「正在加载」", async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenCalled()); + }); + + it("普通编辑器同样报告就绪", async () => { + const onReady = vi.fn(); + await act(async () => { + render(); + }); + await waitFor(() => expect(onReady).toHaveBeenCalled()); + }); + it("revealLine 在普通编辑器上滚动并选中整行", async () => { + const ref = createRef>(); await act(async () => { - render( - - ); + render(); }); + await waitFor(() => expect(h.create).toHaveBeenCalled()); + act(() => ref.current?.revealLine(2)); + const instance = h.create.mock.results[0].value; + expect(instance.revealLineInCenter).toHaveBeenCalledWith(2); + expect(instance.setSelection).toHaveBeenCalled(); + }); - await waitFor(() => expect(onEditorMount).toHaveBeenCalledWith(h.diffModifiedEditor)); + it("revealLine 在 diff 预览里定位到修改侧——诊断说的是新版本的那一行", async () => { + const ref = createRef>(); + render(); + await waitFor(() => expect(h.createDiffEditor).toHaveBeenCalled()); + act(() => ref.current?.revealLine(2)); + const modified = (h.createDiffEditor.mock.results[0].value as any).__modified; + expect(modified.revealLineInCenter).toHaveBeenCalledWith(2); }); }); diff --git a/src/pages/components/CodeEditor/index.tsx b/src/pages/components/CodeEditor/index.tsx index cd1fc1d9c..621336ce3 100644 --- a/src/pages/components/CodeEditor/index.tsx +++ b/src/pages/components/CodeEditor/index.tsx @@ -7,15 +7,25 @@ import { clearModelEslintFixes, getModelEslintFixKey } from "@App/pkg/utils/mona import { useTheme } from "@App/pages/components/theme-provider"; import { resolveMonacoTheme } from "./theme"; +export interface CodeEditorHandle { + /** 普通编辑器实例;diff 预览没有可编辑实例,为 undefined */ + editor: editor.IStandaloneCodeEditor | undefined; + /** 滚动到指定行并选中整行;diff 预览定位到修改侧 */ + revealLine: (line: number) => void; +} + type Props = { - ref?: Ref<{ editor: editor.IStandaloneCodeEditor | undefined }>; + ref?: Ref; className?: string; diffCode?: string; // 代码加载是异步的:undefined=不确定(不加载),""=无 diff,有值=diff editable?: boolean; id: string; code?: string; onChange?: (code: string) => void; + /** 普通编辑器实例就绪;diff 预览没有可编辑实例,不会触发 */ onEditorMount?: (editor: editor.IStandaloneCodeEditor) => void; + /** 编辑器已创建(含 diff 预览);用于收起加载占位 */ + onReady?: () => void; }; type TMarker = { @@ -44,7 +54,7 @@ function toMonacoEditorPreferenceOptions( } satisfies editor.IEditorOptions; } -function CodeEditor({ id, className, code, diffCode, editable, onChange, onEditorMount, ref }: Props) { +function CodeEditor({ id, className, code, diffCode, editable, onChange, onEditorMount, onReady, ref }: Props) { const [monacoEditor, setEditor] = useState(); const editorInstanceRef = useRef(undefined); // 普通 editor 与 diff editor 都会置位,供主题切换 effect 判断实例是否就绪 @@ -58,14 +68,27 @@ function CodeEditor({ id, className, code, diffCode, editable, onChange, onEdito // 用 ref 保存最新回调,避免 stale closure 同时不让创建 effect 重跑 const onChangeRef = useRef(onChange); const onEditorMountRef = useRef(onEditorMount); + const onReadyRef = useRef(onReady); // ref 赋值须在创建 effect 之前,确保 mount 时创建 effect 同步读到最新 onEditorMount useEffect(() => { onChangeRef.current = onChange; onEditorMountRef.current = onEditorMount; + onReadyRef.current = onReady; }); const divRef = useRef(null); - useImperativeHandle(ref, () => ({ editor: monacoEditor })); + useImperativeHandle(ref, () => ({ + editor: monacoEditor, + revealLine: (line: number) => { + const instance = editorInstanceRef.current; + if (!instance) return; + // diff 预览的行号说的是新版本,定位到修改侧 + const target = "getModifiedEditor" in instance ? instance.getModifiedEditor() : instance; + target.revealLineInCenter(line); + const maxColumn = target.getModel()?.getLineMaxColumn(line) ?? 1; + target.setSelection(new Range(line, 1, line, maxColumn)); + }, + })); // 注册 monaco 全局环境(只需执行一次) useEffect(() => { @@ -162,7 +185,7 @@ function CodeEditor({ id, className, code, diffCode, editable, onChange, onEdito const initialTheme = resolveMonacoTheme(resolvedTheme); if (diffCode) { - const diffEditor = editor.createDiffEditor(container, { + edit = editor.createDiffEditor(container, { hideUnchangedRegions: { enabled: true }, enableSplitViewResizing: false, renderSideBySide: false, @@ -171,7 +194,6 @@ function CodeEditor({ id, className, code, diffCode, editable, onChange, onEdito theme: initialTheme, ...commonEditorOptions, }); - edit = diffEditor; // standalone model 不随 editor.dispose 自动清理,需手动跟踪并在 cleanup 释放 originalModel = editor.createModel(diffCode, "javascript"); modifiedModel = editor.createModel(code, "javascript"); @@ -181,7 +203,7 @@ function CodeEditor({ id, className, code, diffCode, editable, onChange, onEdito }); editorInstanceRef.current = edit; editorReadyRef.current = true; - onEditorMountRef.current?.(diffEditor.getModifiedEditor()); + onReadyRef.current?.(); } else { const standaloneEdit = editor.create(container, { language: "javascript", @@ -201,6 +223,7 @@ function CodeEditor({ id, className, code, diffCode, editable, onChange, onEdito editorInstanceRef.current = standaloneEdit; editorReadyRef.current = true; onEditorMountRef.current?.(standaloneEdit); + onReadyRef.current?.(); } }); diff --git a/src/pages/install/App.test.tsx b/src/pages/install/App.test.tsx index 48b874fd4..1ae789f3b 100644 --- a/src/pages/install/App.test.tsx +++ b/src/pages/install/App.test.tsx @@ -7,6 +7,7 @@ vi.mock("./useInstallData", () => ({ useInstallData: vi.fn() })); // Monaco 编辑器无法在 DOM 测试环境中渲染(需 worker + ThemeProvider),用桩替换 vi.mock("@App/pages/components/CodeEditor", () => import("@Tests/mocks/CodeEditor.tsx")); +import { revealLine } from "@Tests/mocks/CodeEditor"; import { useInstallData, type InstallView } from "./useInstallData"; import App from "./App"; @@ -41,11 +42,13 @@ const readyView = (over: Partial = {}): InstallView => ({ schedule: null, code: "// a\n// b", subscribeScripts: [], + compat: { grants: new Map(), tags: [], matches: new Map(), scriptcatOnlyTags: [] }, ...over, }); beforeEach(() => { mockMatchMedia(); + revealLine.calls.length = 0; }); beforeAll(() => initTestLanguage("zh-CN")); @@ -386,3 +389,32 @@ describe("Install App 不再渲染安全警示条", () => { expect(screen.queryByTestId("install-warning-risk")).not.toBeInTheDocument(); }); }); + +describe("安装页的不生效标记", () => { + it("把不受支持的 GM 能力标在权限行上,点击跳到代码对应行", () => { + mockHook.mockReturnValue({ + ...baseHook(), + state: { + status: "ready", + view: readyView({ + permissions: [{ kind: "grant", risk: "warn", values: ["GM_setValue", "GM_audio"], sensitive: [] }], + code: "// ==UserScript==\n// @name X\n// @grant GM_audio\n// ==/UserScript==", + compat: { grants: new Map([["GM_audio", 3]]), tags: [], matches: new Map(), scriptcatOnlyTags: [] }, + }), + }, + }); + render(); + + const chip = screen.getByTestId("compat-chip"); + expect(chip).toHaveTextContent("GM_audio"); + fireEvent.click(chip); + expect(revealLine.calls).toEqual([3]); + }); + + it("没有不生效项时权限区一字不改", () => { + mockHook.mockReturnValue({ ...baseHook(), state: { status: "ready", view: readyView() } }); + render(); + expect(screen.queryByTestId("compat-chip")).not.toBeInTheDocument(); + expect(screen.queryByTestId("permission-row-other")).not.toBeInTheDocument(); + }); +}); diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index b3629ba45..9f446e384 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Download, RefreshCw, Rss, HardDrive, RotateCcw, PlugZap } from "lucide-react"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; @@ -8,7 +8,7 @@ import { ScriptIdentity } from "./components/ScriptIdentity"; import { PermissionCard } from "./components/PermissionCard"; import { SubscribeScripts } from "./components/SubscribeScripts"; import { SkillInstallView } from "./components/SkillInstallView"; -import { CodePreview } from "./components/CodePreview"; +import { CodePreview, type CodePreviewHandle } from "./components/CodePreview"; import { InstallActions } from "./components/InstallActions"; import { InstallLoading, InstallError, InstallExpired } from "./components/InstallStates"; import { WatchingBanner } from "./components/WatchingBanner"; @@ -52,6 +52,7 @@ export default function App() { retryInstall, } = useInstallData(); const [bgPrompt, setBgPrompt] = useState<{ scriptType: string; permission: PromptPermission } | null>(null); + const codePreviewRef = useRef(null); const installed = outcome.phase === "installed" ? outcome.result : null; const externalAccessFailure = state.status === "ready" && !!state.view.externalAccess; const errorBar = @@ -237,9 +238,16 @@ export default function App() { codePreviewRef.current?.jumpToLine(line) }} /> )} - + { + const code = `// ==UserScript==\n${header}// ==/UserScript==\n\nconsole.log(1);\n`; + const metadata: SCMetadata = {}; + for (const line of header.split("\n")) { + const m = /^\/\/ @(\S+)[ \t]*(.*)$/.exec(line); + if (!m) continue; + (metadata[m[1].toLowerCase()] ||= []).push(m[2].trim()); + } + return { code, metadata }; +}; + +describe("安装页兼容性标记", () => { + it("全部受支持时不产生任何标记", () => { + const { code, metadata } = build(`// @name X\n// @match *://a.com/*\n// @grant GM_setValue\n`); + expect(deriveCompatMarks(metadata, code)).toEqual({ + grants: new Map(), + matches: new Map(), + tags: [], + scriptcatOnlyTags: [], + }); + }); + + it("标出脚本猫未实现的 @grant,并给出所在行", () => { + const { code, metadata } = build(`// @name X\n// @grant GM_setValue\n// @grant GM_audio\n`); + const marks = deriveCompatMarks(metadata, code); + expect(marks.grants).toEqual(new Map([["GM_audio", 4]])); + }); + + it("@grant none 不是能力请求,不标记", () => { + const { code, metadata } = build(`// @name X\n// @grant none\n`); + expect(deriveCompatMarks(metadata, code).grants.size).toBe(0); + }); + + it("标记按作者的原始写法呈现", () => { + const { code, metadata } = build(`// @name X\n// @Exclude-Match *://b.com/*\n// @storageName s\n`); + const marks = deriveCompatMarks(metadata, code); + expect(marks.tags).toEqual([{ tag: "Exclude-Match", line: 3 }]); + expect(marks.scriptcatOnlyTags).toEqual([{ tag: "storageName", line: 4 }]); + }); + + it("支持表之外的指令一律标出,不需要事先登记——别家以后新增的指令也不会漏", () => { + const { code, metadata } = build(`// @name X\n// @match-website-only a.com\n// @sandbox raw\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([ + { tag: "match-website-only", line: 3 }, + { tag: "sandbox", line: 4 }, + ]); + }); + + it("同一指令写了多行只标一枚,行号取第一次出现处", () => { + const { code, metadata } = build(`// @name X\n// @sandbox a\n// @sandbox b\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "sandbox", line: 3 }]); + }); + + it("代码里定位不到时仍然成条,只是没有行号——诊断不能因为缺位置而消失", () => { + const metadata: SCMetadata = { name: ["X"], sandbox: ["raw"], grant: ["GM_audio"] }; + const marks = deriveCompatMarks(metadata, ""); + expect(marks.tags).toEqual([{ tag: "sandbox", line: undefined }]); + expect(marks.grants).toEqual(new Map([["GM_audio", undefined]])); + }); + + it("标记顺序跟随代码出现顺序,便于与代码对读", () => { + const { code, metadata } = build(`// @name X\n// @top-level-await\n// @sandbox raw\n`); + expect(deriveCompatMarks(metadata, code).tags.map((t) => t.tag)).toEqual(["top-level-await", "sandbox"]); + }); + + it("受支持的指令写了不认得的取值,连同取值一起标出并定位到那一行", () => { + const { code, metadata } = build(`// @name X\n// @run-at document-weird\n// @inject-into auto\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([ + { tag: "run-at", value: "document-weird", line: 3 }, + { tag: "inject-into", value: "auto", line: 4 }, + ]); + }); + + it("只读第一个取值的指令,后续取值定位到各自所在的行", () => { + const { code, metadata } = build(`// @name X\n// @run-in normal-tabs\n// @run-in incognito-tabs\n`); + expect(deriveCompatMarks(metadata, code).tags).toEqual([{ tag: "run-in", value: "incognito-tabs", line: 4 }]); + }); + + it("标出脚本猫独有的指令,按代码顺序给出行号", () => { + const { code, metadata } = build(`// @name X\n// @background\n// @run-at document-start\n// @early-start\n`); + const marks = deriveCompatMarks(metadata, code); + expect(marks.scriptcatOnlyTags).toEqual([ + { tag: "background", line: 3 }, + { tag: "early-start", line: 5 }, + ]); + expect(marks.tags).toEqual([]); + }); + + it("解析不出规则的 @match 按取值给出行号,交给运行网站行就地替换,不另成条", () => { + const { code, metadata } = build(`// @name X\n// @match *://a.com/*\n// @match hello-world^^\n`); + const marks = deriveCompatMarks(metadata, code); + expect(marks.matches).toEqual(new Map([["hello-world^^", 4]])); + expect(marks.tags).toEqual([]); + }); + + it("脚本猫独有的指令写了也不生效时只按不生效标一次", () => { + const { code, metadata } = build(`// @name X\n// @early-start\n`); + const marks = deriveCompatMarks(metadata, code); + expect(marks.tags).toEqual([{ tag: "early-start", value: "", line: 3 }]); + expect(marks.scriptcatOnlyTags).toEqual([]); + }); +}); diff --git a/src/pages/install/compat.ts b/src/pages/install/compat.ts new file mode 100644 index 000000000..d44569555 --- /dev/null +++ b/src/pages/install/compat.ts @@ -0,0 +1,116 @@ +import type { SCMetadata } from "@App/app/repo/metadata"; +import type { PermissionKind, PermissionRow } from "./permissions"; +import { parseMetadataLines, type MetadataLine } from "@App/pkg/utils/script"; +import { + SCRIPTCAT_ONLY_METADATA_TAGS, + ineffectiveMetadataValues, + isSupportedGrant, + isSupportedMetadataTag, + resolveMetadataTagBase, +} from "@App/pkg/utils/script_compat"; + +/** 不生效的指令;统一在「其他声明」行呈现 */ +export interface IneffectiveTag { + /** 作者写的指令名,不含 @ */ + tag: string; + /** 指令本身受支持、只是取值不生效时给出该取值 */ + value?: string; + line: number | undefined; +} + +export interface ScriptCatOnlyTag { + tag: string; + line: number | undefined; +} + +export interface CompatMarks { + /** 不生效的 @grant → 所在行号;键与权限卡 GM 能力行的 chip 取值一致,直接按名字打标 */ + grants: Map; + /** 解析不出规则的 @match 取值 → 所在行号;运行网站行按取值就地打标 */ + matches: Map; + /** 不生效的元数据指令;权限卡里没有对应 chip,统一落在「其他声明」行 */ + tags: IneffectiveTag[]; + /** 脚本猫独有的指令;在脚本猫里生效,换到别的管理器不生效 */ + scriptcatOnlyTags: ScriptCatOnlyTag[]; +} + +/** 传给权限行的兼容性标记与跳转入口 */ +export interface CompatView { + marks: CompatMarks; + /** 跳到代码预览的指定行;无预览可跳时不传 */ + onJump?: (line: number) => void; +} + +/** 该取值在这一类权限行里是否带不生效标记 */ +export const isMarkedValue = (marks: CompatMarks, kind: PermissionKind, value: string): boolean => + (kind === "grant" && marks.grants.has(value)) || (kind === "match" && marks.matches.has(value)); + +/** 该权限行是否带不生效标记;带标记的行不能被折叠藏起来 */ +export const rowHasCompatMarks = (marks: CompatMarks, row: PermissionRow): boolean => + row.values.some((value) => isMarkedValue(marks, row.kind, value)); + +/** 不生效项总数,用于卡头徽章 */ +export const compatMarkCount = (marks: CompatMarks): number => + marks.grants.size + marks.matches.size + marks.tags.length; + +/** + * 派生安装页的兼容性标记:脚本写了、但脚本猫不会执行的指令与 GM 能力。 + * 判定是二元的(见 script_compat.ts):认识且支持的才不标,其余一律不生效,不再分「知道但不支持」的中间档。 + */ +export function deriveCompatMarks(metadata: SCMetadata, code: string): CompatMarks { + const lines = parseMetadataLines(code); + // 同一指令的第 i 个取值 → 所在行;parseMetadata 按出现顺序聚合取值,下标一一对应。 + // metadata 与代码不同源时取值会对不上,此时宁可不给行号也不跳错行 + const tagLines = new Map(); + for (const entry of lines) { + const list = tagLines.get(entry.tag); + if (list) list.push(entry); + else tagLines.set(entry.tag, [entry]); + } + const lineOf = (tag: string, index = 0, value?: string) => { + const entry = tagLines.get(tag)?.[index]; + return entry && (value === undefined || entry.value === value) ? entry.line : undefined; + }; + + const grants = new Map(); + (metadata.grant || []).forEach((grant, index) => { + if (grant === "none" || isSupportedGrant(grant) || grants.has(grant)) return; + grants.set(grant, lineOf("grant", index, grant)); + }); + + const nameOf = (tag: string) => tagLines.get(tag)?.[0].name ?? tag; + + const byLine = (a: { line: number | undefined }, b: { line: number | undefined }) => + (a.line ?? Infinity) - (b.line ?? Infinity); + + const ineffectiveValues = ineffectiveMetadataValues(metadata); + // 独有指令在这份脚本里本身不生效时,读者要知道的是「不生效」,不再重复标「仅限脚本猫」 + const seen = new Set(ineffectiveValues.map((v) => v.tag).filter((tag) => SCRIPTCAT_ONLY_METADATA_TAGS.has(tag))); + const tags: IneffectiveTag[] = []; + const scriptcatOnlyTags: ScriptCatOnlyTag[] = []; + // 以代码出现顺序为准;metadata 是对象,键序不表达脚本里的书写顺序 + const ordered = [...lines.map((l) => l.tag), ...Object.keys(metadata)]; + for (const rawTag of ordered) { + const tag = resolveMetadataTagBase(rawTag); + if (seen.has(tag)) continue; + seen.add(tag); + if (!isSupportedMetadataTag(tag)) { + tags.push({ tag: nameOf(tag), line: lineOf(tag) }); + } else if (SCRIPTCAT_ONLY_METADATA_TAGS.has(tag)) { + scriptcatOnlyTags.push({ tag: nameOf(tag), line: lineOf(tag) }); + } + } + const matches = new Map(); + for (const { tag, index, value } of ineffectiveValues) { + const line = lineOf(tag, index, value); + // @match 的取值本身就是运行网站行里的一枚 chip,就地打标,不再另成一条 + if (tag === "match") { + if (!matches.has(value)) matches.set(value, line); + } else { + tags.push({ tag: nameOf(tag), value, line }); + } + } + tags.sort(byLine); + + return { grants, matches, tags, scriptcatOnlyTags }; +} diff --git a/src/pages/install/compat_docs.test.ts b/src/pages/install/compat_docs.test.ts new file mode 100644 index 000000000..93ab94378 --- /dev/null +++ b/src/pages/install/compat_docs.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { catApiDocHref, metadataDocHref } from "./compat_docs"; + +describe("兼容性标记的文档地址", () => { + it("文档有对应小节的指令链到该小节,大小写不敏感", () => { + expect(metadataDocHref("run-at")).toBe("https://docs.scriptcat.org/docs/dev/meta#run-at"); + expect(metadataDocHref("storageName")).toBe("https://docs.scriptcat.org/docs/dev/meta#storagename-"); + expect(metadataDocHref("early-start")).toBe("https://docs.scriptcat.org/docs/dev/meta#early-start-v110"); + }); + + it("文档里没有小节的指令不给地址", () => { + expect(metadataDocHref("unwrap")).toBeUndefined(); + expect(metadataDocHref("exclude-match")).toBeUndefined(); + }); + + it("CAT 能力只链文档里写了的那几个", () => { + expect(catApiDocHref("CAT_userConfig")).toBe("https://docs.scriptcat.org/docs/dev/cat-api#cat_userconfig"); + expect(catApiDocHref("CAT_createBlobUrl")).toBeUndefined(); + }); +}); diff --git a/src/pages/install/compat_docs.ts b/src/pages/install/compat_docs.ts new file mode 100644 index 000000000..f53692297 --- /dev/null +++ b/src/pages/install/compat_docs.ts @@ -0,0 +1,34 @@ +import { DocumentationSite } from "@App/app/const"; +import { localePath } from "@App/locales/locales"; + +// 文档站(scriptcat.org 仓库 docs/dev/*.md)里有独立小节的条目 → Docusaurus 生成的锚点,中英文站一致。 +// 不在表里的条目文档里没有说明(不支持的指令与 API 本来就不会写进文档),不给链接,免得点过去找不到。 +// 锚点随标题文字生成,文档改标题后这里会退化成跳到页首,不会跳错地方。 +const META_ANCHORS: Readonly> = { + "run-at": "run-at", + "run-in": "run-in", + "early-start": "early-start-v110", + "inject-into": "inject-into", + storagename: "storagename-", + background: "background", + crontab: "crontab", + match: "match", + "require-css": "require-css", +}; + +const CAT_API_ANCHORS: Readonly> = { + CAT_userConfig: "cat_userconfig", + CAT_fileStorage: "cat_filestorage", + CAT_scriptLoaded: "cat_scriptloaded", +}; + +/** 元数据指令在描述文档里的小节地址;localePath 随界面语言切换,须在调用时读取 */ +export const metadataDocHref = (tag: string): string | undefined => { + const anchor = META_ANCHORS[tag.toLowerCase()]; + return anchor ? `${DocumentationSite}${localePath}/docs/dev/meta#${anchor}` : undefined; +}; + +export const catApiDocHref = (grant: string): string | undefined => { + const anchor = CAT_API_ANCHORS[grant]; + return anchor ? `${DocumentationSite}${localePath}/docs/dev/cat-api#${anchor}` : undefined; +}; diff --git a/src/pages/install/components/CodePreview.test.tsx b/src/pages/install/components/CodePreview.test.tsx index 52bcb0beb..7ff08e20a 100644 --- a/src/pages/install/components/CodePreview.test.tsx +++ b/src/pages/install/components/CodePreview.test.tsx @@ -1,11 +1,12 @@ +import { createRef, type ComponentRef } from "react"; import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; -import { render, screen, cleanup, fireEvent, within, waitFor } from "@testing-library/react"; +import { render, screen, cleanup, fireEvent, act, within, waitFor } from "@testing-library/react"; import { initTestLanguage } from "@Tests/initTestLanguage"; // Monaco 无法在 DOM 测试环境中渲染(需 worker),用轻量桩替换,仅暴露 props 供断言接线 vi.mock("@App/pages/components/CodeEditor", () => import("@Tests/mocks/CodeEditor.tsx")); -import { setEditorMounts } from "@Tests/mocks/CodeEditor"; +import { setEditorMounts, revealLine } from "@Tests/mocks/CodeEditor"; import { CodePreview } from "./CodePreview"; const code = "// line1\nconst a = 1;\nconsole.log(a);"; @@ -149,3 +150,37 @@ describe("CodePreview 编辑器加载期的占位", () => { expect(screen.queryByTestId("code-skeleton")).not.toBeInTheDocument(); }); }); + +describe("CodePreview 跳到指定行", () => { + afterEach(() => { + revealLine.calls.length = 0; + setEditorMounts(true); + }); + + it("跳转把编辑器定位到该行", async () => { + const ref = createRef>(); + render(); + await act(async () => ref.current!.jumpToLine(2)); + expect(revealLine.calls).toEqual([2]); + }); + + it("代码卡折叠时先展开再定位——移动端默认折叠,点了却什么都没发生说不过去", async () => { + const ref = createRef>(); + render(); + expect(screen.queryByTestId("code-body")).not.toBeInTheDocument(); + await act(async () => ref.current!.jumpToLine(3)); + expect(screen.getByTestId("code-body")).toBeInTheDocument(); + expect(revealLine.calls).toEqual([3]); + }); + + it("编辑器还没就绪时把定位排队,就绪后补上", async () => { + setEditorMounts(false); + const ref = createRef>(); + const { rerender } = render(); + await act(async () => ref.current!.jumpToLine(2)); + expect(revealLine.calls).toEqual([]); + setEditorMounts(true); + await act(async () => rerender()); + expect(revealLine.calls).toEqual([2]); + }); +}); diff --git a/src/pages/install/components/CodePreview.tsx b/src/pages/install/components/CodePreview.tsx index f4ae8e21e..eb7243250 100644 --- a/src/pages/install/components/CodePreview.tsx +++ b/src/pages/install/components/CodePreview.tsx @@ -1,12 +1,12 @@ -import { useMemo, useState } from "react"; +import { useImperativeHandle, useMemo, useRef, useState, type Ref } from "react"; import { useTranslation } from "react-i18next"; import { CodeXml, Copy, Check, ChevronDown, ChevronRight, Maximize2 } from "lucide-react"; -import CodeEditor from "@App/pages/components/CodeEditor"; +import CodeEditor, { type CodeEditorHandle } from "@App/pages/components/CodeEditor"; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@App/pages/components/ui/dialog"; import { Skeleton } from "@App/pages/components/ui/skeleton"; import { cn } from "@App/pkg/utils/cn"; -/** 代码形态的骨架:缩进与行宽错落,读起来像代码而不是一堆等长灰条 */ +/** 代码形态的骨架:缩进与行宽错落,读起来像代码而不是一堆灰条 */ const CODE_SKELETON_LINES = [ "w-[58%]", "ml-4 w-[74%]", @@ -17,7 +17,13 @@ const CODE_SKELETON_LINES = [ "ml-4 w-[62%]", ]; +export interface CodePreviewHandle { + /** 展开代码卡并滚动到指定行 */ + jumpToLine: (line: number) => void; +} + export interface CodePreviewProps { + ref?: Ref; code: string; /** 更新态的旧版本代码;与 code 不同则触发内联 diff,全新安装为 undefined */ oldCode?: string; @@ -27,6 +33,7 @@ export interface CodePreviewProps { } export function CodePreview({ + ref, code, oldCode, language = "JavaScript", @@ -38,6 +45,33 @@ export function CodePreview({ const [copied, setCopied] = useState(false); const [editorReady, setEditorReady] = useState(false); const [fullscreen, setFullscreen] = useState(false); + const editorRef = useRef(null); + const sectionRef = useRef(null); + // 折叠态下编辑器实例尚未创建,跳转请求先排队,等 onReady 再补上定位 + const pendingLineRef = useRef(null); + + const revealLine = (line: number) => { + if (editorRef.current) editorRef.current.revealLine(line); + else pendingLineRef.current = line; + }; + + useImperativeHandle(ref, () => ({ + jumpToLine: (line: number) => { + setCollapsed(false); + sectionRef.current?.scrollIntoView?.({ behavior: "smooth", block: "nearest" }); + if (editorReady) revealLine(line); + else pendingLineRef.current = line; + }, + })); + + const handleReady = () => { + setEditorReady(true); + const pending = pendingLineRef.current; + if (pending !== null) { + pendingLineRef.current = null; + revealLine(pending); + } + }; const lineCount = useMemo(() => code.split("\n").length, [code]); // diffCode 语义:""=无 diff(普通只读预览),有值=内联 diff;切勿传 undefined(表示不加载) @@ -56,16 +90,18 @@ export function CodePreview({ const codeEditor = (id: string) => ( setEditorReady(true)} + onReady={handleReady} className="h-full w-full" /> ); const editorSkeleton = ( + // 编辑器实例要等偏好设置读出来才创建,这段时间这里本来是一块纯空白,看着像加载失败
-
+
{t("editor:code")} diff --git a/src/pages/install/components/CompatChip.test.tsx b/src/pages/install/components/CompatChip.test.tsx new file mode 100644 index 000000000..1046ad788 --- /dev/null +++ b/src/pages/install/components/CompatChip.test.tsx @@ -0,0 +1,144 @@ +import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; +import { render, screen, cleanup, fireEvent, act } from "@testing-library/react"; +import { initTestLanguage } from "@Tests/initTestLanguage"; +import { CompatChip } from "./CompatChip"; + +beforeAll(() => initTestLanguage("zh-CN")); +afterEach(cleanup); + +describe("CompatChip 不生效标记", () => { + it("渲染取值本身,并带上不生效的可读标注——含义不只靠颜色传达", () => { + render(); + const chip = screen.getByTestId("compat-chip"); + expect(chip).toHaveTextContent("GM_audio"); + expect(chip).toHaveAccessibleName(expect.stringContaining("不生效")); + }); + + it("鼠标移入弹出说明:GM 能力说清调用会报错", () => { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByText("脚本猫未实现该 API,脚本调用时会报错,依赖它的功能不可用。")).toBeInTheDocument(); + }); + + it("鼠标移入弹出说明:元数据指令说清会被忽略", () => { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByText("脚本猫不支持该声明,安装后会被忽略。")).toBeInTheDocument(); + }); + + it("鼠标移入弹出说明:不认得的取值说清声明不会按写法生效", () => { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByText("脚本猫不支持这个取值,该声明不会按写法生效。")).toBeInTheDocument(); + }); + + it("移出后收起说明", async () => { + vi.useFakeTimers(); + try { + render(); + const chip = screen.getByTestId("compat-chip"); + fireEvent.mouseEnter(chip); + expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); + fireEvent.mouseLeave(chip); + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + expect(screen.queryByTestId("compat-popover")).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it("鼠标从 chip 移到浮层上不会关闭——否则浮层里的文档链接永远点不到", async () => { + vi.useFakeTimers(); + try { + render( + + ); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + const popover = screen.getByTestId("compat-popover"); + fireEvent.mouseEnter(popover); + fireEvent.mouseLeave(screen.getByTestId("compat-chip")); + await act(async () => { + await vi.advanceTimersByTimeAsync(400); + }); + expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); + expect(screen.getByTestId("compat-docs")).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it("键盘聚焦同样弹出说明——没有鼠标也拿得到这段信息", () => { + render(); + fireEvent.focus(screen.getByTestId("compat-chip")); + expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); + }); + + it("点击跳到该指令所在行", () => { + const onJump = vi.fn(); + render(); + fireEvent.click(screen.getByTestId("compat-chip")); + expect(onJump).toHaveBeenCalledWith(7); + }); + + it("浮层给出行号,让用户知道点下去会去哪", () => { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.getByText("跳到第 7 行")).toBeInTheDocument(); + }); + + it("代码里定位不到时仍然成条,只是不可跳转", () => { + const onJump = vi.fn(); + render(); + const chip = screen.getByTestId("compat-chip"); + fireEvent.click(chip); + expect(onJump).not.toHaveBeenCalled(); + fireEvent.mouseEnter(chip); + expect(screen.getByTestId("compat-popover")).toBeInTheDocument(); + expect(screen.queryByText(/跳到第/)).not.toBeInTheDocument(); + }); + + it("给了文档地址时浮层链过去,文案说明是描述文档", () => { + render( + + ); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + const link = screen.getByTestId("compat-docs"); + expect(link).toHaveAttribute("href", "https://docs.scriptcat.org/docs/dev/meta#run-at"); + expect(link).toHaveTextContent("描述文档"); + }); + + it("文档里没有对应说明时不给链接", () => { + render(); + fireEvent.mouseEnter(screen.getByTestId("compat-chip")); + expect(screen.queryByTestId("compat-docs")).not.toBeInTheDocument(); + }); +}); + +describe("多枚标记之间的浮层互斥", () => { + it("悬停另一枚 chip 时上一枚的浮层立刻收起——两枚浮层会互相盖住", () => { + render( + <> + + + + ); + const [first, second] = screen.getAllByTestId("compat-chip"); + fireEvent.mouseEnter(first); + expect(screen.getAllByTestId("compat-popover")).toHaveLength(1); + fireEvent.mouseEnter(second); + const open = screen.getAllByTestId("compat-popover"); + expect(open).toHaveLength(1); + expect(open[0]).toHaveTextContent("GM_audio"); + }); +}); diff --git a/src/pages/install/components/CompatChip.tsx b/src/pages/install/components/CompatChip.tsx new file mode 100644 index 000000000..649d07900 --- /dev/null +++ b/src/pages/install/components/CompatChip.tsx @@ -0,0 +1,130 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Ban, ExternalLink } from "lucide-react"; +import { Popover, PopoverAnchor, PopoverContent } from "@App/pages/components/ui/popover"; +import { useHoverMenu } from "@App/pages/components/ui/use-hover-menu"; + +export type CompatChipKind = "metadata" | "value" | "grant"; + +// 同一时刻只开一枚浮层:标记 chip 常常并排,浮层有 288px 宽,两枚同时开会互相盖住。 +// 悬停切换时旧浮层要等关闭延迟才收,靠这里立刻收掉。 +let closeActivePopover: (() => void) | null = null; + +const DESC_KEY: Record = { + metadata: "install:compat_tag_desc", + value: "install:compat_value_desc", + grant: "install:compat_grant_desc", +}; + +/** + * 写了但不会生效的指令 / GM 能力。就近标在它所属的权限行上,不单独成卡。 + * 说明走浮层(hover 与键盘聚焦都能开),点击本体跳到代码对应行—— + * 点击恒为跳转,不做浮层开关,否则「点一下跳过去」这个主动作会被折叠状态吃掉。 + */ +export function CompatChip({ + label, + kind, + line, + onJump, + docHref, +}: { + label: string; + kind: CompatChipKind; + line?: number; + onJump?: (line: number) => void; + /** 文档里有对应说明时才传(见 compat_docs.ts) */ + docHref?: string; +}) { + const { t } = useTranslation(["install", "common"]); + // 浮层里有文档链接,必须把浮层本体也纳入悬停范围:只盯 chip 的话鼠标一移向链接浮层就关了 + const { rootProps, hoverProps, contentProps, close } = useHoverMenu(150); + const canJump = line !== undefined && !!onJump; + // close 来自 useHoverMenu 的 useCallback([]),恒定,故清理只在卸载时发生 + useEffect( + () => () => { + if (closeActivePopover === close) closeActivePopover = null; + }, + [close] + ); + + const openPopover = () => { + if (closeActivePopover && closeActivePopover !== close) closeActivePopover(); + closeActivePopover = close; + hoverProps.onMouseEnter(); + }; + + return ( + + + + + +

+

+

{t(DESC_KEY[kind])}

+
+ {canJump && ( + + {t("install:compat_jump", { line })} + + )} + {docHref && ( + + {t("install:compat_docs")} + + )} +
+
+
+ ); +} + +/** 在脚本猫里生效、换到别的脚本管理器就不生效的指令或 API */ +export function ScriptCatOnlyBadge({ docHref }: { docHref?: string }) { + const { t } = useTranslation(["install", "common"]); + const className = "shrink-0 rounded bg-primary-light px-1 font-sans text-[10px] font-medium text-primary"; + if (!docHref) { + return ( + + {t("install:compat_scriptcat_only")} + + ); + } + return ( + + {t("install:compat_scriptcat_only")} + + ); +} diff --git a/src/pages/install/components/PermissionCard.test.tsx b/src/pages/install/components/PermissionCard.test.tsx index 605c48c61..9cf20f0ff 100644 --- a/src/pages/install/components/PermissionCard.test.tsx +++ b/src/pages/install/components/PermissionCard.test.tsx @@ -198,3 +198,248 @@ describe("PermissionCard 更新零变化态", () => { expect(screen.queryByText("api.a.com")).not.toBeInTheDocument(); }); }); + +describe("PermissionCard 上的不生效标记", () => { + const rows: PermissionRow[] = [ + { kind: "match", risk: "normal", values: ["*://a.com/*"], sensitive: [] }, + { kind: "grant", risk: "warn", values: ["GM_setValue", "GM_audio"], sensitive: [] }, + ]; + + it("卡头给出不生效项总数,权限与 GM 能力合并计数", () => { + render( + + ); + expect(screen.getByText("2 项不生效")).toBeInTheDocument(); + }); + + it("归不到任何权限类别的指令单独成行,只在有内容时出现", () => { + render( + + ); + const row = screen.getByTestId("permission-row-other"); + expect(row).toHaveTextContent("其他声明"); + expect( + within(row) + .getAllByTestId("compat-chip") + .map((chip) => chip.textContent) + ).toEqual(["@top-level-await", "@sandbox"]); + expect(screen.getAllByTestId("compat-chip")).toHaveLength(2); + }); + + it("没有不生效项时既无徽章也无其他声明行——全兼容的安装页一字不改", () => { + render( + + ); + expect(screen.queryByTestId("permission-row-other")).not.toBeInTheDocument(); + expect(screen.queryByText(/项不生效/)).not.toBeInTheDocument(); + }); +}); + +describe("PermissionCard 其他声明行的取值与脚本猫独有指令", () => { + const rows: PermissionRow[] = [{ kind: "match", risk: "normal", values: ["*://a.com/*"], sensitive: [] }]; + + it("不认得的取值连同取值一起标为不生效", () => { + render( + + ); + expect(within(screen.getByTestId("permission-row-other")).getByTestId("compat-chip")).toHaveTextContent( + "@run-at document-weird" + ); + }); + + it("取值类标记的浮层链到文档里该指令的小节,不支持的指令本身不给链接", () => { + render( + + ); + const [value, unsupported] = within(screen.getByTestId("permission-row-other")).getAllByTestId("compat-chip"); + fireEvent.mouseEnter(value); + expect(screen.getByTestId("compat-docs")).toHaveAttribute( + "href", + expect.stringMatching(/\/docs\/dev\/meta#run-at$/) + ); + expect(screen.getByTestId("compat-docs")).toHaveTextContent("描述文档"); + fireEvent.mouseEnter(unsupported); + expect(screen.queryByTestId("compat-docs")).not.toBeInTheDocument(); + }); + + it("脚本猫独有的指令标签链到文档对应小节", () => { + render( + + ); + expect(screen.getByText("@storageName")).toBeInTheDocument(); + expect(screen.getByTestId("scriptcat-only")).toHaveAttribute( + "href", + expect.stringMatching(/\/docs\/dev\/meta#storagename-$/) + ); + }); + + it("脚本猫独有的指令带仅限脚本猫标签,不算进不生效计数", () => { + render( + + ); + const row = screen.getByTestId("permission-row-other"); + const chip = within(row).getByText("@early-start").closest("[data-chip]")!; + expect(within(chip as HTMLElement).getByTestId("scriptcat-only")).toHaveTextContent("仅限脚本猫"); + expect(within(row).queryByTestId("compat-chip")).not.toBeInTheDocument(); + expect(screen.queryByTestId("permission-card-compat")).not.toBeInTheDocument(); + }); +}); + +describe("不生效标记与折叠形态的关系", () => { + const rows: PermissionRow[] = [ + { kind: "grant", risk: "warn", values: ["GM_audio"], sensitive: [], diff: { added: [], removed: [] } }, + ]; + + it("权限一项没变但有不生效项时整卡不塌——塌了这些标记就没人看得见", () => { + render( + + ); + expect(screen.queryByTestId("permission-card-collapsed")).not.toBeInTheDocument(); + expect(screen.getByTestId("compat-chip")).toHaveTextContent("GM_audio"); + }); + + it("没有不生效项时仍按原样塌成单行", () => { + render( + + ); + expect(screen.getByTestId("permission-card-collapsed")).toBeInTheDocument(); + }); +}); + +describe("有变动时未变动类别的塌行与不生效标记", () => { + it("未变动但带不生效标记的类别不塌成单行", () => { + render( + + ); + expect(screen.queryByTestId("permission-row-collapsed")).not.toBeInTheDocument(); + expect(screen.getByTestId("compat-chip")).toHaveTextContent("GM_audio"); + }); +}); + +describe("移动端的不生效标记", () => { + it("有不生效项的类别默认展开,否则标记藏在折叠面板里等于没做", () => { + mobile = true; + render( + + ); + mobile = false; + expect(screen.getByTestId("compat-chip")).toBeVisible(); + }); +}); + +describe("移动端运行网站行的不生效标记", () => { + it("运行网站行挂了不生效标记时默认展开", () => { + mobile = true; + render( + + ); + mobile = false; + expect(screen.getByTestId("compat-chip")).toBeVisible(); + }); +}); diff --git a/src/pages/install/components/PermissionCard.tsx b/src/pages/install/components/PermissionCard.tsx index c989d5be9..ff517c62b 100644 --- a/src/pages/install/components/PermissionCard.tsx +++ b/src/pages/install/components/PermissionCard.tsx @@ -1,19 +1,23 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { ChevronDown, ShieldCheck } from "lucide-react"; +import { ChevronDown, FileCode2, ShieldCheck } from "lucide-react"; import { cn } from "@App/pkg/utils/cn"; import { useIsMobile } from "@App/pages/components/use-is-mobile"; import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@App/pages/components/ui/accordion"; +import { compatMarkCount, rowHasCompatMarks, type CompatView } from "../compat"; +import { metadataDocHref } from "../compat_docs"; +import { CompatChip, ScriptCatOnlyBadge } from "./CompatChip"; import { isPermissionChanged, type PermissionRow as PermissionRowData } from "../permissions"; import { PermissionRow, PermissionChips, PermissionDelta, NoChangeTag, KIND_META, RISK_STYLE } from "./PermissionRow"; -function MobilePermissions({ rows }: { rows: PermissionRowData[] }) { +function MobilePermissions({ rows, compat }: { rows: PermissionRowData[]; compat?: CompatView }) { const { t } = useTranslation(["install", "common"]); // 有变动时默认只展开有变动的类别;全新安装、以及用户主动点开的零变化整卡都退回只展开高风险项, // 否则零变化整卡展开后每一类都是收起的,「点开即得到全量清单」在移动端会落空。 const hasChanged = rows.some(isPermissionChanged); + const isMarked = (row: PermissionRowData) => !!compat && rowHasCompatMarks(compat.marks, row); const defaultValue = rows - .filter((r) => (hasChanged ? isPermissionChanged(r) : r.risk === "danger")) + .filter((r) => isMarked(r) || (hasChanged ? isPermissionChanged(r) : r.risk === "danger")) .map((r) => r.kind); return ( @@ -37,7 +41,7 @@ function MobilePermissions({ rows }: { rows: PermissionRowData[] }) { - + ); @@ -47,13 +51,13 @@ function MobilePermissions({ rows }: { rows: PermissionRowData[] }) { } /** 未变动类别的单行形态:名称、计数与「无变化」,点开即还原成完整权限行 */ -function CollapsedRow({ row }: { row: PermissionRowData }) { +function CollapsedRow({ row, compat }: { row: PermissionRowData; compat?: CompatView }) { const { t } = useTranslation(["install", "common"]); const [open, setOpen] = useState(false); const { icon: Icon, labelKey } = KIND_META[row.kind]; const style = RISK_STYLE[row.risk]; - if (open) return ; + if (open) return ; return (
+ ); +} + +export function PermissionCard({ + rows, + baselineVersion, + compat, +}: { + rows: PermissionRowData[]; + baselineVersion?: string; + compat?: CompatView; +}) { const { t } = useTranslation(["install", "common"]); const isMobile = useIsMobile(); const [expanded, setExpanded] = useState(false); @@ -126,8 +186,10 @@ export function PermissionCard({ rows, baselineVersion }: { rows: PermissionRowD const added = changed.reduce((n, r) => n + r.diff!.added.length, 0); const removed = changed.reduce((n, r) => n + r.diff!.removed.length, 0); const noChange = isUpdate && changed.length === 0; + const compatCount = compat ? compatMarkCount(compat.marks) : 0; - if (noChange && !expanded) { + // 有不生效项时不塌:折叠的是「上次已确认过的权限」,而标记是这次才出现的新信息 + if (noChange && !expanded && compatCount === 0) { return setExpanded(true)} />; } @@ -148,6 +210,14 @@ export function PermissionCard({ rows, baselineVersion }: { rows: PermissionRowD )} {isUpdate && !changed.length && } + {compatCount > 0 && ( + + {t("install:compat_count", { count: compatCount })} + + )} {isUpdate ? t("install:perm_card_compare", { version: baselineVersion }) : t("install:perm_card_hint")} @@ -156,19 +226,21 @@ export function PermissionCard({ rows, baselineVersion }: { rows: PermissionRowD {rows.length === 0 ? (

{t("install:perm_card_empty")}

) : isMobile ? ( - + ) : ( rows.map((row, i) => (
0 && "border-t border-border")}> - {/* 有变动时未变动的类别塌成单行让位;整卡零变化时用户是主动点开的,给全量 */} - {changed.length > 0 && !isPermissionChanged(row) ? ( - + {/* 有变动时未变动的类别塌成单行让位;整卡零变化时用户是主动点开的,给全量。 + 带不生效标记的类别不塌:标记是这次才出现的新信息 */} + {changed.length > 0 && !isPermissionChanged(row) && !(compat && rowHasCompatMarks(compat.marks, row)) ? ( + ) : ( - + )}
)) )} + {compat && }
); diff --git a/src/pages/install/components/PermissionRow.test.tsx b/src/pages/install/components/PermissionRow.test.tsx index 36b11c75a..5fb316c8e 100644 --- a/src/pages/install/components/PermissionRow.test.tsx +++ b/src/pages/install/components/PermissionRow.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, beforeAll, afterEach } from "vitest"; import { render, screen, cleanup, within, fireEvent } from "@testing-library/react"; import { initTestLanguage } from "@Tests/initTestLanguage"; +import type { IneffectiveTag } from "../compat"; import { PermissionRow } from "./PermissionRow"; beforeAll(() => initTestLanguage("zh-CN")); @@ -149,3 +150,186 @@ describe("PermissionRow 零变动行的取值折叠", () => { expect(within(row).queryByTestId("permission-more")).not.toBeInTheDocument(); }); }); + +describe("PermissionRow 上的不生效标记", () => { + const compat = ( + over: Partial<{ + grants: Map; + matches: Map; + tags: IneffectiveTag[]; + }> = {} + ) => ({ + marks: { grants: new Map(), tags: [], matches: new Map(), scriptcatOnlyTags: [], ...over }, + }); + + it("不受支持的 GM 能力就地换成不生效标记,其余 chip 不变", () => { + render( + + ); + const marks = screen.getAllByTestId("compat-chip"); + expect(marks).toHaveLength(1); + expect(marks[0]).toHaveTextContent("GM_audio"); + expect(screen.getByText("GM_setValue")).toBeInTheDocument(); + expect(screen.getByText("GM_setValue").closest('[data-testid="compat-chip"]')).toBeNull(); + }); + + it("不生效的指令不在权限行上呈现——它们统一归「其他声明」行", () => { + render( + + ); + expect(screen.queryByTestId("compat-chip")).not.toBeInTheDocument(); + }); + + it("解析不出规则的 @match 取值就地换成不生效标记,并链到文档的 match 小节", () => { + render( + + ); + const marks = screen.getAllByTestId("compat-chip"); + expect(marks).toHaveLength(1); + expect(marks[0]).toHaveTextContent("hello-world^^"); + expect(screen.getAllByText("hello-world^^")).toHaveLength(1); + fireEvent.mouseEnter(marks[0]); + expect(screen.getByTestId("compat-docs")).toHaveAttribute( + "href", + expect.stringMatching(/\/docs\/dev\/meta#match$/) + ); + }); + + it("更新态里被移除的能力不标记——它已经不在新版本里了", () => { + render( + + ); + expect(screen.queryByTestId("compat-chip")).not.toBeInTheDocument(); + }); +}); + +describe("PermissionRow 上的仅限脚本猫标记", () => { + it("CAT_ 与 CAT. 能力标出仅限脚本猫,通用 GM 能力不标", () => { + render( + + ); + const row = screen.getByTestId("permission-row"); + expect(within(row).getAllByTestId("scriptcat-only")).toHaveLength(2); + expect( + within(screen.getByText("CAT_fileStorage").closest("[data-chip]")!).getByText("仅限脚本猫") + ).toBeInTheDocument(); + expect(within(screen.getByText("GM_setValue").closest("[data-chip]")!).queryByTestId("scriptcat-only")).toBeNull(); + }); + + it("更新态里被移除的 CAT 能力不标——它已经不在新版本里了", () => { + render( + + ); + expect(screen.queryByTestId("scriptcat-only")).not.toBeInTheDocument(); + }); +}); + +describe("仅限脚本猫标签的文档链接", () => { + it("文档里有对应小节的 CAT 能力,标签链到该小节", () => { + render(); + expect(screen.getByTestId("scriptcat-only")).toHaveAttribute( + "href", + expect.stringMatching(/\/docs\/dev\/cat-api#cat_filestorage$/) + ); + }); + + it("文档里没有说明的 CAT 能力只标不链——点过去找不到比不给链接更糟", () => { + render(); + expect(screen.getByTestId("scriptcat-only")).not.toHaveAttribute("href"); + }); +}); + +describe("不生效标记不会被折叠藏起来", () => { + const marks = (grants: [string, number][]) => ({ + marks: { grants: new Map(grants), matches: new Map(), tags: [], scriptcatOnlyTags: [] }, + }); + + it("取值超过 maxVisible 时,排在后面的不生效能力仍然可见", () => { + render( + + ); + const row = screen.getByTestId("permission-row"); + expect(within(row).getByTestId("compat-chip")).toHaveTextContent("GM_audio"); + expect(within(row).queryByText("GM_log")).not.toBeInTheDocument(); + expect(within(row).getByTestId("permission-more")).toHaveTextContent("+1"); + }); + + it("更新态里属于未变动的不生效能力不收进折叠桶", () => { + render( + + ); + const row = screen.getByTestId("permission-row"); + expect(within(row).getByTestId("compat-chip")).toHaveTextContent("GM_audio"); + expect(within(row).queryByText("GM_setValue")).not.toBeInTheDocument(); + expect(within(row).getByTestId("permission-more")).toHaveTextContent("未变动 1 项"); + }); + + it("零变动行超过 maxVisible 时,不生效能力同样可见", () => { + render( + + ); + expect(screen.getByTestId("compat-chip")).toHaveTextContent("GM_audio"); + expect(screen.getByTestId("permission-more")).toHaveTextContent("+1"); + }); +}); diff --git a/src/pages/install/components/PermissionRow.tsx b/src/pages/install/components/PermissionRow.tsx index c83d703fe..67cbf00fc 100644 --- a/src/pages/install/components/PermissionRow.tsx +++ b/src/pages/install/components/PermissionRow.tsx @@ -2,6 +2,10 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Globe, ArrowLeftRight, ChevronDown, KeyRound, Package, TriangleAlert, type LucideIcon } from "lucide-react"; import { cn } from "@App/pkg/utils/cn"; +import { isScriptCatOnlyGrant } from "@App/pkg/utils/script_compat"; +import { isMarkedValue, type CompatView } from "../compat"; +import { catApiDocHref, metadataDocHref } from "../compat_docs"; +import { CompatChip, ScriptCatOnlyBadge } from "./CompatChip"; import { isPermissionChanged, type PermissionKind, @@ -51,6 +55,8 @@ const CHANGE_LABEL_KEY: Record = { function Chip({ value, row, change }: { value: string; row: PermissionRowData; change?: ChangeState }) { const { t } = useTranslation(["install", "common"]); const isSensitive = row.sensitive.includes(value); + // 装在脚本猫里能用,但换到别的脚本管理器就不行了;已移除的取值不再属于新版本,不标 + const isScriptCatOnly = row.kind === "grant" && change !== "removed" && isScriptCatOnlyGrant(value); const base = isSensitive ? "border border-warning-fg bg-muted text-warning-fg" : RISK_STYLE[row.risk].chip; // 变动状态只用字重、描边与 +/− 记号表达,底色继续留给风险等级—— @@ -76,6 +82,7 @@ function Chip({ value, row, change }: { value: string; row: PermissionRowData; c {isSensitive && change !== "removed" && } {change && {t(CHANGE_LABEL_KEY[change])}} {value} + {isScriptCatOnly && } ); } @@ -101,21 +108,55 @@ function MoreButton({ label, onClick }: { label: string; onClick: () => void }) export function PermissionChips({ row, maxVisible = DEFAULT_MAX_VISIBLE, + compat, }: { row: PermissionRowData; maxVisible?: number; + compat?: CompatView; }) { const { t } = useTranslation(["install", "common"]); const [expanded, setExpanded] = useState(false); + // 不受支持的 @grant、解析不出规则的 @match 就地换成不生效标记;已被移除的取值不标——它已经不在新版本里了 + const renderChip = (value: string, change?: ChangeState) => { + if (compat && change !== "removed") { + if (row.kind === "grant" && compat.marks.grants.has(value)) { + return ( + + ); + } + if (row.kind === "match" && compat.marks.matches.has(value)) { + return ( + + ); + } + } + return ; + }; + + // 带不生效标记的取值不参与截断与折叠,否则标记会藏在「+N」后面 + const marked = (value: string) => !!compat && isMarkedValue(compat.marks, row.kind, value); + const truncate = (values: string[]) => values.filter((v, i) => i < maxVisible || marked(v)); + if (!row.diff) { - const visible = expanded ? row.values : row.values.slice(0, maxVisible); + const visible = expanded ? row.values : truncate(row.values); const hidden = row.values.length - visible.length; return (
- {visible.map((v) => ( - - ))} + {visible.map((v) => renderChip(v))} {hidden > 0 && setExpanded(true)} />}
); @@ -127,20 +168,14 @@ export function PermissionChips({ const pinned = added.length + removed.length; // 有增删时未变动项整体让位给折叠桶;一项没变时没有可钉住的内容,桶会变成必须点开才能看到全部的空壳, // 故退回全新安装的 maxVisible 截断——否则几十条 @match 的脚本一更新就会整片摊开。 - const visibleUnchanged = expanded ? unchanged : pinned > 0 ? [] : unchanged.slice(0, maxVisible); + const visibleUnchanged = expanded ? unchanged : pinned > 0 ? unchanged.filter(marked) : truncate(unchanged); const hidden = unchanged.length - visibleUnchanged.length; return (
- {added.map((v) => ( - - ))} - {removed.map((v) => ( - - ))} - {visibleUnchanged.map((v) => ( - - ))} + {added.map((v) => renderChip(v, "added"))} + {removed.map((v) => renderChip(v, "removed"))} + {visibleUnchanged.map((v) => renderChip(v, "unchanged"))} {hidden > 0 && ( 0 ? t("install:perm_unchanged_more", { count: hidden }) : `+${hidden}`} @@ -173,7 +208,15 @@ export function PermissionDelta({ row }: { row: PermissionRowData }) { ); } -export function PermissionRow({ row, maxVisible }: { row: PermissionRowData; maxVisible?: number }) { +export function PermissionRow({ + row, + maxVisible, + compat, +}: { + row: PermissionRowData; + maxVisible?: number; + compat?: CompatView; +}) { const { t } = useTranslation(["install", "common"]); const { icon: Icon, labelKey, summaryKey } = KIND_META[row.kind]; const style = RISK_STYLE[row.risk]; @@ -191,7 +234,7 @@ export function PermissionRow({ row, maxVisible }: { row: PermissionRowData; max {row.diff && !isPermissionChanged(row) && } {t(summaryKey)}
- + ); diff --git a/src/pages/install/useInstallData.test.ts b/src/pages/install/useInstallData.test.ts index caee21fd2..d9619b3cb 100644 --- a/src/pages/install/useInstallData.test.ts +++ b/src/pages/install/useInstallData.test.ts @@ -117,6 +117,25 @@ describe("assembleInstallView 组装安装视图", () => { expect(assembleInstallView(base).inTrash).toBe(false); }); + it("组装时派生不生效标记,行号取自待安装代码", () => { + const metadata = { name: ["示例脚本"], version: ["2.3.1"], "exclude-match": ["*://a.com/*"], grant: ["GM_audio"] }; + const code = `// ==UserScript== +// @name 示例脚本 +// @exclude-match *://a.com/* +// @grant GM_audio +// ==/UserScript==`; + const view = assembleInstallView({ + isUpdate: false, + scriptInfo: makeScriptInfo(metadata), + action: makeAction(metadata), + code, + oldVersion: null, + }); + expect(view.compat.grants).toEqual(new Map([["GM_audio", 4]])); + expect(view.compat.tags).toEqual([{ tag: "exclude-match", line: 3 }]); + expect(view.compat.scriptcatOnlyTags).toEqual([]); + }); + it("全新安装组装名称、来源、版本与权限", () => { const metadata = { name: ["示例脚本"], diff --git a/src/pages/install/useInstallData.ts b/src/pages/install/useInstallData.ts index 64683a847..9c6eeaee5 100644 --- a/src/pages/install/useInstallData.ts +++ b/src/pages/install/useInstallData.ts @@ -29,6 +29,7 @@ import { type ScheduleInfo, type DiffStat, } from "./model"; +import { deriveCompatMarks, type CompatMarks } from "./compat"; export interface InstallView { isUpdate: boolean; @@ -54,6 +55,8 @@ export interface InstallView { subscribeScripts: string[]; /** 由 MCP 客户端请求安装时附加;非 MCP 来源为 undefined */ externalAccess?: ScriptInfo["externalAccess"]; + /** 写了但脚本猫不会执行的指令与 GM 能力,就近标在权限行上 */ + compat: CompatMarks; } /** @@ -93,6 +96,7 @@ export function assembleInstallView(args: { diffStat: oldCode !== undefined && oldCode !== code ? deriveDiffStat(oldCode, code) : undefined, subscribeScripts: scriptInfo.userSubscribe ? metadata.scripturl || [] : [], externalAccess: scriptInfo.externalAccess, + compat: deriveCompatMarks(metadata, code), }; } diff --git a/src/pkg/utils/monaco-editor/index.ts b/src/pkg/utils/monaco-editor/index.ts index 90ce5a151..d3d2a5b25 100644 --- a/src/pkg/utils/monaco-editor/index.ts +++ b/src/pkg/utils/monaco-editor/index.ts @@ -13,11 +13,11 @@ import { getUndefinedMetadataTagMatches, isMetadataAlignmentBlockAligned, metadataHoverPattern, - resolveMetadataTagBase, type MetadataAlignmentBlock, type MetadataAlignmentLine, type MetadataBlockRange, } from "./metadata"; +import { resolveMetadataTagBase, SUPPORTED_METADATA_TAGS } from "@App/pkg/utils/script_compat"; interface ILinterWorker extends Worker { myLinterHook: EventEmitter; @@ -59,14 +59,12 @@ const configuredLanguagePromise = systemConfig.getLanguage(); let currentEditorLang: EditorLangEntry; type EditorLangEntryPrompt = typeof currentEditorLang.prompt; let promptByMetadataTag: EditorLangEntryPrompt; -let knownMetadataTagSet: ReadonlySet; const loadEditorLangEntry = (languageCode: EditorLangCode) => { currentEditorLang = asEditorLangEntry(languageCode); promptByMetadataTag = Object.fromEntries( Object.entries(currentEditorLang.prompt).map(([metadataTag, prompt]) => [metadataTag.toLowerCase(), prompt]) ) as typeof currentEditorLang.prompt; - knownMetadataTagSet = new Set(Object.keys(promptByMetadataTag)); }; loadEditorLangEntry("en-US"); @@ -660,7 +658,7 @@ const getUndefinedMetadataTagMarkers = ( model: editor.ITextModel, blocks: MetadataAlignmentBlock[] ): editor.IMarkerData[] => - getUndefinedMetadataTagMatches(model, blocks, knownMetadataTagSet).map((match) => ({ + getUndefinedMetadataTagMatches(model, blocks, SUPPORTED_METADATA_TAGS).map((match) => ({ severity: MarkerSeverity.Warning, message: currentEditorLang.undefinedPrompt, source: scriptcatMarkerOwner, diff --git a/src/pkg/utils/monaco-editor/metadata.test.ts b/src/pkg/utils/monaco-editor/metadata.test.ts index 0d49b13d2..704706819 100644 --- a/src/pkg/utils/monaco-editor/metadata.test.ts +++ b/src/pkg/utils/monaco-editor/metadata.test.ts @@ -5,7 +5,6 @@ import { getMetadataAlignmentBlocks, getUndefinedMetadataTagMatches, isKnownMetadataTag, - resolveMetadataTagBase, } from "./metadata"; // 与 utils.test.ts 相同风格的简单 Monaco Editor 模型 mock @@ -60,17 +59,7 @@ describe("getMetadataAlignmentBlocks", () => { }); }); -describe("resolveMetadataTagBase / isKnownMetadataTag", () => { - it("本地化标签(name:)应解析为对应的基础标签", () => { - expect(resolveMetadataTagBase("name:zh-CN")).toBe("name"); - expect(resolveMetadataTagBase("description:en")).toBe("description"); - }); - - it("非本地化标签保持原样(小写化)", () => { - expect(resolveMetadataTagBase("Grant")).toBe("grant"); - expect(resolveMetadataTagBase("run-at")).toBe("run-at"); - }); - +describe("isKnownMetadataTag", () => { it("已知的本地化标签应视为已定义", () => { expect(isKnownMetadataTag("name:zh-CN", knownTags)).toBe(true); expect(isKnownMetadataTag("description:ja", knownTags)).toBe(true); diff --git a/src/pkg/utils/monaco-editor/metadata.ts b/src/pkg/utils/monaco-editor/metadata.ts index 0a248232e..a81621e8a 100644 --- a/src/pkg/utils/monaco-editor/metadata.ts +++ b/src/pkg/utils/monaco-editor/metadata.ts @@ -1,4 +1,5 @@ import type { editor } from "monaco-editor"; +import { resolveMetadataTagBase } from "@App/pkg/utils/script_compat"; export type MetadataAlignmentLine = { lineNumber: number; @@ -32,9 +33,6 @@ export const metadataLineStartPattern = /^\s*\/\/[ \t]*@/; export const userscriptHeaderPattern = /^\s*\/\/[ \t]*==UserScript==[ \t]*$/; export const userscriptEndPattern = /^\s*\/\/[ \t]*==\/UserScript==[ \t]*$/; const metadataAlignmentPattern = /^(\s*\/\/[ \t]*@)(\S+)([ \t]+)(.*)$/; -// ScriptCat 运行时消费 `name:`/`description:` (src/locales/locales.ts) 等本地化标签, -// 它们是合法标签而非拼写错误,需与其余标签区分开来单独判断。 -const localeSuffixedMetadataTagPattern = /^(name|description):(.+)$/; export const getMetadataAlignmentLine = (lineNumber: number, lineText: string): MetadataAlignmentLine | null => { const match = metadataAlignmentPattern.exec(lineText); @@ -94,12 +92,6 @@ export const isMetadataAlignmentBlockAligned = (block: MetadataAlignmentBlock) = return block.lines.every((line) => line.valueColumn === firstValueColumn); }; -export const resolveMetadataTagBase = (tag: string): string => { - const normalizedTag = tag.toLowerCase(); - const localeMatch = localeSuffixedMetadataTagPattern.exec(normalizedTag); - return localeMatch ? localeMatch[1] : normalizedTag; -}; - export const isKnownMetadataTag = (tag: string, knownTags: ReadonlySet): boolean => knownTags.has(resolveMetadataTagBase(tag)); diff --git a/src/pkg/utils/script.test.ts b/src/pkg/utils/script.test.ts index 2c1cf044b..a1850bfc1 100644 --- a/src/pkg/utils/script.test.ts +++ b/src/pkg/utils/script.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { parseMetadata, parseScriptFromCode, fetchScriptBody, prepareScriptByCode } from "./script"; +import { parseMetadata, parseMetadataLines, parseScriptFromCode, fetchScriptBody, prepareScriptByCode } from "./script"; import { getMetadataStr, getUserConfigStr } from "./utils"; import { parseUserConfig } from "./yaml"; import { @@ -724,6 +724,67 @@ console.log('Hello World'); }); }); +describe.concurrent("parseMetadataLines", () => { + const code = `// 开头的普通注释 +// ==UserScript== +// @name 示例 +// @namespace https://example.com +// @match *://example.com/* +// @exclude-match *://live.example.com/* +// @grant GM_setValue +// ==/UserScript== + +console.log(1); +`; + + it("逐条给出指令名、取值与 1 起算的全文行号", () => { + expect(parseMetadataLines(code)).toEqual([ + { tag: "name", name: "name", value: "示例", line: 3 }, + { tag: "namespace", name: "namespace", value: "https://example.com", line: 4 }, + { tag: "match", name: "match", value: "*://example.com/*", line: 5 }, + { tag: "exclude-match", name: "exclude-match", value: "*://live.example.com/*", line: 6 }, + { tag: "grant", name: "grant", value: "GM_setValue", line: 7 }, + ]); + }); + + it("指令名小写归一,与 parseMetadata 的取键一致", () => { + const lines = parseMetadataLines(`// ==UserScript== +// @Name X +// @MATCH *://a.com/* +// ==/UserScript==`); + expect(lines.map((l) => l.tag)).toEqual(["name", "match"]); + // 呈现给用户时要用作者的原始写法 + expect(lines.map((l) => l.name)).toEqual(["Name", "MATCH"]); + }); + + it("同名指令重复出现时逐条保留,不合并", () => { + const lines = parseMetadataLines(`// ==UserScript== +// @name X +// @match *://a.com/* +// @match *://b.com/* +// ==/UserScript==`); + expect(lines.filter((l) => l.tag === "match").map((l) => l.line)).toEqual([3, 4]); + }); + + it("没有元数据区块时返回空列表", () => { + expect(parseMetadataLines("console.log(1);")).toEqual([]); + }); + + it("只认第一个闭合区块——与 parseMetadata 的 HEADER_BLOCK 语义一致", () => { + const lines = parseMetadataLines(`// ==UserScript== +// @name X +// ==/UserScript== +// ==UserScript== +// @name Y +// ==/UserScript==`); + expect(lines).toEqual([{ tag: "name", name: "name", value: "X", line: 2 }]); + }); + + it("区块未闭合时不产出任何指令——与 parseMetadata 一致", () => { + expect(parseMetadataLines(`// ==UserScript==\n// @name X\n`)).toEqual([]); + }); +}); + describe.concurrent("getMetadataStr", () => { it.concurrent("提取UserScript元数据字符串", () => { const code = ` diff --git a/src/pkg/utils/script.ts b/src/pkg/utils/script.ts index 8473cedeb..877dd63a1 100644 --- a/src/pkg/utils/script.ts +++ b/src/pkg/utils/script.ts @@ -21,6 +21,48 @@ import { readRawContent } from "@App/pkg/utils/encoding"; const HEADER_BLOCK = /\/\/[ \t]*==User(Script|Subscribe)==([\s\S]+?)\/\/[ \t]*==\/User\1==/m; const META_LINE = /\/\/[ \t]*@(\S+)[ \t]*(.*)$/gm; +export interface MetadataLine { + /** 小写归一后的指令名,与 parseMetadata 的取键一致 */ + tag: string; + /** 作者的原始写法,仅供呈现 */ + name: string; + value: string; + /** 1 起算的全文行号 */ + line: number; +} + +const HEADER_OPEN = /^\/\/[ \t]*==User(?:Script|Subscribe)==/; + +/** + * 带位置的元数据解析:与 parseMetadata 共用 HEADER_BLOCK / META_LINE, + * 保证诊断看到的指令集合与运行时实际解析出的完全一致(否则会对着一条运行时根本没读到的行报警)。 + * parseMetadata 按指令名聚合取值、丢弃位置,这里逐条保留顺序与行号。 + */ +export function parseMetadataLines(code: string): MetadataLine[] { + const block = HEADER_BLOCK.exec(code); + if (!block) return []; + const headerContent = block[2]; + const open = HEADER_OPEN.exec(block[0]); + if (!open) return []; + const headerStart = block.index + open[0].length; + + const lines: MetadataLine[] = []; + // META_LINE 的匹配按位置递增,逐段累计换行数即可,无需为每条指令从头数 + let scanned = 0; + let line = 1; + let m: RegExpExecArray | null; + META_LINE.lastIndex = 0; + while ((m = META_LINE.exec(headerContent)) !== null) { + const absolute = headerStart + m.index; + for (let i = scanned; i < absolute; i++) { + if (code.charCodeAt(i) === 10) line += 1; + } + scanned = absolute; + lines.push({ tag: m[1].toLowerCase(), name: m[1], value: m[2]?.trim() ?? "", line }); + } + return lines; +} + // 从脚本代码抽出Metadata export function parseMetadata(code: string): SCMetadata | null { let isSubscribe = false; diff --git a/src/pkg/utils/script_compat.test.ts b/src/pkg/utils/script_compat.test.ts new file mode 100644 index 000000000..c2a6a0761 --- /dev/null +++ b/src/pkg/utils/script_compat.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from "vitest"; +import "@App/app/service/content/gm_api/gm_api"; +import { GMContextApiNames } from "@App/app/service/content/gm_api/gm_context"; +import { compatMap as eslintHeaderCompatMap } from "@Packages/eslint/compat-headers"; +import { compatMap as upstreamHeaderCompatMap } from "eslint-plugin-userscripts/dist/data/compat-headers.js"; +import { + CONSUMED_METADATA_TAGS, + CONTEXT_PROVIDED_GRANTS, + SCRIPTCAT_ONLY_METADATA_TAGS, + VALUE_CONSTRAINED_TAGS, + SUPPORTED_GRANTS, + SUPPORTED_METADATA_TAGS, + SYNTHETIC_METADATA_TAGS, + isSupportedGrant, + ineffectiveMetadataValues, + isScriptCatOnlyGrant, + isSupportedMetadataTag, + resolveMetadataTagBase, +} from "./script_compat"; + +describe("元数据指令支持判定", () => { + it("运行时会消费的指令视为支持", () => { + for (const tag of ["match", "include", "exclude", "grant", "run-at", "run-in", "noframes", "connect"]) { + expect(isSupportedMetadataTag(tag), tag).toBe(true); + } + }); + + it("脚本猫独有的指令视为支持", () => { + for (const tag of ["crontab", "background", "early-start", "require-css", "storagename", "cloudcat"]) { + expect(isSupportedMetadataTag(tag), tag).toBe(true); + } + }); + + it("别家管理器会执行、脚本猫未实现的指令视为不支持", () => { + for (const tag of ["exclude-match", "top-level-await", "sandbox", "webRequest", "allFrames", "user-agent"]) { + expect(isSupportedMetadataTag(tag), tag).toBe(false); + } + }); + + it("脚本站与著作信息类指令不报不支持——脚本猫不执行它们,但也不影响脚本行为", () => { + for (const tag of ["license", "compatible", "contributionURL", "uso:script", "oujs:author", "screenshot"]) { + expect(isSupportedMetadataTag(tag), tag).toBe(true); + } + }); + + it("指令名大小写不敏感", () => { + expect(isSupportedMetadataTag("MATCH")).toBe(true); + expect(isSupportedMetadataTag("Run-At")).toBe(true); + expect(isSupportedMetadataTag("EXCLUDE-MATCH")).toBe(false); + }); + + it("name/description 的语言后缀归到同一指令", () => { + expect(isSupportedMetadataTag("name:zh-CN")).toBe(true); + expect(isSupportedMetadataTag("description:ja")).toBe(true); + expect(resolveMetadataTagBase("Name:zh-CN")).toBe("name"); + // 只有 name/description 有语言后缀,其余指令的冒号后缀不参与归一 + expect(resolveMetadataTagBase("uso:script")).toBe("uso:script"); + }); + + it("拼写错误等未知指令视为不支持", () => { + expect(isSupportedMetadataTag("mathc")).toBe(false); + expect(isSupportedMetadataTag("matchAboutBlank")).toBe(false); + }); +}); + +describe("GM 能力支持判定", () => { + it("注册表中的 GM API 视为支持", () => { + for (const grant of ["GM_setValue", "GM.setValue", "CAT_fileStorage", "CAT.agent.dom", "window.close"]) { + expect(isSupportedGrant(grant), grant).toBe(true); + } + }); + + it("GM_ 与 GM. 前缀互认——与运行时 getGrantCandidates 同一套规则", () => { + // 注册表只登记了 GM_xmlhttpRequest / GM.xmlHttpRequest 两种写法,交叉写法靠候选规则兜住 + expect(isSupportedGrant("GM.deleteValues")).toBe(true); + expect(isSupportedGrant("GM_deleteValues")).toBe(true); + }); + + it("不经注册表、由上下文直接提供的能力也视为支持", () => { + for (const grant of ["unsafeWindow", "window.onurlchange", "GM_info", "none"]) { + expect(isSupportedGrant(grant), grant).toBe(true); + } + }); + + it("脚本猫未实现的 GM API 视为不支持", () => { + for (const grant of ["GM_audio", "GM_webRequest", "GM_addScript", "GM_createObjectURL"]) { + expect(isSupportedGrant(grant), grant).toBe(false); + } + }); +}); + +describe("元数据取值支持判定", () => { + it("取值在脚本猫认得的范围内时不报", () => { + expect( + ineffectiveMetadataValues({ + "run-at": ["document-start"], + "run-in": ["incognito-tabs"], + "inject-into": ["content"], + "early-start": [""], + unwrap: ["true"], + }) + ).toEqual([]); + for (const runAt of ["document-body", "document-end", "document-idle", "context-menu"]) { + expect(ineffectiveMetadataValues({ "run-at": [runAt] }), runAt).toEqual([]); + } + }); + + it("取值不在白名单内一律报出,不需要事先登记——运行时对不认识的取值会静默回退", () => { + expect( + ineffectiveMetadataValues({ + "run-at": ["document-weird"], + "run-in": ["container-id-2"], + "inject-into": ["auto"], + unwrap: ["yes"], + }) + ).toEqual([ + { tag: "run-at", index: 0, value: "document-weird" }, + { tag: "run-in", index: 0, value: "container-id-2" }, + { tag: "inject-into", index: 0, value: "auto" }, + { tag: "unwrap", index: 0, value: "yes" }, + ]); + }); + + it("取值大小写敏感,与运行时的比较方式一致", () => { + expect(ineffectiveMetadataValues({ "run-at": ["Document-Start"] })).toEqual([ + { tag: "run-at", index: 0, value: "Document-Start" }, + ]); + }); + + it("运行时只读第一个取值的指令,后续取值报为不生效", () => { + expect(ineffectiveMetadataValues({ "run-in": ["normal-tabs", "incognito-tabs"] })).toEqual([ + { tag: "run-in", index: 1, value: "incognito-tabs" }, + ]); + }); + + it("@early-start 只在 @run-at document-start 下生效", () => { + expect(ineffectiveMetadataValues({ "early-start": [""] })).toEqual([{ tag: "early-start", index: 0, value: "" }]); + expect(ineffectiveMetadataValues({ "early-start": [""], "run-at": ["document-start"] })).toEqual([]); + }); + + it("运行时解析不出匹配规则的 @match 报为不生效,能解析的(含兼容 TM 的简写)不报", () => { + expect( + ineffectiveMetadataValues({ + match: ["*://a.com/*", "www.youtube.com/*", "*", "hello-world^^", ""], + }) + ).toEqual([ + { tag: "match", index: 3, value: "hello-world^^" }, + { tag: "match", index: 4, value: "" }, + ]); + }); + + it("不限取值的指令不做取值判定", () => { + expect(ineffectiveMetadataValues({ include: ["anything"], namespace: ["x"], noframes: ["whatever"] })).toEqual([]); + }); + + it("有取值约束的指令都是脚本猫会消费的指令", () => { + expect(VALUE_CONSTRAINED_TAGS.filter((tag) => !CONSUMED_METADATA_TAGS.has(tag))).toEqual([]); + }); +}); + +describe("仅限脚本猫的元数据指令", () => { + // 只对照会被消费的指令:信息类(如 @definition)在脚本猫里同样不起作用,标「仅限脚本猫」会误导 + it("与 eslint-plugin-userscripts 收录的别家指令对照:脚本猫会消费、别家都没有的,就是脚本猫独有的", () => { + const upstream = new Set( + [ + ...Object.keys(upstreamHeaderCompatMap.unlocalized), + ...Object.keys(upstreamHeaderCompatMap.nonFunctional), + ...Object.keys(upstreamHeaderCompatMap.localized), + ].map((key) => key.toLowerCase()) + ); + const expected = [...CONSUMED_METADATA_TAGS].filter( + (tag) => !upstream.has(tag) && !SYNTHETIC_METADATA_TAGS.has(tag) + ); + expect([...SCRIPTCAT_ONLY_METADATA_TAGS].sort()).toEqual(expected.sort()); + }); + + it("包含 @early-start 与 @background,不包含通用指令", () => { + expect(SCRIPTCAT_ONLY_METADATA_TAGS.has("early-start")).toBe(true); + expect(SCRIPTCAT_ONLY_METADATA_TAGS.has("background")).toBe(true); + expect(SCRIPTCAT_ONLY_METADATA_TAGS.has("match")).toBe(false); + }); +}); + +describe("仅限脚本猫的 GM 能力判定", () => { + it("CAT_ 与 CAT. 命名空间下的已实现能力仅限脚本猫", () => { + for (const grant of ["CAT_fileStorage", "CAT_userConfig", "CAT.agent.dom"]) { + expect(isScriptCatOnlyGrant(grant), grant).toBe(true); + } + }); + + it("通用 GM 能力、上下文能力与脚本猫未实现的 CAT 名字都不算", () => { + for (const grant of ["GM_setValue", "GM.xmlHttpRequest", "unsafeWindow", "window.close", "CAT_notExist"]) { + expect(isScriptCatOnlyGrant(grant), grant).toBe(false); + } + }); +}); + +describe("支持表与运行时注册表的一致性", () => { + it("注册表里的每个 @grant 都在支持表内——新增 GM API 漏进表会在此转红", () => { + const missing = GMContextApiNames().filter((name) => !SUPPORTED_GRANTS.has(name)); + expect(missing).toEqual([]); + }); + + it("支持表不含注册表之外的名字——上下文直接提供的能力单独登记,便于审阅", () => { + const registered = new Set(GMContextApiNames()); + const extra = [...SUPPORTED_GRANTS].filter((name) => !registered.has(name) && !CONTEXT_PROVIDED_GRANTS.has(name)); + expect(extra).toEqual([]); + }); +}); + +describe("支持表与编辑器 ESLint 合法 header 集合的一致性", () => { + it("支持表里的指令,编辑器都应认为是合法 header——否则用户会在编辑器里收到「不是合法 header」的误报", () => { + // no-invalid-headers 按原样比对 header 名,而脚本猫运行时把指令名小写化,故按小写比对 + const validHeaders = new Set( + [ + ...Object.keys(eslintHeaderCompatMap.unlocalized), + ...Object.keys(eslintHeaderCompatMap.nonFunctional), + ...Object.keys(eslintHeaderCompatMap.localized), + ].map((key) => key.toLowerCase()) + ); + const missing = [...SUPPORTED_METADATA_TAGS].filter( + (tag) => !SYNTHETIC_METADATA_TAGS.has(tag) && !validHeaders.has(tag) + ); + expect(missing).toEqual([]); + }); +}); diff --git a/src/pkg/utils/script_compat.ts b/src/pkg/utils/script_compat.ts new file mode 100644 index 000000000..1a164b57f --- /dev/null +++ b/src/pkg/utils/script_compat.ts @@ -0,0 +1,272 @@ +import type { SCMetadata } from "@App/app/repo/metadata"; +import { getGrantCandidates } from "@App/app/service/content/gm_api/grant"; +import { extractUrlPatterns } from "./url_matcher"; + +/** + * 脚本猫的兼容性支持表:安装页与编辑器共用的唯一判定来源。 + * + * 判定是二元的——指令/能力要么被脚本猫消费,要么写了也不会生效,没有中间档。 + * 表外即「不生效」,因此收录标准是「脚本猫会消费它」或「脚本猫不消费但它也不改变脚本运行行为」; + * 只有会改变别家管理器下脚本行为、而脚本猫没实现的指令才刻意留在表外(如 @exclude-match)。 + * 取值同理:取值有限的指令只认白名单里的取值,运行时对不认得的取值会静默回退。 + */ + +// name/description/antifeature 可带 `:` 后缀取本地化值(src/locales/locales.ts), +// 是同一条指令的语言变体而非独立指令。其余指令的冒号(uso:script 等)是名字的一部分。 +const LOCALE_SUFFIXED_TAG = /^(name|description|antifeature):(.+)$/; + +/** 归一到判定用的指令名:小写,并去掉本地化后缀 */ +export const resolveMetadataTagBase = (tag: string): string => { + const normalized = tag.toLowerCase(); + const localeMatch = LOCALE_SUFFIXED_TAG.exec(normalized); + return localeMatch ? localeMatch[1] : normalized; +}; + +// 脚本猫会读取的指令:注入与匹配链路、脚本类型判定、云端/订阅、以及列表与安装页的展示字段。 +const CONSUMED_TAGS = [ + "name", + "namespace", + "version", + "description", + "author", + "match", + "include", + "exclude", + "connect", + "grant", + "require", + "require-css", + "resource", + "run-at", + "run-in", + "noframes", + "inject-into", + "unwrap", + "early-start", + "background", + "crontab", + "antifeature", + "tag", + "storagename", + "cloudcat", + "cloudserver", + "exportvalue", + "exportcookie", + "scripturl", + "usersubscribe", + "updateurl", + "downloadurl", + "icon", + "iconurl", + "icon64", + "icon64url", + "defaulticon", +]; + +// 脚本猫不消费,但也不改变脚本运行行为的指令:著作信息、脚本站元数据、构建提示。 +// 它们出现在脚本里是正常的,标成「不生效」只会制造噪音。 +// definition 是脚本猫自己文档化的编辑器指令,当前没有任何消费方;在它被实现或从文档撤下之前 +// 按信息类处理,避免安装页对着脚本猫自己的文档报警。 +const INFORMATIONAL_TAGS = [ + "license", + "copyright", + "homepage", + "homepageurl", + "website", + "source", + "supporturl", + "installurl", + "compatible", + "definition", + "contributor", + "contributors", + "collaborator", + "creator", + "developer", + "contributionurl", + "contributionamount", + "screenshot", + "history", + "id", + "major", + "minor", + "build", + "unstableminify", + "oujs:author", + "oujs:collaborator", + "uso:script", + "uso:version", + "uso:timestamp", + "uso:hash", + "uso:rating", + "uso:installs", + "uso:reviews", + "uso:discussions", + "uso:fans", + "uso:unlisted", +]; + +// 不是作者写出来的指令,而是 parseMetadata 为 .user.sub.js 合成的标记; +// 订阅脚本的 metadata 里会出现这个键,必须视为支持,否则安装页会把它标成不生效。 +export const SYNTHETIC_METADATA_TAGS: ReadonlySet = new Set(["usersubscribe"]); + +export const CONSUMED_METADATA_TAGS: ReadonlySet = new Set(CONSUMED_TAGS); + +export const SUPPORTED_METADATA_TAGS: ReadonlySet = new Set([...CONSUMED_TAGS, ...INFORMATIONAL_TAGS]); + +export const isSupportedMetadataTag = (tag: string): boolean => + SUPPORTED_METADATA_TAGS.has(resolveMetadataTagBase(tag)); + +// 脚本猫独有、别家管理器不认的指令。script_compat.test.ts 对照 eslint-plugin-userscripts 收录的别家指令守卫 +export const SCRIPTCAT_ONLY_METADATA_TAGS: ReadonlySet = new Set([ + "require-css", + "early-start", + "background", + "crontab", + "storagename", + "cloudcat", + "cloudserver", + "exportvalue", + "exportcookie", + "scripturl", +]); + +// 运行时按 metadata[tag][0] 与这些取值逐字比较,其余取值(含大小写不同)都会回退到默认行为: +// run-at → getRunAt / isContextMenuScript / script_executor;run-in → runtime.ts; +// inject-into → isInjectIntoContent,page 即默认行为;unwrap / early-start → metadataBlankOrTrue。 +// false 与不写的效果一致,写出来也符合作者本意,不算不生效。 +const BLANK_OR_BOOLEAN = new Set(["", "true", "false"]); +const SUPPORTED_TAG_VALUES: Readonly>> = { + "run-at": new Set(["document-start", "document-body", "document-end", "document-idle", "context-menu"]), + "run-in": new Set(["all", "normal-tabs", "incognito-tabs"]), + "inject-into": new Set(["page", "content"]), + unwrap: BLANK_OR_BOOLEAN, + "early-start": BLANK_OR_BOOLEAN, +}; + +export const VALUE_CONSTRAINED_TAGS: readonly string[] = Object.keys(SUPPORTED_TAG_VALUES); + +export interface IneffectiveMetadataValue { + tag: string; + /** 在 metadata[tag] 中的下标,用于回找代码行 */ + index: number; + value: string; +} + +/** 受支持指令里不会按写法生效的取值:不在白名单内、运行时只读第一个而被忽略的后续取值、解析不出规则的 @match */ +export function ineffectiveMetadataValues(metadata: SCMetadata): IneffectiveMetadataValue[] { + const result: IneffectiveMetadataValue[] = []; + for (const [tag, allowed] of Object.entries(SUPPORTED_TAG_VALUES)) { + (metadata[tag] || []).forEach((value, index) => { + const effective = + index === 0 && + allowed.has(value) && + // early-start 只在 document-start 下接管注入(isEarlyStartScript) + (tag !== "early-start" || metadata["run-at"]?.[0] === "document-start"); + if (!effective) result.push({ tag, index, value }); + }); + } + // @match 写法开放,交给运行时同一个解析器判:解析不出规则的会被静默丢弃(@include/@exclude 总能退化成 glob) + (metadata.match || []).forEach((value, index) => { + if (!extractUrlPatterns([`@match ${value}`]).length) result.push({ tag: "match", index, value }); + }); + return result; +} + +// 不经 GMContext 注册表、由沙盒上下文直接提供或无需授权的能力: +// unsafeWindow 与 GM_info 恒定注入(src/app/service/content/create_context.ts、exec_script.ts), +// window.onurlchange 在 createContext 里单独接管,none 表示不请求任何 GM 能力。 +export const CONTEXT_PROVIDED_GRANTS: ReadonlySet = new Set([ + "none", + "unsafeWindow", + "GM_info", + "GM.info", + "window.onurlchange", +]); + +// GMContext 注册表在 src/app/service/content/gm_api/ 由装饰器填充,安装页不能为了查一次支持性 +// 把整套 GM 实现拉进包里,因此在此静态镜像一份;script_compat.test.ts 守卫两者一致。 +const REGISTERED_GRANTS = [ + "CAT.agent.conversation", + "CAT.agent.dom", + "CAT.agent.model", + "CAT.agent.opfs", + "CAT.agent.skills", + "CAT.agent.task", + "CAT_createBlobUrl", + "CAT_fetchBlob", + "CAT_fetchDocument", + "CAT_fileStorage", + "CAT_registerMenuInput", + "CAT_scriptLoaded", + "CAT_unregisterMenuInput", + "CAT_userConfig", + "GM.addElement", + "GM.addStyle", + "GM.addValueChangeListener", + "GM.closeInTab", + "GM.closeNotification", + "GM.cookie", + "GM.deleteValue", + "GM.deleteValues", + "GM.download", + "GM.getResourceText", + "GM.getResourceURL", + "GM.getResourceUrl", + "GM.getTab", + "GM.getTabs", + "GM.getValue", + "GM.getValues", + "GM.listValues", + "GM.log", + "GM.notification", + "GM.openInTab", + "GM.registerMenuCommand", + "GM.removeValueChangeListener", + "GM.saveTab", + "GM.setClipboard", + "GM.setValue", + "GM.setValues", + "GM.unregisterMenuCommand", + "GM.updateNotification", + "GM.xmlHttpRequest", + "GM_addElement", + "GM_addStyle", + "GM_addValueChangeListener", + "GM_closeInTab", + "GM_closeNotification", + "GM_cookie", + "GM_deleteValue", + "GM_deleteValues", + "GM_download", + "GM_getResourceText", + "GM_getResourceURL", + "GM_getTab", + "GM_getTabs", + "GM_getValue", + "GM_getValues", + "GM_listValues", + "GM_log", + "GM_notification", + "GM_openInTab", + "GM_registerMenuCommand", + "GM_removeValueChangeListener", + "GM_saveTab", + "GM_setClipboard", + "GM_setValue", + "GM_setValues", + "GM_unregisterMenuCommand", + "GM_updateNotification", + "GM_xmlhttpRequest", + "window.close", + "window.focus", +]; + +export const SUPPORTED_GRANTS: ReadonlySet = new Set([...REGISTERED_GRANTS, ...CONTEXT_PROVIDED_GRANTS]); + +// 与运行时同一套候选规则:@grant GM.foo 与 GM_foo 互认(src/app/service/content/gm_api/grant.ts) +export const isSupportedGrant = (grant: string): boolean => + getGrantCandidates(grant).some((candidate) => SUPPORTED_GRANTS.has(candidate)); + +// CAT_ / CAT. 是脚本猫自有的 API 命名空间,其他脚本管理器没有这些能力 +export const isScriptCatOnlyGrant = (grant: string): boolean => /^CAT[_.]/.test(grant) && isSupportedGrant(grant); diff --git a/src/types/eslint-plugin-userscripts.d.ts b/src/types/eslint-plugin-userscripts.d.ts new file mode 100644 index 000000000..5846c63f7 --- /dev/null +++ b/src/types/eslint-plugin-userscripts.d.ts @@ -0,0 +1,8 @@ +// 未经 packages/eslint 覆盖的上游数据,script_compat.test.ts 用它推出哪些指令是脚本猫独有的 +declare module "eslint-plugin-userscripts/dist/data/compat-headers.js" { + export const compatMap: { + localized: Record; + unlocalized: Record; + nonFunctional: Record; + }; +} diff --git a/tests/mocks/CodeEditor.tsx b/tests/mocks/CodeEditor.tsx index 2d9b93f47..6bde3d896 100644 --- a/tests/mocks/CodeEditor.tsx +++ b/tests/mocks/CodeEditor.tsx @@ -1,25 +1,38 @@ -import { useEffect } from "react"; +import { useEffect, useImperativeHandle, type Ref } from "react"; -// 真实编辑器要等偏好设置读出来才创建实例并回调 onEditorMount。默认模拟「已就绪」, +// 真实编辑器要等偏好设置读出来才创建实例并回调。默认模拟「已就绪」, // 需要停在就绪之前(例如断言代码骨架)的用例调 setEditorMounts(false)。 let editorMounts = true; export function setEditorMounts(v: boolean) { editorMounts = v; } +export const revealLine = { calls: [] as number[] }; + export default function MockCodeEditor({ id, code, diffCode, onEditorMount, + onReady, + ref, }: { id: string; code?: string; diffCode?: string; onEditorMount?: (editor: unknown) => void; + onReady?: () => void; + ref?: Ref<{ editor: unknown; revealLine: (line: number) => void }>; }) { + useImperativeHandle(ref, () => ({ + editor: {}, + revealLine: (line: number) => revealLine.calls.push(line), + })); useEffect(() => { - if (editorMounts) onEditorMount?.({}); - }, [onEditorMount]); + if (!editorMounts) return; + // 与真实实现一致:diff 预览没有可编辑实例,只报告就绪 + if (!diffCode) onEditorMount?.({}); + onReady?.(); + }, [diffCode, onEditorMount, onReady]); return
; }