迁移到 Manifest V3 并精简项目

- manifest.json 升级到 MV3:Service Worker + action,权限改用 declarativeNetRequest
- background.js 重写为 Service Worker,用 DNR 动态/session 规则修改请求/响应头,
  支持 URL 正则、资源类型过滤、Tab 锁、暂停;badge/右键菜单改用 chrome.action/onClicked
- main.js 存储层从 localStorage 迁移到 chrome.storage.local(Proxy 同步适配 + 手动
  bootstrap),并对旧 MV2 数据做一次性自动迁移;改动即时持久化
- popup.html 去掉 404 的 associate.js 与自动 ng-app;移除侧边栏无用入口
- 清理死代码与冗余资源:openCloudBackup、cloudbackupdialog 模板、推广 tips、
  _DS_Store、8 个未引用图标
- README 用中文重写,说明项目来源、MV3 改造与使用方法
This commit is contained in:
呈祥
2026-07-10 15:14:34 +08:00
parent f7e41765c1
commit 5df5fab875
15 changed files with 477 additions and 622 deletions
+64 -44
View File
@@ -1,59 +1,79 @@
# ModHeader browser extension # ModHeaderManifest V3 版)
<h3 align="center"> 一个用于**修改 HTTP 请求头和响应头**的浏览器扩展。
ModHeader is a browser extension that allow you to modify HTTP request and response headers.
</h3>
<h3 align="center">
<img src="https://mod-header.appspot.com/android-icon-96x96.png" width="24px" alt="ModHeader" />
<a href="https://mod-header.appspot.com/">
Official Website
</a>
</h3>
<p align="center">
<a href="https://chrome.google.com/webstore/detail/modheader/idgpnmonknjnojddfkpgkljpfnnfcklj">
<img src="https://mod-header.appspot.com/images/chrome_1x.png" srcset="https://mod-header.appspot.com/images/chrome_2x.png 2x">
</a>
<a href="https://addons.mozilla.org/firefox/addon/modheader-firefox/">
<img src="https://mod-header.appspot.com/images/firefox_1x.png" srcset="https://mod-header.appspot.com/images/firefox_2x.png 2x">
</a>
</p>
## Donation ## 项目来源
If you find ModHeader useful, please consider making a donation. If you use it for your company project, please ask your company to make a monthly donation! 本项目 fork/整理自开源扩展 [ModHeader](https://github.com/hao1300/modheader)(原作者 Hao Nguyen,MIT 许可)。原始代码基于 **Manifest V2**,依赖已被 Chrome 废弃的阻塞式 `webRequest` API。
[![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donate_SM.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=3XFKZ8PCRB8P6&currency_code=USD&amount=5&source=url) 本仓库在其早期源码基础上做了以下改造:
<a href="https://www.buymeacoffee.com/scCieVdfj">
<img src="https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png" alt="Buy Me A Coffee">
</a>
## Features - **迁移到 Manifest V3**:后台页改为 Service Worker,请求/响应头修改改用 `declarativeNetRequest`DNR)动态规则实现。
- **存储层迁移**:状态从 `localStorage` 迁移到 `chrome.storage.local`MV3 的 Service Worker 无法访问 `localStorage`),并保留对旧数据的一次性自动迁移。
- **精简**:移除了云备份、捐赠、推广提示等无用功能与相关死代码、冗余资源。
* Add/modify/remove request headers and response headers 前端仍保留原有的 AngularJS + Angular Material 界面。
* Enable header modification based on URL/resource type
* Add comments to header
* Multiple different profiles
* Sorting headers by name, value, or comments
* Append value to existing request or response header
* Export and import header
* Clone profile
* Cloud backup
* Tab locking!
## Screenshots ## 功能
<img src="https://mod-header.appspot.com/images/ss1.png"> - 增加 / 修改 / 删除**请求头**与**响应头**
- 按 **URL 正则**或**资源类型**限定生效范围(Filter)
- 多套 **Profile** 配置,可快速切换
- 给每条 header 添加**备注**
- 按名称 / 值 / 备注**排序**
- 在已有 header 值后**追加**(拼接 / 逗号拼接)
- **导出 / 导入** ProfileJSON
- **克隆** Profile
- **Tab 锁**:仅对指定标签页生效
- **暂停**:一键临时停用全部修改
## Forking and contribution ## 安装 / 本地开发
Feel free to send pull requests to add new features to ModHeader. It will benefit everyone! That said, I reserve the rights to reject pull requests that does not seem useful, or if they add too much complexity for very little benefits. 无需任何构建工具,直接加载 `src` 目录即可:
You may fork and redistribute ModHeader for a small group of friends / colleagues, but please do not impersonate ModHeader, or try to sell it for a profit. If you use ModHeader in any commercial product, please let me know. 1. 浏览器打开 `chrome://extensions`
2. 打开右上角**开发者模式**
3. 点击**加载已解压的扩展程序**,选择本仓库的 `src` 目录
4. 修改代码后,回到该页面点扩展卡片上的**刷新(↻)**即可重新加载
## Installation ## 使用方法
ModHeader does not require any extra tool for building. Simply load the src directory into Chrome / Firefox to start local development. 1. 点击工具栏中的 ModHeader 图标打开弹窗。
2. 点右上角 **+**
- **Request header**:添加请求头(如 `User-Agent``Authorization` 等)
- **Response header**:添加响应头(如 `Access-Control-Allow-Origin`
- **Filter**:添加生效条件(URL 正则或资源类型)
3. 勾选每行左侧的复选框以**启用**该条修改;填写 Name / Value。
4. 打开目标网站并刷新,在 DevTools 的 **Network** 面板中即可确认头已被修改。
5. 图标上的**数字角标**表示当前生效的 header 数量。
## Selenium usage ### 生效范围说明
If you need to use ModHeader for Selenium tests, please visit: https://github.com/hao1300/modheader_selenium - **不加 Filter**:对所有 URL 生效。
- **URL Pattern**:填写正则(如 `https://example\.com/.*`),仅匹配的请求生效;多条之间为「或」的关系。
- **Resource Type**:仅对选中的资源类型(main_frame、xmlhttprequest 等)生效。
- **Tab 锁**:点弹窗中的 “Tab lock”,修改将只作用于当前标签页。
## 目录结构
```
src/
├── manifest.json # MV3 清单
├── background.js # Service WorkerDNR 规则生成、角标、右键菜单
├── popup.html # 弹窗界面
├── scripts/
│ ├── main.js # AngularJS 逻辑 + storage.local 适配层
│ └── angular*.min.js # AngularJS 1.7.8 及其模块(官方,MIT
├── styles/ # 样式
├── images/ # 图标
└── *.tmpl.html # 各对话框模板
```
## 已知限制
- `declarativeNetRequest``append` 操作对部分请求头有限制,遇到不支持的 header 时该规则会失败并在 Service Worker 控制台打印错误。
- Tab 锁通过 DNR 的 **session 规则** + `tabIds` 实现,浏览器重启后会失效(符合预期)。
## 许可
沿用原项目的 **MIT** 许可。
+307 -397
View File
@@ -1,397 +1,307 @@
const browser = chrome; // ModHeader background service worker (Manifest V3).
const SPECIAL_CHARS = '^$&+?.()|{}[]/'.split(''); // Header modification is done through the declarativeNetRequest (DNR) API
const MAX_PROFILES_IN_CLOUD = 50; // since MV3 no longer allows blocking webRequest.
const CHROME_VERSION = getChromeVersion();
const EXTRA_REQUEST_HEADERS = new Set(['accept-language', 'accept-encoding', 'referer', 'cookie']); const SPECIAL_CHARS = '^$&+?.()|{}[]/'.split('');
const EXTRA_RESPONSE_HEADERS = new Set(['set-cookie']); const RULE_ID = 1;
let currentProfile;
let tabUrls = {}; // -- Storage helpers ---------------------------------------------------------
/** async function getState() {
* Check whether the current request url pass the given list of filters. return chrome.storage.local.get([
*/ 'profiles', 'selectedProfile', 'isPaused', 'lockedTabId', 'activeTabId'
function passFilters_(url, type, filters) { ]);
if (!filters) { }
return true;
} function compileUrlPattern(urlPattern) {
let allowUrls = false; const joiner = [];
let hasUrlFilters = false; for (let i = 0; i < urlPattern.length; ++i) {
let allowTypes = false; let c = urlPattern.charAt(i);
let hasResourceTypeFilters = false; if (SPECIAL_CHARS.indexOf(c) >= 0) {
for (let filter of filters) { c = '\\' + c;
if (filter.enabled) { } else if (c == '\\') {
switch (filter.type) { c = '\\\\';
case 'urls': } else if (c == '*') {
hasUrlFilters = true; c = '.*';
if (url.search(filter.urlRegex) == 0) { }
allowUrls = true; joiner.push(c);
} }
break; return joiner.join('');
case 'types': }
hasResourceTypeFilters = true;
if (filter.resourceType.indexOf(type) >= 0) { function loadSelectedProfile(state) {
allowTypes = true; if (!state.profiles) {
} return null;
break; }
} let profiles;
} try {
} profiles = JSON.parse(state.profiles);
return (!hasUrlFilters || allowUrls) } catch (e) {
&& (!hasResourceTypeFilters || allowTypes); return null;
}; }
const index = Number(state.selectedProfile) || 0;
function loadSelectedProfile_() { const profile = profiles[index];
let appendMode = false; if (!profile) {
let headers = []; return null;
let respHeaders = []; }
let filters = [];
if (localStorage.profiles) { const filterEnabled = (headers) => (headers || [])
const profiles = JSON.parse(localStorage.profiles); .filter(h => h.enabled && h.name)
if (!localStorage.selectedProfile) { .map(h => ({ name: h.name, value: h.value || '' }));
localStorage.selectedProfile = 0;
} const filters = [];
const selectedProfile = profiles[localStorage.selectedProfile]; for (const filter of profile.filters || []) {
const normalized = Object.assign({}, filter);
function filterEnabledHeaders_(headers) { // Backward compatibility: convert legacy urlPattern to urlRegex.
let output = []; if (normalized.urlPattern) {
for (let header of headers) { normalized.urlRegex = compileUrlPattern(normalized.urlPattern);
// Overrides the header if it is enabled and its name is not empty. delete normalized.urlPattern;
if (header.enabled && header.name) { }
output.push({name: header.name, value: header.value}); filters.push(normalized);
} }
}
return output; return {
}; appendMode: profile.appendMode,
for (let filter of selectedProfile.filters) { headers: filterEnabled(profile.headers),
if (filter.urlPattern) { respHeaders: filterEnabled(profile.respHeaders),
const urlPattern = filter.urlPattern; filters
const joiner = []; };
for (let i = 0; i < urlPattern.length; ++i) { }
let c = urlPattern.charAt(i);
if (SPECIAL_CHARS.indexOf(c) >= 0) { // -- DNR rule generation -----------------------------------------------------
c = '\\' + c;
} else if (c == '\\') { function anchor(regex) {
c = '\\\\'; return regex.startsWith('^') ? regex : '^' + regex;
} else if (c == '*') { }
c = '.*';
} function buildRule(profile) {
joiner.push(c); const operation = profile.appendMode ? 'append' : 'set';
} const requestHeaders = profile.headers.map(
filter.urlRegex = joiner.join(''); h => ({ header: h.name, operation, value: h.value }));
} const responseHeaders = profile.respHeaders.map(
filters.push(filter); h => ({ header: h.name, operation, value: h.value }));
} if (!requestHeaders.length && !responseHeaders.length) {
appendMode = selectedProfile.appendMode; return null;
headers = filterEnabledHeaders_(selectedProfile.headers); }
respHeaders = filterEnabledHeaders_(selectedProfile.respHeaders);
} const condition = {};
return { const urlRegexes = [];
appendMode: appendMode, const resourceTypes = [];
headers: headers, for (const filter of profile.filters) {
respHeaders: respHeaders, if (!filter.enabled) {
filters: filters continue;
}; }
}; if (filter.type === 'urls' && filter.urlRegex) {
urlRegexes.push(filter.urlRegex);
function modifyHeader(source, dest) { } else if (filter.type === 'types' && filter.resourceType) {
if (!source.length) { resourceTypes.push(...filter.resourceType);
return; }
} }
// Create an index map so that we can more efficiently override if (urlRegexes.length === 1) {
// existing header. condition.regexFilter = anchor(urlRegexes[0]);
const indexMap = {}; } else if (urlRegexes.length > 1) {
for (const index in dest) { condition.regexFilter = urlRegexes.map(r => `(${anchor(r)})`).join('|');
const header = dest[index]; }
indexMap[header.name.toLowerCase()] = index; if (resourceTypes.length) {
} condition.resourceTypes = [...new Set(resourceTypes)];
for (let header of source) { }
const index = indexMap[header.name.toLowerCase()];
if (index !== undefined) { const action = { type: 'modifyHeaders' };
if (!currentProfile.appendMode) { if (requestHeaders.length) {
dest[index].value = header.value; action.requestHeaders = requestHeaders;
} else if (currentProfile.appendMode == 'comma') { }
if (dest[index].value) { if (responseHeaders.length) {
dest[index].value += ','; action.responseHeaders = responseHeaders;
} }
dest[index].value += header.value; return { id: RULE_ID, priority: 1, action, condition };
} else { }
dest[index].value += header.value;
} async function clearAllRules() {
} else { const [dynamic, session] = await Promise.all([
dest.push({name: header.name, value: header.value}); chrome.declarativeNetRequest.getDynamicRules(),
indexMap[header.name.toLowerCase()] = dest.length - 1; chrome.declarativeNetRequest.getSessionRules()
} ]);
} await Promise.all([
}; chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: dynamic.map(r => r.id)
function modifyRequestHeaderHandler_(details) { }),
if (localStorage.isPaused) { chrome.declarativeNetRequest.updateSessionRules({
return {}; removeRuleIds: session.map(r => r.id)
} })
if (details.type == 'main_frame' && details.url && details.tabId) { ]);
tabUrls[details.tabId] = details.url; }
localStorage.activeTabId = details.tabId;
browser.tabs.get(details.tabId, onTabUpdated); async function updateRules(state, profile) {
} await clearAllRules();
if (!localStorage.lockedTabId if (state.isPaused || !profile) {
|| localStorage.lockedTabId == details.tabId) { return;
if (currentProfile }
&& passFilters_(details.url, details.type, currentProfile.filters)) { const rule = buildRule(profile);
modifyHeader(currentProfile.headers, details.requestHeaders); if (!rule) {
} return;
} }
return {requestHeaders: details.requestHeaders}; try {
}; if (state.lockedTabId) {
// tabIds conditions are only allowed on session-scoped rules.
function modifyResponseHeaderHandler_(details) { rule.condition.tabIds = [Number(state.lockedTabId)];
if (localStorage.isPaused) { await chrome.declarativeNetRequest.updateSessionRules({ addRules: [rule] });
return {}; } else {
} await chrome.declarativeNetRequest.updateDynamicRules({ addRules: [rule] });
if (!localStorage.lockedTabId }
|| localStorage.lockedTabId == details.tabId) { } catch (e) {
if (currentProfile console.error('ModHeader: failed to apply rules', e, rule);
&& passFilters_(details.url, details.type, currentProfile.filters)) { }
const serializedOriginalResponseHeaders = JSON.stringify(details.responseHeaders); }
const responseHeaders = JSON.parse(serializedOriginalResponseHeaders);
modifyHeader(currentProfile.respHeaders, responseHeaders); // -- Badge / icon ------------------------------------------------------------
if (JSON.stringify(responseHeaders) != serializedOriginalResponseHeaders) {
return {responseHeaders: responseHeaders}; async function updateBadge(state, profile) {
} if (state.isPaused) {
} await chrome.action.setIcon({ path: 'icon_bw.png' });
} await chrome.action.setBadgeText({ text: '❚❚' });
}; await chrome.action.setBadgeBackgroundColor({ color: '#666' });
return;
function getChromeVersion() { }
let pieces = navigator.userAgent.match(/Chrom(?:e|ium)\/([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)/); const numHeaders = profile
if (pieces == null || pieces.length != 5) { ? profile.headers.length + profile.respHeaders.length
return {}; : 0;
} if (numHeaders === 0) {
pieces = pieces.map(piece => parseInt(piece, 10)); await chrome.action.setBadgeText({ text: '' });
return { await chrome.action.setIcon({ path: 'icon_bw.png' });
major: pieces[1], } else if (state.lockedTabId
minor: pieces[2], && String(state.lockedTabId) !== String(state.activeTabId)) {
build: pieces[3], await chrome.action.setIcon({ path: 'icon_bw.png' });
patch: pieces[4] await chrome.action.setBadgeText({ text: '🔒' });
}; await chrome.action.setBadgeBackgroundColor({ color: '#ff8e8e' });
} } else {
await chrome.action.setIcon({ path: 'icon.png' });
function setupHeaderModListener() { await chrome.action.setBadgeText({ text: numHeaders.toString() });
browser.webRequest.onBeforeSendHeaders.removeListener(modifyRequestHeaderHandler_); await chrome.action.setBadgeBackgroundColor({ color: '#db4343' });
browser.webRequest.onHeadersReceived.removeListener(modifyResponseHeaderHandler_); }
}
// Chrome 72+ requires 'extraHeaders' to be added for some headers to be modifiable.
// Older versions break with it. // -- Context menu ------------------------------------------------------------
if (currentProfile.headers.length > 0) {
let requiresExtraRequestHeaders = false; async function updateContextMenu(state) {
if (CHROME_VERSION.major >= 72) { try {
for (let header of currentProfile.headers) { await chrome.contextMenus.update('pause', {
if (EXTRA_REQUEST_HEADERS.has(header.name.toLowerCase())) { title: state.isPaused ? 'Unpause ModHeader' : 'Pause ModHeader'
requiresExtraRequestHeaders = true; });
break; await chrome.contextMenus.update('lock', {
} title: state.lockedTabId ? 'Unlock to all tabs' : 'Lock to this tab'
} });
} } catch (e) {
browser.webRequest.onBeforeSendHeaders.addListener( // Menu may not exist yet; ignore.
modifyRequestHeaderHandler_, }
{urls: ["<all_urls>"]}, }
requiresExtraRequestHeaders ? ['requestHeaders', 'blocking', 'extraHeaders'] : ['requestHeaders', 'blocking']
); // -- Main refresh ------------------------------------------------------------
}
if (currentProfile.respHeaders.length > 0) { async function refresh() {
let requiresExtraResponseHeaders = false; const state = await getState();
if (CHROME_VERSION.major >= 72) { const profile = loadSelectedProfile(state);
for (let header of currentProfile.respHeaders) { await updateRules(state, profile);
if (EXTRA_RESPONSE_HEADERS.has(header.name.toLowerCase())) { await updateBadge(state, profile);
requiresExtraResponseHeaders = true; await updateContextMenu(state);
break; }
}
} async function refreshBadgeOnly() {
} const state = await getState();
browser.webRequest.onHeadersReceived.addListener( const profile = loadSelectedProfile(state);
modifyResponseHeaderHandler_, await updateBadge(state, profile);
{urls: ["<all_urls>"]}, }
requiresExtraResponseHeaders ? ['responseHeaders', 'blocking', 'extraHeaders'] : ['responseHeaders', 'blocking']
); // -- Tab tracking ------------------------------------------------------------
}
} async function onActiveTab(tab) {
if (!tab || tab.id === undefined) {
function onTabUpdated(tab) { return;
if (tab.active) { }
delete localStorage.currentTabUrl; const update = { activeTabId: tab.id };
// Since we don't have access to the "tabs" permission, we may not have if (tab.url) {
// access to the url property all the time. So, match it against the URL update.currentTabUrl = tab.url;
// found during request modification. }
let url = tab.url; await chrome.storage.local.set(update);
if (url) { await refreshBadgeOnly();
tabUrls[tab.id] = url; }
} else {
url = tabUrls[tab.id]; chrome.tabs.onActivated.addListener(async ({ tabId }) => {
} try {
localStorage.activeTabId = tab.id; const tab = await chrome.tabs.get(tabId);
await onActiveTab(tab);
// Only set the currentTabUrl property if the tab is active and the window } catch (e) { /* tab gone */ }
// is in focus. });
browser.windows.get(tab.windowId, {}, (win) => {
if (win.focused) { chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
localStorage.currentTabUrl = url; if (tab && tab.active) {
} onActiveTab(tab);
}); }
if (!url) { });
return;
} chrome.windows.onFocusChanged.addListener(async (windowId) => {
resetBadgeAndContextMenu(); if (windowId === chrome.windows.WINDOW_ID_NONE) {
} return;
} }
try {
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { const [tab] = await chrome.tabs.query({ active: true, windowId });
onTabUpdated(tab); if (tab) {
}); await onActiveTab(tab);
}
browser.tabs.onActivated.addListener((activeInfo) => { } catch (e) { /* no window */ }
browser.tabs.get(activeInfo.tabId, onTabUpdated); });
});
// -- Storage change ----------------------------------------------------------
browser.windows.onFocusChanged.addListener((windowId) => {
if (windowId == browser.windows.WINDOW_ID_NONE) { chrome.storage.onChanged.addListener((changes, area) => {
return; if (area !== 'local') {
} return;
browser.windows.get(windowId, {populate: true}, (win) => { }
for (let tab of win.tabs) { if (changes.profiles || changes.selectedProfile
onTabUpdated(tab); || changes.isPaused || changes.lockedTabId) {
} refresh();
}); } else if (changes.activeTabId) {
}); refreshBadgeOnly();
}
function saveStorageToCloud() { });
browser.storage.sync.get(null, (items) => {
const keys = items ? Object.keys(items) : []; // -- Context menu clicks -----------------------------------------------------
keys.sort();
if (keys.length == 0 || chrome.contextMenus.onClicked.addListener(async (info, tab) => {
items[keys[keys.length - 1]] != localStorage.profiles) { const state = await getState();
const data = {}; if (info.menuItemId === 'pause') {
data[Date.now()] = localStorage.profiles; if (state.isPaused) {
browser.storage.sync.set(data); await chrome.storage.local.remove('isPaused');
localStorage.savedToCloud = true; } else {
} await chrome.storage.local.set({ isPaused: 'true' });
if (keys.length >= MAX_PROFILES_IN_CLOUD) { }
browser.storage.sync.remove(keys.slice(0, keys.length - MAX_PROFILES_IN_CLOUD)); } else if (info.menuItemId === 'lock') {
} if (state.lockedTabId) {
}); await chrome.storage.local.remove('lockedTabId');
} } else if (tab) {
await chrome.storage.local.set({ lockedTabId: tab.id });
function createContextMenu() { }
if (localStorage.isPaused) { }
browser.contextMenus.update( // refresh() is triggered by the resulting storage change event.
'pause', });
{
title: 'Unpause ModHeader', // -- Lifecycle ---------------------------------------------------------------
contexts: ['browser_action'],
onclick: () => { chrome.runtime.onInstalled.addListener(() => {
localStorage.removeItem('isPaused'); chrome.contextMenus.removeAll(() => {
resetBadgeAndContextMenu(); chrome.contextMenus.create({
} id: 'pause',
}); title: 'Pause ModHeader',
} else { contexts: ['action']
browser.contextMenus.update( });
'pause', chrome.contextMenus.create({
{ id: 'lock',
title: 'Pause ModHeader', title: 'Lock to this tab',
contexts: ['browser_action'], contexts: ['action']
onclick: () => { });
localStorage.isPaused = true; });
resetBadgeAndContextMenu(); refresh();
} });
});
} chrome.runtime.onStartup.addListener(() => {
if (localStorage.lockedTabId) { refresh();
browser.contextMenus.update( });
'lock',
{
title: 'Unlock to all tabs',
contexts: ['browser_action'],
onclick: () => {
localStorage.removeItem('lockedTabId');
resetBadgeAndContextMenu();
}
});
} else {
browser.contextMenus.update(
'lock',
{
title: 'Lock to this tab',
contexts: ['browser_action'],
onclick: () => {
localStorage.lockedTabId = localStorage.activeTabId;
resetBadgeAndContextMenu();
}
});
}
}
function resetBadgeAndContextMenu() {
if (localStorage.isPaused) {
browser.browserAction.setIcon({path: 'icon_bw.png'});
browser.browserAction.setBadgeText({text: '\u275A\u275A'});
browser.browserAction.setBadgeBackgroundColor({color: '#666'});
} else {
const numHeaders = (currentProfile.headers.length + currentProfile.respHeaders.length);
if (numHeaders == 0) {
browser.browserAction.setBadgeText({text: ''});
browser.browserAction.setIcon({path: 'icon_bw.png'});
} else if (localStorage.lockedTabId
&& localStorage.lockedTabId != localStorage.activeTabId) {
browser.browserAction.setIcon({path: 'icon_bw.png'});
browser.browserAction.setBadgeText({text: '\uD83D\uDD12'});
browser.browserAction.setBadgeBackgroundColor({color: '#ff8e8e'});
} else {
browser.browserAction.setIcon({path: 'icon.png'});
browser.browserAction.setBadgeText({text: numHeaders.toString()});
browser.browserAction.setBadgeBackgroundColor({color: '#db4343'});
}
}
createContextMenu();
}
function initializeStorage() {
currentProfile = loadSelectedProfile_();
setupHeaderModListener();
window.addEventListener('storage', function(e) {
currentProfile = loadSelectedProfile_();
setupHeaderModListener();
resetBadgeAndContextMenu();
if (e.key == 'profiles') {
saveStorageToCloud();
}
});
// Async initialization.
setTimeout(() => {
if (localStorage.profiles && !localStorage.savedToCloud) {
saveStorageToCloud();
}
if (!localStorage.profiles) {
browser.storage.sync.get(null, (items) => {
const keys = items ? Object.keys(items) : [];
keys.sort();
if (keys.length > 0) {
localStorage.profiles = items[keys[keys.length - 1]];
localStorage.savedToCloud = true;
}
});
}
}, 100);
}
browser.contextMenus.create({
id: 'pause',
title: 'Pause',
contexts: ['browser_action'],
});
browser.contextMenus.create({
id: 'lock',
title: 'Lock',
contexts: ['browser_action'],
});
initializeStorage();
resetBadgeAndContextMenu();
-23
View File
@@ -1,23 +0,0 @@
<md-dialog flex="70">
<md-toolbar>
<div class="md-toolbar-tools">
<h2>Cloud backup</h2>
</div>
</md-toolbar>
<md-dialog-content>
<div layout="row" flex
ng-repeat="data in savedData | orderBy:'-timeInMs'">
<md-button ng-click="selectProfiles(data.profiles)" class="md-primary">
Backup at {{data.timeInMs | date:'short'}} -
<span ng-repeat="profile in data.profiles">
{{profile.title}}{{$last ? '' : ','}}
</span>
</md-button>
</div>
</md-dialog-content>
<div class="md-actions" layout="row">
<md-button ng-click="closeDialog()" class="md-primary">
Cancel
</md-button>
</div>
</md-dialog>
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>

