This update introduces full Firefox support using a new manifest-firefox.json (Manifest V2), adds a webext-compat.js compatibility layer for unified API usage, and refactors all extension scripts (background.js, content.js, popup.js) to use the new webext API. The build and development process is automated with new Python scripts (build.py, dev.py), and GitHub Actions workflows are updated to support multi-platform build, test, and release. Platform detection and capability abstraction are added, and all relevant files and manifests are updated for cross-browser compatibility.
244 lines
7.5 KiB
JavaScript
244 lines
7.5 KiB
JavaScript
/**
|
||
* WebExtension API 兼容层
|
||
* 提供跨浏览器的统一API接口
|
||
*/
|
||
|
||
// 检测当前浏览器环境
|
||
const detectBrowser = () => {
|
||
const ua = navigator.userAgent;
|
||
const manifest = chrome.runtime.getManifest();
|
||
|
||
const flavor = {
|
||
major: 0,
|
||
soup: new Set(["webext"]),
|
||
};
|
||
|
||
// 检测 Firefox
|
||
if (
|
||
typeof browser !== "undefined" &&
|
||
browser.runtime &&
|
||
browser.runtime.getURL("").startsWith("moz-extension://")
|
||
) {
|
||
flavor.soup.add("firefox");
|
||
const match = /Firefox\/(\d+)/.exec(ua);
|
||
flavor.major = match ? parseInt(match[1], 10) : 115;
|
||
|
||
// Firefox 特性检测
|
||
if (CSS.supports("selector(a:has(b))")) {
|
||
flavor.soup.add("native_css_has");
|
||
}
|
||
}
|
||
// 检测基于 Chromium 的浏览器
|
||
else {
|
||
const match = /\bChrom(?:e|ium)\/(\d+)/.exec(ua);
|
||
if (match !== null) {
|
||
flavor.soup.add("chromium");
|
||
}
|
||
flavor.major = match ? parseInt(match[1], 10) : 120;
|
||
|
||
// 检测具体浏览器
|
||
if (/\bEdg\/\b/.test(ua)) {
|
||
flavor.soup.add("edge");
|
||
} else if (/\bOPR\/\b/.test(ua)) {
|
||
flavor.soup.add("opera");
|
||
} else {
|
||
flavor.soup.add("chrome");
|
||
}
|
||
}
|
||
|
||
// 检测移动端
|
||
if (/\bMobile\b/.test(ua)) {
|
||
flavor.soup.add("mobile");
|
||
}
|
||
|
||
// 检测开发版本
|
||
if (/^\d+\.\d+\.\d+\D/.test(manifest.version)) {
|
||
flavor.soup.add("devbuild");
|
||
}
|
||
|
||
return flavor;
|
||
};
|
||
|
||
// 浏览器特性检测
|
||
const webextFlavor = detectBrowser();
|
||
|
||
// 统一的 WebExtension API
|
||
const webext = (() => {
|
||
// Firefox 使用 browser API,Chrome 使用 chrome API
|
||
const api =
|
||
typeof browser !== "undefined" && browser.runtime ? browser : chrome;
|
||
|
||
// Promise 化函数的包装器
|
||
const promisify = (context, methodName) => {
|
||
const method = context[methodName];
|
||
if (!method) {
|
||
throw new Error(`Method ${methodName} not found`);
|
||
}
|
||
|
||
return function (...args) {
|
||
return new Promise((resolve, reject) => {
|
||
method.call(context, ...args, (result) => {
|
||
if (api.runtime.lastError) {
|
||
reject(new Error(api.runtime.lastError.message));
|
||
} else {
|
||
resolve(result);
|
||
}
|
||
});
|
||
});
|
||
};
|
||
};
|
||
|
||
// 无失败的 Promise 包装器
|
||
const promisifyNoFail = (context, methodName, defaultValue = undefined) => {
|
||
try {
|
||
const promisified = promisify(context, methodName);
|
||
return async function (...args) {
|
||
try {
|
||
return await promisified(...args);
|
||
} catch (error) {
|
||
console.warn(`WebExt API call failed: ${methodName}`, error);
|
||
return defaultValue;
|
||
}
|
||
};
|
||
} catch (error) {
|
||
// 如果方法不存在,返回空函数
|
||
return async function () {
|
||
console.warn(`WebExt API method not available: ${methodName}`);
|
||
return defaultValue;
|
||
};
|
||
}
|
||
};
|
||
|
||
return {
|
||
// Runtime API
|
||
runtime: {
|
||
getManifest: () => api.runtime.getManifest(),
|
||
getURL: (path) => api.runtime.getURL(path),
|
||
sendMessage: promisifyNoFail(api.runtime, "sendMessage"),
|
||
connect: (connectInfo) => api.runtime.connect(connectInfo),
|
||
onMessage: api.runtime.onMessage,
|
||
onConnect: api.runtime.onConnect,
|
||
lastError: api.runtime.lastError,
|
||
id: api.runtime.id,
|
||
},
|
||
|
||
// Tabs API
|
||
tabs: {
|
||
query: promisifyNoFail(api.tabs, "query", []),
|
||
get: promisifyNoFail(api.tabs, "get"),
|
||
create: promisifyNoFail(api.tabs, "create"),
|
||
update: promisifyNoFail(api.tabs, "update"),
|
||
remove: promisifyNoFail(api.tabs, "remove"),
|
||
sendMessage: promisifyNoFail(api.tabs, "sendMessage"),
|
||
executeScript: promisifyNoFail(api.tabs, "executeScript"),
|
||
insertCSS: promisifyNoFail(api.tabs, "insertCSS"),
|
||
removeCSS: promisifyNoFail(api.tabs, "removeCSS"),
|
||
onUpdated: api.tabs.onUpdated,
|
||
onActivated: api.tabs.onActivated,
|
||
onRemoved: api.tabs.onRemoved,
|
||
},
|
||
|
||
// Storage API
|
||
storage: {
|
||
local: {
|
||
get: promisifyNoFail(api.storage.local, "get", {}),
|
||
set: promisifyNoFail(api.storage.local, "set"),
|
||
remove: promisifyNoFail(api.storage.local, "remove"),
|
||
clear: promisifyNoFail(api.storage.local, "clear"),
|
||
getBytesInUse: promisifyNoFail(api.storage.local, "getBytesInUse", 0),
|
||
},
|
||
sync: api.storage.sync
|
||
? {
|
||
get: promisifyNoFail(api.storage.sync, "get", {}),
|
||
set: promisifyNoFail(api.storage.sync, "set"),
|
||
remove: promisifyNoFail(api.storage.sync, "remove"),
|
||
clear: promisifyNoFail(api.storage.sync, "clear"),
|
||
getBytesInUse: promisifyNoFail(
|
||
api.storage.sync,
|
||
"getBytesInUse",
|
||
0
|
||
),
|
||
}
|
||
: null,
|
||
},
|
||
|
||
// Downloads API
|
||
downloads: {
|
||
download: promisifyNoFail(api.downloads, "download"),
|
||
search: promisifyNoFail(api.downloads, "search", []),
|
||
cancel: promisifyNoFail(api.downloads, "cancel"),
|
||
pause: promisifyNoFail(api.downloads, "pause"),
|
||
resume: promisifyNoFail(api.downloads, "resume"),
|
||
erase: promisifyNoFail(api.downloads, "erase"),
|
||
removeFile: promisifyNoFail(api.downloads, "removeFile"),
|
||
show: promisifyNoFail(api.downloads, "show"),
|
||
showDefaultFolder: promisifyNoFail(api.downloads, "showDefaultFolder"),
|
||
onCreated: api.downloads.onCreated,
|
||
onChanged: api.downloads.onChanged,
|
||
onErased: api.downloads.onErased,
|
||
},
|
||
|
||
// Browser Action / Action API (兼容不同版本)
|
||
action: (() => {
|
||
// Manifest V3 使用 action,V2 使用 browserAction
|
||
const actionAPI = api.action || api.browserAction;
|
||
if (!actionAPI) return null;
|
||
|
||
return {
|
||
setTitle: promisifyNoFail(actionAPI, "setTitle"),
|
||
getTitle: promisifyNoFail(actionAPI, "getTitle"),
|
||
setIcon: promisifyNoFail(actionAPI, "setIcon"),
|
||
setPopup: promisifyNoFail(actionAPI, "setPopup"),
|
||
getPopup: promisifyNoFail(actionAPI, "getPopup"),
|
||
setBadgeText: promisifyNoFail(actionAPI, "setBadgeText"),
|
||
getBadgeText: promisifyNoFail(actionAPI, "getBadgeText"),
|
||
setBadgeBackgroundColor: promisifyNoFail(
|
||
actionAPI,
|
||
"setBadgeBackgroundColor"
|
||
),
|
||
getBadgeBackgroundColor: promisifyNoFail(
|
||
actionAPI,
|
||
"getBadgeBackgroundColor"
|
||
),
|
||
setBadgeTextColor: promisifyNoFail(actionAPI, "setBadgeTextColor"),
|
||
getBadgeTextColor: promisifyNoFail(actionAPI, "getBadgeTextColor"),
|
||
enable: promisifyNoFail(actionAPI, "enable"),
|
||
disable: promisifyNoFail(actionAPI, "disable"),
|
||
onClicked: actionAPI.onClicked,
|
||
};
|
||
})(),
|
||
|
||
// Permissions API
|
||
permissions: {
|
||
contains: promisifyNoFail(api.permissions, "contains", false),
|
||
getAll: promisifyNoFail(api.permissions, "getAll", {
|
||
origins: [],
|
||
permissions: [],
|
||
}),
|
||
request: promisifyNoFail(api.permissions, "request", false),
|
||
remove: promisifyNoFail(api.permissions, "remove", false),
|
||
onAdded: api.permissions?.onAdded,
|
||
onRemoved: api.permissions?.onRemoved,
|
||
},
|
||
|
||
// i18n API
|
||
i18n: {
|
||
getMessage: (messageName, substitutions) =>
|
||
api.i18n.getMessage(messageName, substitutions),
|
||
getAcceptLanguages: promisifyNoFail(api.i18n, "getAcceptLanguages", []),
|
||
getUILanguage: () => api.i18n.getUILanguage(),
|
||
detectLanguage: promisifyNoFail(api.i18n, "detectLanguage"),
|
||
},
|
||
};
|
||
})();
|
||
|
||
// 导出接口
|
||
if (typeof module !== "undefined" && module.exports) {
|
||
// Node.js 环境
|
||
module.exports = { webext, webextFlavor };
|
||
} else {
|
||
// 浏览器环境
|
||
window.webext = webext;
|
||
window.webextFlavor = webextFlavor;
|
||
}
|