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 6const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); 7const { XPCOMUtils } = ChromeUtils.import( 8 "resource://gre/modules/XPCOMUtils.jsm" 9); 10 11XPCOMUtils.defineLazyModuleGetters(this, { 12 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", 13 EveryWindow: "resource:///modules/EveryWindow.jsm", 14 AboutReaderParent: "resource:///actors/AboutReaderParent.jsm", 15}); 16 17const FEW_MINUTES = 15 * 60 * 1000; // 15 mins 18 19function isPrivateWindow(win) { 20 return ( 21 !(win instanceof Ci.nsIDOMWindow) || 22 win.closed || 23 PrivateBrowsingUtils.isWindowPrivate(win) 24 ); 25} 26 27/** 28 * Check current location against the list of allowed hosts 29 * Additionally verify for redirects and check original request URL against 30 * the list. 31 * 32 * @returns {object} - {host, url} pair that matched the list of allowed hosts 33 */ 34function checkURLMatch(aLocationURI, { hosts, matchPatternSet }, aRequest) { 35 // If checks pass we return a match 36 let match; 37 try { 38 match = { host: aLocationURI.host, url: aLocationURI.spec }; 39 } catch (e) { 40 // nsIURI.host can throw for non-nsStandardURL nsIURIs 41 return false; 42 } 43 44 // Check current location against allowed hosts 45 if (hosts.has(match.host)) { 46 return match; 47 } 48 49 if (matchPatternSet) { 50 if (matchPatternSet.matches(match.url)) { 51 return match; 52 } 53 } 54 55 // Nothing else to check, return early 56 if (!aRequest) { 57 return false; 58 } 59 60 // The original URL at the start of the request 61 const originalLocation = aRequest.QueryInterface(Ci.nsIChannel).originalURI; 62 // We have been redirected 63 if (originalLocation.spec !== aLocationURI.spec) { 64 return ( 65 hosts.has(originalLocation.host) && { 66 host: originalLocation.host, 67 url: originalLocation.spec, 68 } 69 ); 70 } 71 72 return false; 73} 74 75function createMatchPatternSet(patterns, flags) { 76 try { 77 return new MatchPatternSet(new Set(patterns), flags); 78 } catch (e) { 79 Cu.reportError(e); 80 } 81 return new MatchPatternSet([]); 82} 83 84/** 85 * A Map from trigger IDs to singleton trigger listeners. Each listener must 86 * have idempotent `init` and `uninit` methods. 87 */ 88this.ASRouterTriggerListeners = new Map([ 89 [ 90 "openArticleURL", 91 { 92 id: "openArticleURL", 93 _initialized: false, 94 _triggerHandler: null, 95 _hosts: new Set(), 96 _matchPatternSet: null, 97 readerModeEvent: "Reader:UpdateReaderButton", 98 99 init(triggerHandler, hosts, patterns) { 100 if (!this._initialized) { 101 this.receiveMessage = this.receiveMessage.bind(this); 102 AboutReaderParent.addMessageListener(this.readerModeEvent, this); 103 this._triggerHandler = triggerHandler; 104 this._initialized = true; 105 } 106 if (patterns) { 107 this._matchPatternSet = createMatchPatternSet([ 108 ...(this._matchPatternSet ? this._matchPatternSet.patterns : []), 109 ...patterns, 110 ]); 111 } 112 if (hosts) { 113 hosts.forEach(h => this._hosts.add(h)); 114 } 115 }, 116 117 receiveMessage({ data, target }) { 118 if (data && data.isArticle) { 119 const match = checkURLMatch(target.currentURI, { 120 hosts: this._hosts, 121 matchPatternSet: this._matchPatternSet, 122 }); 123 if (match) { 124 this._triggerHandler(target, { id: this.id, param: match }); 125 } 126 } 127 }, 128 129 uninit() { 130 if (this._initialized) { 131 AboutReaderParent.removeMessageListener(this.readerModeEvent, this); 132 this._initialized = false; 133 this._triggerHandler = null; 134 this._hosts = new Set(); 135 this._matchPatternSet = null; 136 } 137 }, 138 }, 139 ], 140 [ 141 "openBookmarkedURL", 142 { 143 id: "openBookmarkedURL", 144 _initialized: false, 145 _triggerHandler: null, 146 _hosts: new Set(), 147 bookmarkEvent: "bookmark-icon-updated", 148 149 init(triggerHandler) { 150 if (!this._initialized) { 151 Services.obs.addObserver(this, this.bookmarkEvent); 152 this._triggerHandler = triggerHandler; 153 this._initialized = true; 154 } 155 }, 156 157 observe(subject, topic, data) { 158 if (topic === this.bookmarkEvent && data === "starred") { 159 const browser = Services.wm.getMostRecentBrowserWindow(); 160 if (browser) { 161 this._triggerHandler(browser.gBrowser.selectedBrowser, { 162 id: this.id, 163 }); 164 } 165 } 166 }, 167 168 uninit() { 169 if (this._initialized) { 170 Services.obs.removeObserver(this, this.bookmarkEvent); 171 this._initialized = false; 172 this._triggerHandler = null; 173 this._hosts = new Set(); 174 } 175 }, 176 }, 177 ], 178 [ 179 "frequentVisits", 180 { 181 id: "frequentVisits", 182 _initialized: false, 183 _triggerHandler: null, 184 _hosts: null, 185 _matchPatternSet: null, 186 _visits: null, 187 188 init(triggerHandler, hosts = [], patterns) { 189 if (!this._initialized) { 190 this.onTabSwitch = this.onTabSwitch.bind(this); 191 EveryWindow.registerCallback( 192 this.id, 193 win => { 194 if (!isPrivateWindow(win)) { 195 win.addEventListener("TabSelect", this.onTabSwitch); 196 win.gBrowser.addTabsProgressListener(this); 197 } 198 }, 199 win => { 200 if (!isPrivateWindow(win)) { 201 win.removeEventListener("TabSelect", this.onTabSwitch); 202 win.gBrowser.removeTabsProgressListener(this); 203 } 204 } 205 ); 206 this._visits = new Map(); 207 this._initialized = true; 208 } 209 this._triggerHandler = triggerHandler; 210 if (patterns) { 211 this._matchPatternSet = createMatchPatternSet([ 212 ...(this._matchPatternSet ? this._matchPatternSet.patterns : []), 213 ...patterns, 214 ]); 215 } 216 if (this._hosts) { 217 hosts.forEach(h => this._hosts.add(h)); 218 } else { 219 this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour 220 } 221 }, 222 223 /* _updateVisits - Record visit timestamps for websites that match `this._hosts` and only 224 * if it's been more than FEW_MINUTES since the last visit. 225 * @param {string} host - Location host of current selected tab 226 * @returns {boolean} - If the new visit has been recorded 227 */ 228 _updateVisits(host) { 229 const visits = this._visits.get(host); 230 231 if (visits && Date.now() - visits[0] > FEW_MINUTES) { 232 this._visits.set(host, [Date.now(), ...visits]); 233 return true; 234 } 235 if (!visits) { 236 this._visits.set(host, [Date.now()]); 237 return true; 238 } 239 240 return false; 241 }, 242 243 onTabSwitch(event) { 244 if (!event.target.ownerGlobal.gBrowser) { 245 return; 246 } 247 248 const { gBrowser } = event.target.ownerGlobal; 249 const match = checkURLMatch(gBrowser.currentURI, { 250 hosts: this._hosts, 251 matchPatternSet: this._matchPatternSet, 252 }); 253 if (match) { 254 this.triggerHandler(gBrowser.selectedBrowser, match); 255 } 256 }, 257 258 triggerHandler(aBrowser, match) { 259 const updated = this._updateVisits(match.host); 260 261 // If the previous visit happend less than FEW_MINUTES ago 262 // no updates were made, no need to trigger the handler 263 if (!updated) { 264 return; 265 } 266 267 this._triggerHandler(aBrowser, { 268 id: this.id, 269 param: match, 270 context: { 271 // Remapped to {host, timestamp} because JEXL operators can only 272 // filter over collections (arrays of objects) 273 recentVisits: this._visits 274 .get(match.host) 275 .map(timestamp => ({ host: match.host, timestamp })), 276 }, 277 }); 278 }, 279 280 onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) { 281 // Some websites trigger redirect events after they finish loading even 282 // though the location remains the same. This results in onLocationChange 283 // events to be fired twice. 284 const isSameDocument = !!( 285 aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT 286 ); 287 if (aWebProgress.isTopLevel && !isSameDocument) { 288 const match = checkURLMatch( 289 aLocationURI, 290 { hosts: this._hosts, matchPatternSet: this._matchPatternSet }, 291 aRequest 292 ); 293 if (match) { 294 this.triggerHandler(aBrowser, match); 295 } 296 } 297 }, 298 299 uninit() { 300 if (this._initialized) { 301 EveryWindow.unregisterCallback(this.id); 302 303 this._initialized = false; 304 this._triggerHandler = null; 305 this._hosts = null; 306 this._matchPatternSet = null; 307 this._visits = null; 308 } 309 }, 310 }, 311 ], 312 313 /** 314 * Attach listeners to every browser window to detect location changes, and 315 * notify the trigger handler whenever we navigate to a URL with a hostname 316 * we're looking for. 317 */ 318 [ 319 "openURL", 320 { 321 id: "openURL", 322 _initialized: false, 323 _triggerHandler: null, 324 _hosts: null, 325 _matchPatternSet: null, 326 _visits: null, 327 328 /* 329 * If the listener is already initialised, `init` will replace the trigger 330 * handler and add any new hosts to `this._hosts`. 331 */ 332 init(triggerHandler, hosts = [], patterns) { 333 if (!this._initialized) { 334 this.onLocationChange = this.onLocationChange.bind(this); 335 EveryWindow.registerCallback( 336 this.id, 337 win => { 338 if (!isPrivateWindow(win)) { 339 win.addEventListener("TabSelect", this.onTabSwitch); 340 win.gBrowser.addTabsProgressListener(this); 341 } 342 }, 343 win => { 344 if (!isPrivateWindow(win)) { 345 win.removeEventListener("TabSelect", this.onTabSwitch); 346 win.gBrowser.removeTabsProgressListener(this); 347 } 348 } 349 ); 350 351 this._visits = new Map(); 352 this._initialized = true; 353 } 354 this._triggerHandler = triggerHandler; 355 if (patterns) { 356 this._matchPatternSet = createMatchPatternSet([ 357 ...(this._matchPatternSet ? this._matchPatternSet.patterns : []), 358 ...patterns, 359 ]); 360 } 361 if (this._hosts) { 362 hosts.forEach(h => this._hosts.add(h)); 363 } else { 364 this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour 365 } 366 }, 367 368 uninit() { 369 if (this._initialized) { 370 EveryWindow.unregisterCallback(this.id); 371 372 this._initialized = false; 373 this._triggerHandler = null; 374 this._hosts = null; 375 this._matchPatternSet = null; 376 this._visits = null; 377 } 378 }, 379 380 onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) { 381 // Some websites trigger redirect events after they finish loading even 382 // though the location remains the same. This results in onLocationChange 383 // events to be fired twice. 384 const isSameDocument = !!( 385 aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT 386 ); 387 if (aWebProgress.isTopLevel && !isSameDocument) { 388 const match = checkURLMatch( 389 aLocationURI, 390 { hosts: this._hosts, matchPatternSet: this._matchPatternSet }, 391 aRequest 392 ); 393 if (match) { 394 let visitsCount = (this._visits.get(match.url) || 0) + 1; 395 this._visits.set(match.url, visitsCount); 396 this._triggerHandler(aBrowser, { 397 id: this.id, 398 param: match, 399 context: { visitsCount }, 400 }); 401 } 402 } 403 }, 404 }, 405 ], 406 407 /** 408 * Add an observer notification to notify the trigger handler whenever the user 409 * saves or updates a login via the login capture doorhanger. 410 */ 411 [ 412 "newSavedLogin", 413 { 414 _initialized: false, 415 _triggerHandler: null, 416 417 /** 418 * If the listener is already initialised, `init` will replace the trigger 419 * handler. 420 */ 421 init(triggerHandler) { 422 if (!this._initialized) { 423 Services.obs.addObserver(this, "LoginStats:NewSavedPassword"); 424 Services.obs.addObserver(this, "LoginStats:LoginUpdateSaved"); 425 this._initialized = true; 426 } 427 this._triggerHandler = triggerHandler; 428 }, 429 430 uninit() { 431 if (this._initialized) { 432 Services.obs.removeObserver(this, "LoginStats:NewSavedPassword"); 433 Services.obs.removeObserver(this, "LoginStats:LoginUpdateSaved"); 434 435 this._initialized = false; 436 this._triggerHandler = null; 437 } 438 }, 439 440 observe(aSubject, aTopic, aData) { 441 if (aSubject.currentURI.asciiHost === "accounts.firefox.com") { 442 // Don't notify about saved logins on the FxA login origin since this 443 // trigger is used to promote login Sync and getting a recommendation 444 // to enable Sync during the sign up process is a bad UX. 445 return; 446 } 447 448 switch (aTopic) { 449 case "LoginStats:NewSavedPassword": { 450 this._triggerHandler(aSubject, { 451 id: "newSavedLogin", 452 context: { type: "save" }, 453 }); 454 break; 455 } 456 case "LoginStats:LoginUpdateSaved": { 457 this._triggerHandler(aSubject, { 458 id: "newSavedLogin", 459 context: { type: "update" }, 460 }); 461 break; 462 } 463 default: { 464 throw new Error(`Unexpected observer notification: ${aTopic}`); 465 } 466 } 467 }, 468 }, 469 ], 470 471 [ 472 "contentBlocking", 473 { 474 _initialized: false, 475 _triggerHandler: null, 476 _events: [], 477 _sessionPageLoad: 0, 478 onLocationChange: null, 479 480 init(triggerHandler, params, patterns) { 481 params.forEach(p => this._events.push(p)); 482 483 if (!this._initialized) { 484 Services.obs.addObserver(this, "SiteProtection:ContentBlockingEvent"); 485 Services.obs.addObserver( 486 this, 487 "SiteProtection:ContentBlockingMilestone" 488 ); 489 this.onLocationChange = this._onLocationChange.bind(this); 490 EveryWindow.registerCallback( 491 this.id, 492 win => { 493 if (!isPrivateWindow(win)) { 494 win.gBrowser.addTabsProgressListener(this); 495 } 496 }, 497 win => { 498 if (!isPrivateWindow(win)) { 499 win.gBrowser.removeTabsProgressListener(this); 500 } 501 } 502 ); 503 504 this._initialized = true; 505 } 506 this._triggerHandler = triggerHandler; 507 }, 508 509 uninit() { 510 if (this._initialized) { 511 Services.obs.removeObserver( 512 this, 513 "SiteProtection:ContentBlockingEvent" 514 ); 515 Services.obs.removeObserver( 516 this, 517 "SiteProtection:ContentBlockingMilestone" 518 ); 519 EveryWindow.unregisterCallback(this.id); 520 this.onLocationChange = null; 521 this._initialized = false; 522 } 523 this._triggerHandler = null; 524 this._events = []; 525 this._sessionPageLoad = 0; 526 }, 527 528 observe(aSubject, aTopic, aData) { 529 switch (aTopic) { 530 case "SiteProtection:ContentBlockingEvent": 531 const { browser, host, event } = aSubject.wrappedJSObject; 532 if (this._events.filter(e => (e & event) === e).length) { 533 this._triggerHandler(browser, { 534 id: "contentBlocking", 535 param: { 536 host, 537 type: event, 538 }, 539 context: { 540 pageLoad: this._sessionPageLoad, 541 }, 542 }); 543 } 544 break; 545 case "SiteProtection:ContentBlockingMilestone": 546 if (this._events.includes(aSubject.wrappedJSObject.event)) { 547 this._triggerHandler( 548 Services.wm.getMostRecentBrowserWindow().gBrowser 549 .selectedBrowser, 550 { 551 id: "contentBlocking", 552 context: { 553 pageLoad: this._sessionPageLoad, 554 }, 555 param: { 556 type: aSubject.wrappedJSObject.event, 557 }, 558 } 559 ); 560 } 561 break; 562 } 563 }, 564 565 _onLocationChange( 566 aBrowser, 567 aWebProgress, 568 aRequest, 569 aLocationURI, 570 aFlags 571 ) { 572 // Some websites trigger redirect events after they finish loading even 573 // though the location remains the same. This results in onLocationChange 574 // events to be fired twice. 575 const isSameDocument = !!( 576 aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT 577 ); 578 if ( 579 ["http", "https"].includes(aLocationURI.scheme) && 580 aWebProgress.isTopLevel && 581 !isSameDocument 582 ) { 583 this._sessionPageLoad += 1; 584 } 585 }, 586 }, 587 ], 588]); 589 590const EXPORTED_SYMBOLS = ["ASRouterTriggerListeners"]; 591