toolbox.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. 'use strict';
  2. /* exported
  3. CHROME_POPUP_BORDER_BUG
  4. RX_META
  5. capitalize
  6. closeCurrentTab
  7. deepEqual
  8. download
  9. getActiveTab
  10. getOwnTab
  11. getTab
  12. ignoreChromeError
  13. isEmptyObj
  14. onTabReady
  15. openURL
  16. sessionStore
  17. stringAsRegExp
  18. tryCatch
  19. tryRegExp
  20. tryURL
  21. waitForTabUrl
  22. */
  23. const CHROME = Boolean(chrome.app) && Number(navigator.userAgent.match(/Chrom\w+\/(\d+)|$/)[1]);
  24. const OPERA = Boolean(chrome.app) && parseFloat(navigator.userAgent.match(/\bOPR\/(\d+\.\d+)|$/)[1]);
  25. const VIVALDI = Boolean(chrome.app) && navigator.userAgent.includes('Vivaldi');
  26. let FIREFOX = !chrome.app && parseFloat(navigator.userAgent.match(/\bFirefox\/(\d+\.\d+)|$/)[1]);
  27. // see PR #781
  28. const CHROME_POPUP_BORDER_BUG = CHROME >= 62 && CHROME <= 74;
  29. if (!CHROME && !chrome.browserAction.openPopup) {
  30. // in FF pre-57 legacy addons can override useragent so we assume the worst
  31. // until we know for sure in the async getBrowserInfo()
  32. // (browserAction.openPopup was added in 57)
  33. FIREFOX = browser.runtime.getBrowserInfo ? 51 : 50;
  34. // getBrowserInfo was added in FF 51
  35. Promise.resolve(FIREFOX >= 51 ? browser.runtime.getBrowserInfo() : {version: 50}).then(info => {
  36. FIREFOX = parseFloat(info.version);
  37. document.documentElement.classList.add('moz-appearance-bug', FIREFOX && FIREFOX < 54);
  38. });
  39. }
  40. const URLS = {
  41. ownOrigin: chrome.runtime.getURL(''),
  42. configureCommands:
  43. OPERA ? 'opera://settings/configureCommands'
  44. : 'chrome://extensions/configureCommands',
  45. installUsercss: chrome.runtime.getURL('install-usercss.html'),
  46. // CWS cannot be scripted in chromium, see ChromeExtensionsClient::IsScriptableURL
  47. // https://cs.chromium.org/chromium/src/chrome/common/extensions/chrome_extensions_client.cc
  48. browserWebStore:
  49. FIREFOX ? 'https://addons.mozilla.org/' :
  50. OPERA ? 'https://addons.opera.com/' :
  51. 'https://chrome.google.com/webstore/',
  52. emptyTab: [
  53. // Chrome and simple forks
  54. 'chrome://newtab/',
  55. // Opera
  56. 'chrome://startpage/',
  57. // Vivaldi
  58. 'chrome-extension://mpognobbkildjkofajifpdfhcoklimli/components/startpage/startpage.html',
  59. // Firefox
  60. 'about:home',
  61. 'about:newtab',
  62. ],
  63. // Chrome 61.0.3161+ doesn't run content scripts on NTP https://crrev.com/2978953002/
  64. // TODO: remove when "minimum_chrome_version": "61" or higher
  65. chromeProtectsNTP: CHROME >= 61,
  66. uso: 'https://userstyles.org/',
  67. usoJson: 'https://userstyles.org/styles/chrome/',
  68. usoArchive: 'https://uso.kkx.one/',
  69. usoArchiveRaw: [
  70. 'https://cdn.jsdelivr.net/gh/33kk/uso-archive@flomaster/data/',
  71. 'https://raw.githubusercontent.com/33kk/uso-archive/flomaster/data/',
  72. ],
  73. usw: 'https://userstyles.world/',
  74. extractUsoArchiveId: url =>
  75. url &&
  76. URLS.usoArchiveRaw.some(u => url.startsWith(u)) &&
  77. Number(url.match(/\/(\d+)\.user\.css|$/)[1]),
  78. extractUsoArchiveInstallUrl: url => {
  79. const id = URLS.extractUsoArchiveId(url);
  80. return id ? `${URLS.usoArchive}style/${id}` : '';
  81. },
  82. makeUsoArchiveCodeUrl: id => `${URLS.usoArchiveRaw[0]}usercss/${id}.user.css`,
  83. extractGreasyForkInstallUrl: url =>
  84. /^(https:\/\/(?:greasy|sleazy)fork\.org\/scripts\/\d+)[^/]*\/code\/[^/]*\.user\.css$|$/.exec(url)[1],
  85. extractUSwId: url =>
  86. url &&
  87. url.startsWith(URLS.usw) &&
  88. Number(url.match(/\/(\d+)\.user\.css|$/)[1]),
  89. extractUSwInstallUrl: url => {
  90. const id = URLS.extractUSwId(url);
  91. return id ? `${URLS.usw}style/${id}` : '';
  92. },
  93. makeUswCodeUrl: id => `${URLS.usw}api/style/${id}.user.css`,
  94. supported: url => (
  95. url.startsWith('http') ||
  96. url.startsWith('ftp') ||
  97. url.startsWith('file') ||
  98. url.startsWith(URLS.ownOrigin) ||
  99. !URLS.chromeProtectsNTP && url.startsWith('chrome://newtab/')
  100. ),
  101. isLocalhost: url => /^file:|^https?:\/\/(localhost|127\.0\.0\.1)\//.test(url),
  102. };
  103. const RX_META = /\/\*!?\s*==userstyle==[\s\S]*?==\/userstyle==\s*\*\//i;
  104. if (FIREFOX || OPERA || VIVALDI) {
  105. document.documentElement.classList.add(
  106. FIREFOX && 'firefox' ||
  107. OPERA && 'opera' ||
  108. VIVALDI && 'vivaldi');
  109. }
  110. // FF57+ supports openerTabId, but not in Android
  111. // (detecting FF57 by the feature it added, not navigator.ua which may be spoofed in about:config)
  112. const openerTabIdSupported = (!FIREFOX || window.AbortController) && chrome.windows != null;
  113. function getOwnTab() {
  114. return browser.tabs.getCurrent();
  115. }
  116. async function getActiveTab() {
  117. return (await browser.tabs.query({currentWindow: true, active: true}))[0];
  118. }
  119. function urlToMatchPattern(url, ignoreSearch) {
  120. // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns
  121. if (!/^(http|https|ws|wss|ftp|data|file)$/.test(url.protocol)) {
  122. return undefined;
  123. }
  124. if (ignoreSearch) {
  125. return [
  126. `${url.protocol}//${url.hostname}/${url.pathname}`,
  127. `${url.protocol}//${url.hostname}/${url.pathname}?*`,
  128. ];
  129. }
  130. // FIXME: is %2f allowed in pathname and search?
  131. return `${url.protocol}//${url.hostname}/${url.pathname}${url.search}`;
  132. }
  133. async function findExistingTab({url, currentWindow, ignoreHash = true, ignoreSearch = false}) {
  134. url = tryURL(url);
  135. const tabs = await browser.tabs.query({
  136. url: urlToMatchPattern(url, ignoreSearch),
  137. currentWindow,
  138. });
  139. return tabs.find(tab => {
  140. const tabUrl = tryURL(tab.pendingUrl || tab.url);
  141. return tabUrl.protocol === url.protocol &&
  142. tabUrl.username === url.username &&
  143. tabUrl.password === url.password &&
  144. tabUrl.hostname === url.hostname &&
  145. tabUrl.port === url.port &&
  146. tabUrl.pathname === url.pathname &&
  147. (ignoreSearch || tabUrl.search === url.search) &&
  148. (ignoreHash || tabUrl.hash === url.hash);
  149. });
  150. }
  151. /**
  152. * Opens a tab or activates an existing one,
  153. * reuses the New Tab page or about:blank if it's focused now
  154. * @param {Object} _
  155. * @param {string} _.url - if relative, it's auto-expanded to the full extension URL
  156. * @param {number} [_.index] move the tab to this index in the tab strip, -1 = last
  157. * @param {number} [_.openerTabId] defaults to the active tab
  158. * @param {Boolean} [_.active=true] `true` to activate the tab
  159. * @param {Boolean|null} [_.currentWindow=true] `null` to check all windows
  160. * @param {chrome.windows.CreateData} [_.newWindow] creates a new window with these params if specified
  161. * @param {boolean} [_.ignoreExisting] specify to skip findExistingTab
  162. * @returns {Promise<chrome.tabs.Tab>} Promise -> opened/activated tab
  163. */
  164. async function openURL({
  165. url,
  166. index,
  167. openerTabId,
  168. active = true,
  169. currentWindow = true,
  170. newWindow,
  171. ignoreExisting,
  172. }) {
  173. if (!url.includes('://')) {
  174. url = chrome.runtime.getURL(url);
  175. }
  176. let tab = !ignoreExisting && await findExistingTab({url, currentWindow});
  177. if (tab) {
  178. return activateTab(tab, {
  179. index,
  180. openerTabId,
  181. // when hash is different we can only set `url` if it has # otherwise the tab would reload
  182. url: url !== (tab.pendingUrl || tab.url) && url.includes('#') ? url : undefined,
  183. });
  184. }
  185. if (newWindow && browser.windows) {
  186. return (await browser.windows.create(Object.assign({url}, newWindow))).tabs[0];
  187. }
  188. tab = await getActiveTab() || {url: ''};
  189. if (isTabReplaceable(tab, url)) {
  190. return activateTab(tab, {url, openerTabId});
  191. }
  192. const id = openerTabId == null ? tab.id : openerTabId;
  193. const opener = id != null && !tab.incognito && openerTabIdSupported && {openerTabId: id};
  194. return browser.tabs.create(Object.assign({url, index, active}, opener));
  195. }
  196. /**
  197. * Replaces empty tab (NTP or about:blank)
  198. * except when new URL is chrome:// or chrome-extension:// and the empty tab is in incognito
  199. */
  200. function isTabReplaceable(tab, newUrl) {
  201. return tab &&
  202. URLS.emptyTab.includes(tab.pendingUrl || tab.url) &&
  203. !(tab.incognito && newUrl.startsWith('chrome'));
  204. }
  205. async function activateTab(tab, {url, index, openerTabId} = {}) {
  206. const options = {active: true};
  207. if (url) {
  208. options.url = url;
  209. }
  210. if (openerTabId != null && openerTabIdSupported) {
  211. options.openerTabId = openerTabId;
  212. }
  213. await Promise.all([
  214. browser.tabs.update(tab.id, options),
  215. browser.windows && browser.windows.update(tab.windowId, {focused: true}),
  216. index != null && browser.tabs.move(tab.id, {index}),
  217. ]);
  218. return tab;
  219. }
  220. function stringAsRegExp(s, flags, asString) {
  221. s = s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&');
  222. return asString ? s : new RegExp(s, flags);
  223. }
  224. function ignoreChromeError() {
  225. // eslint-disable-next-line no-unused-expressions
  226. chrome.runtime.lastError;
  227. }
  228. function isEmptyObj(obj) {
  229. if (obj) {
  230. for (const k in obj) {
  231. if (Object.prototype.hasOwnProperty.call(obj, k)) {
  232. return false;
  233. }
  234. }
  235. }
  236. return true;
  237. }
  238. /**
  239. * js engine can't optimize the entire function if it contains try-catch
  240. * so we should keep it isolated from normal code in a minimal wrapper
  241. * 2020 update: probably fixed at least in V8
  242. */
  243. function tryCatch(func, ...args) {
  244. try {
  245. return func(...args);
  246. } catch (e) {}
  247. }
  248. function tryRegExp(regexp, flags) {
  249. try {
  250. return new RegExp(regexp, flags);
  251. } catch (e) {}
  252. }
  253. function tryJSONparse(jsonString) {
  254. try {
  255. return JSON.parse(jsonString);
  256. } catch (e) {}
  257. }
  258. function tryURL(
  259. url,
  260. fallback = {
  261. hash: '',
  262. host: '',
  263. hostname: '',
  264. href: '',
  265. origin: '',
  266. password: '',
  267. pathname: '',
  268. port: '',
  269. protocol: '',
  270. search: '',
  271. searchParams: new URLSearchParams(),
  272. username: '',
  273. }) {
  274. try {
  275. return new URL(url);
  276. } catch (e) {
  277. return fallback;
  278. }
  279. }
  280. function debounce(fn, delay, ...args) {
  281. clearTimeout(debounce.timers.get(fn));
  282. debounce.timers.set(fn, setTimeout(debounce.run, delay, fn, ...args));
  283. }
  284. Object.assign(debounce, {
  285. timers: new Map(),
  286. run(fn, ...args) {
  287. debounce.timers.delete(fn);
  288. fn(...args);
  289. },
  290. unregister(fn) {
  291. clearTimeout(debounce.timers.get(fn));
  292. debounce.timers.delete(fn);
  293. },
  294. });
  295. function deepMerge(src, dst) {
  296. if (!src || typeof src !== 'object') {
  297. return src;
  298. }
  299. if (Array.isArray(src)) {
  300. // using `Array` that belongs to this `window`; not using Array.from as it's slower
  301. if (!dst) dst = Array.prototype.map.call(src, deepCopy);
  302. else for (const v of src) dst.push(deepMerge(v));
  303. } else {
  304. // using an explicit {} that belongs to this `window`
  305. if (!dst) dst = {};
  306. for (const [k, v] of Object.entries(src)) {
  307. dst[k] = deepMerge(v, dst[k]);
  308. }
  309. }
  310. return dst;
  311. }
  312. /** Useful in arr.map(deepCopy) to ignore the extra parameters passed by map() */
  313. function deepCopy(src) {
  314. return deepMerge(src);
  315. }
  316. function deepEqual(a, b, ignoredKeys) {
  317. if (!a || !b) return a === b;
  318. const type = typeof a;
  319. if (type !== typeof b) return false;
  320. if (type !== 'object') return a === b;
  321. if (Array.isArray(a)) {
  322. return Array.isArray(b) &&
  323. a.length === b.length &&
  324. a.every((v, i) => deepEqual(v, b[i], ignoredKeys));
  325. }
  326. for (const key in a) {
  327. if (!Object.hasOwnProperty.call(a, key) ||
  328. ignoredKeys && ignoredKeys.includes(key)) continue;
  329. if (!Object.hasOwnProperty.call(b, key)) return false;
  330. if (!deepEqual(a[key], b[key], ignoredKeys)) return false;
  331. }
  332. for (const key in b) {
  333. if (!Object.hasOwnProperty.call(b, key) ||
  334. ignoredKeys && ignoredKeys.includes(key)) continue;
  335. if (!Object.hasOwnProperty.call(a, key)) return false;
  336. }
  337. return true;
  338. }
  339. /* A simple polyfill in case DOM storage is disabled in the browser */
  340. const sessionStore = new Proxy({}, {
  341. get(target, name) {
  342. try {
  343. return sessionStorage[name];
  344. } catch (e) {
  345. Object.defineProperty(window, 'sessionStorage', {value: target});
  346. }
  347. },
  348. set(target, name, value, proxy) {
  349. try {
  350. sessionStorage[name] = `${value}`;
  351. } catch (e) {
  352. proxy[name]; // eslint-disable-line no-unused-expressions
  353. target[name] = `${value}`;
  354. }
  355. return true;
  356. },
  357. deleteProperty(target, name) {
  358. return delete target[name];
  359. },
  360. });
  361. /**
  362. * @param {String} url
  363. * @param {Object} params
  364. * @param {String} [params.method]
  365. * @param {String|Object} [params.body]
  366. * @param {'arraybuffer'|'blob'|'document'|'json'|'text'} [params.responseType]
  367. * @param {Number} [params.requiredStatusCode] resolved when matches, otherwise rejected
  368. * @param {Number} [params.timeout] ms
  369. * @param {Object} [params.headers] {name: value}
  370. * @param {string[]} [params.responseHeaders]
  371. * @returns {Promise}
  372. */
  373. function download(url, {
  374. method = 'GET',
  375. body,
  376. responseType = 'text',
  377. requiredStatusCode = 200,
  378. timeout = 60e3, // connection timeout, USO is that bad
  379. loadTimeout = 2 * 60e3, // data transfer timeout (counted from the first remote response)
  380. headers,
  381. responseHeaders,
  382. } = {}) {
  383. /* USO can't handle POST requests for style json and XHR/fetch can't handle super long URL
  384. * so we need to collapse all long variables and expand them in the response */
  385. const queryPos = url.startsWith(URLS.uso) ? url.indexOf('?') : -1;
  386. if (queryPos >= 0) {
  387. if (body === undefined) {
  388. method = 'POST';
  389. body = url.slice(queryPos);
  390. url = url.slice(0, queryPos);
  391. }
  392. if (headers === undefined) {
  393. headers = {
  394. 'Content-type': 'application/x-www-form-urlencoded',
  395. };
  396. }
  397. }
  398. const usoVars = [];
  399. return new Promise((resolve, reject) => {
  400. const xhr = new XMLHttpRequest();
  401. const u = new URL(collapseUsoVars(url));
  402. const onTimeout = () => {
  403. xhr.abort();
  404. reject(new Error('Timeout fetching ' + u.href));
  405. };
  406. let timer = setTimeout(onTimeout, timeout);
  407. xhr.onreadystatechange = () => {
  408. if (xhr.readyState >= XMLHttpRequest.HEADERS_RECEIVED) {
  409. xhr.onreadystatechange = null;
  410. clearTimeout(timer);
  411. timer = loadTimeout && setTimeout(onTimeout, loadTimeout);
  412. }
  413. };
  414. xhr.onload = () => {
  415. if (xhr.status === requiredStatusCode || !requiredStatusCode || u.protocol === 'file:') {
  416. const response = expandUsoVars(xhr.response);
  417. if (responseHeaders) {
  418. const headers = {};
  419. for (const h of responseHeaders) headers[h] = xhr.getResponseHeader(h);
  420. resolve({headers, response});
  421. } else {
  422. resolve(response);
  423. }
  424. } else {
  425. reject(xhr.status);
  426. }
  427. };
  428. xhr.onerror = () => reject(xhr.status);
  429. xhr.onloadend = () => clearTimeout(timer);
  430. xhr.responseType = responseType;
  431. xhr.open(method, u.href);
  432. for (const [name, value] of Object.entries(headers || {})) {
  433. xhr.setRequestHeader(name, value);
  434. }
  435. xhr.send(body);
  436. });
  437. function collapseUsoVars(url) {
  438. if (queryPos < 0 ||
  439. url.length < 2000 ||
  440. !url.startsWith(URLS.usoJson) ||
  441. !/^get$/i.test(method)) {
  442. return url;
  443. }
  444. const params = new URLSearchParams(url.slice(queryPos + 1));
  445. for (const [k, v] of params.entries()) {
  446. if (v.length < 10 || v.startsWith('ik-')) continue;
  447. usoVars.push(v);
  448. params.set(k, `\x01${usoVars.length}\x02`);
  449. }
  450. return url.slice(0, queryPos + 1) + params.toString();
  451. }
  452. function expandUsoVars(response) {
  453. if (!usoVars.length || !response) return response;
  454. const isText = typeof response === 'string';
  455. const json = isText && tryJSONparse(response) || response;
  456. json.updateUrl = url;
  457. for (const section of json.sections || []) {
  458. const {code} = section;
  459. if (code.includes('\x01')) {
  460. section.code = code.replace(/\x01(\d+)\x02/g, (_, num) => usoVars[num - 1] || '');
  461. }
  462. }
  463. return isText ? JSON.stringify(json) : json;
  464. }
  465. }
  466. async function closeCurrentTab() {
  467. // https://bugzil.la/1409375
  468. const tab = await getOwnTab();
  469. if (tab) chrome.tabs.remove(tab.id);
  470. }
  471. function waitForTabUrl(tab) {
  472. return new Promise(resolve => {
  473. browser.tabs.onUpdated.addListener(...[
  474. function onUpdated(tabId, info, updatedTab) {
  475. if (info.url && tabId === tab.id) {
  476. browser.tabs.onUpdated.removeListener(onUpdated);
  477. resolve(updatedTab);
  478. }
  479. },
  480. ...'UpdateFilter' in browser.tabs ? [{tabId: tab.id}] : [], // FF only
  481. ]);
  482. });
  483. }
  484. function capitalize(s) {
  485. return s[0].toUpperCase() + s.slice(1);
  486. }