diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index e19818a..73a1628 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -66,6 +66,7 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "zip_name=stackprism-v${version}.zip" >> "$GITHUB_OUTPUT" echo "crx_name=stackprism-v${version}.crx" >> "$GITHUB_OUTPUT" + echo "xpi_name=stackprism-v${version}.xpi" >> "$GITHUB_OUTPUT" - name: 打包 zip shell: bash @@ -78,6 +79,13 @@ jobs: ) sha256sum "release/${{ steps.meta.outputs.zip_name }}" > "release/${{ steps.meta.outputs.zip_name }}.sha256" + - name: 打包 Firefox .xpi + shell: bash + run: | + set -euo pipefail + node build-scripts/package-firefox.mjs + sha256sum "release/${{ steps.meta.outputs.xpi_name }}" > "release/${{ steps.meta.outputs.xpi_name }}.sha256" + - name: 签名 crx id: crx shell: bash @@ -105,6 +113,8 @@ jobs: assets=( "release/${{ steps.meta.outputs.zip_name }}" "release/${{ steps.meta.outputs.zip_name }}.sha256" + "release/${{ steps.meta.outputs.xpi_name }}" + "release/${{ steps.meta.outputs.xpi_name }}.sha256" ) if [ -f "release/${{ steps.meta.outputs.crx_name }}" ]; then assets+=( diff --git a/.gitignore b/.gitignore index e46c22a..f900e0c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ docs/public/icon.svg # Build and packaged extension output dist/ +dist-firefox/ build/ release/ releases/ @@ -16,6 +17,7 @@ artifacts/ public/injected/ *.zip *.crx +*.xpi *.pem # Test output and local reports diff --git a/build-scripts/package-firefox.mjs b/build-scripts/package-firefox.mjs new file mode 100644 index 0000000..82f4a69 --- /dev/null +++ b/build-scripts/package-firefox.mjs @@ -0,0 +1,155 @@ +import { cpSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs' +import { resolve, dirname, basename } from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const distDir = resolve(root, 'dist') +const firefoxDir = resolve(root, 'dist-firefox') + +if (!existsSync(distDir)) { + console.error('[package-firefox] dist/ not found, run `pnpm build` first') + process.exit(1) +} + +rmSync(firefoxDir, { recursive: true, force: true }) +cpSync(distDir, firefoxDir, { recursive: true }) + +// --- Inline ES module chunks into a single background script --- +// CRXJS outputs background as ES modules with code-split shared chunks. +// Firefox background scripts don't support ES modules, so we: +// 1. Wrap each shared chunk in an IIFE to isolate scope (prevents variable name collisions) +// 2. Store each chunk's exports in a per-chunk namespace +// 3. Replace the entry chunk's imports with variable declarations from those namespaces + +const parseAllImportBindings = (code) => { + const re = /import\{([^}]*)\}from"([^"]*)"/g + const imports = [] + let match + while ((match = re.exec(code)) !== null) { + const bindings = match[1].split(',').map(s => { + const parts = s.trim().split(/\s+as\s+/) + return { exported: parts[0].trim(), local: (parts[1] || parts[0]).trim() } + }) + imports.push({ path: match[2], bindings }) + } + if (!imports.length && /import\s/.test(code)) { + throw new Error('[package-firefox] unsupported import syntax in entry chunk — cannot inline') + } + return imports +} + +const parseExportBindings = (code) => { + const match = code.match(/export\{([^}]*)\};?\s*$/) + if (!match) { + if (/export\s/.test(code)) { + throw new Error('[package-firefox] unsupported export syntax in shared chunk — cannot inline') + } + return [] + } + return match[1].split(',').map(s => { + const parts = s.trim().split(/\s+as\s+/) + return { local: parts[0].trim(), exported: (parts[1] || parts[0]).trim() } + }) +} + +const stripModuleSyntax = (code) => + code.replace(/import\{[^}]*\}from"[^"]*";?/g, '').replace(/export\{[^}]*\};?/g, '') + +const resolveImports = (filePath) => { + const code = readFileSync(filePath, 'utf8') + const dir = dirname(filePath) + const importRe = /import\{[^}]*\}from"(\.\/[^"]+)"/g + const deps = [] + let match + while ((match = importRe.exec(code)) !== null) { + deps.push(resolve(dir, match[1])) + } + return { code, deps } +} + +const topologicalSort = (entryPath) => { + const visited = new Set() + const order = [] + const visit = (filePath) => { + if (visited.has(filePath)) return + visited.add(filePath) + const { deps } = resolveImports(filePath) + for (const dep of deps) visit(dep) + order.push(filePath) + } + visit(entryPath) + return order +} + +// Read the loader to find the entry chunk +const loaderPath = resolve(firefoxDir, 'service-worker-loader.js') +const loaderCode = readFileSync(loaderPath, 'utf8') +const entryMatch = loaderCode.match(/import\s+'\.\/(assets\/[^']+)'/) +if (!entryMatch) { + console.error('[package-firefox] could not resolve service-worker-loader entry') + process.exit(1) +} + +const entryPath = resolve(firefoxDir, entryMatch[1]) +const chunkOrder = topologicalSort(entryPath) +const sharedChunks = chunkOrder.slice(0, -1) +const entryChunkPath = chunkOrder[chunkOrder.length - 1] + +const parts = [`var __chunks = {};`] + +for (const filePath of sharedChunks) { + const code = readFileSync(filePath, 'utf8') + const chunkId = basename(filePath, '.js') + const exports = parseExportBindings(code) + const stripped = stripModuleSyntax(code) + const exportAssignments = exports + .map(e => `__chunks["${chunkId}"].${e.exported} = ${e.local};`) + .join(' ') + parts.push(`(function() { ${stripped} __chunks["${chunkId}"] = {}; ${exportAssignments} })();`) +} + +const entryCode = readFileSync(entryChunkPath, 'utf8') +const entryImports = parseAllImportBindings(entryCode) +let entryBody = stripModuleSyntax(entryCode) +if (entryImports.length) { + const varDecls = entryImports.flatMap(imp => { + const chunkId = basename(resolve(dirname(entryChunkPath), imp.path), '.js') + return imp.bindings.map(b => `var ${b.local} = __chunks["${chunkId}"].${b.exported};`) + }).join(' ') + entryBody = varDecls + '\n' + entryBody +} +parts.push(entryBody) + +const backgroundPath = resolve(firefoxDir, 'background.js') +writeFileSync(backgroundPath, parts.join('\n')) +console.log(`[package-firefox] inlined ${chunkOrder.length} chunks into background.js`) + +// --- Transform manifest.json --- + +const manifestPath = resolve(firefoxDir, 'manifest.json') +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + +if (manifest.background?.service_worker) { + manifest.background = { scripts: ['background.js'] } +} + +manifest.browser_specific_settings = { + gecko: { + id: 'stackprism@stackprism.dev', + strict_min_version: '128.0' + } +} + +writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)) +console.log('[package-firefox] manifest.json transformed') + +// --- Package .xpi --- + +const releaseDir = resolve(root, 'release') +if (!existsSync(releaseDir)) mkdirSync(releaseDir) + +const version = manifest.version +const xpiName = `stackprism-v${version}.xpi` +execFileSync('zip', ['-r', resolve(releaseDir, xpiName), '.'], { cwd: firefoxDir, stdio: 'inherit' }) +console.log(`[package-firefox] created release/${xpiName}`) diff --git a/package.json b/package.json index aaec668..68b8d98 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint src", "build:injected": "node build-scripts/build-injected.mjs", "build": "pnpm run build:injected && vite build", + "build:firefox": "pnpm run build && node build-scripts/package-firefox.mjs", "test:unit": "node --test tests/*.test.mjs", "check:links": "node build-scripts/check-tech-links.mjs", "extract:icons": "node build-scripts/extract-wappalyzer-icons.mjs", diff --git a/src/background/detection.ts b/src/background/detection.ts index c624509..9741f06 100644 --- a/src/background/detection.ts +++ b/src/background/detection.ts @@ -124,7 +124,9 @@ export const runActivePageDetection = async (tabId: number, options: { force?: b world: 'MAIN', files: ['injected/page-detector.iife.js'] }) - const page = injection?.[0]?.result + const rawResult = injection?.[0]?.result + // Firefox may return the raw Promise instead of awaiting it + const page = rawResult && typeof rawResult.then === 'function' ? await rawResult : rawResult if (!page) return const augmentedPage = await augmentPageWithWordPressThemeStyles(page) diff --git a/src/background/index.ts b/src/background/index.ts index 8845314..80fc856 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -15,6 +15,7 @@ import { registerMessageRouter } from './message-router' import { clearBundleLicenseTimer } from './bundle-license' import { clearTabWriteLock, withTabWriteLock } from './tab-write-lock' import { isDetectablePageUrl, isObservableRequestUrl } from '@/utils/page-support' +import { clearLegacySessionKeys } from '@/utils/browser-compat' registerMessageRouter() refreshAllBadges().catch(() => {}) @@ -24,6 +25,7 @@ chrome.runtime.onInstalled.addListener(() => { }) chrome.runtime.onStartup.addListener(() => { + clearLegacySessionKeys().catch(() => {}) injectContentObserverIntoOpenTabs() }) diff --git a/src/background/popup-cache.ts b/src/background/popup-cache.ts index 5eae9f3..ae68e94 100644 --- a/src/background/popup-cache.ts +++ b/src/background/popup-cache.ts @@ -1,3 +1,4 @@ +import { compatStorage } from '@/utils/browser-compat' import { attachTechnologyLinks } from './tech-links' import { addStoredCustomHeaderRules } from './headers' import { clearBadge, clearTabSession, getPopupCache, getTabData, getTabSnapshot, popupStorageKey, storageKey } from './tab-store' @@ -500,7 +501,7 @@ export const getPopupResultResponse = async (tabId: number) => { if (legacyPopup) { nextStorage[storageKey(tabId)] = tabData } - chrome.storage.session.set(nextStorage).catch(() => {}) + compatStorage.session.set(nextStorage).catch(() => {}) } const updatedAt = getStoredUpdatedAt(data) diff --git a/src/background/tab-store.ts b/src/background/tab-store.ts index 4b614b6..65a6531 100644 --- a/src/background/tab-store.ts +++ b/src/background/tab-store.ts @@ -1,3 +1,5 @@ +import { compatStorage } from '@/utils/browser-compat' + const TAB_DATA_PREFIX = 'tab:' const POPUP_DATA_PREFIX = 'popup:' @@ -8,7 +10,7 @@ export const popupStorageKey = (tabId: number): string => `${POPUP_DATA_PREFIX}$ export const getTabData = async (tabId: number): Promise => { const key = storageKey(tabId) try { - const stored = await chrome.storage.session.get(key) + const stored = await compatStorage.session.get(key) return stored[key] || {} } catch { return {} @@ -18,7 +20,7 @@ export const getTabData = async (tabId: number): Promise => { export const getPopupCache = async (tabId: number): Promise => { const key = popupStorageKey(tabId) try { - const stored = await chrome.storage.session.get(key) + const stored = await compatStorage.session.get(key) return stored[key] || null } catch { return null @@ -26,14 +28,14 @@ export const getPopupCache = async (tabId: number): Promise => { } export const writeTabData = async (tabId: number, tabData: Record, popupRecord: any): Promise => { - await chrome.storage.session.set({ + await compatStorage.session.set({ [storageKey(tabId)]: tabData, [popupStorageKey(tabId)]: popupRecord }) } export const clearTabSession = async (tabId: number): Promise => { - await chrome.storage.session.remove([storageKey(tabId), popupStorageKey(tabId)]).catch(() => {}) + await compatStorage.session.remove([storageKey(tabId), popupStorageKey(tabId)]).catch(() => {}) } export const getTabSnapshot = async (tabId: number): Promise<{ id: number; url: string; title: string }> => { diff --git a/src/utils/browser-compat.ts b/src/utils/browser-compat.ts new file mode 100644 index 0000000..567335a --- /dev/null +++ b/src/utils/browser-compat.ts @@ -0,0 +1,54 @@ +const SESSION_PREFIX = '__sp_session__:' + +let sessionSupported: boolean | null = null + +const checkSessionSupport = async (): Promise => { + if (sessionSupported !== null) return sessionSupported + try { + await chrome.storage.session.get('__probe__') + sessionSupported = true + } catch { + sessionSupported = false + } + return sessionSupported +} + +export const compatStorage = { + session: { + get: async (key: string): Promise> => { + if (await checkSessionSupport()) { + return chrome.storage.session.get(key) + } + const storageKey = SESSION_PREFIX + key + const result = await chrome.storage.local.get(storageKey) + return Object.prototype.hasOwnProperty.call(result, storageKey) + ? { [key]: result[storageKey] } + : {} + }, + set: async (items: Record): Promise => { + if (await checkSessionSupport()) { + return chrome.storage.session.set(items) + } + const prefixed: Record = {} + for (const [key, value] of Object.entries(items)) { + prefixed[SESSION_PREFIX + key] = value + } + return chrome.storage.local.set(prefixed) + }, + remove: async (keys: string[]): Promise => { + if (await checkSessionSupport()) { + return chrome.storage.session.remove(keys) + } + return chrome.storage.local.remove(keys.map(k => SESSION_PREFIX + k)) + } + } +} + +export const clearLegacySessionKeys = async (): Promise => { + if (await checkSessionSupport()) return + const all = await chrome.storage.local.get(null) + const sessionKeys = Object.keys(all).filter(k => k.startsWith(SESSION_PREFIX)) + if (sessionKeys.length) { + await chrome.storage.local.remove(sessionKeys) + } +}