messaging.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. /* global BG: true, onRuntimeMessage, applyOnMessage, handleUpdate, handleDelete */
  2. /* global FIREFOX: true */
  3. 'use strict';
  4. // keep message channel open for sendResponse in chrome.runtime.onMessage listener
  5. const KEEP_CHANNEL_OPEN = true;
  6. const CHROME = Boolean(chrome.app) && parseInt(navigator.userAgent.match(/Chrom\w+\/(?:\d+\.){2}(\d+)|$/)[1]);
  7. const OPERA = CHROME && parseFloat(navigator.userAgent.match(/\bOPR\/(\d+\.\d+)|$/)[1]);
  8. const ANDROID = !chrome.windows;
  9. let FIREFOX = !CHROME && parseFloat(navigator.userAgent.match(/\bFirefox\/(\d+\.\d+)|$/)[1]);
  10. if (!CHROME && !chrome.browserAction.openPopup) {
  11. // in FF pre-57 legacy addons can override useragent so we assume the worst
  12. // until we know for sure in the async getBrowserInfo()
  13. // (browserAction.openPopup was added in 57)
  14. FIREFOX = 50;
  15. browser.runtime.getBrowserInfo().then(info => {
  16. FIREFOX = parseFloat(info.version);
  17. document.documentElement.classList.add('moz-appearance-bug', FIREFOX && FIREFOX < 54);
  18. });
  19. }
  20. const URLS = {
  21. ownOrigin: chrome.runtime.getURL(''),
  22. optionsUI: [
  23. chrome.runtime.getURL('options.html'),
  24. 'chrome://extensions/?options=' + chrome.runtime.id,
  25. ],
  26. configureCommands:
  27. OPERA ? 'opera://settings/configureCommands'
  28. : 'chrome://extensions/configureCommands',
  29. // CWS cannot be scripted in chromium, see ChromeExtensionsClient::IsScriptableURL
  30. // https://cs.chromium.org/chromium/src/chrome/common/extensions/chrome_extensions_client.cc
  31. browserWebStore:
  32. FIREFOX ? 'https://addons.mozilla.org/' :
  33. OPERA ? 'https://addons.opera.com/' :
  34. 'https://chrome.google.com/webstore/',
  35. // Chrome 61.0.3161+ doesn't run content scripts on NTP https://crrev.com/2978953002/
  36. // TODO: remove when "minimum_chrome_version": "61" or higher
  37. chromeProtectsNTP: CHROME >= 3161,
  38. supported: url => (
  39. url.startsWith('http') && !url.startsWith(URLS.browserWebStore) ||
  40. url.startsWith('ftp') ||
  41. url.startsWith('file') ||
  42. url.startsWith(URLS.ownOrigin) ||
  43. !URLS.chromeProtectsNTP && url.startsWith('chrome://newtab/')
  44. ),
  45. };
  46. let BG = chrome.extension.getBackgroundPage();
  47. if (BG && !BG.getStyles && BG !== window) {
  48. // own page like editor/manage is being loaded on browser startup
  49. // before the background page has been fully initialized;
  50. // it'll be resolved in onBackgroundReady() instead
  51. BG = null;
  52. }
  53. if (!BG || BG !== window) {
  54. if (FIREFOX) {
  55. document.documentElement.classList.add('firefox');
  56. } else if (OPERA) {
  57. document.documentElement.classList.add('opera');
  58. } else if (chrome.app && navigator.userAgent.includes('Vivaldi')) {
  59. document.documentElement.classList.add('vivaldi');
  60. }
  61. // TODO: remove once our manifest's minimum_chrome_version is 50+
  62. // Chrome 49 doesn't report own extension pages in webNavigation apparently
  63. if (CHROME && CHROME < 2661) {
  64. getActiveTab().then(BG.updateIcon);
  65. }
  66. }
  67. const FIREFOX_NO_DOM_STORAGE = FIREFOX && !tryCatch(() => localStorage);
  68. if (FIREFOX_NO_DOM_STORAGE) {
  69. // may be disabled via dom.storage.enabled
  70. Object.defineProperty(window, 'localStorage', {value: {}});
  71. Object.defineProperty(window, 'sessionStorage', {value: {}});
  72. }
  73. function notifyAllTabs(msg) {
  74. const originalMessage = msg;
  75. if (msg.method === 'styleUpdated' || msg.method === 'styleAdded') {
  76. // apply/popup/manage use only meta for these two methods,
  77. // editor may need the full code but can fetch it directly,
  78. // so we send just the meta to avoid spamming lots of tabs with huge styles
  79. msg = Object.assign({}, msg, {
  80. style: getStyleWithNoCode(msg.style)
  81. });
  82. }
  83. const affectsAll = !msg.affects || msg.affects.all;
  84. const affectsOwnOriginOnly = !affectsAll && (msg.affects.editor || msg.affects.manager);
  85. const affectsTabs = affectsAll || affectsOwnOriginOnly;
  86. const affectsIcon = affectsAll || msg.affects.icon;
  87. const affectsPopup = affectsAll || msg.affects.popup;
  88. const affectsSelf = affectsPopup || msg.prefs;
  89. if (affectsTabs || affectsIcon) {
  90. const notifyTab = tab => {
  91. // own pages will be notified via runtime.sendMessage later
  92. if ((affectsTabs || URLS.optionsUI.includes(tab.url))
  93. && !(affectsSelf && tab.url.startsWith(URLS.ownOrigin))
  94. // skip lazy-loaded aka unloaded tabs that seem to start loading on message in FF
  95. && (!FIREFOX || tab.width)) {
  96. msg.tabId = tab.id;
  97. sendMessage(msg, ignoreChromeError);
  98. }
  99. if (affectsIcon && BG) {
  100. BG.updateIcon(tab);
  101. }
  102. };
  103. // list all tabs including chrome-extension:// which can be ours
  104. Promise.all([
  105. queryTabs(affectsOwnOriginOnly ? {url: URLS.ownOrigin + '*'} : {}),
  106. getActiveTab(),
  107. ]).then(([tabs, activeTab]) => {
  108. const activeTabId = activeTab && activeTab.id;
  109. for (const tab of tabs) {
  110. invokeOrPostpone(tab.id === activeTabId, notifyTab, tab);
  111. }
  112. });
  113. }
  114. // notify self: the message no longer is sent to the origin in new Chrome
  115. if (typeof onRuntimeMessage !== 'undefined') {
  116. onRuntimeMessage(originalMessage);
  117. }
  118. // notify apply.js on own pages
  119. if (typeof applyOnMessage !== 'undefined') {
  120. applyOnMessage(originalMessage);
  121. }
  122. // notify background page and all open popups
  123. if (affectsSelf) {
  124. msg.tabId = undefined;
  125. sendMessage(msg, ignoreChromeError);
  126. }
  127. }
  128. function sendMessage(msg, callback) {
  129. /*
  130. Promise mode [default]:
  131. - rejects on receiving {__ERROR__: message} created by background.js::onRuntimeMessage
  132. - automatically suppresses chrome.runtime.lastError because it's autogenerated
  133. by browserAction.setText which lacks a callback param in chrome API
  134. Standard callback mode:
  135. - enabled by passing a second param
  136. */
  137. const {tabId, frameId} = msg;
  138. if (tabId >= 0 && FIREFOX) {
  139. // FF: reroute all tabs messages to styleViaAPI
  140. const msgForBG = BG === window ? msg : BG.deepCopy(msg);
  141. const sender = {tab: {id: tabId}, frameId};
  142. const task = BG.styleViaAPI.process(msgForBG, sender);
  143. return callback ? task.then(callback) : task;
  144. }
  145. const fn = tabId >= 0 ? chrome.tabs.sendMessage : chrome.runtime.sendMessage;
  146. const args = tabId >= 0 ? [tabId, msg, {frameId}] : [msg];
  147. if (callback) {
  148. fn(...args, callback);
  149. } else {
  150. return new Promise((resolve, reject) => {
  151. fn(...args, r => {
  152. const err = r && r.__ERROR__;
  153. (err ? reject : resolve)(err || r);
  154. chrome.runtime.lastError; // eslint-disable-line no-unused-expressions
  155. });
  156. });
  157. }
  158. }
  159. function queryTabs(options = {}) {
  160. return new Promise(resolve =>
  161. chrome.tabs.query(options, tabs =>
  162. resolve(tabs)));
  163. }
  164. function getTab(id) {
  165. return new Promise(resolve =>
  166. chrome.tabs.get(id, tab =>
  167. !chrome.runtime.lastError && resolve(tab)));
  168. }
  169. function getOwnTab() {
  170. return new Promise(resolve =>
  171. chrome.tabs.getCurrent(tab => resolve(tab)));
  172. }
  173. function getActiveTab() {
  174. return queryTabs({currentWindow: true, active: true})
  175. .then(tabs => tabs[0]);
  176. }
  177. function getActiveTabRealURL() {
  178. return getActiveTab()
  179. .then(getTabRealURL);
  180. }
  181. function getTabRealURL(tab) {
  182. return new Promise(resolve => {
  183. if (tab.url !== 'chrome://newtab/' || URLS.chromeProtectsNTP) {
  184. resolve(tab.url);
  185. } else {
  186. chrome.webNavigation.getFrame({tabId: tab.id, frameId: 0, processId: -1}, frame => {
  187. resolve(frame && frame.url || '');
  188. });
  189. }
  190. });
  191. }
  192. // opens a tab or activates the already opened one,
  193. // reuses the New Tab page if it's focused now
  194. function openURL({url, index, openerTabId, currentWindow = true}) {
  195. if (!url.includes('://')) {
  196. url = chrome.runtime.getURL(url);
  197. }
  198. return new Promise(resolve => {
  199. // [some] chromium forks don't handle their fake branded protocols
  200. url = url.replace(/^(opera|vivaldi)/, 'chrome');
  201. // FF doesn't handle moz-extension:// URLs (bug)
  202. // API doesn't handle the hash-fragment part
  203. const urlQuery = url.startsWith('moz-extension') ? undefined : url.replace(/#.*/, '');
  204. queryTabs({url: urlQuery, currentWindow}).then(tabs => {
  205. for (const tab of tabs) {
  206. if (tab.url === url) {
  207. activateTab(tab).then(resolve);
  208. return;
  209. }
  210. }
  211. getActiveTab().then(tab => {
  212. const chromeInIncognito = tab && tab.incognito && url.startsWith('chrome');
  213. if (tab && (tab.url === 'chrome://newtab/' || tab.url === 'about:newtab') && !chromeInIncognito) {
  214. // update current NTP, except for chrome:// or chrome-extension:// in incognito
  215. chrome.tabs.update({url}, resolve);
  216. } else {
  217. // create a new tab
  218. const options = {url, index};
  219. // FF57+ supports openerTabId, but not in Android (indicated by the absence of chrome.windows)
  220. if (tab && (!FIREFOX || FIREFOX >= 57 && chrome.windows) && !chromeInIncognito) {
  221. options.openerTabId = tab.id;
  222. }
  223. chrome.tabs.create(options, resolve);
  224. }
  225. });
  226. });
  227. });
  228. }
  229. function activateTab(tab) {
  230. return Promise.all([
  231. new Promise(resolve => {
  232. chrome.tabs.update(tab.id, {active: true}, resolve);
  233. }),
  234. chrome.windows && new Promise(resolve => {
  235. chrome.windows.update(tab.windowId, {focused: true}, resolve);
  236. }),
  237. ]);
  238. }
  239. function stringAsRegExp(s, flags) {
  240. return new RegExp(s.replace(/[{}()[\]\\.+*?^$|]/g, '\\$&'), flags);
  241. }
  242. function ignoreChromeError() {
  243. chrome.runtime.lastError; // eslint-disable-line no-unused-expressions
  244. }
  245. function getStyleWithNoCode(style) {
  246. const stripped = Object.assign({}, style, {sections: []});
  247. for (const section of style.sections) {
  248. stripped.sections.push(Object.assign({}, section, {code: null}));
  249. }
  250. return stripped;
  251. }
  252. // js engine can't optimize the entire function if it contains try-catch
  253. // so we should keep it isolated from normal code in a minimal wrapper
  254. // Update: might get fixed in V8 TurboFan in the future
  255. function tryCatch(func, ...args) {
  256. try {
  257. return func(...args);
  258. } catch (e) {}
  259. }
  260. function tryRegExp(regexp, flags) {
  261. try {
  262. return new RegExp(regexp, flags);
  263. } catch (e) {}
  264. }
  265. function tryJSONparse(jsonString) {
  266. try {
  267. return JSON.parse(jsonString);
  268. } catch (e) {}
  269. }
  270. const debounce = Object.assign((fn, delay, ...args) => {
  271. clearTimeout(debounce.timers.get(fn));
  272. debounce.timers.set(fn, setTimeout(debounce.run, delay, fn, ...args));
  273. }, {
  274. timers: new Map(),
  275. run(fn, ...args) {
  276. debounce.timers.delete(fn);
  277. fn(...args);
  278. },
  279. unregister(fn) {
  280. clearTimeout(debounce.timers.get(fn));
  281. debounce.timers.delete(fn);
  282. },
  283. });
  284. function deepCopy(obj) {
  285. return obj !== null && obj !== undefined && typeof obj === 'object'
  286. ? deepMerge(typeof obj.slice === 'function' ? [] : {}, obj)
  287. : obj;
  288. }
  289. function deepMerge(target, ...args) {
  290. const isArray = typeof target.slice === 'function';
  291. for (const obj of args) {
  292. if (isArray && obj !== null && obj !== undefined) {
  293. for (const element of obj) {
  294. target.push(deepCopy(element));
  295. }
  296. continue;
  297. }
  298. for (const k in obj) {
  299. const value = obj[k];
  300. if (k in target && typeof value === 'object' && value !== null) {
  301. deepMerge(target[k], value);
  302. } else {
  303. target[k] = deepCopy(value);
  304. }
  305. }
  306. }
  307. return target;
  308. }
  309. function sessionStorageHash(name) {
  310. return {
  311. name,
  312. value: tryCatch(JSON.parse, sessionStorage[name]) || {},
  313. set(k, v) {
  314. this.value[k] = v;
  315. this.updateStorage();
  316. },
  317. unset(k) {
  318. delete this.value[k];
  319. this.updateStorage();
  320. },
  321. updateStorage() {
  322. sessionStorage[this.name] = JSON.stringify(this.value);
  323. }
  324. };
  325. }
  326. function onBackgroundReady() {
  327. return BG && BG.getStyles ? Promise.resolve() : new Promise(function ping(resolve) {
  328. sendMessage({method: 'healthCheck'}, health => {
  329. if (health !== undefined) {
  330. BG = chrome.extension.getBackgroundPage();
  331. resolve();
  332. } else {
  333. setTimeout(ping, 0, resolve);
  334. }
  335. });
  336. });
  337. }
  338. // in case Chrome haven't yet loaded the bg page and displays our page like edit/manage
  339. function getStylesSafe(options) {
  340. return onBackgroundReady()
  341. .then(() => BG.getStyles(options));
  342. }
  343. function saveStyleSafe(style) {
  344. return onBackgroundReady()
  345. .then(() => BG.saveStyle(BG.deepCopy(style)))
  346. .then(savedStyle => {
  347. if (style.notify === false) {
  348. handleUpdate(savedStyle, style);
  349. }
  350. return savedStyle;
  351. });
  352. }
  353. function deleteStyleSafe({id, notify = true} = {}) {
  354. return onBackgroundReady()
  355. .then(() => BG.deleteStyle({id, notify}))
  356. .then(() => {
  357. if (!notify) {
  358. handleDelete(id);
  359. }
  360. return id;
  361. });
  362. }
  363. function download(url, {
  364. method = url.includes('?') ? 'POST' : 'GET',
  365. body = url.includes('?') ? url.slice(url.indexOf('?')) : null,
  366. requiredStatusCode = 200,
  367. timeout = 10e3,
  368. headers = {
  369. 'Content-type': 'application/x-www-form-urlencoded',
  370. },
  371. } = {}) {
  372. return new Promise((resolve, reject) => {
  373. url = new URL(url);
  374. if (url.protocol === 'file:' && FIREFOX) {
  375. // https://stackoverflow.com/questions/42108782/firefox-webextensions-get-local-files-content-by-path
  376. // FIXME: add FetchController when it is available.
  377. const timer = setTimeout(reject, timeout, new Error('Timeout fetching ' + url.href));
  378. fetch(url.href, {mode: 'same-origin'})
  379. .then(r => {
  380. clearTimeout(timer);
  381. return r.status === 200 ? r.text() : Promise.reject(r.status);
  382. })
  383. .catch(reject)
  384. .then(resolve);
  385. return;
  386. }
  387. const xhr = new XMLHttpRequest();
  388. xhr.timeout = timeout;
  389. xhr.onloadend = event => {
  390. if (event.type !== 'error' && (
  391. xhr.status === requiredStatusCode || !requiredStatusCode ||
  392. url.protocol === 'file:')) {
  393. resolve(xhr.responseText);
  394. } else {
  395. reject(xhr.status);
  396. }
  397. };
  398. xhr.onerror = xhr.onloadend;
  399. xhr.open(method, url.href, true);
  400. for (const key in headers) {
  401. xhr.setRequestHeader(key, headers[key]);
  402. }
  403. xhr.send(body);
  404. });
  405. }
  406. function invokeOrPostpone(isInvoke, fn, ...args) {
  407. return isInvoke
  408. ? fn(...args)
  409. : setTimeout(invokeOrPostpone, 0, true, fn, ...args);
  410. }
  411. function openEditor(id) {
  412. let url = '/edit.html';
  413. if (id) {
  414. url += `?id=${id}`;
  415. }
  416. if (chrome.windows && prefs.get('openEditInWindow')) {
  417. chrome.windows.create(Object.assign({url}, prefs.get('windowPosition')));
  418. } else {
  419. openURL({url});
  420. }
  421. }
  422. function closeCurrentTab() {
  423. // https://bugzilla.mozilla.org/show_bug.cgi?id=1409375
  424. getOwnTab().then(tab => {
  425. if (tab) {
  426. chrome.tabs.remove(tab.id);
  427. }
  428. });
  429. }