icon.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import { i18n, ignoreChromeErrors, makeDataUri, noop } from '@/common';
  2. import { BLACKLIST } from '@/common/consts';
  3. import { nest, objectPick } from '@/common/object';
  4. import { addOwnCommands, commands, init } from './init';
  5. import { getOption, hookOptions, setOption } from './options';
  6. import { popupTabs } from './popup-tracker';
  7. import storage, { S_CACHE } from './storage';
  8. import { forEachTab, getTabUrl, injectableRe, openDashboard, tabsOnRemoved, tabsOnUpdated } from './tabs';
  9. import { testBlacklist } from './tester';
  10. import { FIREFOX, ua } from './ua';
  11. /** 1x + HiDPI 1.5x, 2x */
  12. const SIZES = !FIREFOX
  13. ? [16, 32]
  14. : ua.mobile
  15. ? [32, 38, 48] // 1x, 1.5x, 2x
  16. : [16, 32, 48, 64]; // 16+32: toolbar, 32+48+64: extensions panel
  17. /** Caching own icon to improve dashboard loading speed, as well as browserAction API
  18. * (e.g. Chrome wastes 40ms in our extension's process to read 4 icons for every tab). */
  19. const iconCache = {};
  20. const iconDataCache = {};
  21. /** @return {string | Promise<string>} */
  22. export const getImageData = url => iconCache[url] || (iconCache[url] = loadIcon(url));
  23. // Firefox Android does not support such APIs, use noop
  24. const browserAction = (() => {
  25. // Using `chrome` namespace in order to skip our browser.js polyfill in Chrome
  26. const api = chrome.browserAction;
  27. // Some methods like setBadgeText added callbacks only in Chrome 67+.
  28. const makeMethod = fn => (...args) => {
  29. try {
  30. // Suppress the "no tab id" error when setting an icon/badge as it cannot be reliably prevented
  31. api::fn(...args, ignoreChromeErrors);
  32. } catch (e) {
  33. api::fn(...args);
  34. }
  35. };
  36. return objectPick(api, [
  37. 'setIcon',
  38. 'setBadgeText',
  39. 'setBadgeBackgroundColor',
  40. 'setTitle',
  41. ], fn => (fn ? makeMethod(fn) : noop));
  42. })();
  43. // Promisifying explicitly because this API returns an id in Firefox and not a Promise
  44. const contextMenus = chrome.contextMenus;
  45. /** @type {{ [tabId: string]: VMBadgeData }}*/
  46. export const badges = {};
  47. const KEY_SHOW_BADGE = 'showBadge';
  48. const KEY_BADGE_COLOR = 'badgeColor';
  49. const KEY_BADGE_COLOR_BLOCKED = 'badgeColorBlocked';
  50. const titleBlacklisted = i18n('failureReasonBlacklisted');
  51. const titleDefault = extensionManifest[BROWSER_ACTION].default_title;
  52. const iconDefault = extensionManifest[BROWSER_ACTION].default_icon[16].match(/\d+(\w*)\./)[1];
  53. const titleDisabled = i18n('menuScriptDisabled');
  54. const titleNoninjectable = i18n('failureReasonNoninjectable');
  55. const titleSkipped = i18n('skipScriptsMsg');
  56. let isApplied;
  57. /** @type {VMBadgeMode} */
  58. let showBadge;
  59. let badgeColor;
  60. let badgeColorBlocked;
  61. addOwnCommands({
  62. GetImageData: getImageData,
  63. });
  64. hookOptions((changes) => {
  65. let v;
  66. const jobs = [];
  67. if ((v = changes[IS_APPLIED]) != null) {
  68. isApplied = v;
  69. setIcon(); // change the default icon
  70. jobs.push(setIcon); // change the current tabs' icons
  71. }
  72. if ((v = changes[KEY_SHOW_BADGE]) != null) {
  73. showBadge = v;
  74. jobs.push(updateBadge);
  75. contextMenus?.update(KEY_SHOW_BADGE + ':' + showBadge, {checked: true});
  76. }
  77. if ((v = changes[KEY_BADGE_COLOR]) && (badgeColor = v)
  78. || (v = changes[KEY_BADGE_COLOR_BLOCKED]) && (badgeColorBlocked = v)) {
  79. jobs.push(updateBadgeColor);
  80. }
  81. if (BLACKLIST in changes) {
  82. jobs.push(updateState);
  83. }
  84. if (jobs.length) {
  85. forEachTab(tab => jobs.forEach(fn => fn(tab)));
  86. }
  87. });
  88. init.then(async () => {
  89. isApplied = getOption(IS_APPLIED);
  90. showBadge = getOption(KEY_SHOW_BADGE);
  91. badgeColor = getOption(KEY_BADGE_COLOR);
  92. badgeColorBlocked = getOption(KEY_BADGE_COLOR_BLOCKED);
  93. forEachTab(updateState);
  94. if (!isApplied) setIcon(); // sets the dimmed icon as default
  95. if (contextMenus) {
  96. const addToIcon = (id, title, opts) => (
  97. new Promise(resolve => (
  98. contextMenus.create({
  99. contexts: [BROWSER_ACTION],
  100. id,
  101. title,
  102. ...opts,
  103. }, resolve)
  104. ))
  105. ).then(ignoreChromeErrors);
  106. const badgeChild = { parentId: KEY_SHOW_BADGE, type: 'radio' };
  107. await addToIcon(SKIP_SCRIPTS, i18n('skipScripts'));
  108. for (const args of [
  109. [KEY_SHOW_BADGE, i18n('labelBadge')],
  110. [`${KEY_SHOW_BADGE}:`, i18n('labelBadgeNone'), badgeChild],
  111. [`${KEY_SHOW_BADGE}:unique`, i18n('labelBadgeUnique'), badgeChild],
  112. [`${KEY_SHOW_BADGE}:total`, i18n('labelBadgeTotal'), badgeChild],
  113. ]) {
  114. await addToIcon(...args);
  115. }
  116. contextMenus.update(KEY_SHOW_BADGE + ':' + showBadge, { checked: true });
  117. // Chrome already adds a built-in "Options" item
  118. if (IS_FIREFOX) await addToIcon(TAB_SETTINGS, i18n('labelSettings'));
  119. }
  120. });
  121. contextMenus?.onClicked.addListener(({ menuItemId: id }, tab) => {
  122. handleHotkeyOrMenu(id, tab);
  123. });
  124. tabsOnRemoved.addListener(id => delete badges[id]);
  125. tabsOnUpdated.addListener((tabId, { url }, tab) => {
  126. if (url) {
  127. const [title] = getFailureReason(url);
  128. if (title) updateState(tab, resetBadgeData(tabId, null), title);
  129. }
  130. }, FIREFOX && { properties: ['status'] });
  131. function resetBadgeData(tabId, isInjected) {
  132. // 'total' and 'unique' must match showBadge in options-defaults.js
  133. /** @type {VMBadgeData} */
  134. const data = nest(badges, tabId);
  135. data.icon = iconDefault;
  136. data.total = 0;
  137. data.unique = 0;
  138. data[IDS] = new Set();
  139. data[kFrameId] = undefined;
  140. data[INJECT] = isInjected;
  141. // Notify popup about non-injectable tab
  142. if (!isInjected) popupTabs[tabId]?.postMessage(null);
  143. return data;
  144. }
  145. /**
  146. * @param {number[] | string} ids
  147. * @param {boolean} reset
  148. * @param {VMMessageSender} src
  149. */
  150. export function setBadge(ids, reset, { tab, [kFrameId]: frameId, [kTop]: isTop }) {
  151. const tabId = tab.id;
  152. const injectable = ids === SKIP_SCRIPTS || ids === 'off' ? ids : !!ids;
  153. /** @type {VMBadgeData} */
  154. const data = !(reset && isTop) && badges[tabId] || resetBadgeData(tabId, injectable);
  155. if (Array.isArray(ids)) {
  156. const {
  157. [IDS]: idMap,
  158. [kFrameId]: totalMap = data[kFrameId] = {},
  159. } = data;
  160. // uniques
  161. ids.forEach(idMap.add, idMap);
  162. data.unique = idMap.size;
  163. // totals
  164. data.total = 0;
  165. totalMap[frameId] = ids.length;
  166. for (const id in totalMap) data.total += totalMap[id];
  167. }
  168. if (isTop) {
  169. data[INJECT] = injectable;
  170. }
  171. updateBadgeColor(tab, data);
  172. updateState(tab, data);
  173. }
  174. function updateBadge({ id: tabId }, data = badges[tabId]) {
  175. if (data) {
  176. browserAction.setBadgeText({
  177. text: `${data[showBadge] || ''}`,
  178. tabId,
  179. });
  180. }
  181. }
  182. function updateBadgeColor({ id: tabId }, data = badges[tabId]) {
  183. if (data) {
  184. browserAction.setBadgeBackgroundColor({
  185. color: data[INJECT] ? badgeColor : badgeColorBlocked,
  186. tabId,
  187. });
  188. }
  189. }
  190. function updateState(tab, data, title) {
  191. const tabId = tab.id;
  192. if (!data) data = badges[tabId] || resetBadgeData(tabId);
  193. if (!title) [title] = getFailureReason(getTabUrl(tab), data);
  194. browserAction.setTitle({ tabId, title });
  195. setIcon(tab, data);
  196. updateBadge(tab, data);
  197. }
  198. async function setIcon({ id: tabId } = {}, data = badges[tabId] || {}) {
  199. const mod = !isApplied ? 'w'
  200. : data[INJECT] !== true ? 'b'
  201. : '';
  202. if (data.icon === mod) return;
  203. data.icon = mod;
  204. const pathData = {};
  205. const iconData = {};
  206. for (const n of SIZES) {
  207. const url = `${ICON_PREFIX}${n}${mod}.png`;
  208. pathData[n] = url;
  209. iconData[n] = iconDataCache[url]
  210. || await (iconCache[url] || (iconCache[url] = loadIcon(url))) && iconDataCache[url];
  211. }
  212. // imageData doesn't work in Firefox Android, so we also set path here
  213. browserAction.setIcon({
  214. tabId,
  215. path: pathData,
  216. imageData: iconData,
  217. });
  218. }
  219. /** Omitting `data` = check whether injection is allowed for `url` */
  220. export function getFailureReason(url, data, def = titleDefault) {
  221. return !injectableRe.test(url) ? [titleNoninjectable, INJECT_INTO]
  222. : ((url = testBlacklist(url))) ? [titleBlacklisted, 'blacklisted', url]
  223. : !isApplied || data?.[INJECT] === 'off' ? [titleDisabled, IS_APPLIED]
  224. : !data ? []
  225. : data[INJECT] === SKIP_SCRIPTS
  226. ? [titleSkipped, SKIP_SCRIPTS]
  227. : [def];
  228. }
  229. export function handleHotkeyOrMenu(id, tab) {
  230. if (id === SKIP_SCRIPTS) {
  231. commands[SKIP_SCRIPTS](tab);
  232. } else if (id === TAB_SETTINGS) {
  233. openDashboard(id);
  234. } else if (id === 'dashboard') {
  235. openDashboard('');
  236. } else if (id === 'newScript') {
  237. commands.OpenEditor();
  238. } else if (id === 'toggleInjection') {
  239. setOption(IS_APPLIED, !isApplied);
  240. } else if (id === 'updateScripts') {
  241. commands.CheckUpdate();
  242. } else if (id === 'updateScriptsInTab') {
  243. id = badges[tab.id]?.[IDS];
  244. if (id) commands.CheckUpdate([...id]);
  245. } else if (id.startsWith(KEY_SHOW_BADGE)) {
  246. setOption(KEY_SHOW_BADGE, id.slice(KEY_SHOW_BADGE.length + 1));
  247. }
  248. }
  249. async function loadIcon(url) {
  250. const img = new Image();
  251. const isOwn = url.startsWith(ICON_PREFIX);
  252. img.src = isOwn ? url.slice(extensionOrigin.length) // must be a relative path in Firefox Android
  253. : url.startsWith('data:') ? url
  254. : makeDataUri(url[0] === 'i' ? url : await loadStorageCache(url))
  255. || url;
  256. await new Promise((resolve) => {
  257. img.onload = resolve;
  258. img.onerror = resolve;
  259. });
  260. let res;
  261. let maxSize = !isOwn && (2 * 38); // dashboard icon size for 2xDPI
  262. let { width, height } = img;
  263. if (!width || !height) { // FF reports 0 for SVG
  264. iconCache[url] = url;
  265. return url;
  266. }
  267. if (maxSize && (width > maxSize || height > maxSize)) {
  268. maxSize /= width > height ? width : height;
  269. width = Math.round(width * maxSize);
  270. height = Math.round(height * maxSize);
  271. }
  272. const canvas = document.createElement('canvas');
  273. const ctx = canvas.getContext('2d');
  274. canvas.width = width;
  275. canvas.height = height;
  276. ctx.drawImage(img, 0, 0, width, height);
  277. try {
  278. res = canvas.toDataURL();
  279. if (isOwn) iconDataCache[url] = ctx.getImageData(0, 0, width, height);
  280. } catch (err) {
  281. res = url;
  282. }
  283. iconCache[url] = res;
  284. return res;
  285. }
  286. async function loadStorageCache(url) {
  287. return await storage[S_CACHE].getOne(url)
  288. ?? await storage[S_CACHE].fetch(url, 'res').catch(console.warn);
  289. }