Before

Width:  |  Height:  |  Size: 136 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><path d="M9.56 8.1c-1.6-.51-2.66-.71-2.66-1.88 0-.83.72-1.62 2.1-1.62 1.59 0 2.1.88 2.1 1.94H13c0-1.79-1.17-3.09-3-3.44V1H8v2.11c-1.58.32-3 1.37-3 3.12 0 2.25 1.78 2.8 4 3.52 1.88.61 2.25 1.04 2.25 2.09 0 .9-.67 1.56-2.25 1.56-1.2 0-2.25-.84-2.25-2.06h-2c0 1.88 1.38 3.2 3.25 3.56V17h2v-2.07c2.04-.29 3.2-1.49 3.2-3.1 0-1.87-.94-2.87-3.64-3.73z"/></svg>

Before

Width:  |  Height:  |  Size: 436 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="#ffffff" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>

Before

Width:  |  Height:  |  Size: 217 B

-4
View File
@@ -1,4 +0,0 @@
<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>

Before

Width:  |  Height:  |  Size: 265 B

-4
View File
@@ -1,4 +0,0 @@
<svg height="18" viewBox="0 0 24 24" width="18" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM17 13l-5 5-5-5h3V9h4v4h3z"/>
</svg>

Before

Width:  |  Height:  |  Size: 327 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/></svg>

