ASRouterTriggerListeners.jsm 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. /* This Source Code Form is subject to the terms of the Mozilla Public
  2. * License, v. 2.0. If a copy of the MPL was not distributed with this
  3. * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
  4. "use strict";
  5. const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
  6. ChromeUtils.defineModuleGetter(this, "PrivateBrowsingUtils",
  7. "resource://gre/modules/PrivateBrowsingUtils.jsm");
  8. const FEW_MINUTES = 15 * 60 * 1000; // 15 mins
  9. const MATCH_PATTERN_OPTIONS = {ignorePath: true};
  10. /**
  11. * Wait for browser startup to finish to avoid accessing uninitialized
  12. * properties
  13. */
  14. async function checkStartupFinished(win) {
  15. if (!win.gBrowserInit.delayedStartupFinished) {
  16. await new Promise(resolve => {
  17. let delayedStartupObserver = (subject, topic) => {
  18. if (topic === "browser-delayed-startup-finished" && subject === win) {
  19. Services.obs.removeObserver(delayedStartupObserver, "browser-delayed-startup-finished");
  20. resolve();
  21. }
  22. };
  23. Services.obs.addObserver(delayedStartupObserver, "browser-delayed-startup-finished");
  24. });
  25. }
  26. }
  27. function isPrivateWindow(win) {
  28. return !(win instanceof Ci.nsIDOMWindow) || win.closed || PrivateBrowsingUtils.isWindowPrivate(win);
  29. }
  30. /**
  31. * Check current location against the list of whitelisted hosts
  32. * Additionally verify for redirects and check original request URL against
  33. * the whitelist.
  34. *
  35. * @returns {object} - {host, url} pair that matched the whitelist
  36. */
  37. function checkURLMatch(aLocationURI, {hosts, matchPatternSet}, aRequest) {
  38. // If checks pass we return a match
  39. let match;
  40. try {
  41. match = {host: aLocationURI.host, url: aLocationURI.spec};
  42. } catch (e) { // nsIURI.host can throw for non-nsStandardURL nsIURIs
  43. return false;
  44. }
  45. // Check current location against whitelisted hosts
  46. if (hosts.has(match.host)) {
  47. return match;
  48. }
  49. if (matchPatternSet) {
  50. if (matchPatternSet.matches(match.url)) {
  51. return match;
  52. }
  53. }
  54. // Nothing else to check, return early
  55. if (!aRequest) {
  56. return false;
  57. }
  58. // The original URL at the start of the request
  59. const originalLocation = aRequest.QueryInterface(Ci.nsIChannel).originalURI;
  60. // We have been redirected
  61. if (originalLocation.spec !== aLocationURI.spec) {
  62. return hosts.has(originalLocation.host) && {
  63. host: originalLocation.host,
  64. url: originalLocation.spec,
  65. };
  66. }
  67. return false;
  68. }
  69. /**
  70. * A Map from trigger IDs to singleton trigger listeners. Each listener must
  71. * have idempotent `init` and `uninit` methods.
  72. */
  73. this.ASRouterTriggerListeners = new Map([
  74. ["frequentVisits", {
  75. _initialized: false,
  76. _triggerHandler: null,
  77. _hosts: null,
  78. _matchPatternSet: null,
  79. _visits: null,
  80. async init(triggerHandler, hosts = [], patterns) {
  81. if (!this._initialized) {
  82. this.onTabSwitch = this.onTabSwitch.bind(this);
  83. // Add listeners to all existing browser windows
  84. for (let win of Services.wm.getEnumerator("navigator:browser")) {
  85. if (isPrivateWindow(win)) {
  86. continue;
  87. }
  88. await checkStartupFinished(win);
  89. win.addEventListener("TabSelect", this.onTabSwitch);
  90. win.gBrowser.addTabsProgressListener(this);
  91. }
  92. this._initialized = true;
  93. this._visits = new Map();
  94. }
  95. this._triggerHandler = triggerHandler;
  96. if (patterns) {
  97. if (this._matchPatternSet) {
  98. this._matchPatternSet = new MatchPatternSet(new Set([
  99. ...this._matchPatternSet.patterns,
  100. ...patterns,
  101. ]), MATCH_PATTERN_OPTIONS);
  102. } else {
  103. this._matchPatternSet = new MatchPatternSet(patterns, MATCH_PATTERN_OPTIONS);
  104. }
  105. }
  106. if (this._hosts) {
  107. hosts.forEach(h => this._hosts.add(h));
  108. } else {
  109. this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour
  110. }
  111. },
  112. /* _updateVisits - Record visit timestamps for websites that match `this._hosts` and only
  113. * if it's been more than FEW_MINUTES since the last visit.
  114. * @param {string} host - Location host of current selected tab
  115. * @returns {boolean} - If the new visit has been recorded
  116. */
  117. _updateVisits(host) {
  118. const visits = this._visits.get(host);
  119. if (visits && Date.now() - visits[0] > FEW_MINUTES) {
  120. this._visits.set(host, [Date.now(), ...visits]);
  121. return true;
  122. }
  123. if (!visits) {
  124. this._visits.set(host, [Date.now()]);
  125. return true;
  126. }
  127. return false;
  128. },
  129. onTabSwitch(event) {
  130. if (!event.target.ownerGlobal.gBrowser) {
  131. return;
  132. }
  133. const {gBrowser} = event.target.ownerGlobal;
  134. const match = checkURLMatch(gBrowser.currentURI, {hosts: this._hosts, matchPatternSet: this._matchPatternSet});
  135. if (match) {
  136. this.triggerHandler(gBrowser.selectedBrowser, match);
  137. }
  138. },
  139. triggerHandler(aBrowser, match) {
  140. const updated = this._updateVisits(match.host);
  141. // If the previous visit happend less than FEW_MINUTES ago
  142. // no updates were made, no need to trigger the handler
  143. if (!updated) {
  144. return;
  145. }
  146. this._triggerHandler(aBrowser, {
  147. id: "frequentVisits",
  148. param: match,
  149. context: {
  150. // Remapped to {host, timestamp} because JEXL operators can only
  151. // filter over collections (arrays of objects)
  152. recentVisits: this._visits.get(match.host).map(timestamp => ({host: match.host, timestamp})),
  153. },
  154. });
  155. },
  156. onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) {
  157. // Some websites trigger redirect events after they finish loading even
  158. // though the location remains the same. This results in onLocationChange
  159. // events to be fired twice.
  160. const isSameDocument = !!(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT);
  161. if (aWebProgress.isTopLevel && !isSameDocument) {
  162. const match = checkURLMatch(aLocationURI, {hosts: this._hosts, matchPatternSet: this._matchPatternSet}, aRequest);
  163. if (match) {
  164. this.triggerHandler(aBrowser, match);
  165. }
  166. }
  167. },
  168. observe(win, topic, data) {
  169. let onLoad;
  170. switch (topic) {
  171. case "domwindowopened":
  172. if (isPrivateWindow(win)) {
  173. break;
  174. }
  175. onLoad = () => {
  176. // Ignore non-browser windows.
  177. if (win.document.documentElement.getAttribute("windowtype") === "navigator:browser") {
  178. win.addEventListener("TabSelect", this.onTabSwitch);
  179. win.gBrowser.addTabsProgressListener(this);
  180. }
  181. };
  182. win.addEventListener("load", onLoad, {once: true});
  183. break;
  184. case "domwindowclosed":
  185. if ((win instanceof Ci.nsIDOMWindow) &&
  186. win.document.documentElement.getAttribute("windowtype") === "navigator:browser") {
  187. win.removeEventListener("TabSelect", this.onTabSwitch);
  188. win.gBrowser.removeTabsProgressListener(this);
  189. }
  190. break;
  191. }
  192. },
  193. uninit() {
  194. if (this._initialized) {
  195. Services.ww.unregisterNotification(this);
  196. for (let win of Services.wm.getEnumerator("navigator:browser")) {
  197. if (isPrivateWindow(win)) {
  198. continue;
  199. }
  200. win.removeEventListener("TabSelect", this.onTabSwitch);
  201. win.gBrowser.removeTabsProgressListener(this);
  202. }
  203. this._initialized = false;
  204. this._triggerHandler = null;
  205. this._hosts = null;
  206. this._matchPatternSet = null;
  207. this._visits = null;
  208. }
  209. },
  210. }],
  211. /**
  212. * Attach listeners to every browser window to detect location changes, and
  213. * notify the trigger handler whenever we navigate to a URL with a hostname
  214. * we're looking for.
  215. */
  216. ["openURL", {
  217. _initialized: false,
  218. _triggerHandler: null,
  219. _hosts: null,
  220. /*
  221. * If the listener is already initialised, `init` will replace the trigger
  222. * handler and add any new hosts to `this._hosts`.
  223. */
  224. async init(triggerHandler, hosts = [], patterns) {
  225. if (!this._initialized) {
  226. this.onLocationChange = this.onLocationChange.bind(this);
  227. // Listen for new windows being opened
  228. Services.ww.registerNotification(this);
  229. // Add listeners to all existing browser windows
  230. for (let win of Services.wm.getEnumerator("navigator:browser")) {
  231. if (isPrivateWindow(win)) {
  232. continue;
  233. }
  234. await checkStartupFinished(win);
  235. win.gBrowser.addTabsProgressListener(this);
  236. }
  237. this._initialized = true;
  238. }
  239. this._triggerHandler = triggerHandler;
  240. if (this._hosts) {
  241. hosts.forEach(h => this._hosts.add(h));
  242. } else {
  243. this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour
  244. }
  245. },
  246. uninit() {
  247. if (this._initialized) {
  248. Services.ww.unregisterNotification(this);
  249. for (let win of Services.wm.getEnumerator("navigator:browser")) {
  250. if (isPrivateWindow(win)) {
  251. continue;
  252. }
  253. win.gBrowser.removeTabsProgressListener(this);
  254. }
  255. this._initialized = false;
  256. this._triggerHandler = null;
  257. this._hosts = null;
  258. }
  259. },
  260. onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) {
  261. // Some websites trigger redirect events after they finish loading even
  262. // though the location remains the same. This results in onLocationChange
  263. // events to be fired twice.
  264. const isSameDocument = !!(aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT);
  265. if (aWebProgress.isTopLevel && !isSameDocument) {
  266. const match = checkURLMatch(aLocationURI, {hosts: this._hosts}, aRequest);
  267. if (match) {
  268. this._triggerHandler(aBrowser, {id: "openURL", param: match});
  269. }
  270. }
  271. },
  272. observe(win, topic, data) {
  273. let onLoad;
  274. switch (topic) {
  275. case "domwindowopened":
  276. if (isPrivateWindow(win)) {
  277. break;
  278. }
  279. onLoad = () => {
  280. // Ignore non-browser windows.
  281. if (win.document.documentElement.getAttribute("windowtype") === "navigator:browser") {
  282. win.gBrowser.addTabsProgressListener(this);
  283. }
  284. };
  285. win.addEventListener("load", onLoad, {once: true});
  286. break;
  287. case "domwindowclosed":
  288. if ((win instanceof Ci.nsIDOMWindow) &&
  289. win.document.documentElement.getAttribute("windowtype") === "navigator:browser") {
  290. win.gBrowser.removeTabsProgressListener(this);
  291. }
  292. break;
  293. }
  294. },
  295. }],
  296. ]);
  297. const EXPORTED_SYMBOLS = ["ASRouterTriggerListeners"];