background.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. /* global dbExec, getStyles, saveStyle */
  2. /* global handleCssTransitionBug */
  3. /* global usercssHelper openEditor */
  4. /* global styleViaAPI */
  5. 'use strict';
  6. // eslint-disable-next-line no-var
  7. var browserCommands, contextMenus;
  8. // *************************************************************************
  9. // register all listeners
  10. chrome.runtime.onMessage.addListener(onRuntimeMessage);
  11. {
  12. const [listener] = [
  13. [webNavigationListenerChrome, CHROME],
  14. [webNavigationListenerFF, FIREFOX],
  15. [webNavigationListener, true],
  16. ].find(([, selected]) => selected);
  17. chrome.webNavigation.onBeforeNavigate.addListener(data =>
  18. listener(null, data));
  19. chrome.webNavigation.onCommitted.addListener(data =>
  20. listener('styleApply', data));
  21. chrome.webNavigation.onHistoryStateUpdated.addListener(data =>
  22. listener('styleReplaceAll', data));
  23. chrome.webNavigation.onReferenceFragmentUpdated.addListener(data =>
  24. listener('styleReplaceAll', data));
  25. if (FIREFOX) {
  26. // FF applies page CSP even to content scripts, https://bugzil.la/1267027
  27. chrome.webNavigation.onCommitted.addListener(webNavUsercssInstallerFF, {
  28. url: [
  29. {urlPrefix: 'https://raw.githubusercontent.com/', urlSuffix: '.user.css'},
  30. {urlPrefix: 'https://raw.githubusercontent.com/', urlSuffix: '.user.styl'},
  31. ]
  32. });
  33. }
  34. }
  35. if (chrome.contextMenus) {
  36. chrome.contextMenus.onClicked.addListener((info, tab) =>
  37. contextMenus[info.menuItemId].click(info, tab));
  38. }
  39. if (chrome.commands) {
  40. // Not available in Firefox - https://bugzilla.mozilla.org/show_bug.cgi?id=1240350
  41. chrome.commands.onCommand.addListener(command => browserCommands[command]());
  42. }
  43. if (!chrome.browserAction ||
  44. !['setIcon', 'setBadgeBackgroundColor', 'setBadgeText'].every(name => chrome.browserAction[name])) {
  45. window.updateIcon = () => {};
  46. }
  47. // *************************************************************************
  48. // set the default icon displayed after a tab is created until webNavigation kicks in
  49. prefs.subscribe(['iconset'], () => updateIcon({id: undefined}, {}));
  50. // *************************************************************************
  51. {
  52. const onInstall = ({reason}) => {
  53. chrome.runtime.onInstalled.removeListener(onInstall);
  54. const manifest = chrome.runtime.getManifest();
  55. // Open FAQs page once after installation to guide new users.
  56. // Do not display it in development mode.
  57. if (reason === 'install' && manifest.update_url) {
  58. // don't hardcode homepage URL, extract it from "Get Help" label translation
  59. // TODO: add a built-in tour page in the extension
  60. const getHelpHtml = chrome.i18n.getMessage('manageText').match(/<a\s+href=[^>]+/g);
  61. const url = (getHelpHtml[1] || '').replace(/^.+?=\s*/, '').replace(/^['"]|["']$/g, '');
  62. if (url) {
  63. setTimeout(openURL, 100, {url});
  64. }
  65. }
  66. // reset L10N cache on update
  67. if (reason === 'update') {
  68. localStorage.L10N = JSON.stringify({
  69. browserUIlanguage: chrome.i18n.getUILanguage(),
  70. });
  71. }
  72. if (!FIREFOX && chrome.declarativeContent) {
  73. chrome.declarativeContent.onPageChanged.removeRules(null, () => {
  74. chrome.declarativeContent.onPageChanged.addRules([{
  75. conditions: [
  76. new chrome.declarativeContent.PageStateMatcher({
  77. pageUrl: {urlContains: ':'},
  78. })
  79. ],
  80. actions: [
  81. new chrome.declarativeContent.RequestContentScript({
  82. js: ['/content/apply.js'],
  83. allFrames: true,
  84. matchAboutBlank: true,
  85. }),
  86. ],
  87. }]);
  88. });
  89. }
  90. };
  91. // bind for 60 seconds max and auto-unbind if it's a normal run
  92. chrome.runtime.onInstalled.addListener(onInstall);
  93. setTimeout(onInstall, 60e3, {reason: 'unbindme'});
  94. }
  95. // *************************************************************************
  96. // browser commands
  97. browserCommands = {
  98. openManage() {
  99. openURL({url: 'manage.html'});
  100. },
  101. styleDisableAll(info) {
  102. prefs.set('disableAll', info ? info.checked : !prefs.get('disableAll'));
  103. },
  104. };
  105. // *************************************************************************
  106. // context menus
  107. contextMenus = Object.assign({
  108. 'show-badge': {
  109. title: 'menuShowBadge',
  110. click: info => prefs.set(info.menuItemId, info.checked),
  111. },
  112. 'disableAll': {
  113. title: 'disableAllStyles',
  114. click: browserCommands.styleDisableAll,
  115. },
  116. 'open-manager': {
  117. title: 'openStylesManager',
  118. click: browserCommands.openManage,
  119. },
  120. }, !FIREFOX && prefs.get('editor.contextDelete') && {
  121. 'editor.contextDelete': {
  122. title: 'editDeleteText',
  123. type: 'normal',
  124. contexts: ['editable'],
  125. documentUrlPatterns: [URLS.ownOrigin + 'edit*'],
  126. click: (info, tab) => {
  127. sendMessage(tab.id, {method: 'editDeleteText'});
  128. },
  129. }
  130. });
  131. if (chrome.contextMenus) {
  132. const createContextMenus = (ids = Object.keys(contextMenus)) => {
  133. for (const id of ids) {
  134. const item = Object.assign({id}, contextMenus[id]);
  135. const prefValue = prefs.readOnlyValues[id];
  136. item.title = chrome.i18n.getMessage(item.title);
  137. if (!item.type && typeof prefValue === 'boolean') {
  138. item.type = 'checkbox';
  139. item.checked = prefValue;
  140. }
  141. if (!item.contexts) {
  142. item.contexts = ['browser_action'];
  143. }
  144. delete item.click;
  145. chrome.contextMenus.create(item, ignoreChromeError);
  146. }
  147. };
  148. createContextMenus();
  149. const toggleableIds = Object.keys(contextMenus).filter(key =>
  150. typeof prefs.readOnlyValues[key] === 'boolean');
  151. prefs.subscribe(toggleableIds, (id, checked) => {
  152. if (id === 'editor.contextDelete') {
  153. if (checked) {
  154. createContextMenus([id]);
  155. } else {
  156. chrome.contextMenus.remove(id, ignoreChromeError);
  157. }
  158. } else {
  159. chrome.contextMenus.update(id, {checked}, ignoreChromeError);
  160. }
  161. });
  162. }
  163. // *************************************************************************
  164. // [re]inject content scripts
  165. window.addEventListener('storageReady', function _() {
  166. window.removeEventListener('storageReady', _);
  167. updateIcon({id: undefined}, {});
  168. const NTP = 'chrome://newtab/';
  169. const ALL_URLS = '<all_urls>';
  170. const contentScripts = chrome.runtime.getManifest().content_scripts;
  171. if (!FIREFOX) {
  172. contentScripts.push({
  173. js: ['content/apply.js'],
  174. matches: ['<all_urls>'],
  175. run_at: 'document_start',
  176. match_about_blank: true,
  177. all_frames: true
  178. });
  179. }
  180. // expand * as .*?
  181. const wildcardAsRegExp = (s, flags) => new RegExp(
  182. s.replace(/[{}()[\]/\\.+?^$:=!|]/g, '\\$&')
  183. .replace(/\*/g, '.*?'), flags);
  184. for (const cs of contentScripts) {
  185. cs.matches = cs.matches.map(m => (
  186. m === ALL_URLS ? m : wildcardAsRegExp(m)
  187. ));
  188. }
  189. const injectCS = (cs, tabId) => {
  190. chrome.tabs.executeScript(tabId, {
  191. file: cs.js[0],
  192. runAt: cs.run_at,
  193. allFrames: cs.all_frames,
  194. matchAboutBlank: cs.match_about_blank,
  195. }, ignoreChromeError);
  196. };
  197. const pingCS = (cs, {id, url}) => {
  198. const maybeInject = pong => !pong && injectCS(cs, id);
  199. cs.matches.some(match => {
  200. if ((match === ALL_URLS || url.match(match)) &&
  201. (!url.startsWith('chrome') || url === NTP)) {
  202. sendMessage({method: 'ping', tabId: id}, maybeInject);
  203. return true;
  204. }
  205. });
  206. };
  207. queryTabs().then(tabs =>
  208. tabs.forEach(tab => {
  209. if (FIREFOX) {
  210. const tabId = tab.id;
  211. const frameUrls = {'0': tab.url};
  212. styleViaAPI.allFrameUrls.set(tabId, frameUrls);
  213. chrome.webNavigation.getAllFrames({tabId}, frames => frames &&
  214. frames.forEach(({frameId, parentFrameId, url}) => {
  215. if (frameId) {
  216. frameUrls[frameId] = url === 'about:blank' ? frameUrls[parentFrameId] : url;
  217. }
  218. }));
  219. } else if (tab.width) {
  220. // skip lazy-loaded aka unloaded tabs that seem to start loading on message
  221. contentScripts.forEach(cs =>
  222. setTimeout(pingCS, 0, cs, tab));
  223. }
  224. }));
  225. });
  226. // *************************************************************************
  227. function webNavigationListener(method, {url, tabId, frameId}) {
  228. getStyles({matchUrl: url, enabled: true, asHash: true}).then(styles => {
  229. if (method && URLS.supported(url) && tabId >= 0) {
  230. if (method === 'styleApply') {
  231. handleCssTransitionBug({tabId, frameId, url, styles});
  232. }
  233. sendMessage({
  234. tabId,
  235. frameId,
  236. method,
  237. // ping own page so it retrieves the styles directly
  238. styles: url.startsWith(URLS.ownOrigin) ? 'DIY' : styles,
  239. });
  240. }
  241. // main page frame id is 0
  242. if (frameId === 0) {
  243. updateIcon({id: tabId, url}, styles);
  244. }
  245. });
  246. }
  247. function webNavigationListenerChrome(method, data) {
  248. const {tabId, frameId, url} = data;
  249. if (url.startsWith('https://www.google.') && url.includes('/_/chrome/newtab?')) {
  250. // Chrome 61.0.3161+ doesn't run content scripts on NTP
  251. getTab(tabId).then(tab => {
  252. data.url = tab.url === 'chrome://newtab/' ? tab.url : url;
  253. webNavigationListener(method, data);
  254. });
  255. } else {
  256. webNavigationListener(method, data);
  257. // chrome.declarativeContent doesn't inject scripts in about:blank iframes
  258. if (method && frameId && url === 'about:blank') {
  259. chrome.tabs.executeScript(tabId, {
  260. file: '/content/apply.js',
  261. runAt: 'document_start',
  262. matchAboutBlank: true,
  263. frameId,
  264. }, ignoreChromeError);
  265. }
  266. }
  267. }
  268. function webNavigationListenerFF(method, data) {
  269. const {tabId, frameId, url} = data;
  270. if (url !== 'about:blank' || !frameId) {
  271. styleViaAPI.setFrameUrl(tabId, frameId, url);
  272. webNavigationListener(method, data);
  273. return;
  274. }
  275. const frames = styleViaAPI.allFrameUrls.get(tabId);
  276. if (Object.keys(frames).length === 1) {
  277. frames[frameId] = frames['0'];
  278. webNavigationListener(method, data);
  279. return;
  280. }
  281. chrome.webNavigation.getFrame({tabId, frameId}, info => {
  282. const hasParent = !chrome.runtime.lastError && info.parentFrameId >= 0;
  283. frames[frameId] = hasParent ? frames[info.parentFrameId] : url;
  284. webNavigationListener(method, data);
  285. });
  286. }
  287. function webNavUsercssInstallerFF(data) {
  288. const {tabId} = data;
  289. // we need tab index to open the installer next to the original one
  290. // and also to skip the double-invocation in FF which assigns tab url later
  291. getTab(tabId).then(tab => {
  292. if (tab.url !== 'about:blank') {
  293. usercssHelper.openInstallPage(tab, {direct: true});
  294. }
  295. });
  296. }
  297. function updateIcon(tab, styles) {
  298. if (tab.id < 0) {
  299. return;
  300. }
  301. if (URLS.chromeProtectsNTP && tab.url === 'chrome://newtab/') {
  302. styles = {};
  303. }
  304. if (styles) {
  305. stylesReceived(styles);
  306. return;
  307. }
  308. getTabRealURL(tab)
  309. .then(url => getStyles({matchUrl: url, enabled: true, asHash: true}))
  310. .then(stylesReceived);
  311. function stylesReceived(styles) {
  312. let numStyles = styles.length;
  313. if (numStyles === undefined) {
  314. // for 'styles' asHash:true fake the length by counting numeric ids manually
  315. numStyles = 0;
  316. for (const id of Object.keys(styles)) {
  317. numStyles += id.match(/^\d+$/) ? 1 : 0;
  318. }
  319. }
  320. const disableAll = 'disableAll' in styles ? styles.disableAll : prefs.get('disableAll');
  321. const postfix = disableAll ? 'x' : numStyles === 0 ? 'w' : '';
  322. const color = prefs.get(disableAll ? 'badgeDisabled' : 'badgeNormal');
  323. const text = prefs.get('show-badge') && numStyles ? String(numStyles) : '';
  324. const iconset = ['', 'light/'][prefs.get('iconset')] || '';
  325. const path = 'images/icon/' + iconset;
  326. chrome.browserAction.setIcon({
  327. tabId: tab.id,
  328. path: {
  329. // Material Design 2016 new size is 16px
  330. 16: `${path}16${postfix}.png`,
  331. 32: `${path}32${postfix}.png`,
  332. // Chromium forks or non-chromium browsers may still use the traditional 19px
  333. 19: `${path}19${postfix}.png`,
  334. 38: `${path}38${postfix}.png`,
  335. // TODO: add Edge preferred sizes: 20, 25, 30, 40
  336. },
  337. }, () => {
  338. if (chrome.runtime.lastError || tab.id === undefined) {
  339. return;
  340. }
  341. // Vivaldi bug workaround: setBadgeText must follow setBadgeBackgroundColor
  342. chrome.browserAction.setBadgeBackgroundColor({color});
  343. setTimeout(() => {
  344. getTab(tab.id).then(realTab => {
  345. // skip pre-rendered tabs
  346. if (realTab.index >= 0) {
  347. chrome.browserAction.setBadgeText({text, tabId: tab.id});
  348. }
  349. });
  350. });
  351. });
  352. }
  353. }
  354. function onRuntimeMessage(request, sender, sendResponseInternal) {
  355. const sendResponse = data => {
  356. // wrap Error object instance as {__ERROR__: message} - will be unwrapped in sendMessage
  357. if (data instanceof Error) {
  358. data = {__ERROR__: data.message};
  359. }
  360. // prevent browser exception bug on sending a response to a closed tab
  361. tryCatch(sendResponseInternal, data);
  362. };
  363. switch (request.method) {
  364. case 'getStyles':
  365. getStyles(request).then(sendResponse);
  366. return KEEP_CHANNEL_OPEN;
  367. case 'saveStyle':
  368. saveStyle(request).then(sendResponse);
  369. return KEEP_CHANNEL_OPEN;
  370. case 'saveUsercss':
  371. usercssHelper.save(request, true).then(sendResponse);
  372. return KEEP_CHANNEL_OPEN;
  373. case 'buildUsercss':
  374. usercssHelper.build(request, true).then(sendResponse);
  375. return KEEP_CHANNEL_OPEN;
  376. case 'healthCheck':
  377. dbExec()
  378. .then(() => sendResponse(true))
  379. .catch(() => sendResponse(false));
  380. return KEEP_CHANNEL_OPEN;
  381. case 'download':
  382. download(request.url)
  383. .then(sendResponse)
  384. .catch(() => sendResponse(null));
  385. return KEEP_CHANNEL_OPEN;
  386. case 'openUsercssInstallPage':
  387. usercssHelper.openInstallPage(sender.tab, request).then(sendResponse);
  388. return KEEP_CHANNEL_OPEN;
  389. case 'closeTab':
  390. chrome.tabs.remove(request.tabId || sender.tab.id, () => {
  391. if (chrome.runtime.lastError && request.tabId !== sender.tab.id) {
  392. sendResponse(new Error(chrome.runtime.lastError.message));
  393. }
  394. });
  395. return KEEP_CHANNEL_OPEN;
  396. case 'openEditor':
  397. openEditor(request.id);
  398. return;
  399. }
  400. }