diff --git a/CONTEXT.md b/CONTEXT.md index 23c13f3..8c4a3e8 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -36,3 +36,7 @@ _Avoid_: 下载、匯出 **L1 零泄漏**: 左栏 IP 列表(扫描层)只显示共识徽章与多源一致国家;任何单源数据(运营商、分数、城市)不进 L1,留在详情层。 _Avoid_: 列表层泄漏 + +**通道**: +同一套插件代码面向特定浏览器(Chrome/Firefox)的发行变体;各有独立的 manifest 派生与「检查更新」比对的 Release 资产前缀。 +_Avoid_: 浏览器版、浏览器分支 diff --git a/docs/adr/0002-firefox-channel-amo-unlisted-derived-manifest.md b/docs/adr/0002-firefox-channel-amo-unlisted-derived-manifest.md new file mode 100644 index 0000000..1adf831 --- /dev/null +++ b/docs/adr/0002-firefox-channel-amo-unlisted-derived-manifest.md @@ -0,0 +1,8 @@ +# Firefox 通道经 AMO unlisted 签名分发,manifest 构建期派生 + +Chrome 与 Firefox 共用 `extension/` 同一套代码,`public/manifest.json` 是唯一 manifest 事实源(Chrome 版原样);Firefox 版由 `scripts/make-firefox-manifest.mjs` 在构建时派生 `dist/manifest.json`:background 以事件页 `scripts` 取代 `service_worker`(`type: module` 必须保留——background.js 与 index 共享 rollup 代码分块,顶层有 import),并加入 `browser_specific_settings.gecko`(id=`ip-radar-extension@steponeerror.github.io`,strict_min_version=127,即 optional_host_permissions 的支持门槛)。发行走 AMO unlisted 自签名(免费,不公开上架):`web-ext sign` 产物 xpi 直接分发,新版本重复上传签名后,已安装者由 AMO 更新服务自动更新。唯一不可逆点:gecko id 首次签名后即为 AMO 永久身份,换 id 等于发布一个新扩展。 + +## Considered Options + +- 未签名 zip + `about:debugging` 临时加载 — 否决:重启即失,日常自用不可接受。 +- 单 manifest 双写 background(`service_worker`+`scripts` 并存)— 否决:Chrome 侧产生未知键告警,公共 manifest 混入通道细节;派生保 Chrome 版零污染。 diff --git a/extension/.gitignore b/extension/.gitignore index 3a77e8c..d0d0f6c 100644 --- a/extension/.gitignore +++ b/extension/.gitignore @@ -1,3 +1,5 @@ node_modules/ dist/ *.zip +web-ext-artifacts/ +amo.env diff --git a/extension/README.md b/extension/README.md index 0143dd2..5a073dc 100644 --- a/extension/README.md +++ b/extension/README.md @@ -19,6 +19,17 @@ npm run build 也可解壓 `ip-radar-extension.zip`,載入解壓後包含 `manifest.json` 的資料夾。 +## Firefox + +在 `extension` 目錄執行 `npm run build:firefox`:產出 Firefox 版 `dist`(manifest 由 Chrome 版派生,`background` 改為事件頁並加入 `browser_specific_settings`),並打包 `IP.Radar.Firefox_v<版本>.zip`。 + +- 臨時安裝:Firefox 開啟 `about:debugging#/runtime/this-firefox`,按「Load Temporary Add-on」,選擇 `dist` 內的 `manifest.json`。臨時附加元件重啟 Firefox 後即消失。 +- 永久安裝需 AMO 簽名(免費,unlisted 不會公開上架):建立 `amo.env`(內含 `WEB_EXT_API_KEY`、`WEB_EXT_API_SECRET`),執行 `set -a; . amo.env; set +a` 後再 `npx web-ext sign --source-dir dist --channel unlisted`,簽好的 XPI 在 `web-ext-artifacts/`。 +- 新版本重複簽名上傳後,已安裝者由 AMO 自動更新。 +- gecko id 為 `ip-radar-extension@steponeerror.github.io`,一經簽名即永久綁定,不可更換。 +- Firefox 通道的「檢查更新」比對 Release 的 `IP.Radar.Firefox_*.zip` 資產。 +- 發佈時需在 GitHub Release 上傳名為 `IP.Radar.Firefox_v<版本>.zip` 的資產,Firefox 端「檢查更新」才會提示就緒;已簽名安裝者由 AMO 自動更新。 + ## 功能 - 一批預設最多 100 個 IPv4 / IPv6,可在設定調整單次最大查詢數(1–1,000);換行、逗號、分號或空格分隔,去重並提示無效項目。 diff --git a/extension/package.json b/extension/package.json index 0bb8691..24c1641 100644 --- a/extension/package.json +++ b/extension/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite --host 127.0.0.1", "build": "tsc --noEmit && vite build", + "build:firefox": "VITE_CHANNEL=firefox npm run build && node scripts/make-firefox-manifest.mjs && rm -f IP.Radar.Firefox_v$npm_package_version.zip && cd dist && zip -qr ../IP.Radar.Firefox_v$npm_package_version.zip .", "test": "vitest run" }, "dependencies": { diff --git a/extension/public/icons/icon-128.png b/extension/public/icons/icon-128.png index b20935c..7e7e3ee 100644 Binary files a/extension/public/icons/icon-128.png and b/extension/public/icons/icon-128.png differ diff --git a/extension/public/icons/icon-32.png b/extension/public/icons/icon-32.png index ac0f34b..4f8d7aa 100644 Binary files a/extension/public/icons/icon-32.png and b/extension/public/icons/icon-32.png differ diff --git a/extension/public/icons/icon-64.png b/extension/public/icons/icon-64.png index aedb829..56d37b9 100644 Binary files a/extension/public/icons/icon-64.png and b/extension/public/icons/icon-64.png differ diff --git a/extension/scripts/make-firefox-manifest.mjs b/extension/scripts/make-firefox-manifest.mjs new file mode 100644 index 0000000..7d10308 --- /dev/null +++ b/extension/scripts/make-firefox-manifest.mjs @@ -0,0 +1,26 @@ +// Firefox manifest 从 Chrome 版派生:单一事实源 public/manifest.json,不双维护 +import { readFile, writeFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +const GECKO_ID = "ip-radar-extension@steponeerror.github.io"; + +export function firefoxManifest(manifest) { + return { + ...manifest, + // Firefox MV3 用事件页(background.scripts),不支持 service_worker;type:module 必须 + // 保留:background.js 与 index 共享 rollup 代码分块,顶层有 import + background: { scripts: ["background.js"], type: "module" }, + browser_specific_settings: { + gecko: { id: GECKO_ID, strict_min_version: "127.0" }, // 127 = optional_host_permissions + }, + }; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const manifest = JSON.parse(await readFile("public/manifest.json", "utf8")); + await writeFile( + "dist/manifest.json", + JSON.stringify(firefoxManifest(manifest), null, 2) + "\n", + ); + console.log(`dist/manifest.json → Firefox (gecko id ${GECKO_ID})`); +} diff --git a/extension/scripts/make-firefox-manifest.test.mjs b/extension/scripts/make-firefox-manifest.test.mjs new file mode 100644 index 0000000..04e0ba1 --- /dev/null +++ b/extension/scripts/make-firefox-manifest.test.mjs @@ -0,0 +1,19 @@ +import { readFileSync } from "node:fs"; +import { test, expect } from "vitest"; +import { firefoxManifest } from "./make-firefox-manifest.mjs"; + +test("firefox manifest derives from the chrome manifest", () => { + const chrome = JSON.parse( + readFileSync(new URL("../public/manifest.json", import.meta.url), "utf8"), + ); + const fx = firefoxManifest(chrome); + expect(fx.background).toEqual({ scripts: ["background.js"], type: "module" }); + expect(fx.browser_specific_settings.gecko).toEqual({ + id: "ip-radar-extension@steponeerror.github.io", + strict_min_version: "127.0", + }); + // 除 background/gecko 外逐字段继承 Chrome manifest:权限/图标/popup 不双维护 + const { background, browser_specific_settings, ...rest } = fx; + const { background: chromeBackground, ...chromeRest } = chrome; + expect(rest).toEqual(chromeRest); +}); diff --git a/extension/src/UpdateCheck.tsx b/extension/src/UpdateCheck.tsx index 2d4d5e8..582b408 100644 --- a/extension/src/UpdateCheck.tsx +++ b/extension/src/UpdateCheck.tsx @@ -10,6 +10,11 @@ const RELEASES_API = "https://api.github.com/repos/steponeerror/ip-radar-desktop/releases/latest"; const RELEASES_URL = "https://github.com/steponeerror/ip-radar-desktop/releases/latest"; +// 通道资产前缀:Firefox 构建(VITE_CHANNEL=firefox)比对 Firefox zip,默认 Chrome +const ASSET_PREFIX = + import.meta.env.VITE_CHANNEL === "firefox" + ? "IP.Radar.Firefox_" + : "IP.Radar.Chrome_"; export function UpdateCheck() { const { t } = useI18n(); const [busy, setBusy] = useState(false); @@ -39,7 +44,7 @@ export function UpdateCheck() { !Array.isArray(release.assets) || !release.assets.some( (asset: { name?: string }) => - asset.name?.startsWith("IP.Radar.Chrome_") && asset.name.endsWith(".zip"), + asset.name?.startsWith(ASSET_PREFIX) && asset.name.endsWith(".zip"), ) ) { setMessage("新版本的擴充功能下載尚未就緒,請稍後再試。"); diff --git a/extension/src/__tests__/background-firefox.test.ts b/extension/src/__tests__/background-firefox.test.ts new file mode 100644 index 0000000..493478f --- /dev/null +++ b/extension/src/__tests__/background-firefox.test.ts @@ -0,0 +1,38 @@ +import { it, expect, vi, afterEach } from "vitest"; +afterEach(() => vi.unstubAllGlobals()); +// Firefox 没有 storage.local.setAccessLevel;initialize 必须跳过它并照常完成其余初始化。 +// 独立文件:background 模块的监听注册副作用需要全新的模块注册表。 +it("initialize survives without setAccessLevel (Firefox)", async () => { + let onInstalled: Function = () => {}; + const setPopup = vi.fn(); + let state: Record = {}; + vi.stubGlobal("chrome", { + runtime: { + id: "test", + getURL: (p: string) => `chrome-extension://test/${p}`, + onMessage: { addListener: vi.fn() }, + onInstalled: { addListener: (f: Function) => (onInstalled = f) }, + onStartup: { addListener: vi.fn() }, + }, + storage: { + local: { + get: async () => state, + set: async (v: Record) => { + state = v; + }, + // 故意不提供 setAccessLevel + }, + }, + alarms: { + create: vi.fn(), + clear: vi.fn(), + onAlarm: { addListener: vi.fn() }, + }, + action: { setPopup, onClicked: { addListener: vi.fn() } }, + tabs: { create: vi.fn() }, + }); + await import("../background"); + onInstalled(); + await vi.waitFor(() => expect(setPopup).toHaveBeenCalled()); + expect(setPopup).toHaveBeenCalledWith({ popup: "index.html?mode=popup" }); +}); diff --git a/extension/src/background.ts b/extension/src/background.ts index 94b1955..4cc52fa 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -147,9 +147,11 @@ chrome.action.onClicked.addListener(() => { }); }); async function initialize() { - await chrome.storage.local.setAccessLevel({ - accessLevel: "TRUSTED_CONTEXTS", - }); + // Firefox 没有 setAccessLevel(Chrome 专属);缺省即仅受信上下文可读,跳过不阻塞其余初始化 + if (chrome.storage.local.setAccessLevel) + await chrome.storage.local.setAccessLevel({ + accessLevel: "TRUSTED_CONTEXTS", + }); const state = await read(); await chrome.action.setPopup({ popup: state.settings.openInTab ? "" : "index.html?mode=popup",