update-manager.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. /* global API */// msg.js
  2. /* global RX_META URLS debounce download ignoreChromeError */// toolbox.js
  3. /* global calcStyleDigest styleJSONseemsValid styleSectionsEqual */ // sections-util.js
  4. /* global chromeLocal */// storage-util.js
  5. /* global compareVersion */// cmpver.js
  6. /* global db */
  7. /* global prefs */
  8. 'use strict';
  9. /* exported updateMan */
  10. const updateMan = (() => {
  11. const STATES = /** @namespace UpdaterStates */ {
  12. UPDATED: 'updated',
  13. SKIPPED: 'skipped',
  14. UNREACHABLE: 'server unreachable',
  15. // details for SKIPPED status
  16. EDITED: 'locally edited',
  17. MAYBE_EDITED: 'may be locally edited',
  18. SAME_MD5: 'up-to-date: MD5 is unchanged',
  19. SAME_CODE: 'up-to-date: code sections are unchanged',
  20. SAME_VERSION: 'up-to-date: version is unchanged',
  21. ERROR_MD5: 'error: MD5 is invalid',
  22. ERROR_JSON: 'error: JSON is invalid',
  23. ERROR_VERSION: 'error: version is older than installed style',
  24. };
  25. const RH_ETAG = {responseHeaders: ['etag']}; // a hashsum of file contents
  26. const RX_DATE2VER = new RegExp([
  27. /^(\d{4})/,
  28. /(0[1-9]|1(?:0|[12](?=\d\d))?|[2-9])/, // in ambiguous cases like yyyy123 the month will be 1
  29. /(0[1-9]|[1-2][0-9]?|3[0-1]?|[4-9])/,
  30. /\.([01][0-9]?|2[0-3]?|[3-9])/,
  31. /\.([0-5][0-9]?|[6-9])$/,
  32. ].map(rx => rx.source).join(''));
  33. const ALARM_NAME = 'scheduledUpdate';
  34. const MIN_INTERVAL_MS = 60e3;
  35. const RETRY_ERRORS = [
  36. 503, // service unavailable
  37. 429, // too many requests
  38. ];
  39. let lastUpdateTime;
  40. let checkingAll = false;
  41. let logQueue = [];
  42. let logLastWriteTime = 0;
  43. chromeLocal.getValue('lastUpdateTime').then(val => {
  44. lastUpdateTime = val || Date.now();
  45. prefs.subscribe('updateInterval', schedule, {runNow: true});
  46. chrome.alarms.onAlarm.addListener(onAlarm);
  47. });
  48. return {
  49. checkAllStyles,
  50. checkStyle,
  51. getStates: () => STATES,
  52. };
  53. async function checkAllStyles({
  54. save = true,
  55. ignoreDigest,
  56. observe,
  57. } = {}) {
  58. resetInterval();
  59. checkingAll = true;
  60. const port = observe && chrome.runtime.connect({name: 'updater'});
  61. const styles = (await API.styles.getAll())
  62. .filter(style => style.updateUrl);
  63. if (port) port.postMessage({count: styles.length});
  64. log('');
  65. log(`${save ? 'Scheduled' : 'Manual'} update check for ${styles.length} styles`);
  66. await Promise.all(
  67. styles.map(style =>
  68. checkStyle({style, port, save, ignoreDigest})));
  69. if (port) port.postMessage({done: true});
  70. if (port) port.disconnect();
  71. log('');
  72. checkingAll = false;
  73. }
  74. /**
  75. * @param {{
  76. id?: number
  77. style?: StyleObj
  78. port?: chrome.runtime.Port
  79. save?: boolean = true
  80. ignoreDigest?: boolean
  81. }} opts
  82. * @returns {{
  83. style: StyleObj
  84. updated?: boolean
  85. error?: any
  86. STATES: UpdaterStates
  87. }}
  88. Original style digests are calculated in these cases:
  89. * style is installed or updated from server
  90. * non-usercss style is checked for an update and styleSectionsEqual considers it unchanged
  91. Update check proceeds in these cases:
  92. * style has the original digest and it's equal to the current digest
  93. * [ignoreDigest: true] style doesn't yet have the original digest but we ignore it
  94. * [ignoreDigest: none/false] style doesn't yet have the original digest
  95. so we compare the code to the server code and if it's the same we save the digest,
  96. otherwise we skip the style and report MAYBE_EDITED status
  97. 'ignoreDigest' option is set on the second manual individual update check on the manage page.
  98. */
  99. async function checkStyle(opts) {
  100. let {id} = opts;
  101. const {
  102. style = await API.styles.get(id),
  103. ignoreDigest,
  104. port,
  105. save,
  106. } = opts;
  107. if (!id) id = style.id;
  108. const ucd = style.usercssData;
  109. let res, state;
  110. try {
  111. await checkIfEdited();
  112. res = {
  113. style: await (ucd ? updateUsercss : updateUSO)().then(maybeSave),
  114. updated: true,
  115. };
  116. state = STATES.UPDATED;
  117. } catch (err) {
  118. const error = err === 0 && STATES.UNREACHABLE ||
  119. err && err.message ||
  120. err;
  121. res = {error, style, STATES};
  122. state = `${STATES.SKIPPED} (${error})`;
  123. }
  124. log(`${state} #${id} ${style.customName || style.name}`);
  125. if (port) port.postMessage(res);
  126. return res;
  127. async function checkIfEdited() {
  128. if (!ignoreDigest &&
  129. style.originalDigest &&
  130. style.originalDigest !== await calcStyleDigest(style)) {
  131. return Promise.reject(STATES.EDITED);
  132. }
  133. }
  134. async function updateUSO() {
  135. const url = URLS.makeUsoArchiveCodeUrl(style.md5Url.match(/\d+/)[0]);
  136. const req = await tryDownload(url, RH_ETAG).catch(() => null);
  137. if (req) {
  138. return updateToUSOArchive(url, req);
  139. }
  140. const md5 = await tryDownload(style.md5Url);
  141. if (!md5 || md5.length !== 32) {
  142. return Promise.reject(STATES.ERROR_MD5);
  143. }
  144. if (md5 === style.originalMd5 && style.originalDigest && !ignoreDigest) {
  145. return Promise.reject(STATES.SAME_MD5);
  146. }
  147. const json = await tryDownload(style.updateUrl, {responseType: 'json'});
  148. if (!styleJSONseemsValid(json)) {
  149. return Promise.reject(STATES.ERROR_JSON);
  150. }
  151. // USO may not provide a correctly updated originalMd5 (#555)
  152. json.originalMd5 = md5;
  153. return json;
  154. }
  155. async function updateToUSOArchive(url, req) {
  156. const m2 = getUsoEmbeddedMeta(req.response);
  157. if (m2) {
  158. url = (await m2).updateUrl;
  159. req = await tryDownload(url, RH_ETAG);
  160. }
  161. const json = await API.usercss.buildMeta({
  162. id,
  163. etag: req.headers.etag,
  164. md5Url: null,
  165. originalMd5: null,
  166. sourceCode: req.response,
  167. updateUrl: url,
  168. url: URLS.extractUsoArchiveInstallUrl(url),
  169. });
  170. const varUrlValues = style.updateUrl.split('?')[1];
  171. const varData = json.usercssData.vars;
  172. if (varUrlValues && varData) {
  173. const IK = 'ik-';
  174. const IK_LEN = IK.length;
  175. for (let [key, val] of new URLSearchParams(varUrlValues)) {
  176. if (!key.startsWith(IK)) continue;
  177. key = key.slice(IK_LEN);
  178. const varDef = varData[key];
  179. if (!varDef) continue;
  180. if (varDef.options) {
  181. let sel = val.startsWith(IK) && getVarOptByName(varDef, val.slice(IK_LEN));
  182. if (!sel) {
  183. key += '-custom';
  184. sel = getVarOptByName(varDef, key + '-dropdown');
  185. if (sel) varData[key].value = val;
  186. }
  187. if (sel) varDef.value = sel.name;
  188. } else {
  189. varDef.value = val;
  190. }
  191. }
  192. }
  193. return API.usercss.buildCode(json);
  194. }
  195. async function updateUsercss() {
  196. let oldVer = ucd.version;
  197. let {etag: oldEtag, updateUrl} = style;
  198. let m2 = URLS.extractUsoArchiveId(updateUrl) && getUsoEmbeddedMeta();
  199. if (m2 && (m2 = await m2).updateUrl) {
  200. updateUrl = m2.updateUrl;
  201. oldVer = m2.usercssData.version || '0';
  202. oldEtag = '';
  203. }
  204. if (oldEtag && oldEtag === await downloadEtag()) {
  205. return Promise.reject(STATES.SAME_CODE);
  206. }
  207. // TODO: when sourceCode is > 100kB use http range request(s) for version check
  208. const {headers: {etag}, response} = await tryDownload(updateUrl, RH_ETAG);
  209. const json = await API.usercss.buildMeta({sourceCode: response, etag, updateUrl});
  210. const delta = compareVersion(json.usercssData.version, oldVer);
  211. let err;
  212. if (!delta && !ignoreDigest) {
  213. // re-install is invalid in a soft upgrade
  214. err = response === style.sourceCode
  215. ? STATES.SAME_CODE
  216. : !URLS.isLocalhost(updateUrl) && STATES.SAME_VERSION;
  217. }
  218. if (delta < 0) {
  219. // downgrade is always invalid
  220. err = STATES.ERROR_VERSION;
  221. }
  222. if (err && etag && !style.etag) {
  223. // first check of ETAG, gonna write it directly to DB as it's too trivial to sync or announce
  224. style.etag = etag;
  225. await db.exec('put', style);
  226. }
  227. return err
  228. ? Promise.reject(err)
  229. : API.usercss.buildCode(json);
  230. }
  231. async function maybeSave(json) {
  232. json.id = id;
  233. // keep current state
  234. delete json.customName;
  235. delete json.enabled;
  236. const newStyle = Object.assign({}, style, json);
  237. newStyle.updateDate = getDateFromVer(newStyle) || Date.now();
  238. // update digest even if save === false as there might be just a space added etc.
  239. if (!ucd && styleSectionsEqual(json, style)) {
  240. style.originalDigest = (await API.styles.install(newStyle)).originalDigest;
  241. return Promise.reject(STATES.SAME_CODE);
  242. }
  243. if (!style.originalDigest && !ignoreDigest) {
  244. return Promise.reject(STATES.MAYBE_EDITED);
  245. }
  246. return !save ? newStyle :
  247. (ucd ? API.usercss.install : API.styles.install)(newStyle);
  248. }
  249. async function tryDownload(url, params) {
  250. let {retryDelay = 1000} = opts;
  251. while (true) {
  252. try {
  253. return await download(url, params);
  254. } catch (code) {
  255. if (!RETRY_ERRORS.includes(code) ||
  256. retryDelay > MIN_INTERVAL_MS) {
  257. return Promise.reject(code);
  258. }
  259. }
  260. retryDelay *= 1.25;
  261. await new Promise(resolve => setTimeout(resolve, retryDelay));
  262. }
  263. }
  264. async function downloadEtag() {
  265. const opts = Object.assign({method: 'head'}, RH_ETAG);
  266. const req = await tryDownload(style.updateUrl, opts);
  267. return req.headers.etag;
  268. }
  269. function getDateFromVer(style) {
  270. const m = URLS.extractUsoArchiveId(style.updateUrl) &&
  271. style.usercssData.version.match(RX_DATE2VER);
  272. if (m) {
  273. m[2]--; // month is 0-based in `Date` constructor
  274. return new Date(...m.slice(1)).getTime();
  275. }
  276. }
  277. /** UserCSS metadata may be embedded in the original USO style so let's use its updateURL */
  278. function getUsoEmbeddedMeta(code = style.sourceCode) {
  279. const m = code.includes('@updateURL') && code.replace(RX_META, '').match(RX_META);
  280. return m && API.usercss.buildMeta({sourceCode: m[0]}).catch(() => null);
  281. }
  282. function getVarOptByName(varDef, name) {
  283. return varDef.options.find(o => o.name === name);
  284. }
  285. }
  286. function schedule() {
  287. const interval = prefs.get('updateInterval') * 60 * 60 * 1000;
  288. if (interval > 0) {
  289. const elapsed = Math.max(0, Date.now() - lastUpdateTime);
  290. chrome.alarms.create(ALARM_NAME, {
  291. when: Date.now() + Math.max(MIN_INTERVAL_MS, interval - elapsed),
  292. });
  293. } else {
  294. chrome.alarms.clear(ALARM_NAME, ignoreChromeError);
  295. }
  296. }
  297. function onAlarm({name}) {
  298. if (name === ALARM_NAME) checkAllStyles();
  299. }
  300. function resetInterval() {
  301. chromeLocal.setValue('lastUpdateTime', lastUpdateTime = Date.now());
  302. schedule();
  303. }
  304. function log(text) {
  305. logQueue.push({text, time: new Date().toLocaleString()});
  306. debounce(flushQueue, text && checkingAll ? 1000 : 0);
  307. }
  308. async function flushQueue(lines) {
  309. if (!lines) {
  310. flushQueue(await chromeLocal.getValue('updateLog') || []);
  311. return;
  312. }
  313. const time = Date.now() - logLastWriteTime > 11e3 ?
  314. logQueue[0].time + ' ' :
  315. '';
  316. if (logQueue[0] && !logQueue[0].text) {
  317. logQueue.shift();
  318. if (lines[lines.length - 1]) lines.push('');
  319. }
  320. lines.splice(0, lines.length - 1000);
  321. lines.push(time + (logQueue[0] && logQueue[0].text || ''));
  322. lines.push(...logQueue.slice(1).map(item => item.text));
  323. chromeLocal.setValue('updateLog', lines);
  324. logLastWriteTime = Date.now();
  325. logQueue = [];
  326. }
  327. })();