install-hook-userstyles.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. /* global API msg */// msg.js
  2. 'use strict';
  3. // eslint-disable-next-line no-unused-expressions
  4. /^\/styles\/(\d+)(\/([^/]*))?([?#].*)?$/.test(location.pathname) && (() => {
  5. const styleId = RegExp.$1;
  6. const pageEventId = `${performance.now()}${Math.random()}`;
  7. window.dispatchEvent(new CustomEvent(chrome.runtime.id + '-install'));
  8. window.addEventListener(chrome.runtime.id + '-install', orphanCheck, true);
  9. document.addEventListener('stylishInstallChrome', onClick);
  10. document.addEventListener('stylishUpdateChrome', onClick);
  11. msg.on(onMessage);
  12. let currentMd5;
  13. const md5Url = getMeta('stylish-md5-url') || `https://update.userstyles.org/${styleId}.md5`;
  14. Promise.all([
  15. API.styles.find({md5Url}),
  16. getResource(md5Url),
  17. onDOMready(),
  18. ]).then(checkUpdatability);
  19. document.documentElement.appendChild(
  20. Object.assign(document.createElement('script'), {
  21. textContent: `(${inPageContext})('${pageEventId}')`,
  22. }));
  23. function onMessage(msg) {
  24. switch (msg.method) {
  25. case 'ping':
  26. // orphaned content script check
  27. return true;
  28. case 'openSettings':
  29. openSettings();
  30. return true;
  31. }
  32. }
  33. /* since we are using "stylish-code-chrome" meta key on all browsers and
  34. US.o does not provide "advanced settings" on this url if browser is not Chrome,
  35. we need to fix this URL using "stylish-update-url" meta key
  36. */
  37. function getStyleURL() {
  38. const textUrl = getMeta('stylish-update-url') || '';
  39. const jsonUrl = getMeta('stylish-code-chrome') ||
  40. textUrl.replace(/styles\/(\d+)\/[^?]*/, 'styles/chrome/$1.json');
  41. const paramsMissing = !jsonUrl.includes('?') && textUrl.includes('?');
  42. return jsonUrl + (paramsMissing ? textUrl.replace(/^[^?]+/, '') : '');
  43. }
  44. function checkUpdatability([installedStyle, md5]) {
  45. // TODO: remove the following statement when USO is fixed
  46. document.dispatchEvent(new CustomEvent(pageEventId, {
  47. detail: installedStyle && installedStyle.updateUrl,
  48. }));
  49. currentMd5 = md5;
  50. if (!installedStyle) {
  51. sendEvent({type: 'styleCanBeInstalledChrome'});
  52. return;
  53. }
  54. const isCustomizable = /\?/.test(installedStyle.updateUrl);
  55. const md5Url = getMeta('stylish-md5-url');
  56. if (md5Url && installedStyle.md5Url && installedStyle.originalMd5) {
  57. reportUpdatable(isCustomizable || md5 !== installedStyle.originalMd5);
  58. } else {
  59. getStyleJson().then(json => {
  60. reportUpdatable(
  61. isCustomizable ||
  62. !json ||
  63. !styleSectionsEqual(json, installedStyle));
  64. });
  65. }
  66. function prepareInstallButton() {
  67. return new Promise(resolve => {
  68. const observer = new MutationObserver(check);
  69. observer.observe(document.documentElement, {
  70. childList: true,
  71. subtree: true,
  72. });
  73. check();
  74. function check() {
  75. if (document.querySelector('#install_style_button')) {
  76. resolve();
  77. observer.disconnect();
  78. }
  79. }
  80. });
  81. }
  82. function reportUpdatable(isUpdatable) {
  83. prepareInstallButton().then(() => {
  84. sendEvent({
  85. type: isUpdatable
  86. ? 'styleCanBeUpdatedChrome'
  87. : 'styleAlreadyInstalledChrome',
  88. detail: {
  89. updateUrl: installedStyle.updateUrl,
  90. },
  91. });
  92. });
  93. }
  94. }
  95. function sendEvent(event) {
  96. sendEvent.lastEvent = event;
  97. let {type, detail = null} = event;
  98. if (typeof cloneInto !== 'undefined') {
  99. // Firefox requires explicit cloning, however USO can't process our messages anyway
  100. // because USO tries to use a global "event" variable deprecated in Firefox
  101. detail = cloneInto({detail}, document); /* global cloneInto */
  102. } else {
  103. detail = {detail};
  104. }
  105. document.dispatchEvent(new CustomEvent(type, detail));
  106. }
  107. function onClick(event) {
  108. if (onClick.processing || !orphanCheck()) {
  109. return;
  110. }
  111. onClick.processing = true;
  112. doInstall()
  113. .then(() => {
  114. if (!event.type.includes('Update')) {
  115. // FIXME: sometimes the button is broken i.e. the button sends
  116. // 'install' instead of 'update' event while the style is already
  117. // install.
  118. // This triggers an incorrect install count but we don't really care.
  119. return getResource(getMeta('stylish-install-ping-url-chrome'));
  120. }
  121. })
  122. .catch(console.error)
  123. .then(done);
  124. function done() {
  125. setTimeout(() => {
  126. onClick.processing = false;
  127. });
  128. }
  129. }
  130. function doInstall() {
  131. let oldStyle;
  132. return API.styles.find({
  133. md5Url: getMeta('stylish-md5-url') || location.href,
  134. })
  135. .then(_oldStyle => {
  136. oldStyle = _oldStyle;
  137. return oldStyle ?
  138. oldStyle.name :
  139. getResource(getMeta('stylish-description'));
  140. })
  141. .then(name => {
  142. const props = {};
  143. if (oldStyle) {
  144. props.id = oldStyle.id;
  145. }
  146. return saveStyleCode(oldStyle ? 'styleUpdate' : 'styleInstall', name, props);
  147. });
  148. }
  149. async function saveStyleCode(message, name, addProps = {}) {
  150. const isNew = message === 'styleInstall';
  151. const needsConfirmation = isNew || !saveStyleCode.confirmed;
  152. if (needsConfirmation && !confirm(chrome.i18n.getMessage(message, [name]))) {
  153. return Promise.reject();
  154. }
  155. saveStyleCode.confirmed = true;
  156. enableUpdateButton(false);
  157. const json = await getStyleJson();
  158. if (!json) {
  159. prompt(chrome.i18n.getMessage('styleInstallFailed', ''),
  160. 'https://github.com/openstyles/stylus/issues/195');
  161. return;
  162. }
  163. // Update originalMd5 since USO changed it (2018-11-11) to NOT match the current md5
  164. const style = await API.styles.install(Object.assign(json, addProps, {originalMd5: currentMd5}));
  165. if (!isNew && style.updateUrl.includes('?')) {
  166. enableUpdateButton(true);
  167. } else {
  168. sendEvent({type: 'styleInstalledChrome'});
  169. }
  170. function enableUpdateButton(state) {
  171. const important = s => s.replace(/;/g, '!important;');
  172. const button = document.getElementById('update_style_button');
  173. if (button) {
  174. button.style.cssText = state ? '' : important('pointer-events: none; opacity: .35;');
  175. const icon = button.querySelector('img[src*=".svg"]');
  176. if (icon) {
  177. icon.style.cssText = state ? '' : important('transition: transform 5s; transform: rotate(0);');
  178. if (state) {
  179. setTimeout(() => (icon.style.cssText += important('transform: rotate(10turn);')));
  180. }
  181. }
  182. }
  183. }
  184. }
  185. function getMeta(name) {
  186. const e = document.querySelector(`link[rel="${name}"]`);
  187. return e ? e.getAttribute('href') : null;
  188. }
  189. async function getResource(url, opts) {
  190. try {
  191. return url.startsWith('#')
  192. ? document.getElementById(url.slice(1)).textContent
  193. : await API.download(url, opts);
  194. } catch (error) {
  195. alert('Error\n' + error.message);
  196. return Promise.reject(error);
  197. }
  198. }
  199. // USO providing md5Url as "https://update.update.userstyles.org/#####.md5"
  200. // instead of "https://update.userstyles.org/#####.md5"
  201. async function getStyleJson() {
  202. try {
  203. const style = await getResource(getStyleURL(), {responseType: 'json'});
  204. const codeElement = document.getElementById('stylish-code');
  205. if (!style || !Array.isArray(style.sections) || style.sections.length ||
  206. codeElement && !codeElement.textContent.trim()) {
  207. return style;
  208. }
  209. const code = await getResource(getMeta('stylish-update-url'));
  210. style.sections = (await API.worker.parseMozFormat({code})).sections;
  211. if (style.md5Url) style.md5Url = style.md5Url.replace('update.update', 'update');
  212. return style;
  213. } catch (e) {}
  214. }
  215. /**
  216. * The sections are checked in successive order because it matters when many sections
  217. * match the same URL and they have rules with the same CSS specificity
  218. * @param {Object} a - first style object
  219. * @param {Object} b - second style object
  220. * @returns {?boolean}
  221. */
  222. function styleSectionsEqual({sections: a}, {sections: b}) {
  223. const targets = ['urls', 'urlPrefixes', 'domains', 'regexps'];
  224. return a && b && a.length === b.length && a.every(sameSection);
  225. function sameSection(secA, i) {
  226. return equalOrEmpty(secA.code, b[i].code, 'string', (a, b) => a === b) &&
  227. targets.every(target => equalOrEmpty(secA[target], b[i][target], 'array', arrayMirrors));
  228. }
  229. function equalOrEmpty(a, b, type, comparator) {
  230. const typeA = type === 'array' ? Array.isArray(a) : typeof a === type;
  231. const typeB = type === 'array' ? Array.isArray(b) : typeof b === type;
  232. return typeA && typeB && comparator(a, b) ||
  233. (a == null || typeA && !a.length) &&
  234. (b == null || typeB && !b.length);
  235. }
  236. function arrayMirrors(a, b) {
  237. return a.length === b.length &&
  238. a.every(el => b.includes(el)) &&
  239. b.every(el => a.includes(el));
  240. }
  241. }
  242. function onDOMready() {
  243. return document.readyState !== 'loading'
  244. ? Promise.resolve()
  245. : new Promise(resolve => document.addEventListener('DOMContentLoaded', resolve, {once: true}));
  246. }
  247. function openSettings(countdown = 10e3) {
  248. const button = document.querySelector('.customize_button');
  249. if (button) {
  250. button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
  251. setTimeout(function pollArea(countdown = 2000) {
  252. const area = document.getElementById('advancedsettings_area');
  253. if (area || countdown < 0) {
  254. (area || button).scrollIntoView({behavior: 'smooth', block: area ? 'end' : 'center'});
  255. } else {
  256. setTimeout(pollArea, 100, countdown - 100);
  257. }
  258. }, 500);
  259. } else if (countdown > 0) {
  260. setTimeout(openSettings, 100, countdown - 100);
  261. }
  262. }
  263. function orphanCheck() {
  264. try {
  265. if (chrome.i18n.getUILanguage()) {
  266. return true;
  267. }
  268. } catch (e) {}
  269. // In Chrome content script is orphaned on an extension update/reload
  270. // so we need to detach event listeners
  271. window.removeEventListener(chrome.runtime.id + '-install', orphanCheck, true);
  272. document.removeEventListener('stylishInstallChrome', onClick);
  273. document.removeEventListener('stylishUpdateChrome', onClick);
  274. try {
  275. msg.off(onMessage);
  276. } catch (e) {}
  277. }
  278. })();
  279. function inPageContext(eventId) {
  280. document.currentScript.remove();
  281. window.isInstalled = true;
  282. const origMethods = {
  283. json: Response.prototype.json,
  284. byId: document.getElementById,
  285. };
  286. let vars;
  287. // USO bug workaround: prevent errors in console after install and busy cursor
  288. document.getElementById = id =>
  289. origMethods.byId.call(document, id) ||
  290. (/^(stylish-code|stylish-installed-style-installed-\w+|post-install-ad|style-install-unknown)$/.test(id)
  291. ? Object.assign(document.createElement('p'), {className: 'afterdownload-ad'})
  292. : null);
  293. // USO bug workaround: use the actual image data in customized settings
  294. document.addEventListener(eventId, ({detail}) => {
  295. vars = /\?/.test(detail) && new URL(detail).searchParams;
  296. if (!vars) Response.prototype.json = origMethods.json;
  297. }, {once: true});
  298. Response.prototype.json = async function () {
  299. const json = await origMethods.json.apply(this, arguments);
  300. if (vars && json && Array.isArray(json.style_settings)) {
  301. Response.prototype.json = origMethods.json;
  302. const images = new Map();
  303. for (const ss of json.style_settings) {
  304. let value = vars.get('ik-' + ss.install_key);
  305. if (!value || !(ss.style_setting_options || [])[0]) {
  306. continue;
  307. }
  308. if (value.startsWith('ik-')) {
  309. value = value.replace(/^ik-/, '');
  310. const def = ss.style_setting_options.find(item => item.default);
  311. if (!def || def.install_key !== value) {
  312. if (def) def.default = false;
  313. for (const item of ss.style_setting_options) {
  314. if (item.install_key === value) {
  315. item.default = true;
  316. break;
  317. }
  318. }
  319. }
  320. } else if (ss.setting_type === 'image') {
  321. let isListed;
  322. for (const opt of ss.style_setting_options) {
  323. isListed |= opt.default = (opt.value === value);
  324. }
  325. images.set(ss.install_key, {url: value, isListed});
  326. } else {
  327. const item = ss.style_setting_options[0];
  328. if (item.value !== value && item.install_key === 'placeholder') {
  329. item.value = value;
  330. }
  331. }
  332. }
  333. if (images.size) {
  334. new MutationObserver((_, observer) => {
  335. if (document.getElementById('style-settings')) {
  336. observer.disconnect();
  337. for (const [name, {url, isListed}] of images) {
  338. const elRadio = document.querySelector(`input[name="ik-${name}"][value="user-url"]`);
  339. const elUrl = elRadio &&
  340. document.getElementById(elRadio.id.replace('url-choice', 'user-url'));
  341. if (elUrl) {
  342. elRadio.checked = !isListed;
  343. elUrl.value = url;
  344. }
  345. }
  346. }
  347. }).observe(document, {childList: true, subtree: true});
  348. }
  349. }
  350. return json;
  351. };
  352. }