install-hook-userstyles.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. 'use strict';
  2. (() => {
  3. const FIREFOX = !chrome.app;
  4. const VIVALDI = chrome.app && /Vivaldi/.test(navigator.userAgent);
  5. const OPERA = chrome.app && /OPR/.test(navigator.userAgent);
  6. window.dispatchEvent(new CustomEvent(chrome.runtime.id + '-install'));
  7. window.addEventListener(chrome.runtime.id + '-install', orphanCheck, true);
  8. ['Update', 'Install'].forEach(type =>
  9. ['', 'Chrome', 'Opera'].forEach(browser =>
  10. document.addEventListener('stylish' + type + browser, onClick)));
  11. chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  12. // orphaned content script check
  13. if (msg.method === 'ping') {
  14. sendResponse(true);
  15. }
  16. });
  17. new MutationObserver((mutations, observer) => {
  18. if (document.body) {
  19. observer.disconnect();
  20. // TODO: remove the following statement when USO pagination title is fixed
  21. document.title = document.title.replace(/^(\d+)&\w+=/, '#$1: ');
  22. chrome.runtime.sendMessage({
  23. method: 'getStyles',
  24. md5Url: getMeta('stylish-md5-url') || location.href
  25. }, checkUpdatability);
  26. }
  27. }).observe(document.documentElement, {childList: true});
  28. /* since we are using "stylish-code-chrome" meta key on all browsers and
  29. US.o does not provide "advanced settings" on this url if browser is not Chrome,
  30. we need to fix this URL using "stylish-update-url" meta key
  31. */
  32. function getStyleURL() {
  33. const textUrl = getMeta('stylish-update-url') || '';
  34. const jsonUrl = getMeta('stylish-code-chrome') ||
  35. textUrl.replace(/styles\/(\d+)\/[^?]*/, 'styles/chrome/$1.json');
  36. const paramsMissing = !jsonUrl.includes('?') && textUrl.includes('?');
  37. return jsonUrl + (paramsMissing ? textUrl.replace(/^[^?]+/, '') : '');
  38. }
  39. function checkUpdatability([installedStyle]) {
  40. // TODO: remove the following statement when USO is fixed
  41. document.dispatchEvent(new CustomEvent('stylusFixBuggyUSOsettings', {
  42. detail: installedStyle && installedStyle.updateUrl,
  43. }));
  44. if (!installedStyle) {
  45. sendEvent('styleCanBeInstalledChrome');
  46. return;
  47. }
  48. const md5Url = getMeta('stylish-md5-url');
  49. if (md5Url && installedStyle.md5Url && installedStyle.originalMd5) {
  50. getResource(md5Url).then(md5 => {
  51. reportUpdatable(md5 !== installedStyle.originalMd5);
  52. });
  53. } else {
  54. getStyleJson().then(json => {
  55. reportUpdatable(!json ||
  56. !styleSectionsEqual(json, installedStyle));
  57. });
  58. }
  59. function reportUpdatable(isUpdatable) {
  60. sendEvent(
  61. isUpdatable
  62. ? 'styleCanBeUpdatedChrome'
  63. : 'styleAlreadyInstalledChrome',
  64. {
  65. updateUrl: installedStyle.updateUrl
  66. }
  67. );
  68. }
  69. }
  70. function sendEvent(type, detail = null) {
  71. if (FIREFOX) {
  72. type = type.replace('Chrome', '');
  73. } else if (OPERA || VIVALDI) {
  74. type = type.replace('Chrome', 'Opera');
  75. }
  76. detail = {detail};
  77. if (typeof cloneInto !== 'undefined') {
  78. // Firefox requires explicit cloning, however USO can't process our messages anyway
  79. // because USO tries to use a global "event" variable deprecated in Firefox
  80. detail = cloneInto(detail, document); // eslint-disable-line no-undef
  81. }
  82. onDOMready().then(() => {
  83. document.dispatchEvent(new CustomEvent(type, detail));
  84. });
  85. }
  86. function onClick(event) {
  87. if (onClick.processing || !orphanCheck()) {
  88. return;
  89. }
  90. onClick.processing = true;
  91. (event.type.includes('Update') ? onUpdate() : onInstall())
  92. .then(done, done);
  93. function done() {
  94. setTimeout(() => {
  95. onClick.processing = false;
  96. });
  97. }
  98. }
  99. function onInstall() {
  100. return getResource(getMeta('stylish-description'))
  101. .then(name => saveStyleCode('styleInstall', name))
  102. .then(() => getResource(getMeta('stylish-install-ping-url-chrome')));
  103. }
  104. function onUpdate() {
  105. return new Promise((resolve, reject) => {
  106. chrome.runtime.sendMessage({
  107. method: 'getStyles',
  108. md5Url: getMeta('stylish-md5-url') || location.href,
  109. }, ([style]) => {
  110. saveStyleCode('styleUpdate', style.name, {id: style.id})
  111. .then(resolve, reject);
  112. });
  113. });
  114. }
  115. function saveStyleCode(message, name, addProps) {
  116. return new Promise((resolve, reject) => {
  117. const isNew = message === 'styleInstall';
  118. const needsConfirmation = isNew || !saveStyleCode.confirmed;
  119. if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) {
  120. reject();
  121. return;
  122. }
  123. saveStyleCode.confirmed = true;
  124. enableUpdateButton(false);
  125. getStyleJson().then(json => {
  126. if (!json) {
  127. prompt(chrome.i18n.getMessage('styleInstallFailed', ''),
  128. 'https://github.com/openstyles/stylus/issues/195');
  129. return;
  130. }
  131. chrome.runtime.sendMessage(
  132. Object.assign(json, addProps, {
  133. method: 'saveStyle',
  134. reason: isNew ? 'install' : 'update',
  135. }),
  136. style => {
  137. if (!isNew && style.updateUrl.includes('?')) {
  138. enableUpdateButton(true);
  139. } else {
  140. sendEvent('styleInstalledChrome');
  141. }
  142. }
  143. );
  144. resolve();
  145. });
  146. });
  147. function enableUpdateButton(state) {
  148. const important = s => s.replace(/;/g, '!important;');
  149. const button = document.getElementById('update_style_button');
  150. if (button) {
  151. button.style.cssText = state ? '' : important('pointer-events: none; opacity: .35;');
  152. const icon = button.querySelector('img[src*=".svg"]');
  153. if (icon) {
  154. icon.style.cssText = state ? '' : important('transition: transform 5s; transform: rotate(0);');
  155. if (state) {
  156. setTimeout(() => (icon.style.cssText += important('transform: rotate(10turn);')));
  157. }
  158. }
  159. }
  160. }
  161. }
  162. function getMeta(name) {
  163. const e = document.querySelector(`link[rel="${name}"]`);
  164. return e ? e.getAttribute('href') : null;
  165. }
  166. function getResource(url) {
  167. return new Promise(resolve => {
  168. if (url.startsWith('#')) {
  169. resolve(document.getElementById(url.slice(1)).textContent);
  170. } else {
  171. chrome.runtime.sendMessage({method: 'download', url}, resolve);
  172. }
  173. });
  174. }
  175. function getStyleJson() {
  176. const url = getStyleURL();
  177. return getResource(url).then(code => {
  178. try {
  179. return JSON.parse(code);
  180. } catch (e) {
  181. return fetch(url).then(r => r.json()).catch(() => null);
  182. }
  183. });
  184. }
  185. function styleSectionsEqual({sections: a}, {sections: b}) {
  186. if (!a || !b) {
  187. return undefined;
  188. }
  189. if (a.length !== b.length) {
  190. return false;
  191. }
  192. // order of sections should be identical to account for the case of multiple
  193. // sections matching the same URL because the order of rules is part of cascading
  194. return a.every((sectionA, index) => propertiesEqual(sectionA, b[index]));
  195. function propertiesEqual(secA, secB) {
  196. for (const name of ['urlPrefixes', 'urls', 'domains', 'regexps']) {
  197. if (!equalOrEmpty(secA[name], secB[name], 'every', arrayMirrors)) {
  198. return false;
  199. }
  200. }
  201. return equalOrEmpty(secA.code, secB.code, 'substr', (a, b) => a === b);
  202. }
  203. function equalOrEmpty(a, b, telltale, comparator) {
  204. const typeA = a && typeof a[telltale] === 'function';
  205. const typeB = b && typeof b[telltale] === 'function';
  206. return (
  207. (a === null || a === undefined || (typeA && !a.length)) &&
  208. (b === null || b === undefined || (typeB && !b.length))
  209. ) || typeA && typeB && a.length === b.length && comparator(a, b);
  210. }
  211. function arrayMirrors(array1, array2) {
  212. return (
  213. array1.every(el => array2.includes(el)) &&
  214. array2.every(el => array1.includes(el))
  215. );
  216. }
  217. }
  218. function onDOMready() {
  219. if (document.readyState !== 'loading') {
  220. return Promise.resolve();
  221. }
  222. return new Promise(resolve => {
  223. document.addEventListener('DOMContentLoaded', function _() {
  224. document.removeEventListener('DOMContentLoaded', _);
  225. resolve();
  226. });
  227. });
  228. }
  229. function orphanCheck() {
  230. if (chrome.i18n && chrome.i18n.getUILanguage()) {
  231. return true;
  232. }
  233. // In Chrome content script is orphaned on an extension update/reload
  234. // so we need to detach event listeners
  235. window.removeEventListener(chrome.runtime.id + '-install', orphanCheck, true);
  236. ['Update', 'Install'].forEach(type =>
  237. ['', 'Chrome', 'Opera'].forEach(browser =>
  238. document.addEventListener('stylish' + type + browser, onClick)));
  239. }
  240. })();
  241. // TODO: remove the following statement when USO is fixed
  242. document.documentElement.appendChild(document.createElement('script')).text = '(' +
  243. function () {
  244. let settings;
  245. const originalResponseJson = Response.prototype.json;
  246. document.currentScript.remove();
  247. document.addEventListener('stylusFixBuggyUSOsettings', function _({detail}) {
  248. document.removeEventListener('stylusFixBuggyUSOsettings', _);
  249. // TODO: remove .replace(/^\?/, '') when minimum_chrome_version >= 52 (https://crbug.com/601425)
  250. settings = /\?/.test(detail) && new URLSearchParams(new URL(detail).search.replace(/^\?/, ''));
  251. if (!settings) {
  252. Response.prototype.json = originalResponseJson;
  253. }
  254. });
  255. Response.prototype.json = function (...args) {
  256. return originalResponseJson.call(this, ...args).then(json => {
  257. if (!settings || typeof ((json || {}).style_settings || {}).every !== 'function') {
  258. return json;
  259. }
  260. Response.prototype.json = originalResponseJson;
  261. const images = new Map();
  262. for (const jsonSetting of json.style_settings) {
  263. let value = settings.get('ik-' + jsonSetting.install_key);
  264. if (!value
  265. || !jsonSetting.style_setting_options
  266. || !jsonSetting.style_setting_options[0]) {
  267. continue;
  268. }
  269. if (value.startsWith('ik-')) {
  270. value = value.replace(/^ik-/, '');
  271. const defaultItem = jsonSetting.style_setting_options.find(item => item.default);
  272. if (!defaultItem || defaultItem.install_key !== value) {
  273. if (defaultItem) {
  274. defaultItem.default = false;
  275. }
  276. jsonSetting.style_setting_options.some(item => {
  277. if (item.install_key === value) {
  278. item.default = true;
  279. return true;
  280. }
  281. });
  282. }
  283. } else if (jsonSetting.setting_type === 'image') {
  284. jsonSetting.style_setting_options.some(item => {
  285. if (item.default) {
  286. item.default = false;
  287. return true;
  288. }
  289. });
  290. images.set(jsonSetting.install_key, value);
  291. } else {
  292. const item = jsonSetting.style_setting_options[0];
  293. if (item.value !== value && item.install_key === 'placeholder') {
  294. item.value = value;
  295. }
  296. }
  297. }
  298. if (images.size) {
  299. new MutationObserver((_, observer) => {
  300. if (!document.getElementById('style-settings')) {
  301. return;
  302. }
  303. observer.disconnect();
  304. for (const [name, url] of images.entries()) {
  305. const elRadio = document.querySelector(`input[name="ik-${name}"][value="user-url"]`);
  306. const elUrl = elRadio && document.getElementById(elRadio.id.replace('url-choice', 'user-url'));
  307. if (elUrl) {
  308. elUrl.value = url;
  309. }
  310. }
  311. }).observe(document, {childList: true, subtree: true});
  312. }
  313. return json;
  314. });
  315. };
  316. } + ')()';
  317. // TODO: remove the following statement when USO pagination is fixed
  318. if (location.search.includes('category=')) {
  319. document.addEventListener('DOMContentLoaded', function _() {
  320. document.removeEventListener('DOMContentLoaded', _);
  321. new MutationObserver((_, observer) => {
  322. if (!document.getElementById('pagination')) {
  323. return;
  324. }
  325. observer.disconnect();
  326. const category = '&' + location.search.match(/category=[^&]+/)[0];
  327. const links = document.querySelectorAll('#pagination a[href*="page="]:not([href*="category="])');
  328. for (let i = 0; i < links.length; i++) {
  329. links[i].href += category;
  330. }
  331. }).observe(document, {childList: true, subtree: true});
  332. });
  333. }