Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/late-tigers-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"headertweaker": minor
---

Add support for Chromium based browsers
2 changes: 1 addition & 1 deletion .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
run: pnpm check-types

- name: Build
run: pnpm build
run: pnpm build:all

- name: Lint
run: pnpm lint
Expand Down
40 changes: 27 additions & 13 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 }}
18 changes: 18 additions & 0 deletions manifests/chrome.json
Original file line number Diff line number Diff line change
@@ -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": ["<all_urls>"],
"background": {
"service_worker": "js/background.js",
"type": "module"
},
"action": {
"default_popup": "headertweaker.html",
"default_icon": {
"48": "icon.png"
}
}
}
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
137 changes: 90 additions & 47 deletions src/background.ts
Original file line number Diff line number Diff line change
@@ -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> = T extends typeof browser.webRequest
? browser.webRequest._OnBeforeSendHeadersDetails
: T extends typeof chrome.webRequest
? chrome.webRequest.OnBeforeSendHeadersDetails
: never;

const getHeaders = async (): Promise<Header[]> => {
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 <D extends OnBeforeSendHeadersDetails<typeof webRequest>>(
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<browser.webRequest.BlockingResponse> => {
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: ['<all_urls>'] }, [
'blocking',
'requestHeaders',
]);
browser.webRequest.onBeforeSendHeaders.addListener(
onBeforeSendHeaders,
{ urls: ['<all_urls>'] },
['blocking', 'requestHeaders']
);
}
2 changes: 2 additions & 0 deletions src/interfaces/declarations.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
declare const __BROWSER__: 'firefox' | 'chrome';

declare module '*.scss' {
const content: { [className: string]: string };
export default content;
Expand Down
30 changes: 22 additions & 8 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
},
};
};
Expand All @@ -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',
Expand All @@ -41,7 +55,7 @@ export default defineConfig({
extensions: ['.js', '.ts', '.tsx', '.jsx'],
},
build: {
outDir: '../dist',
outDir: `../dist/${BROWSER}`,
emptyOutDir: true,
rollupOptions: {
input: {
Expand Down
2 changes: 1 addition & 1 deletion web-ext-config.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"sourceDir": "dist"
"sourceDir": "dist/firefox"
}
Loading