Before

Width:  |  Height:  |  Size: 281 B

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>

Before

Width:  |  Height:  |  Size: 230 B

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"/>
</svg>

Before

Width:  |  Height:  |  Size: 418 B

+29 -27
View File
@@ -1,27 +1,29 @@
{ {
"name": "ModHeader", "name": "ModHeader",
"version": "2.3.2", "version": "3.0.0",
"manifest_version": 2, "manifest_version": 3,
"description": "Modify request and response headers", "description": "Modify request and response headers",
"background": { "background": {
"scripts": [ "background.js" ] "service_worker": "background.js"
}, },
"browser_action": { "action": {
"default_icon": "icon.png", "default_icon": "icon.png",
"default_title": "Modify Headers", "default_title": "Modify Headers",
"default_popup": "popup.html" "default_popup": "popup.html"
}, },
"icons": { "icons": {
"16": "icon_16.png", "16": "icon_16.png",
"48": "icon_48.png", "48": "icon_48.png",
"128": "icon_128.png" "128": "icon_128.png"
}, },
"incognito": "spanning", "incognito": "spanning",
"permissions": [ "permissions": [
"contextMenus", "contextMenus",
"storage", "storage",
"webRequest", "tabs",
"webRequestBlocking", "declarativeNetRequest"
"<all_urls>" ],
] "host_permissions": [
} "<all_urls>"
]
}
+1 -19
View File
@@ -7,10 +7,9 @@
<script src="scripts/angular-animate.min.js"></script> <script src="scripts/angular-animate.min.js"></script>
<script src="scripts/angular-aria.min.js"></script> <script src="scripts/angular-aria.min.js"></script>
<script src="scripts/angular-material.min.js"></script> <script src="scripts/angular-material.min.js"></script>
<script src="scripts/associate.js"></script>
<script src="scripts/main.js"></script> <script src="scripts/main.js"></script>
</head> </head>
<body ng-csp ng-app="modheader-popup" ng-controller="AppController"> <body ng-csp ng-controller="AppController">
<md-toolbar> <md-toolbar>
<div class="md-toolbar-tools"> <div class="md-toolbar-tools">
<md-button class="md-icon-button" aria-label="Settings" md-no-ink <md-button class="md-icon-button" aria-label="Settings" md-no-ink
@@ -157,23 +156,6 @@
<md-icon md-svg-src="images/ic_add_18px.svg"></md-icon> <md-icon md-svg-src="images/ic_add_18px.svg"></md-icon>
<p>Add Profile</p> <p>Add Profile</p>
</md-list-item> </md-list-item>
<md-list-item ng-click="profileService.openCloudBackup($event)">
<md-icon md-svg-src="images/ic_cloud_download_18px.svg"></md-icon>
<p>Cloud backup</p>
</md-list-item>
<md-divider></md-divider>
<md-list-item ng-click="openLink('https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=3XFKZ8PCRB8P6&currency_code=USD&amount=5&source=url')">
<md-icon md-svg-src="images/ic_attach_money_18px.svg"></md-icon>
<p>Donate</p>
</md-list-item>
<md-list-item ng-click="openLink('https://github.com/hao1300/modheader')">
<md-icon md-svg-src="images/ic_code_18px.svg"></md-icon>
<p>Source code</p>
</md-list-item>
<md-list-item ng-click="openLink('https://mod-header.appspot.com/help.html')">
<md-icon md-svg-src="images/ic_help_24px.svg"></md-icon>
<p>Help</p>
</md-list-item>
</md-list> </md-list>
</md-sidenav> </md-sidenav>
<md-content class="main-content" ng-class="{disabled: dataSource.isPaused}"> <md-content class="main-content" ng-class="{disabled: dataSource.isPaused}">
+76 -95
View File
@@ -1,5 +1,37 @@
const SPECIAL_CHARS = '^$&+?.()|{}[]/'.split(''); const SPECIAL_CHARS = '^$&+?.()|{}[]/'.split('');
const browser = chrome; const browser = chrome;
// Synchronous, in-memory mirror of chrome.storage.local. It is preloaded
// before AngularJS bootstraps (see the bottom of this file) so that the
// existing synchronous `localStorage`-style access keeps working, while every
// write is mirrored back to chrome.storage.local (the source of truth in MV3).
const _storeCache = {};
const store = new Proxy(_storeCache, {
get(target, prop) {
if (prop === 'getItem') {
return (key) => target[key];
}
if (prop === 'setItem') {
return (key, value) => {
target[key] = String(value);
chrome.storage.local.set({ [key]: String(value) });
};
}
if (prop === 'removeItem') {
return (key) => {
delete target[key];
chrome.storage.local.remove(key);
};
}
return target[prop];
},
set(target, prop, value) {
target[prop] = String(value);
chrome.storage.local.set({ [prop]: String(value) });
return true;
}
});
const modHeader = angular.module('modheader-popup', ['ngMaterial']); const modHeader = angular.module('modheader-popup', ['ngMaterial']);
modHeader.config(['$compileProvider', function ($compileProvider) { modHeader.config(['$compileProvider', function ($compileProvider) {
$compileProvider.debugInfoEnabled(false); $compileProvider.debugInfoEnabled(false);
@@ -43,9 +75,9 @@ modHeader.factory('dataSource', function($mdToast) {
dataSource.addFilter = function(filters) { dataSource.addFilter = function(filters) {
let urlRegex = ''; let urlRegex = '';
if (localStorage.currentTabUrl) { if (store.currentTabUrl) {
const parser = document.createElement('a'); const parser = document.createElement('a');
parser.href = localStorage.currentTabUrl; parser.href = store.currentTabUrl;
urlRegex = parser.origin + '/.*'; urlRegex = parser.origin + '/.*';
} }
filters.push({ filters.push({
@@ -81,7 +113,7 @@ modHeader.factory('dataSource', function($mdToast) {
dataSource.pause = function() { dataSource.pause = function() {
dataSource.isPaused = true; dataSource.isPaused = true;
localStorage.isPaused = true; store.isPaused = true;
$mdToast.show( $mdToast.show(
$mdToast.simple() $mdToast.simple()
.content('ModHeader paused') .content('ModHeader paused')
@@ -92,7 +124,7 @@ modHeader.factory('dataSource', function($mdToast) {
dataSource.play = function() { dataSource.play = function() {
dataSource.isPaused = false; dataSource.isPaused = false;
localStorage.removeItem('isPaused'); store.removeItem('isPaused');
$mdToast.show( $mdToast.show(
$mdToast.simple() $mdToast.simple()
.content('ModHeader unpaused') .content('ModHeader unpaused')
@@ -102,8 +134,8 @@ modHeader.factory('dataSource', function($mdToast) {
}; };
dataSource.lockToTab = function() { dataSource.lockToTab = function() {
dataSource.lockedTabId = localStorage.activeTabId; dataSource.lockedTabId = store.activeTabId;
localStorage.lockedTabId = dataSource.lockedTabId; store.lockedTabId = dataSource.lockedTabId;
$mdToast.show( $mdToast.show(
$mdToast.simple() $mdToast.simple()
.content('Restricted ModHeader to the current tab') .content('Restricted ModHeader to the current tab')
@@ -114,7 +146,7 @@ modHeader.factory('dataSource', function($mdToast) {
dataSource.unlockAllTab = function() { dataSource.unlockAllTab = function() {
dataSource.lockedTabId = null; dataSource.lockedTabId = null;
localStorage.removeItem('lockedTabId'); store.removeItem('lockedTabId');
$mdToast.show( $mdToast.show(
$mdToast.simple() $mdToast.simple()
.content('Applying ModHeader to all tabs') .content('Applying ModHeader to all tabs')
@@ -152,8 +184,8 @@ modHeader.factory('dataSource', function($mdToast) {
dataSource.predicate = ''; dataSource.predicate = '';
dataSource.reverse = false; dataSource.reverse = false;
if (localStorage.profiles) { if (store.profiles) {
dataSource.profiles = angular.fromJson(localStorage.profiles); dataSource.profiles = angular.fromJson(store.profiles);
for (let profile of dataSource.profiles) { for (let profile of dataSource.profiles) {
fixProfile(profile); fixProfile(profile);
} }
@@ -182,23 +214,23 @@ modHeader.factory('dataSource', function($mdToast) {
profile.appendMode = ''; profile.appendMode = '';
} }
}); });
if (localStorage.selectedProfile) { if (store.selectedProfile) {
dataSource.selectedProfile = dataSource.profiles[Number(localStorage.selectedProfile)]; dataSource.selectedProfile = dataSource.profiles[Number(store.selectedProfile)];
} }
if (!dataSource.selectedProfile) { if (!dataSource.selectedProfile) {
dataSource.selectedProfile = dataSource.profiles[0]; dataSource.selectedProfile = dataSource.profiles[0];
} }
if (localStorage.isPaused) { if (store.isPaused) {
dataSource.isPaused = localStorage.isPaused; dataSource.isPaused = store.isPaused;
} }
if (localStorage.lockedTabId) { if (store.lockedTabId) {
dataSource.lockedTabId = localStorage.lockedTabId; dataSource.lockedTabId = store.lockedTabId;
} }
dataSource.save = function() { dataSource.save = function() {
var serializedProfiles = angular.toJson(dataSource.profiles); var serializedProfiles = angular.toJson(dataSource.profiles);
var selectedProfileIndex = dataSource.profiles.indexOf(dataSource.selectedProfile); var selectedProfileIndex = dataSource.profiles.indexOf(dataSource.selectedProfile);
localStorage.profiles = serializedProfiles; store.profiles = serializedProfiles;
localStorage.selectedProfile = selectedProfileIndex; store.selectedProfile = selectedProfileIndex;
}; };
return dataSource; return dataSource;
}); });
@@ -338,71 +370,6 @@ modHeader.factory('profileService', function(
} }
}; };
profileService.openCloudBackup = (event) => {
const parentEl = angular.element(document.body);
$mdDialog.show({
parent: parentEl,
targetEvent: event,
focusOnOpen: false,
templateUrl: 'cloudbackupdialog.tmpl.html',
controller: DialogController_
}).then((profiles) => {
if (!profiles) {
return;
}
try {
dataSource.profiles = profiles;
dataSource.selectedProfile = dataSource.profiles[0];
dataSource.save();
$mdToast.show(
$mdToast.simple()
.content('Profiles successfully import')
.position('top')
.hideDelay(1000)
);
} catch (e) {
$mdToast.show(
$mdToast.simple()
.content('Failed to import profiles')
.position('top')
.hideDelay(1000)
);
}
});
function DialogController_($scope, $mdDialog) {
browser.storage.sync.get(null, (items) => {
let savedData = [];
if (!items) {
items = [];
}
for (const key in items) {
try {
const serializedProfiles = items[key];
const profiles = angular.fromJson(serializedProfiles);
for (let profile of profiles) {
fixProfile(profile);
}
savedData.push({
'timeInMs': key,
'profiles': profiles,
});
} catch(e) {
// skip invalid profile.
}
}
$scope.savedData = savedData;
});
$scope.selectProfiles = function(profiles) {
$mdDialog.hide(profiles);
};
$scope.closeDialog = function() {
$mdDialog.hide();
};
}
};
return profileService; return profileService;
}); });
@@ -558,6 +525,15 @@ modHeader.controller('AppController', function(
$scope.dataSource = dataSource; $scope.dataSource = dataSource;
$scope.profileService = profileService; $scope.profileService = profileService;
// chrome.storage.local writes are async and may not flush during the popup's
// unload, so persist eagerly on every change instead of relying on onunload.
$scope.$watch('dataSource.profiles', function() {
dataSource.save();
}, true);
$scope.$watch('dataSource.selectedProfile', function() {
dataSource.save();
});
const tips = [ const tips = [
{text: 'Tip: You can switch between multiple profile'}, {text: 'Tip: You can switch between multiple profile'},
{text: 'Tip: You can export your profile to share with others'}, {text: 'Tip: You can export your profile to share with others'},
@@ -569,19 +545,6 @@ modHeader.controller('AppController', function(
{text: 'Tip: Go to profile setting to toggle comment column'}, {text: 'Tip: Go to profile setting to toggle comment column'},
{text: 'Tip: Append header value to existing one in profile setting'}, {text: 'Tip: Append header value to existing one in profile setting'},
{text: 'Tip: Pause button will temporarily pause all modifications'}, {text: 'Tip: Pause button will temporarily pause all modifications'},
{text: 'Tip: Go to cloud backup to retrieve your auto-synced profile'},
{
text: 'If you like ModHeader, please consider donating',
buttonText: 'Donate',
url: 'https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=3XFKZ8PCRB8P6&currency_code=USD&amount=5&source=url'
},
{
text: 'Enjoying ModHeader, leave us a review',
buttonText: 'Review',
url: navigator.userAgent.indexOf('Firefox') >= 0
? 'https://addons.mozilla.org/firefox/addon/modheader-firefox/'
: 'https://chrome.google.com/webstore/detail/modheader/idgpnmonknjnojddfkpgkljpfnnfcklj'
},
]; ];
const tip = tips[Math.floor(Math.random() * tips.length)]; const tip = tips[Math.floor(Math.random() * tips.length)];
$mdToast.show({ $mdToast.show({
@@ -602,3 +565,21 @@ modHeader.controller('ToastCtrl', function($mdToast, $mdDialog, $document, $scop
browser.tabs.create({url: url}); browser.tabs.create({url: url});
}; };
}); });
// Preload chrome.storage.local into the synchronous cache, migrate any legacy
// MV2 localStorage data on first run, then bootstrap AngularJS manually.
document.addEventListener('DOMContentLoaded', async () => {
let items = await chrome.storage.local.get(null);
if ((!items || !items.profiles)
&& window.localStorage && window.localStorage.getItem('profiles')) {
const legacy = {};
for (let i = 0; i < window.localStorage.length; ++i) {
const key = window.localStorage.key(i);
legacy[key] = window.localStorage.getItem(key);
}
await chrome.storage.local.set(legacy);
items = Object.assign({}, items, legacy);
}
Object.assign(_storeCache, items || {});
angular.bootstrap(document.body, ['modheader-popup']);
});
Binary file not shown.