From baa0188ae07b23ff1b6947d183d21508bd23006a Mon Sep 17 00:00:00 2001 From: Maurice Kappelhof Date: Thu, 19 Mar 2026 13:28:36 +0100 Subject: [PATCH 1/2] Add supoort for chromium based browsers --- .changeset/late-tigers-push.md | 5 ++ .github/workflows/release.yml | 40 ++++++---- manifests/chrome.json | 18 +++++ package.json | 10 ++- src/background.ts | 137 ++++++++++++++++++++++----------- src/interfaces/declarations.ts | 2 + vite.config.ts | 30 ++++++-- web-ext-config.json | 2 +- 8 files changed, 172 insertions(+), 72 deletions(-) create mode 100644 .changeset/late-tigers-push.md create mode 100644 manifests/chrome.json diff --git a/.changeset/late-tigers-push.md b/.changeset/late-tigers-push.md new file mode 100644 index 0000000..0229c01 --- /dev/null +++ b/.changeset/late-tigers-push.md @@ -0,0 +1,5 @@ +--- +"headertweaker": minor +--- + +Add support for Chromium based browsers diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f45615a..274eb94 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,26 +56,38 @@ jobs: VERSION=$(node -p "require('./package.json').version") echo "version=$VERSION" >> $GITHUB_OUTPUT - - name: Build + - name: Build Firefox extension if: steps.changesets.outputs.published == 'true' - run: pnpm run build + run: pnpm run build:firefox - - name: Build web extension + - name: Build Chrome extension if: steps.changesets.outputs.published == 'true' - run: pnpm run web-ext:build + run: pnpm run build:chrome - - name: Find built zip file + - name: Package Firefox extension if: steps.changesets.outputs.published == 'true' - id: find_zip + run: pnpm exec web-ext build --source-dir=dist/firefox --artifacts-dir=web-ext-artifacts/firefox + + - name: Package Chrome extension + if: steps.changesets.outputs.published == 'true' + run: pnpm exec web-ext build --source-dir=dist/chrome --artifacts-dir=web-ext-artifacts/chrome + + - name: Find built zip files + if: steps.changesets.outputs.published == 'true' + id: find_zips run: | - ZIP_FILE=$(find web-ext-artifacts -name "*.zip" -type f | head -n 1) - if [ -z "$ZIP_FILE" ]; then - echo "Error: No zip file found in web-ext-artifacts directory" - ls -la web-ext-artifacts/ + FIREFOX_ZIP=$(find web-ext-artifacts/firefox -name "*.zip" -type f | head -n 1) + CHROME_ZIP=$(find web-ext-artifacts/chrome -name "*.zip" -type f | head -n 1) + if [ -z "$FIREFOX_ZIP" ]; then + echo "Error: No zip file found for Firefox" + exit 1 + fi + if [ -z "$CHROME_ZIP" ]; then + echo "Error: No zip file found for Chrome" exit 1 fi - echo "zip_file=$ZIP_FILE" >> $GITHUB_OUTPUT - echo "Found zip file: $ZIP_FILE" + echo "firefox_zip=$FIREFOX_ZIP" >> $GITHUB_OUTPUT + echo "chrome_zip=$CHROME_ZIP" >> $GITHUB_OUTPUT - name: Create GitHub Release if: steps.changesets.outputs.published == 'true' @@ -85,6 +97,8 @@ jobs: name: Release v${{ steps.get_version.outputs.version }} body: | Automated release for version ${{ steps.get_version.outputs.version }}. - files: ${{ steps.find_zip.outputs.zip_file }} + files: | + ${{ steps.find_zips.outputs.firefox_zip }} + ${{ steps.find_zips.outputs.chrome_zip }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/manifests/chrome.json b/manifests/chrome.json new file mode 100644 index 0000000..a509596 --- /dev/null +++ b/manifests/chrome.json @@ -0,0 +1,18 @@ +{ + "manifest_version": 3, + "name": "HeaderTweaker", + "version": "1.0", + "description": "Lightweight browser extension that lets you easily modify outgoing HTTP headers", + "permissions": ["storage", "declarativeNetRequest", "declarativeNetRequestWithHostAccess"], + "host_permissions": [""], + "background": { + "service_worker": "js/background.js", + "type": "module" + }, + "action": { + "default_popup": "headertweaker.html", + "default_icon": { + "48": "icon.png" + } + } +} diff --git a/package.json b/package.json index e3b9a76..6bd2b0f 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,13 @@ "web-ext": "8.9.0" }, "scripts": { - "dev": "vite build && web-ext run --source-dir=dist --watch-files=dist/*", - "dev:console": "vite build && web-ext run --source-dir=dist --watch-files=dist/* --browser-console", - "build": "vite build", + "dev:firefox": "BROWSER=firefox vite build && web-ext run --source-dir=dist/firefox --watch-files=dist/firefox/*", + "dev:firefox:console": "BROWSER=firefox vite build && web-ext run --source-dir=dist/firefox --watch-files=dist/firefox/* --browser-console", + "build:firefox": "BROWSER=firefox vite build", + "dev:chrome": "BROWSER=chrome vite build && web-ext run --target=chromium --source-dir=dist/chrome --watch-files=dist/chrome/*", + "dev:chrome:console": "BROWSER=chrome vite build && web-ext run --target=chromium --source-dir=dist/chrome --watch-files=dist/chrome/* --browser-console", + "build:chrome": "BROWSER=chrome vite build", + "build:all": "pnpm run build:firefox && pnpm run build:chrome", "change": "changeset", "lint": "biome lint .", "lint:fix": "biome lint . --fix", diff --git a/src/background.ts b/src/background.ts index 7c9e3e3..4f5b289 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,61 +1,104 @@ -const isFirefox = typeof browser !== 'undefined'; -const webRequest = isFirefox ? browser.webRequest : chrome.webRequest; -const storage = isFirefox ? browser.storage : chrome.storage; - // Keep in sync with STATUS_KEY in headertweaker.helper.ts const STATUS_KEY = 'isDisabled'; +type Header = { name: string; value: string; enabled: boolean }; + +// chrome.storage works in both Firefox (MV2) and Chrome (MV3) const getStatus = async (): Promise<'enabled' | 'disabled'> => { - const result = await storage.local.get(STATUS_KEY); + const result = await chrome.storage.local.get(STATUS_KEY); return result[STATUS_KEY] ? 'disabled' : 'enabled'; }; -type Header = { name: string; value: string; enabled: boolean }; - -type OnBeforeSendHeadersDetails = T extends typeof browser.webRequest - ? browser.webRequest._OnBeforeSendHeadersDetails - : T extends typeof chrome.webRequest - ? chrome.webRequest.OnBeforeSendHeadersDetails - : never; - const getHeaders = async (): Promise => { - const result = await storage.local.get('headers'); + const result = await chrome.storage.local.get('headers'); return result.headers || []; }; -// Listener to modify request headers -const onBeforeSendHeaders = async >( - details: D -) => { - const isEnabled = await getStatus(); - - if (isEnabled === 'enabled') { - const headers = (await getHeaders()) as Header[]; - if (!headers.length || !details.requestHeaders) return {} as D; - - // Only modify enabled headers - const enabledHeaders = headers.filter(({ enabled }) => enabled); - if (!enabledHeaders.length) return {} as D; - - // Clone and modify request headers - const requestHeaders = details.requestHeaders.slice(); - enabledHeaders.forEach(({ name, value }) => { - // Remove any existing header with the same name - for (let i = requestHeaders.length - 1; i >= 0; i--) { - if (requestHeaders[i].name.toLowerCase() === name.toLowerCase()) { - requestHeaders.splice(i, 1); +if (__BROWSER__ === 'chrome') { + // Chrome MV3: use declarativeNetRequest to modify outgoing request headers + const { ResourceType } = chrome.declarativeNetRequest; + const ALL_RESOURCE_TYPES: chrome.declarativeNetRequest.ResourceType[] = [ + ResourceType.MAIN_FRAME, + ResourceType.SUB_FRAME, + ResourceType.STYLESHEET, + ResourceType.SCRIPT, + ResourceType.IMAGE, + ResourceType.FONT, + ResourceType.OBJECT, + ResourceType.XMLHTTPREQUEST, + ResourceType.PING, + ResourceType.CSP_REPORT, + ResourceType.MEDIA, + ResourceType.WEBSOCKET, + ResourceType.OTHER, + ]; + + const updateRules = async () => { + const isEnabled = await getStatus(); + const headers = await getHeaders(); + + const existingRules = await chrome.declarativeNetRequest.getDynamicRules(); + const removeRuleIds = existingRules.map((rule) => rule.id); + const addRules: chrome.declarativeNetRequest.Rule[] = []; + + if (isEnabled === 'enabled') { + const enabledHeaders = headers.filter(({ enabled }) => enabled); + enabledHeaders.forEach(({ name, value }, index) => { + addRules.push({ + id: index + 1, + priority: 1, + action: { + type: 'modifyHeaders', + requestHeaders: [{ header: name, operation: 'set', value }], + }, + condition: { + resourceTypes: ALL_RESOURCE_TYPES, + }, + }); + }); + } + + await chrome.declarativeNetRequest.updateDynamicRules({ removeRuleIds, addRules }); + }; + + chrome.runtime.onInstalled.addListener(updateRules); + chrome.runtime.onStartup.addListener(updateRules); + chrome.storage.onChanged.addListener(() => { + updateRules(); + }); +} else { + // Firefox MV2: use blocking webRequest to modify outgoing request headers + const onBeforeSendHeaders = async ( + details: browser.webRequest._OnBeforeSendHeadersDetails + ): Promise => { + const isEnabled = await getStatus(); + + if (isEnabled === 'enabled') { + const headers = await getHeaders(); + if (!headers.length || !details.requestHeaders) return {}; + + const enabledHeaders = headers.filter(({ enabled }) => enabled); + if (!enabledHeaders.length) return {}; + + const requestHeaders = details.requestHeaders.slice(); + enabledHeaders.forEach(({ name, value }) => { + for (let i = requestHeaders.length - 1; i >= 0; i--) { + if (requestHeaders[i].name.toLowerCase() === name.toLowerCase()) { + requestHeaders.splice(i, 1); + } } - } - // Add new header - requestHeaders.push({ name, value }); - }); + requestHeaders.push({ name, value }); + }); - return { requestHeaders } as D; - } -}; + return { requestHeaders }; + } + + return {}; + }; -// Register the listener -webRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, { urls: [''] }, [ - 'blocking', - 'requestHeaders', -]); + browser.webRequest.onBeforeSendHeaders.addListener( + onBeforeSendHeaders, + { urls: [''] }, + ['blocking', 'requestHeaders'] + ); +} diff --git a/src/interfaces/declarations.ts b/src/interfaces/declarations.ts index 2978cbc..409340a 100644 --- a/src/interfaces/declarations.ts +++ b/src/interfaces/declarations.ts @@ -1,3 +1,5 @@ +declare const __BROWSER__: 'firefox' | 'chrome'; + declare module '*.scss' { const content: { [className: string]: string }; export default content; diff --git a/vite.config.ts b/vite.config.ts index e5f7fe1..c6b64cf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,15 +4,26 @@ import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; const pkg = JSON.parse(readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8')); +const BROWSER = (process.env.BROWSER as 'firefox' | 'chrome') || 'firefox'; -const syncManifestVersion = () => { +const syncManifest = () => { return { - name: 'sync-manifest-version', + name: 'sync-manifest', closeBundle() { - const manifestPath = path.resolve(__dirname, 'dist/manifest.json'); - const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')); - manifest.version = pkg.version; - writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + const distDir = path.resolve(__dirname, `dist/${BROWSER}`); + const distManifestPath = path.join(distDir, 'manifest.json'); + + if (BROWSER === 'chrome') { + // Overwrite the Firefox manifest that was copied from publicDir + const chromeSrc = path.resolve(__dirname, 'manifests/chrome.json'); + const manifest = JSON.parse(readFileSync(chromeSrc, 'utf-8')); + manifest.version = pkg.version; + writeFileSync(distManifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + } else { + const manifest = JSON.parse(readFileSync(distManifestPath, 'utf-8')); + manifest.version = pkg.version; + writeFileSync(distManifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + } }, }; }; @@ -26,8 +37,11 @@ export default defineConfig({ plugins: [['babel-plugin-react-compiler', {}]], }, }), - syncManifestVersion(), + syncManifest(), ], + define: { + __BROWSER__: JSON.stringify(BROWSER), + }, resolve: { alias: { 'react/compiler-runtime': 'react-compiler-runtime', @@ -41,7 +55,7 @@ export default defineConfig({ extensions: ['.js', '.ts', '.tsx', '.jsx'], }, build: { - outDir: '../dist', + outDir: `../dist/${BROWSER}`, emptyOutDir: true, rollupOptions: { input: { diff --git a/web-ext-config.json b/web-ext-config.json index 65c6b91..2bef212 100644 --- a/web-ext-config.json +++ b/web-ext-config.json @@ -1,3 +1,3 @@ { - "sourceDir": "dist" + "sourceDir": "dist/firefox" } From bbd2ff6dc4acadcc590e41149f4ed32938cbcd2e Mon Sep 17 00:00:00 2001 From: Maurice Kappelhof Date: Thu, 19 Mar 2026 13:29:43 +0100 Subject: [PATCH 2/2] Fix build command in pr-checks --- .github/workflows/pr-checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 98843f6..685f18f 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -51,7 +51,7 @@ jobs: run: pnpm check-types - name: Build - run: pnpm build + run: pnpm build:all - name: Lint run: pnpm lint