From 2debea772b933dbb7582bc0c0910fbb7cd53cb1f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:40:46 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=90=9B=20fix=20Firefox=20history=20ba?= =?UTF-8?q?ck=20after=20script=20install?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_runtime.test.ts | 21 +++++++++++++++++++ src/app/service/content/script_runtime.ts | 4 ++++ src/app/service/service_worker/script.ts | 9 +++----- 3 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 src/app/service/content/script_runtime.test.ts diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts new file mode 100644 index 000000000..325b67792 --- /dev/null +++ b/src/app/service/content/script_runtime.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it, vi } from "vitest"; +import EventEmitter from "eventemitter3"; +import { MockMessage } from "@Packages/message/mock_message"; +import { Server } from "@Packages/message/server"; +import { ScriptEnvTag } from "@Packages/message/consts"; +import { ScriptRuntime } from "./script_runtime"; + +describe("ScriptRuntime 内容脚本导航", () => { + it("收到 historyBack 消息时应在内容脚本上下文回退历史记录", () => { + const historyBack = vi.spyOn(window.history, "back").mockImplementation(() => {}); + const emitter = new EventEmitter(); + const server = new Server("content", new MockMessage(emitter)); + const runtime = new ScriptRuntime(ScriptEnvTag.content, server, {} as never, {} as never, undefined); + + runtime.contentInit(); + emitter.emit("message", { action: "content/historyBack" }, () => {}, {}); + + expect(historyBack).toHaveBeenCalledOnce(); + historyBack.mockRestore(); + }); +}); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 817dd2a23..59c916229 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -20,6 +20,10 @@ export class ScriptRuntime { // content环境的特殊初始化 contentInit() { + this.server.on("historyBack", () => { + history.back(); + }); + this.server.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { const [parentNodeId, tagName, tmpAttr] = data.params; diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 48f992422..e810b61b5 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -51,6 +51,8 @@ import { EnableAgent } from "@App/app/const"; import { TrashScriptDAO } from "@App/app/repo/trash_script"; import type { TrashScript } from "@App/app/repo/trash_script"; import { SubscribeDAO } from "@App/app/repo/subscribe"; +import { ExtensionContentMessageSend } from "@Packages/message/extension_message"; +import { sendMessage } from "@Packages/message/client"; export type TCheckScriptUpdateOption = Partial< { checkType: "user"; noUpdateCheck?: number } | ({ checkType: "system" } & Record) @@ -169,12 +171,7 @@ export class ScriptService { }) .finally(() => { // 回退到到安装页 - chrome.scripting.executeScript({ - target: { tabId: req.tabId }, - func: function () { - history.back(); - }, - }); + void sendMessage(new ExtensionContentMessageSend(req.tabId), "content/historyBack"); }); }, { From ffadc8397412c944230d4f0eca4b1417a8c31f55 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 04:57:47 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E5=90=8E=E5=9B=9E=E9=80=80=E5=9C=A8=E6=97=A0=20conten?= =?UTF-8?q?t=20=E8=84=9A=E6=9C=AC=E6=97=B6=E9=9D=99=E9=BB=98=E5=A4=B1?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1594 把 history.back() 改由内容脚本通过消息执行,但 ScriptCat 自身的 content.js 依赖 chrome.userScripts.register() 注册(runtime.ts registerUserscripts),仅在开启 Developer Mode 且脚本运行未被禁用/未被黑名单 排除时才会被注入。普通用户关闭 Developer Mode 时该内容脚本从不存在, chrome.tabs.sendMessage 收不到响应但错误只是被 console.error 吞掉, 安装后标签页无法回退且没有任何异常抛出。 改为直接调用 chrome.tabs.goBack(req.tabId)——issue #1588 本就要求优先使用 浏览器原生的标签页导航 API,该 API 只依赖已必需的 tabs 权限,不依赖任何 内容脚本注入状态。同时移除不再需要的 historyBack 消息处理器与其测试。 Co-Authored-By: Claude Sonnet 5 --- .../service/content/script_runtime.test.ts | 21 ------------------- src/app/service/content/script_runtime.ts | 4 ---- src/app/service/service_worker/script.ts | 6 +++--- 3 files changed, 3 insertions(+), 28 deletions(-) delete mode 100644 src/app/service/content/script_runtime.test.ts diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts deleted file mode 100644 index 325b67792..000000000 --- a/src/app/service/content/script_runtime.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import EventEmitter from "eventemitter3"; -import { MockMessage } from "@Packages/message/mock_message"; -import { Server } from "@Packages/message/server"; -import { ScriptEnvTag } from "@Packages/message/consts"; -import { ScriptRuntime } from "./script_runtime"; - -describe("ScriptRuntime 内容脚本导航", () => { - it("收到 historyBack 消息时应在内容脚本上下文回退历史记录", () => { - const historyBack = vi.spyOn(window.history, "back").mockImplementation(() => {}); - const emitter = new EventEmitter(); - const server = new Server("content", new MockMessage(emitter)); - const runtime = new ScriptRuntime(ScriptEnvTag.content, server, {} as never, {} as never, undefined); - - runtime.contentInit(); - emitter.emit("message", { action: "content/historyBack" }, () => {}, {}); - - expect(historyBack).toHaveBeenCalledOnce(); - historyBack.mockRestore(); - }); -}); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 59c916229..817dd2a23 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -20,10 +20,6 @@ export class ScriptRuntime { // content环境的特殊初始化 contentInit() { - this.server.on("historyBack", () => { - history.back(); - }); - this.server.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { const [parentNodeId, tagName, tmpAttr] = data.params; diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 146cafbd0..f957524b7 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -51,8 +51,6 @@ import { EnableAgent } from "@App/app/const"; import { TrashScriptDAO } from "@App/app/repo/trash_script"; import type { TrashScript } from "@App/app/repo/trash_script"; import { SubscribeDAO } from "@App/app/repo/subscribe"; -import { ExtensionContentMessageSend } from "@Packages/message/extension_message"; -import { sendMessage } from "@Packages/message/client"; export type TCheckScriptUpdateOption = Partial< { checkType: "user"; noUpdateCheck?: number } | ({ checkType: "system" } & Record) @@ -171,7 +169,9 @@ export class ScriptService { }) .finally(() => { // 回退到到安装页 - void sendMessage(new ExtensionContentMessageSend(req.tabId), "content/historyBack"); + chrome.tabs.goBack(req.tabId).catch((e) => { + console.error("chrome.tabs.goBack error:", e); + }); }); }, { From 6b1a01fa8c18db06b586764b9b598f9a4fc952ba Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 05:05:29 +0900 Subject: [PATCH 3/7] Update script.ts --- src/app/service/service_worker/script.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index f957524b7..20b7cc60c 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -167,11 +167,17 @@ export class ScriptService { } ); }) - .finally(() => { - // 回退到到安装页 - chrome.tabs.goBack(req.tabId).catch((e) => { - console.error("chrome.tabs.goBack error:", e); - }); + .finally(async () => { + try { + const currentTab = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + // 仅针对用户自行点击安装的Tab + if (currentTab?.[0]?.id === req.tabId) { + // 回退到到安装页 + await chrome.tabs.goBack(req.tabId); + } + } catch (e) { + console.error("chrome.tabs.goBack/query error:", e); + } }); }, { From 104a56fdbcf670ed2578b8324a4d088d4827efb3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 05:19:57 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E9=A1=B5=E5=9C=A8=E5=90=8C=E6=A0=87=E7=AD=BE=E9=87=8D?= =?UTF-8?q?=E5=AE=9A=E5=90=91=E5=9C=BA=E6=99=AF=E4=B8=8B=E8=AF=AF=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E7=94=A8=E6=88=B7=E6=B5=8F=E8=A7=88=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.html 既可能由 chrome.tabs.create 打开为独立新标签(history.length === 1, 安装/更新完成后关闭该标签无损),也可能由 declarativeNetRequest 的就地 redirect 规则直接在用户当前浏览标签内被替换出来(点击 raw .user.js 链接场景,history.length > 1)。无论哪种入口,install()/close()/installSkill()/cancelSkill() 都无条件调用 window.close(),导致后者场景下用户实际浏览的标签(而非专用安装标签)被整个关闭。 改为共用的 leaveInstallPage():history.length > 1 时改用 history.back() 返回上一页, 仅在确实是独立新标签(history.length === 1)时才 window.close()。新增回归测试验证 两种 history.length 下分别调用 history.back()/window.close();已验证该测试在旧代码 上失败(history.length > 1 时 history.back 从未被调用)。 Co-Authored-By: Claude Sonnet 5 --- src/pages/install/useInstallData.test.ts | 52 ++++++++++++++++++++++++ src/pages/install/useInstallData.ts | 19 +++++++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/pages/install/useInstallData.test.ts b/src/pages/install/useInstallData.test.ts index e38a4573c..928123131 100644 --- a/src/pages/install/useInstallData.test.ts +++ b/src/pages/install/useInstallData.test.ts @@ -248,6 +248,58 @@ describe("useInstallData 数据流编排", () => { expect(state.view.oldCode).toBe("// old code"); }); + describe("安装成功后离开安装页:独立新标签应关闭,同标签内被重定向而来应返回上一页", () => { + const setupReady = async () => { + window.history.replaceState({}, "", "/install.html?uuid=u1"); + const metadata = { name: ["示例脚本"], version: ["1.0.0"], match: ["https://e.com/*"] }; + const info: ScriptInfo = { + url: "https://e.com/x.user.js", + code: "", + uuid: "u1", + userSubscribe: false, + metadata, + source: "user", + }; + (scriptClient.getInstallInfo as Mock).mockResolvedValue([false, info, {}]); + (getTempCode as Mock).mockResolvedValue("// code"); + (prepareScriptByCode as Mock).mockResolvedValue({ script: makeAction(metadata) }); + (scriptClient.install as Mock).mockResolvedValue(undefined); + const { result } = renderHook(() => useInstallData()); + await waitFor(() => expect(result.current.state.status).toBe("ready")); + return result; + }; + + it("history.length 为 1(以新标签打开)时应 window.close()", async () => { + const result = await setupReady(); + const closeSpy = vi.spyOn(window, "close").mockImplementation(() => {}); + const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {}); + vi.spyOn(window.history, "length", "get").mockReturnValue(1); + + await act(async () => { + await result.current.install(); + await new Promise((r) => setTimeout(r, 320)); + }); + + expect(closeSpy).toHaveBeenCalledOnce(); + expect(backSpy).not.toHaveBeenCalled(); + }); + + it("history.length > 1(同一标签被就地重定向而来)时应 history.back() 而非关闭标签", async () => { + const result = await setupReady(); + const closeSpy = vi.spyOn(window, "close").mockImplementation(() => {}); + const backSpy = vi.spyOn(window.history, "back").mockImplementation(() => {}); + vi.spyOn(window.history, "length", "get").mockReturnValue(2); + + await act(async () => { + await result.current.install(); + await new Promise((r) => setTimeout(r, 320)); + }); + + expect(backSpy).toHaveBeenCalledOnce(); + expect(closeSpy).not.toHaveBeenCalled(); + }); + }); + it("?skill= 时读取技能数据进入 skill 状态", async () => { window.history.replaceState({}, "", "/install.html?skill=sk1"); const skill = { diff --git a/src/pages/install/useInstallData.ts b/src/pages/install/useInstallData.ts index e84dc2f28..394542c9d 100644 --- a/src/pages/install/useInstallData.ts +++ b/src/pages/install/useInstallData.ts @@ -121,6 +121,17 @@ const buildScriptInfo = (uuid: string, code: string, url: string, metadata: SCMe source: "user", }); +// 安装页可能是专为安装打开的新标签(history.length === 1,关闭无损), +// 也可能是由 declarativeNetRequest 就地重定向而来的用户原浏览标签(history.length > 1), +// 后者若直接 window.close() 会连带关掉用户本来在看的页面,应改为返回上一页。 +const leaveInstallPage = () => { + if (window.history.length > 1) { + window.history.back(); + } else { + window.close(); + } +}; + let keepAliveTimer: ReturnType | undefined; const startKeepAlive = (uuid: string) => { const tick = () => { @@ -327,7 +338,7 @@ export function useInstallData(): UseInstallData { await scriptClient.install({ script, code: info.code }); notify.success(t("install:success")); } - if (closeAfterInstall) setTimeout(() => window.close(), 300); + if (closeAfterInstall) setTimeout(() => leaveInstallPage(), 300); } catch (e) { notify.error(`${t("install:failed")}: ${(e as Error)?.message || String(e)}`); } @@ -340,7 +351,7 @@ export function useInstallData(): UseInstallData { if (opts?.noMoreUpdates && info && !info.userSubscribe) { void scriptClient.setCheckUpdateUrl(info.uuid, false); } - window.close(); + leaveInstallPage(); }, []); // 监听文件变更后自动重装,并刷新视图代码 @@ -397,7 +408,7 @@ export function useInstallData(): UseInstallData { try { await agentClient.completeSkillInstall(uuid); notify.success(t("install:success")); - setTimeout(() => window.close(), 300); + setTimeout(() => leaveInstallPage(), 300); } catch (e) { notify.error(`${t("install:failed")}: ${(e as Error)?.message || String(e)}`); } @@ -406,7 +417,7 @@ export function useInstallData(): UseInstallData { const cancelSkill = useCallback(() => { const uuid = skillUuidRef.current; if (uuid) void agentClient.cancelSkillInstall(uuid); - window.close(); + leaveInstallPage(); }, []); // 重新触发加载(供加载失败后的重试按钮) From 0b2f74522de522ca0e0947228bf06a5d08d3932d Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 05:29:09 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=E5=AE=89?= =?UTF-8?q?=E8=A3=85=E9=A1=B5=20iframe=20=E9=98=B2=E5=B5=8C=E5=85=A5?= =?UTF-8?q?=E6=8B=A6=E6=88=AA:=E7=BF=BB=E8=AF=91=E7=BC=BA=E5=A4=B1?= =?UTF-8?q?=E4=B8=94=E6=8B=A6=E6=88=AA=E6=97=B6=E6=9C=BA=E8=BF=87=E6=99=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App.tsx 里新增的 isMainFrame() 拦截存在两个问题: 1. t("invalid_page_access") 引用的 key 在任何语言的任何命名空间里都不存在 (defaultNS 是 common,install/common 两个命名空间都没有该 key), i18n-usage.test.ts 静态扫描可直接复现:界面会原样显示 "invalid_page_access" 而非译文。改为正确加了命名空间前缀的 install:frame_blocked_title / install:frame_blocked_desc,并补全 9 个 locale 的翻译。 2. 拦截判断被放在 loading/invalid/error/skill 等提前 return 之后、ready 视图 组装完毕时才执行,意味着安装页在 loading 与 skill(技能安装,同样有安装按钮) 状态下完全不受 iframe 保护,防点击劫持形同虚设。改为紧跟在所有 Hook 调用之后、 任何状态分支之前执行(避免把 Hook 调用放进条件 return 之前,违反 Hooks 规则)。 同时把裸
替换为已有的 InstallError 组件,与其余错误态保持一致的设计风格 和可关闭交互(复用 close(),即上一提交里改好的 leaveInstallPage())。 新增回归测试验证:拦截提示的翻译能正确渲染、loading 状态下也会被拦截、拦截页 关闭按钮可用;已确认这些测试在修复前的占位实现上会失败。 Co-Authored-By: Claude Sonnet 5 --- src/locales/de-DE/install.json | 2 ++ src/locales/en-US/install.json | 2 ++ src/locales/ja-JP/install.json | 2 ++ src/locales/pt-BR/install.json | 2 ++ src/locales/ru-RU/install.json | 2 ++ src/locales/tr-TR/install.json | 2 ++ src/locales/vi-VN/install.json | 2 ++ src/locales/zh-CN/install.json | 2 ++ src/locales/zh-TW/install.json | 2 ++ src/pages/install/App.test.tsx | 32 ++++++++++++++++++++++++++++++++ src/pages/install/App.tsx | 19 +++++++++++++++++++ 11 files changed, 69 insertions(+) diff --git a/src/locales/de-DE/install.json b/src/locales/de-DE/install.json index 72d2f21de..d7f06a163 100644 --- a/src/locales/de-DE/install.json +++ b/src/locales/de-DE/install.json @@ -194,6 +194,8 @@ "downloading_status_percent": "Wird heruntergeladen. {{bytes}} / {{total}} ({{percent}}%) empfangen.", "page_load_failed": "Installationsseite konnte nicht geladen werden", "invalid_page": "Ungültige Seite", + "frame_blocked_title": "Einbettung blockiert", + "frame_blocked_desc": "Aus Sicherheitsgründen kann die Installationsseite nicht in einem iframe eingebettet werden. Bitte öffnen Sie sie direkt in einem Browser-Tab.", "from_legitimate_sources_warning": "Bitte installieren Sie Skripte aus legitimen Quellen! Unbekannte Skripte können Ihre Privatsphäre verletzen oder bösartige Operationen durchführen!", "referral_link_title": "Empfehlungslink", "ads_title": "Mit Werbung", diff --git a/src/locales/en-US/install.json b/src/locales/en-US/install.json index 78f50d422..9747426be 100644 --- a/src/locales/en-US/install.json +++ b/src/locales/en-US/install.json @@ -194,6 +194,8 @@ "downloading_status_percent": "Downloading. Received {{bytes}} / {{total}} ({{percent}}%).", "page_load_failed": "Failed to Load Installation Page", "invalid_page": "Invalid Page", + "frame_blocked_title": "Embedding Blocked", + "frame_blocked_desc": "For security reasons, the install page cannot be embedded in an iframe. Please open it directly in a browser tab.", "from_legitimate_sources_warning": "Please install scripts from legitimate sources! Unknown scripts may invade your privacy or conduct malicious operations.", "referral_link_title": "Referral Link", "ads_title": "Ads", diff --git a/src/locales/ja-JP/install.json b/src/locales/ja-JP/install.json index c492ef9c3..cad3634eb 100644 --- a/src/locales/ja-JP/install.json +++ b/src/locales/ja-JP/install.json @@ -194,6 +194,8 @@ "downloading_status_percent": "ダウンロード中。{{bytes}} / {{total}}({{percent}}%)を受信しました。", "page_load_failed": "インストールページの読み込みに失敗しました", "invalid_page": "無効なページ", + "frame_blocked_title": "埋め込みがブロックされました", + "frame_blocked_desc": "セキュリティ上の理由により、インストールページを iframe に埋め込むことはできません。ブラウザのタブで直接開いてください。", "from_legitimate_sources_warning": "正当なソースからスクリプトをインストールしてください!不明なスクリプトはプライバシーを侵害したり、悪意のある操作を行う可能性があります!", "referral_link_title": "紹介リンク", "ads_title": "広告付き", diff --git a/src/locales/pt-BR/install.json b/src/locales/pt-BR/install.json index 58c40e923..69e79700f 100644 --- a/src/locales/pt-BR/install.json +++ b/src/locales/pt-BR/install.json @@ -194,6 +194,8 @@ "downloading_status_percent": "Baixando. Recebidos {{bytes}} / {{total}} ({{percent}}%).", "page_load_failed": "Falha ao carregar a página de instalação", "invalid_page": "Página inválida", + "frame_blocked_title": "Incorporação bloqueada", + "frame_blocked_desc": "Por motivos de segurança, a página de instalação não pode ser incorporada em um iframe. Abra-a diretamente em uma aba do navegador.", "from_legitimate_sources_warning": "Por favor, instale scripts de fontes legítimas! Scripts desconhecidos podem invadir sua privacidade ou realizar operações maliciosas.", "referral_link_title": "Link de indicação", "ads_title": "Anúncios", diff --git a/src/locales/ru-RU/install.json b/src/locales/ru-RU/install.json index 8ed954f7e..c18676cc3 100644 --- a/src/locales/ru-RU/install.json +++ b/src/locales/ru-RU/install.json @@ -194,6 +194,8 @@ "downloading_status_percent": "Загрузка. Получено {{bytes}} / {{total}} ({{percent}}%).", "page_load_failed": "Не удалось загрузить страницу установки", "invalid_page": "Недействительная страница", + "frame_blocked_title": "Встраивание заблокировано", + "frame_blocked_desc": "В целях безопасности страница установки не может быть встроена в iframe. Пожалуйста, откройте её напрямую во вкладке браузера.", "from_legitimate_sources_warning": "Пожалуйста, устанавливайте скрипты только из законных источников! Неизвестные скрипты могут нарушить вашу конфиденциальность или выполнить злонамеренные операции!", "referral_link_title": "Реферальная ссылка", "ads_title": "Содержит рекламу", diff --git a/src/locales/tr-TR/install.json b/src/locales/tr-TR/install.json index 2e68a5218..63ff61626 100644 --- a/src/locales/tr-TR/install.json +++ b/src/locales/tr-TR/install.json @@ -194,6 +194,8 @@ "downloading_status_percent": "İndiriliyor. {{bytes}} / {{total}} alındı ({{percent}}%).", "page_load_failed": "Yükleme Sayfası Yüklenemedi", "invalid_page": "Geçersiz Sayfa", + "frame_blocked_title": "Gömme Engellendi", + "frame_blocked_desc": "Güvenlik nedeniyle yükleme sayfası bir iframe içine gömülemez. Lütfen doğrudan bir tarayıcı sekmesinde açın.", "from_legitimate_sources_warning": "Lütfen betikleri meşru kaynaklardan yükleyin! Bilinmeyen betikler gizliliğinizi ihlal edebilir veya kötü amaçlı işlemler gerçekleştirebilir.", "referral_link_title": "Yönlendirme Bağlantısı", "ads_title": "Reklamlar", diff --git a/src/locales/vi-VN/install.json b/src/locales/vi-VN/install.json index 233fa1002..8cc0eb058 100644 --- a/src/locales/vi-VN/install.json +++ b/src/locales/vi-VN/install.json @@ -194,6 +194,8 @@ "downloading_status_percent": "Đang tải xuống. Đã nhận {{bytes}} / {{total}} ({{percent}}%).", "page_load_failed": "Tải trang cài đặt thất bại", "invalid_page": "Trang không hợp lệ", + "frame_blocked_title": "Nhúng trang bị chặn", + "frame_blocked_desc": "Vì lý do bảo mật, trang cài đặt không thể được nhúng trong iframe. Vui lòng mở trực tiếp trong một tab trình duyệt.", "from_legitimate_sources_warning": "Vui lòng cài đặt script từ các nguồn hợp pháp! Script không rõ nguồn gốc có thể xâm phạm quyền riêng tư của bạn hoặc thực hiện các hoạt động độc hại.", "referral_link_title": "Liên kết giới thiệu", "ads_title": "Quảng cáo", diff --git a/src/locales/zh-CN/install.json b/src/locales/zh-CN/install.json index eb5a1af55..2d06057fe 100644 --- a/src/locales/zh-CN/install.json +++ b/src/locales/zh-CN/install.json @@ -194,6 +194,8 @@ "downloading_status_percent": "正在下载。已接收 {{bytes}} / {{total}}({{percent}}%)。", "page_load_failed": "安装页面加载失败", "invalid_page": "无效页面", + "frame_blocked_title": "禁止内嵌访问", + "frame_blocked_desc": "出于安全考虑,安装页不允许在 iframe 中嵌入显示,请在浏览器标签页中直接打开。", "from_legitimate_sources_warning": "请从合法来源安装脚本!未知的脚本可能会侵犯您的隐私或者做出恶意的操作!", "referral_link_title": "推荐链接", "ads_title": "附带广告", diff --git a/src/locales/zh-TW/install.json b/src/locales/zh-TW/install.json index 6db4875ff..e7a7927c8 100644 --- a/src/locales/zh-TW/install.json +++ b/src/locales/zh-TW/install.json @@ -194,6 +194,8 @@ "downloading_status_percent": "正在下載。已接收 {{bytes}} / {{total}}({{percent}}%)。", "page_load_failed": "安裝頁面載入失敗", "invalid_page": "無效頁面", + "frame_blocked_title": "禁止內嵌存取", + "frame_blocked_desc": "基於安全考量,安裝頁不允許在 iframe 中嵌入顯示,請在瀏覽器分頁中直接開啟。", "from_legitimate_sources_warning": "請從合法來源安裝腳本!未知的腳本可能會侵犯您的隱私或進行惡意操作!", "referral_link_title": "推薦連結", "ads_title": "附帶廣告", diff --git a/src/pages/install/App.test.tsx b/src/pages/install/App.test.tsx index f51792220..b4c6f406d 100644 --- a/src/pages/install/App.test.tsx +++ b/src/pages/install/App.test.tsx @@ -50,6 +50,38 @@ beforeAll(() => initTestLanguage("zh-CN")); afterEach(cleanup); +describe("Install App 防点击劫持:禁止在 iframe 中嵌入", () => { + const originalTop = window.top; + afterEach(() => { + Object.defineProperty(window, "top", { value: originalTop, configurable: true }); + }); + + it("非顶层 frame 时渲染拦截提示,即使当前状态是 loading 也不渲染原状态", () => { + Object.defineProperty(window, "top", { value: { document: {} }, configurable: true }); + mockHook.mockReturnValue({ ...baseHook(), state: { status: "loading" } }); + render(); + expect(screen.getByText("禁止内嵌访问")).toBeInTheDocument(); + expect(screen.queryByText("正在加载脚本")).not.toBeInTheDocument(); + }); + + it("非顶层 frame 时点击关闭按钮应调用 close", () => { + Object.defineProperty(window, "top", { value: { document: {} }, configurable: true }); + const close = vi.fn(); + mockHook.mockReturnValue({ ...baseHook(), close, state: { status: "ready", view: readyView() } }); + render(); + fireEvent.click(screen.getByText("关闭")); + expect(close).toHaveBeenCalledTimes(1); + }); + + it("顶层 frame 时正常渲染 ready 状态,不触发拦截", () => { + Object.defineProperty(window, "top", { value: window, configurable: true }); + mockHook.mockReturnValue({ ...baseHook(), state: { status: "ready", view: readyView() } }); + render(); + expect(screen.queryByText("禁止内嵌访问")).not.toBeInTheDocument(); + expect(screen.getByText("全网每日签到助手")).toBeInTheDocument(); + }); +}); + describe("Install App 状态分流", () => { it("loading 状态渲染加载屏", () => { mockHook.mockReturnValue({ ...baseHook(), state: { status: "loading" } }); diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index 8579513ab..d9edb4225 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -16,6 +16,14 @@ import { WatchingBanner } from "./components/WatchingBanner"; import { BackgroundPrompt, backgroundPromptShownKey } from "./components/BackgroundPrompt"; import { useInstallData } from "./useInstallData"; +const isMainFrame = () => { + try { + return window.top?.document === window.document; + } catch { + return false; + } +}; + export default function App() { const { t } = useTranslation(["install", "common"]); const isMobile = useIsMobile(); @@ -55,6 +63,17 @@ export default function App() { }; }, [ready, schedule, t]); + // 防点击劫持:安装页禁止被嵌入 iframe,须在 loading/skill/error 等所有状态渲染前拦截 + if (!isMainFrame()) { + return ( + + ); + } + if (state.status === "loading") { return ; } From ba01b1d50c749f1f1723f8ad67160f7319937e96 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 05:29:34 +0900 Subject: [PATCH 6/7] Update useInstallData.ts --- src/pages/install/useInstallData.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/pages/install/useInstallData.ts b/src/pages/install/useInstallData.ts index 394542c9d..e0f162f93 100644 --- a/src/pages/install/useInstallData.ts +++ b/src/pages/install/useInstallData.ts @@ -124,12 +124,18 @@ const buildScriptInfo = (uuid: string, code: string, url: string, metadata: SCMe // 安装页可能是专为安装打开的新标签(history.length === 1,关闭无损), // 也可能是由 declarativeNetRequest 就地重定向而来的用户原浏览标签(history.length > 1), // 后者若直接 window.close() 会连带关掉用户本来在看的页面,应改为返回上一页。 +let leaveInstallPageRunning = false; const leaveInstallPage = () => { - if (window.history.length > 1) { - window.history.back(); - } else { - window.close(); - } + if (leaveInstallPageRunning) return; + leaveInstallPageRunning = true; + requestAnimationFrame(() => { + leaveInstallPageRunning = false; + if (window.history.length > 1) { + window.history.back(); + } else { + window.close(); + } + }); }; let keepAliveTimer: ReturnType | undefined; From 8833ef9fcf07c3d52253412fcdd53ecd600a6af3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 05:43:08 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=93=9D=20=E4=B8=BA=E6=9C=AC=E5=88=86?= =?UTF-8?q?=E6=94=AF=E6=94=B9=E5=8A=A8=E8=A1=A5=E5=85=85=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E8=AF=B4=E6=98=8E=E8=AE=BE=E8=AE=A1=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对已合入改动逐处补上「为什么」注释:goBack 为何绕开 content script 通道、 为何只对当前激活标签生效;isMainFrame 里 SecurityError 的成因; leaveInstallPage 的重入保护与 rAF 延迟目的;测试里 320ms 等待对应的 setTimeout+rAF 时序。均为注释新增,不改变任何运行时行为。 Co-Authored-By: Claude Sonnet 5 --- src/app/service/service_worker/script.ts | 5 ++++- src/pages/install/App.tsx | 1 + src/pages/install/useInstallData.test.ts | 2 ++ src/pages/install/useInstallData.ts | 3 +++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 20b7cc60c..1e12651d5 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -169,8 +169,11 @@ export class ScriptService { }) .finally(async () => { try { + // 直接用 chrome.tabs.goBack,不再走 content script 消息通道: + // content.js 依赖 chrome.userScripts 注册,未开发者模式/脚本被禁用/命中黑名单时不会被注入, + // 消息发不到会静默失败;goBack 只依赖已必需的 tabs 权限,不受这些条件影响。 const currentTab = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); - // 仅针对用户自行点击安装的Tab + // 仅针对用户自行点击安装、且仍停留在该标签的场景;用户若已切到其他标签,不应把后台标签拉回历史记录 if (currentTab?.[0]?.id === req.tabId) { // 回退到到安装页 await chrome.tabs.goBack(req.tabId); diff --git a/src/pages/install/App.tsx b/src/pages/install/App.tsx index d9edb4225..372575f73 100644 --- a/src/pages/install/App.tsx +++ b/src/pages/install/App.tsx @@ -18,6 +18,7 @@ import { useInstallData } from "./useInstallData"; const isMainFrame = () => { try { + // 跨域 iframe 下访问 window.top.document 会抛 SecurityError,此时必然不是同源顶层窗口 return window.top?.document === window.document; } catch { return false; diff --git a/src/pages/install/useInstallData.test.ts b/src/pages/install/useInstallData.test.ts index 928123131..3dce98b64 100644 --- a/src/pages/install/useInstallData.test.ts +++ b/src/pages/install/useInstallData.test.ts @@ -277,6 +277,7 @@ describe("useInstallData 数据流编排", () => { await act(async () => { await result.current.install(); + // leaveInstallPage 延后到 install() 里 300ms 的 setTimeout 再叠一帧 rAF 才真正执行,多等一点确保已触发 await new Promise((r) => setTimeout(r, 320)); }); @@ -292,6 +293,7 @@ describe("useInstallData 数据流编排", () => { await act(async () => { await result.current.install(); + // leaveInstallPage 延后到 install() 里 300ms 的 setTimeout 再叠一帧 rAF 才真正执行,多等一点确保已触发 await new Promise((r) => setTimeout(r, 320)); }); diff --git a/src/pages/install/useInstallData.ts b/src/pages/install/useInstallData.ts index e0f162f93..b54a24f28 100644 --- a/src/pages/install/useInstallData.ts +++ b/src/pages/install/useInstallData.ts @@ -124,6 +124,9 @@ const buildScriptInfo = (uuid: string, code: string, url: string, metadata: SCMe // 安装页可能是专为安装打开的新标签(history.length === 1,关闭无损), // 也可能是由 declarativeNetRequest 就地重定向而来的用户原浏览标签(history.length > 1), // 后者若直接 window.close() 会连带关掉用户本来在看的页面,应改为返回上一页。 +// install()/close() 等可能在短时间内被重复触发(如用户连续点击、close 与 install 的 +// setTimeout 前后脚打到),leaveInstallPageRunning 防止 back()/close() 被并发调用多次; +// 推到 requestAnimationFrame 里执行,让触发它的那次交互(如按钮点击态)先完成一帧渲染。 let leaveInstallPageRunning = false; const leaveInstallPage = () => { if (leaveInstallPageRunning) return;