478 lines
15 KiB
JavaScript
478 lines
15 KiB
JavaScript
/* exported getTab getActiveTab onTabReady stringAsRegExp openURL ignoreChromeError
|
|
getStyleWithNoCode tryRegExp sessionStorageHash download deepEqual
|
|
closeCurrentTab capitalize CHROME_HAS_BORDER_BUG */
|
|
/* global promisify */
|
|
'use strict';
|
|
|
|
const CHROME = Boolean(chrome.app) && parseInt(navigator.userAgent.match(/Chrom\w+\/(?:\d+\.){2}(\d+)|$/)[1]);
|
|
const OPERA = Boolean(chrome.app) && parseFloat(navigator.userAgent.match(/\bOPR\/(\d+\.\d+)|$/)[1]);
|
|
const VIVALDI = Boolean(chrome.app) && navigator.userAgent.includes('Vivaldi');
|
|
// FIXME: who use this?
|
|
// const ANDROID = !chrome.windows;
|
|
let FIREFOX = !chrome.app && parseFloat(navigator.userAgent.match(/\bFirefox\/(\d+\.\d+)|$/)[1]);
|
|
|
|
// see PR #781
|
|
const CHROME_HAS_BORDER_BUG = CHROME >= 3167 && CHROME <= 3704;
|
|
|
|
if (!CHROME && !chrome.browserAction.openPopup) {
|
|
// in FF pre-57 legacy addons can override useragent so we assume the worst
|
|
// until we know for sure in the async getBrowserInfo()
|
|
// (browserAction.openPopup was added in 57)
|
|
FIREFOX = browserApi.runtime.getBrowserInfo ? 51 : 50;
|
|
// getBrowserInfo was added in FF 51
|
|
Promise.resolve(FIREFOX >= 51 ? browserApi.runtime.getBrowserInfo() : {version: 50}).then(info => {
|
|
FIREFOX = parseFloat(info.version);
|
|
document.documentElement.classList.add('moz-appearance-bug', FIREFOX && FIREFOX < 54);
|
|
});
|
|
}
|
|
|
|
const URLS = {
|
|
ownOrigin: chrome.runtime.getURL(''),
|
|
|
|
// FIXME delete?
|
|
optionsUI: [
|
|
chrome.runtime.getURL('options.html'),
|
|
'chrome://extensions/?options=' + chrome.runtime.id,
|
|
],
|
|
|
|
configureCommands:
|
|
OPERA ? 'opera://settings/configureCommands'
|
|
: 'chrome://extensions/configureCommands',
|
|
|
|
installUsercss: chrome.runtime.getURL('install-usercss.html'),
|
|
|
|
// CWS cannot be scripted in chromium, see ChromeExtensionsClient::IsScriptableURL
|
|
// https://cs.chromium.org/chromium/src/chrome/common/extensions/chrome_extensions_client.cc
|
|
browserWebStore:
|
|
FIREFOX ? 'https://addons.mozilla.org/' :
|
|
OPERA ? 'https://addons.opera.com/' :
|
|
'https://chrome.google.com/webstore/',
|
|
|
|
emptyTab: [
|
|
// Chrome and simple forks
|
|
'chrome://newtab/',
|
|
// Opera
|
|
'chrome://startpage/',
|
|
// Vivaldi
|
|
'chrome-extension://mpognobbkildjkofajifpdfhcoklimli/components/startpage/startpage.html',
|
|
// Firefox
|
|
'about:home',
|
|
'about:newtab',
|
|
],
|
|
|
|
// Chrome 61.0.3161+ doesn't run content scripts on NTP https://crrev.com/2978953002/
|
|
// TODO: remove when "minimum_chrome_version": "61" or higher
|
|
chromeProtectsNTP: CHROME >= 3161,
|
|
|
|
userstylesOrgJson: 'https://userstyles.org/styles/chrome/',
|
|
|
|
supported: url => (
|
|
url.startsWith('http') && (FIREFOX || !url.startsWith(URLS.browserWebStore)) ||
|
|
url.startsWith('ftp') ||
|
|
url.startsWith('file') ||
|
|
url.startsWith(URLS.ownOrigin) ||
|
|
!URLS.chromeProtectsNTP && url.startsWith('chrome://newtab/')
|
|
),
|
|
};
|
|
|
|
const IS_BG = chrome.extension.getBackgroundPage && chrome.extension.getBackgroundPage() === window;
|
|
|
|
if (!IS_BG) {
|
|
if (FIREFOX) {
|
|
document.documentElement.classList.add('firefox');
|
|
} else if (OPERA) {
|
|
document.documentElement.classList.add('opera');
|
|
} else {
|
|
if (VIVALDI) document.documentElement.classList.add('vivaldi');
|
|
}
|
|
}
|
|
|
|
if (IS_BG) {
|
|
window.API_METHODS = {};
|
|
}
|
|
|
|
// FIXME: `localStorage` and `sessionStorage` may be disabled via dom.storage.enabled
|
|
// Object.defineProperty(window, 'localStorage', {value: {}});
|
|
// Object.defineProperty(window, 'sessionStorage', {value: {}});
|
|
|
|
const createTab = promisify(chrome.tabs.create.bind(chrome.tabs));
|
|
const queryTabs = promisify(chrome.tabs.query.bind(chrome.tabs));
|
|
const updateTab = promisify(chrome.tabs.update.bind(chrome.tabs));
|
|
const moveTabs = promisify(chrome.tabs.move.bind(chrome.tabs));
|
|
|
|
// Android doesn't have chrome.windows
|
|
const updateWindow = chrome.windows && promisify(chrome.windows.update.bind(chrome.windows));
|
|
const createWindow = chrome.windows && promisify(chrome.windows.create.bind(chrome.windows));
|
|
// FF57+ supports openerTabId, but not in Android
|
|
// (detecting FF57 by the feature it added, not navigator.ua which may be spoofed in about:config)
|
|
const openerTabIdSupported = (!FIREFOX || window.AbortController) && chrome.windows != null;
|
|
|
|
function getTab(id) {
|
|
return new Promise(resolve =>
|
|
chrome.tabs.get(id, tab =>
|
|
!chrome.runtime.lastError && resolve(tab)));
|
|
}
|
|
|
|
|
|
function getOwnTab() {
|
|
return new Promise(resolve =>
|
|
chrome.tabs.getCurrent(tab => resolve(tab)));
|
|
}
|
|
|
|
|
|
function getActiveTab() {
|
|
return queryTabs({currentWindow: true, active: true})
|
|
.then(tabs => tabs[0]);
|
|
}
|
|
|
|
function urlToMatchPattern(url, ignoreSearch) {
|
|
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns
|
|
if (!/^(http|https|ws|wss|ftp|data|file)$/.test(url.protocol)) {
|
|
return undefined;
|
|
}
|
|
if (ignoreSearch) {
|
|
return [
|
|
`${url.protocol}//${url.hostname}/${url.pathname}`,
|
|
`${url.protocol}//${url.hostname}/${url.pathname}?*`
|
|
];
|
|
}
|
|
// FIXME: is %2f allowed in pathname and search?
|
|
return `${url.protocol}//${url.hostname}/${url.pathname}${url.search}`;
|
|
}
|
|
|
|
function findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch = false}) {
|
|
url = new URL(url);
|
|
return queryTabs({url: urlToMatchPattern(url, ignoreSearch), currentWindow})
|
|
// FIXME: is tab.url always normalized?
|
|
.then(tabs => tabs.find(matchTab));
|
|
|
|
function matchTab(tab) {
|
|
const tabUrl = new URL(tab.url);
|
|
return tabUrl.protocol === url.protocol &&
|
|
tabUrl.username === url.username &&
|
|
tabUrl.password === url.password &&
|
|
tabUrl.hostname === url.hostname &&
|
|
tabUrl.port === url.port &&
|
|
tabUrl.pathname === url.pathname &&
|
|
(ignoreSearch || tabUrl.search === url.search) &&
|
|
(ignoreHash || tabUrl.hash === url.hash);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opens a tab or activates an existing one,
|
|
* reuses the New Tab page or about:blank if it's focused now
|
|
* @param {Object} _
|
|
* @param {string} _.url - if relative, it's auto-expanded to the full extension URL
|
|
* @param {number} [_.index] move the tab to this index in the tab strip, -1 = last
|
|
* @param {number} [_.openerTabId] defaults to the active tab
|
|
* @param {Boolean} [_.active=true] `true` to activate the tab
|
|
* @param {Boolean|null} [_.currentWindow=true] `null` to check all windows
|
|
* @param {Boolean} [_.newWindow=false] `true` to open a new window
|
|
* @param {chrome.windows.CreateData} [_.windowPosition] options for chrome.windows.create
|
|
* @returns {Promise<chrome.tabs.Tab>} Promise -> opened/activated tab
|
|
*/
|
|
function openURL({
|
|
url,
|
|
index,
|
|
openerTabId,
|
|
active = true,
|
|
currentWindow = true,
|
|
newWindow = false,
|
|
windowPosition,
|
|
}) {
|
|
if (!url.includes('://')) {
|
|
url = chrome.runtime.getURL(url);
|
|
}
|
|
return findExistingTab({url, currentWindow}).then(tab => {
|
|
if (tab) {
|
|
return activateTab(tab, {
|
|
index,
|
|
openerTabId,
|
|
// when hash is different we can only set `url` if it has # otherwise the tab would reload
|
|
url: url !== tab.url && url.includes('#') ? url : undefined,
|
|
});
|
|
}
|
|
if (newWindow && createWindow) {
|
|
return createWindow(Object.assign({url}, windowPosition))
|
|
.then(wnd => wnd.tabs[0]);
|
|
}
|
|
return getActiveTab().then((activeTab = {url: ''}) =>
|
|
isTabReplaceable(activeTab, url) ?
|
|
activateTab(activeTab, {url, openerTabId}) : // not moving the tab
|
|
createTabWithOpener(activeTab, {url, index, active}));
|
|
});
|
|
function createTabWithOpener(openerTab, options) {
|
|
const id = openerTabId == null ? openerTab.id : openerTabId;
|
|
if (id != null && !openerTab.incognito && openerTabIdSupported) {
|
|
options.openerTabId = id;
|
|
}
|
|
return createTab(options);
|
|
}
|
|
}
|
|
|
|
// replace empty tab (NTP or about:blank)
|
|
// except when new URL is chrome:// or chrome-extension:// and the empty tab is
|
|
// in incognito
|
|
function isTabReplaceable(tab, newUrl) {
|
|
if (!tab || !URLS.emptyTab.includes(tab.url)) {
|
|
return false;
|
|
}
|
|
// FIXME: but why?
|
|
if (tab.incognito && newUrl.startsWith('chrome')) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function activateTab(tab, {url, index, openerTabId} = {}) {
|
|
const options = {active: true};
|
|
if (url) {
|
|
options.url = url;
|
|
}
|
|
if (openerTabId != null && openerTabIdSupported) {
|
|
options.openerTabId = openerTabId;
|
|
}
|
|
return Promise.all([
|
|
updateTab(tab.id, options),
|
|
updateWindow && updateWindow(tab.windowId, {focused: true}),
|
|
index != null && moveTabs(tab.id, {index})
|
|
])
|
|
.then(() => tab);
|
|
}
|
|
|
|
|
|
function stringAsRegExp(s, flags) {
|
|
return new RegExp(s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&'), flags);
|
|
}
|
|
|
|
|
|
function ignoreChromeError() {
|
|
// eslint-disable-next-line no-unused-expressions
|
|
chrome.runtime.lastError;
|
|
}
|
|
|
|
|
|
function getStyleWithNoCode(style) {
|
|
const stripped = deepCopy(style);
|
|
for (const section of stripped.sections) section.code = null;
|
|
stripped.sourceCode = null;
|
|
return stripped;
|
|
}
|
|
|
|
|
|
// js engine can't optimize the entire function if it contains try-catch
|
|
// so we should keep it isolated from normal code in a minimal wrapper
|
|
// Update: might get fixed in V8 TurboFan in the future
|
|
function tryCatch(func, ...args) {
|
|
try {
|
|
return func(...args);
|
|
} catch (e) {}
|
|
}
|
|
|
|
|
|
function tryRegExp(regexp, flags) {
|
|
try {
|
|
return new RegExp(regexp, flags);
|
|
} catch (e) {}
|
|
}
|
|
|
|
|
|
function tryJSONparse(jsonString) {
|
|
try {
|
|
return JSON.parse(jsonString);
|
|
} catch (e) {}
|
|
}
|
|
|
|
|
|
const debounce = Object.assign((fn, delay, ...args) => {
|
|
clearTimeout(debounce.timers.get(fn));
|
|
debounce.timers.set(fn, setTimeout(debounce.run, delay, fn, ...args));
|
|
}, {
|
|
timers: new Map(),
|
|
run(fn, ...args) {
|
|
debounce.timers.delete(fn);
|
|
fn(...args);
|
|
},
|
|
unregister(fn) {
|
|
clearTimeout(debounce.timers.get(fn));
|
|
debounce.timers.delete(fn);
|
|
},
|
|
});
|
|
|
|
|
|
function deepCopy(obj) {
|
|
if (!obj || typeof obj !== 'object') return obj;
|
|
// N.B. the copy should be an explicit literal
|
|
if (Array.isArray(obj)) {
|
|
const copy = [];
|
|
for (const v of obj) {
|
|
copy.push(!v || typeof v !== 'object' ? v : deepCopy(v));
|
|
}
|
|
return copy;
|
|
}
|
|
const copy = {};
|
|
const hasOwnProperty = Object.prototype.hasOwnProperty;
|
|
for (const k in obj) {
|
|
if (!hasOwnProperty.call(obj, k)) continue;
|
|
const v = obj[k];
|
|
copy[k] = !v || typeof v !== 'object' ? v : deepCopy(v);
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
|
|
function deepEqual(a, b, ignoredKeys) {
|
|
if (!a || !b) return a === b;
|
|
const type = typeof a;
|
|
if (type !== typeof b) return false;
|
|
if (type !== 'object') return a === b;
|
|
if (Array.isArray(a)) {
|
|
return Array.isArray(b) &&
|
|
a.length === b.length &&
|
|
a.every((v, i) => deepEqual(v, b[i], ignoredKeys));
|
|
}
|
|
for (const key in a) {
|
|
if (!Object.hasOwnProperty.call(a, key) ||
|
|
ignoredKeys && ignoredKeys.includes(key)) continue;
|
|
if (!Object.hasOwnProperty.call(b, key)) return false;
|
|
if (!deepEqual(a[key], b[key], ignoredKeys)) return false;
|
|
}
|
|
for (const key in b) {
|
|
if (!Object.hasOwnProperty.call(b, key) ||
|
|
ignoredKeys && ignoredKeys.includes(key)) continue;
|
|
if (!Object.hasOwnProperty.call(a, key)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
|
|
function sessionStorageHash(name) {
|
|
return {
|
|
name,
|
|
value: tryCatch(JSON.parse, sessionStorage[name]) || {},
|
|
set(k, v) {
|
|
this.value[k] = v;
|
|
this.updateStorage();
|
|
},
|
|
unset(k) {
|
|
delete this.value[k];
|
|
this.updateStorage();
|
|
},
|
|
updateStorage() {
|
|
sessionStorage[this.name] = JSON.stringify(this.value);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {String} url
|
|
* @param {Object} params
|
|
* @param {String} [params.method]
|
|
* @param {String|Object} [params.body]
|
|
* @param {String} [params.responseType] arraybuffer, blob, document, json, text
|
|
* @param {Number} [params.requiredStatusCode] resolved when matches, otherwise rejected
|
|
* @param {Number} [params.timeout] ms
|
|
* @param {Object} [params.headers] {name: value}
|
|
* @returns {Promise}
|
|
*/
|
|
function download(url, {
|
|
method = 'GET',
|
|
body,
|
|
responseType = 'text',
|
|
requiredStatusCode = 200,
|
|
timeout = 10e3,
|
|
headers = {
|
|
'Content-type': 'application/x-www-form-urlencoded',
|
|
},
|
|
} = {}) {
|
|
const queryPos = url.indexOf('?');
|
|
if (queryPos > 0 && body === undefined) {
|
|
method = 'POST';
|
|
body = url.slice(queryPos);
|
|
url = url.slice(0, queryPos);
|
|
}
|
|
// * USO can't handle POST requests for style json
|
|
// * XHR/fetch can't handle long URL
|
|
// So we need to collapse all long variables and expand them in the response
|
|
const usoVars = [];
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const u = new URL(collapseUsoVars(url));
|
|
if (u.protocol === 'file:' && FIREFOX) {
|
|
// https://stackoverflow.com/questions/42108782/firefox-webextensions-get-local-files-content-by-path
|
|
// FIXME: add FetchController when it is available.
|
|
const timer = setTimeout(reject, timeout, new Error('Timeout fetching ' + u.href));
|
|
fetch(u.href, {mode: 'same-origin'})
|
|
.then(r => {
|
|
clearTimeout(timer);
|
|
return r.status === 200 ? r.text() : Promise.reject(r.status);
|
|
})
|
|
.catch(reject)
|
|
.then(resolve);
|
|
return;
|
|
}
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.timeout = timeout;
|
|
xhr.onloadend = event => {
|
|
if (event.type !== 'error' && (
|
|
xhr.status === requiredStatusCode || !requiredStatusCode ||
|
|
u.protocol === 'file:')) {
|
|
resolve(expandUsoVars(xhr.response));
|
|
} else {
|
|
reject(xhr.status);
|
|
}
|
|
};
|
|
xhr.onerror = xhr.onloadend;
|
|
xhr.responseType = responseType;
|
|
xhr.open(method, u.href, true);
|
|
for (const key in headers) {
|
|
xhr.setRequestHeader(key, headers[key]);
|
|
}
|
|
xhr.send(body);
|
|
});
|
|
|
|
function collapseUsoVars(url) {
|
|
if (queryPos < 0 ||
|
|
url.length < 2000 ||
|
|
!url.startsWith(URLS.userstylesOrgJson) ||
|
|
!/^get$/i.test(method)) {
|
|
return url;
|
|
}
|
|
const params = new URLSearchParams(url.slice(queryPos + 1));
|
|
for (const [k, v] of params.entries()) {
|
|
if (v.length < 10 || v.startsWith('ik-')) continue;
|
|
usoVars.push(v);
|
|
params.set(k, `\x01${usoVars.length}\x02`);
|
|
}
|
|
return url.slice(0, queryPos + 1) + params.toString();
|
|
}
|
|
|
|
function expandUsoVars(response) {
|
|
if (!usoVars.length || !response) return response;
|
|
const isText = typeof response === 'string';
|
|
const json = isText && tryJSONparse(response) || response;
|
|
json.updateUrl = url;
|
|
for (const section of json.sections || []) {
|
|
const {code} = section;
|
|
if (code.includes('\x01')) {
|
|
section.code = code.replace(/\x01(\d+)\x02/g, (_, num) => usoVars[num - 1] || '');
|
|
}
|
|
}
|
|
return isText ? JSON.stringify(json) : json;
|
|
}
|
|
}
|
|
|
|
function closeCurrentTab() {
|
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=1409375
|
|
getOwnTab().then(tab => {
|
|
if (tab) {
|
|
chrome.tabs.remove(tab.id);
|
|
}
|
|
});
|
|
}
|
|
|
|
function capitalize(s) {
|
|
return s[0].toUpperCase() + s.slice(1);
|
|
}
|