Skip to content
Open
22 changes: 14 additions & 8 deletions src/app/service/service_worker/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,14 +167,20 @@ export class ScriptService {
}
);
})
.finally(() => {
// 回退到到安装页
chrome.scripting.executeScript({
target: { tabId: req.tabId },
func: function () {
history.back();
},
});
.finally(async () => {
try {
// 直接用 chrome.tabs.goBack,不再走 content script 消息通道:
// content.js 依赖 chrome.userScripts 注册,未开发者模式/脚本被禁用/命中黑名单时不会被注入,
// 消息发不到会静默失败;goBack 只依赖已必需的 tabs 权限,不受这些条件影响。
const currentTab = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
// 仅针对用户自行点击安装、且仍停留在该标签的场景;用户若已切到其他标签,不应把后台标签拉回历史记录
if (currentTab?.[0]?.id === req.tabId) {
// 回退到到安装页
await chrome.tabs.goBack(req.tabId);
}
} catch (e) {
console.error("chrome.tabs.goBack/query error:", e);
}
});
},
{
Expand Down
2 changes: 2 additions & 0 deletions src/locales/de-DE/install.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en-US/install.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/ja-JP/install.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "広告付き",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/pt-BR/install.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/ru-RU/install.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "Содержит рекламу",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/tr-TR/install.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/vi-VN/install.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/zh-CN/install.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "附带广告",
Expand Down
2 changes: 2 additions & 0 deletions src/locales/zh-TW/install.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "附帶廣告",
Expand Down
32 changes: 32 additions & 0 deletions src/pages/install/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />);
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(<App />);
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(<App />);
expect(screen.queryByText("禁止内嵌访问")).not.toBeInTheDocument();
expect(screen.getByText("全网每日签到助手")).toBeInTheDocument();
});
});

describe("Install App 状态分流", () => {
it("loading 状态渲染加载屏", () => {
mockHook.mockReturnValue({ ...baseHook(), state: { status: "loading" } });
Expand Down
20 changes: 20 additions & 0 deletions src/pages/install/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ import { WatchingBanner } from "./components/WatchingBanner";
import { BackgroundPrompt, backgroundPromptShownKey } from "./components/BackgroundPrompt";
import { useInstallData } from "./useInstallData";

const isMainFrame = () => {
try {
// 跨域 iframe 下访问 window.top.document 会抛 SecurityError,此时必然不是同源顶层窗口
return window.top?.document === window.document;
} catch {
return false;
}
};

export default function App() {
const { t } = useTranslation(["install", "common"]);
const isMobile = useIsMobile();
Expand Down Expand Up @@ -55,6 +64,17 @@ export default function App() {
};
}, [ready, schedule, t]);

// 防点击劫持:安装页禁止被嵌入 iframe,须在 loading/skill/error 等所有状态渲染前拦截
if (!isMainFrame()) {
return (
<InstallError
title={t("install:frame_blocked_title")}
message={t("install:frame_blocked_desc")}
onClose={close}
/>
);
}

if (state.status === "loading") {
return <InstallLoading source={state.source} bytesText={state.bytesText} percent={state.percent} />;
}
Expand Down
54 changes: 54 additions & 0 deletions src/pages/install/useInstallData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,60 @@ 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();
// leaveInstallPage 延后到 install() 里 300ms 的 setTimeout 再叠一帧 rAF 才真正执行,多等一点确保已触发
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();
// leaveInstallPage 延后到 install() 里 300ms 的 setTimeout 再叠一帧 rAF 才真正执行,多等一点确保已触发
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 = {
Expand Down
28 changes: 24 additions & 4 deletions src/pages/install/useInstallData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,26 @@ const buildScriptInfo = (uuid: string, code: string, url: string, metadata: SCMe
source: "user",
});

// 安装页可能是专为安装打开的新标签(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;
leaveInstallPageRunning = true;
requestAnimationFrame(() => {
leaveInstallPageRunning = false;
if (window.history.length > 1) {
window.history.back();
} else {
window.close();
}
});
};

let keepAliveTimer: ReturnType<typeof setInterval> | undefined;
const startKeepAlive = (uuid: string) => {
const tick = () => {
Expand Down Expand Up @@ -327,7 +347,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)}`);
}
Expand All @@ -340,7 +360,7 @@ export function useInstallData(): UseInstallData {
if (opts?.noMoreUpdates && info && !info.userSubscribe) {
void scriptClient.setCheckUpdateUrl(info.uuid, false);
}
window.close();
leaveInstallPage();
}, []);

// 监听文件变更后自动重装,并刷新视图代码
Expand Down Expand Up @@ -397,7 +417,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)}`);
}
Expand All @@ -406,7 +426,7 @@ export function useInstallData(): UseInstallData {
const cancelSkill = useCallback(() => {
const uuid = skillUuidRef.current;
if (uuid) void agentClient.cancelSkillInstall(uuid);
window.close();
leaveInstallPage();
}, []);

// 重新触发加载(供加载失败后的重试按钮)
Expand Down
Loading