Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/release-extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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+=(
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ docs/public/icon.svg

# Build and packaged extension output
dist/
dist-firefox/
build/
release/
releases/
artifacts/
public/injected/
*.zip
*.crx
*.xpi
*.pem

# Test output and local reports
Expand Down
155 changes: 155 additions & 0 deletions build-scripts/package-firefox.mjs
Original file line number Diff line number Diff line change
@@ -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
}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.

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}`)
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/background/detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/background/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {})
Expand All @@ -24,6 +25,7 @@ chrome.runtime.onInstalled.addListener(() => {
})

chrome.runtime.onStartup.addListener(() => {
clearLegacySessionKeys().catch(() => {})
injectContentObserverIntoOpenTabs()
})

Expand Down
3 changes: 2 additions & 1 deletion src/background/popup-cache.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 6 additions & 4 deletions src/background/tab-store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { compatStorage } from '@/utils/browser-compat'

const TAB_DATA_PREFIX = 'tab:'
const POPUP_DATA_PREFIX = 'popup:'

Expand All @@ -8,7 +10,7 @@ export const popupStorageKey = (tabId: number): string => `${POPUP_DATA_PREFIX}$
export const getTabData = async (tabId: number): Promise<any> => {
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 {}
Expand All @@ -18,22 +20,22 @@ export const getTabData = async (tabId: number): Promise<any> => {
export const getPopupCache = async (tabId: number): Promise<any> => {
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
}
}

export const writeTabData = async (tabId: number, tabData: Record<string, unknown>, popupRecord: any): Promise<void> => {
await chrome.storage.session.set({
await compatStorage.session.set({
[storageKey(tabId)]: tabData,
[popupStorageKey(tabId)]: popupRecord
})
}

export const clearTabSession = async (tabId: number): Promise<void> => {
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 }> => {
Expand Down
54 changes: 54 additions & 0 deletions src/utils/browser-compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
const SESSION_PREFIX = '__sp_session__:'

let sessionSupported: boolean | null = null

const checkSessionSupport = async (): Promise<boolean> => {
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<Record<string, unknown>> => {
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<string, unknown>): Promise<void> => {
if (await checkSessionSupport()) {
return chrome.storage.session.set(items)
}
const prefixed: Record<string, unknown> = {}
for (const [key, value] of Object.entries(items)) {
prefixed[SESSION_PREFIX + key] = value
}
return chrome.storage.local.set(prefixed)
},
remove: async (keys: string[]): Promise<void> => {
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<void> => {
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)
}
}