apply.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. /* global API msg */// msg.js
  2. /* global StyleInjector */
  3. /* global prefs */
  4. 'use strict';
  5. (() => {
  6. if (window.INJECTED === 1) return;
  7. /** true -> when the page styles are received,
  8. * false -> when disableAll mode is on at start, the styles won't be sent
  9. * so while disableAll lasts we can ignore messages about style updates because
  10. * the tab will explicitly ask for all styles in bulk when disableAll mode ends */
  11. let hasStyles = false;
  12. let isDisabled = false;
  13. let isTab = !chrome.tabs || location.pathname !== '/popup.html';
  14. const isFrame = window !== parent;
  15. const isFrameAboutBlank = isFrame && location.href === 'about:blank';
  16. const isUnstylable = !chrome.app && document instanceof XMLDocument;
  17. const styleInjector = StyleInjector({
  18. compare: (a, b) => a.id - b.id,
  19. onUpdate: onInjectorUpdate,
  20. });
  21. // dynamic iframes don't have a URL yet so we'll use their parent's URL (hash isn't inherited)
  22. let matchUrl = isFrameAboutBlank && tryCatch(() => parent.location.href.split('#')[0]) ||
  23. location.href;
  24. // save it now because chrome.runtime will be unavailable in the orphaned script
  25. const orphanEventId = chrome.runtime.id;
  26. let isOrphaned;
  27. // firefox doesn't orphanize content scripts so the old elements stay
  28. if (!chrome.app) styleInjector.clearOrphans();
  29. /** @type chrome.runtime.Port */
  30. let port;
  31. let lazyBadge = isFrame;
  32. let parentDomain;
  33. /* about:blank iframes are often used by sites for file upload or background tasks
  34. * and they may break if unexpected DOM stuff is present at `load` event
  35. * so we'll add the styles only if the iframe becomes visible */
  36. const {IntersectionObserver} = window;
  37. const xoEventId = `${Math.random()}`;
  38. /** @type IntersectionObserver */
  39. let xo;
  40. if (IntersectionObserver) {
  41. window[Symbol.for('xo')] = (el, cb) => {
  42. if (!xo) xo = new IntersectionObserver(onIntersect, {rootMargin: '100%'});
  43. el.addEventListener(xoEventId, cb, {once: true});
  44. xo.observe(el);
  45. };
  46. }
  47. // Declare all vars before init() or it'll throw due to "temporal dead zone" of const/let
  48. const ready = init();
  49. // the popup needs a check as it's not a tab but can be opened in a tab manually for whatever reason
  50. if (!isTab) {
  51. chrome.tabs.getCurrent(tab => {
  52. isTab = Boolean(tab);
  53. if (tab && styleInjector.list.length) updateCount();
  54. });
  55. }
  56. msg.onTab(applyOnMessage);
  57. if (!chrome.tabs) {
  58. window.dispatchEvent(new CustomEvent(orphanEventId));
  59. window.addEventListener(orphanEventId, orphanCheck, true);
  60. }
  61. // detect media change in content script
  62. // FIXME: move this to background page when following bugs are fixed:
  63. // https://bugzilla.mozilla.org/show_bug.cgi?id=1561546
  64. // https://bugs.chromium.org/p/chromium/issues/detail?id=968651
  65. const media = window.matchMedia('(prefers-color-scheme: dark)');
  66. media.addListener(() => API.colorScheme.updateSystemPreferDark().catch(console.error));
  67. function onInjectorUpdate() {
  68. if (!isOrphaned) {
  69. updateCount();
  70. const onOff = prefs[styleInjector.list.length ? 'subscribe' : 'unsubscribe'];
  71. onOff('disableAll', updateDisableAll);
  72. if (isFrame) {
  73. updateExposeIframes();
  74. onOff('exposeIframes', updateExposeIframes);
  75. }
  76. }
  77. }
  78. async function init() {
  79. if (isUnstylable) {
  80. await API.styleViaAPI({method: 'styleApply'});
  81. } else {
  82. const SYM_ID = 'styles';
  83. const SYM = Symbol.for(SYM_ID);
  84. const parentStyles = isFrameAboutBlank &&
  85. tryCatch(() => parent[parent.Symbol.for(SYM_ID)]);
  86. const styles =
  87. window[SYM] ||
  88. parentStyles && await new Promise(onFrameElementInView) && parentStyles ||
  89. !isFrameAboutBlank && chrome.app && !chrome.tabs && tryCatch(getStylesViaXhr) ||
  90. await API.styles.getSectionsByUrl(matchUrl, null, true);
  91. isDisabled = styles.disableAll;
  92. hasStyles = !isDisabled;
  93. if (hasStyles) {
  94. window[SYM] = styles;
  95. await styleInjector.apply(styles);
  96. } else {
  97. delete window[SYM];
  98. prefs.subscribe('disableAll', updateDisableAll);
  99. }
  100. styleInjector.toggle(hasStyles);
  101. }
  102. }
  103. /** Must be executed inside try/catch */
  104. function getStylesViaXhr() {
  105. const blobId = document.cookie.split(chrome.runtime.id + '=')[1].split(';')[0];
  106. const url = 'blob:' + chrome.runtime.getURL(blobId);
  107. document.cookie = `${chrome.runtime.id}=1; max-age=0`; // remove our cookie
  108. const xhr = new XMLHttpRequest();
  109. xhr.open('GET', url, false); // synchronous
  110. xhr.send();
  111. URL.revokeObjectURL(url);
  112. return JSON.parse(xhr.response);
  113. }
  114. function applyOnMessage(request) {
  115. const {method} = request;
  116. if (isUnstylable) {
  117. if (method === 'urlChanged') {
  118. request.method = 'styleReplaceAll';
  119. }
  120. if (/^(style|updateCount)/.test(method)) {
  121. API.styleViaAPI(request);
  122. return;
  123. }
  124. }
  125. const {style} = request;
  126. switch (method) {
  127. case 'ping':
  128. return true;
  129. case 'styleDeleted':
  130. styleInjector.remove(style.id);
  131. break;
  132. case 'styleUpdated':
  133. if (!hasStyles && isDisabled) break;
  134. if (style.enabled) {
  135. API.styles.getSectionsByUrl(matchUrl, style.id).then(sections =>
  136. sections[style.id]
  137. ? styleInjector.apply(sections)
  138. : styleInjector.remove(style.id));
  139. } else {
  140. styleInjector.remove(style.id);
  141. }
  142. break;
  143. case 'styleAdded':
  144. if (!hasStyles && isDisabled) break;
  145. if (style.enabled) {
  146. API.styles.getSectionsByUrl(matchUrl, style.id)
  147. .then(styleInjector.apply);
  148. }
  149. break;
  150. case 'urlChanged':
  151. if (!hasStyles && isDisabled || matchUrl === request.url) break;
  152. matchUrl = request.url;
  153. API.styles.getSectionsByUrl(matchUrl).then(sections => {
  154. hasStyles = true;
  155. styleInjector.replace(sections);
  156. });
  157. break;
  158. case 'backgroundReady':
  159. ready.catch(err =>
  160. msg.isIgnorableError(err)
  161. ? init()
  162. : console.error(err));
  163. break;
  164. case 'updateCount':
  165. updateCount();
  166. break;
  167. }
  168. }
  169. function updateDisableAll(key, disableAll) {
  170. isDisabled = disableAll;
  171. if (isUnstylable) {
  172. API.styleViaAPI({method: 'prefChanged', prefs: {disableAll}});
  173. } else if (!hasStyles && !disableAll) {
  174. init();
  175. } else {
  176. styleInjector.toggle(!disableAll);
  177. }
  178. }
  179. async function updateExposeIframes(key, value = prefs.get('exposeIframes')) {
  180. const attr = 'stylus-iframe';
  181. const el = document.documentElement;
  182. if (!el) return; // got no styles so styleInjector didn't wait for <html>
  183. if (!value || !styleInjector.list.length) {
  184. el.removeAttribute(attr);
  185. } else {
  186. if (!parentDomain) parentDomain = await API.getTabUrlPrefix();
  187. // Check first to avoid triggering DOM mutation
  188. if (el.getAttribute(attr) !== parentDomain) {
  189. el.setAttribute(attr, parentDomain);
  190. }
  191. }
  192. }
  193. function updateCount() {
  194. if (!isTab) return;
  195. if (isFrame) {
  196. if (!port && styleInjector.list.length) {
  197. port = chrome.runtime.connect({name: 'iframe'});
  198. } else if (port && !styleInjector.list.length) {
  199. port.disconnect();
  200. }
  201. if (lazyBadge && performance.now() > 1000) lazyBadge = false;
  202. }
  203. (isUnstylable ?
  204. API.styleViaAPI({method: 'updateCount'}) :
  205. API.updateIconBadge(styleInjector.list.map(style => style.id), {lazyBadge})
  206. ).catch(msg.ignoreError);
  207. }
  208. function onFrameElementInView(cb) {
  209. if (IntersectionObserver) {
  210. parent[parent.Symbol.for('xo')](frameElement, cb);
  211. } else {
  212. requestAnimationFrame(cb);
  213. }
  214. }
  215. /** @param {IntersectionObserverEntry[]} entries */
  216. function onIntersect(entries) {
  217. for (const e of entries) {
  218. if (e.isIntersecting) {
  219. xo.unobserve(e.target);
  220. e.target.dispatchEvent(new Event(xoEventId));
  221. }
  222. }
  223. }
  224. function tryCatch(func, ...args) {
  225. try {
  226. return func(...args);
  227. } catch (e) {}
  228. }
  229. function orphanCheck() {
  230. if (tryCatch(() => chrome.i18n.getUILanguage())) return;
  231. // In Chrome content script is orphaned on an extension update/reload
  232. // so we need to detach event listeners
  233. window.removeEventListener(orphanEventId, orphanCheck, true);
  234. isOrphaned = true;
  235. setTimeout(styleInjector.clear, 1000); // avoiding FOUC
  236. tryCatch(msg.off, applyOnMessage);
  237. }
  238. })();