Backed out 2 changesets (bug 1864896) for causing node failures. CLOSED TREE
[gecko.git] / browser / components / asrouter / modules / ASRouterTriggerListeners.sys.mjs
blobd8eaa3994d88cfdc78ed8b59ef9a8b0375a3a3f7
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/. */
5 const lazy = {};
7 ChromeUtils.defineESModuleGetters(lazy, {
8   AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs",
9   BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
10   EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
11   FeatureCalloutBroker:
12     "resource:///modules/asrouter/FeatureCalloutBroker.sys.mjs",
13   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
14   clearTimeout: "resource://gre/modules/Timer.sys.mjs",
15   setTimeout: "resource://gre/modules/Timer.sys.mjs",
16 });
18 ChromeUtils.defineLazyGetter(lazy, "log", () => {
19   const { Logger } = ChromeUtils.importESModule(
20     "resource://messaging-system/lib/Logger.sys.mjs"
21   );
22   return new Logger("ASRouterTriggerListeners");
23 });
25 const FEW_MINUTES = 15 * 60 * 1000; // 15 mins
27 function isPrivateWindow(win) {
28   return (
29     !(win instanceof Ci.nsIDOMWindow) ||
30     win.closed ||
31     lazy.PrivateBrowsingUtils.isWindowPrivate(win)
32   );
35 /**
36  * Check current location against the list of allowed hosts
37  * Additionally verify for redirects and check original request URL against
38  * the list.
39  *
40  * @returns {object} - {host, url} pair that matched the list of allowed hosts
41  */
42 function checkURLMatch(aLocationURI, { hosts, matchPatternSet }, aRequest) {
43   // If checks pass we return a match
44   let match;
45   try {
46     match = { host: aLocationURI.host, url: aLocationURI.spec };
47   } catch (e) {
48     // nsIURI.host can throw for non-nsStandardURL nsIURIs
49     return false;
50   }
52   // Check current location against allowed hosts
53   if (hosts.has(match.host)) {
54     return match;
55   }
57   if (matchPatternSet) {
58     if (matchPatternSet.matches(match.url)) {
59       return match;
60     }
61   }
63   // Nothing else to check, return early
64   if (!aRequest) {
65     return false;
66   }
68   // The original URL at the start of the request
69   const originalLocation = aRequest.QueryInterface(Ci.nsIChannel).originalURI;
70   // We have been redirected
71   if (originalLocation.spec !== aLocationURI.spec) {
72     return (
73       hosts.has(originalLocation.host) && {
74         host: originalLocation.host,
75         url: originalLocation.spec,
76       }
77     );
78   }
80   return false;
83 function createMatchPatternSet(patterns, flags) {
84   try {
85     return new MatchPatternSet(new Set(patterns), flags);
86   } catch (e) {
87     console.error(e);
88   }
89   return new MatchPatternSet([]);
92 /**
93  * A Map from trigger IDs to singleton trigger listeners. Each listener must
94  * have idempotent `init` and `uninit` methods.
95  */
96 export const ASRouterTriggerListeners = new Map([
97   [
98     "openArticleURL",
99     {
100       id: "openArticleURL",
101       _initialized: false,
102       _triggerHandler: null,
103       _hosts: new Set(),
104       _matchPatternSet: null,
105       readerModeEvent: "Reader:UpdateReaderButton",
107       init(triggerHandler, hosts, patterns) {
108         if (!this._initialized) {
109           this.receiveMessage = this.receiveMessage.bind(this);
110           lazy.AboutReaderParent.addMessageListener(this.readerModeEvent, this);
111           this._triggerHandler = triggerHandler;
112           this._initialized = true;
113         }
114         if (patterns) {
115           this._matchPatternSet = createMatchPatternSet([
116             ...(this._matchPatternSet ? this._matchPatternSet.patterns : []),
117             ...patterns,
118           ]);
119         }
120         if (hosts) {
121           hosts.forEach(h => this._hosts.add(h));
122         }
123       },
125       receiveMessage({ data, target }) {
126         if (data && data.isArticle) {
127           const match = checkURLMatch(target.currentURI, {
128             hosts: this._hosts,
129             matchPatternSet: this._matchPatternSet,
130           });
131           if (match) {
132             this._triggerHandler(target, { id: this.id, param: match });
133           }
134         }
135       },
137       uninit() {
138         if (this._initialized) {
139           lazy.AboutReaderParent.removeMessageListener(
140             this.readerModeEvent,
141             this
142           );
143           this._initialized = false;
144           this._triggerHandler = null;
145           this._hosts = new Set();
146           this._matchPatternSet = null;
147         }
148       },
149     },
150   ],
151   [
152     "openBookmarkedURL",
153     {
154       id: "openBookmarkedURL",
155       _initialized: false,
156       _triggerHandler: null,
157       _hosts: new Set(),
158       bookmarkEvent: "bookmark-icon-updated",
160       init(triggerHandler) {
161         if (!this._initialized) {
162           Services.obs.addObserver(this, this.bookmarkEvent);
163           this._triggerHandler = triggerHandler;
164           this._initialized = true;
165         }
166       },
168       observe(subject, topic, data) {
169         if (topic === this.bookmarkEvent && data === "starred") {
170           const browser = Services.wm.getMostRecentBrowserWindow();
171           if (browser) {
172             this._triggerHandler(browser.gBrowser.selectedBrowser, {
173               id: this.id,
174             });
175           }
176         }
177       },
179       uninit() {
180         if (this._initialized) {
181           Services.obs.removeObserver(this, this.bookmarkEvent);
182           this._initialized = false;
183           this._triggerHandler = null;
184           this._hosts = new Set();
185         }
186       },
187     },
188   ],
189   [
190     "frequentVisits",
191     {
192       id: "frequentVisits",
193       _initialized: false,
194       _triggerHandler: null,
195       _hosts: null,
196       _matchPatternSet: null,
197       _visits: null,
199       init(triggerHandler, hosts = [], patterns) {
200         if (!this._initialized) {
201           this.onTabSwitch = this.onTabSwitch.bind(this);
202           lazy.EveryWindow.registerCallback(
203             this.id,
204             win => {
205               if (!isPrivateWindow(win)) {
206                 win.addEventListener("TabSelect", this.onTabSwitch);
207                 win.gBrowser.addTabsProgressListener(this);
208               }
209             },
210             win => {
211               if (!isPrivateWindow(win)) {
212                 win.removeEventListener("TabSelect", this.onTabSwitch);
213                 win.gBrowser.removeTabsProgressListener(this);
214               }
215             }
216           );
217           this._visits = new Map();
218           this._initialized = true;
219         }
220         this._triggerHandler = triggerHandler;
221         if (patterns) {
222           this._matchPatternSet = createMatchPatternSet([
223             ...(this._matchPatternSet ? this._matchPatternSet.patterns : []),
224             ...patterns,
225           ]);
226         }
227         if (this._hosts) {
228           hosts.forEach(h => this._hosts.add(h));
229         } else {
230           this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour
231         }
232       },
234       /* _updateVisits - Record visit timestamps for websites that match `this._hosts` and only
235        * if it's been more than FEW_MINUTES since the last visit.
236        * @param {string} host - Location host of current selected tab
237        * @returns {boolean} - If the new visit has been recorded
238        */
239       _updateVisits(host) {
240         const visits = this._visits.get(host);
242         if (visits && Date.now() - visits[0] > FEW_MINUTES) {
243           this._visits.set(host, [Date.now(), ...visits]);
244           return true;
245         }
246         if (!visits) {
247           this._visits.set(host, [Date.now()]);
248           return true;
249         }
251         return false;
252       },
254       onTabSwitch(event) {
255         if (!event.target.ownerGlobal.gBrowser) {
256           return;
257         }
259         const { gBrowser } = event.target.ownerGlobal;
260         const match = checkURLMatch(gBrowser.currentURI, {
261           hosts: this._hosts,
262           matchPatternSet: this._matchPatternSet,
263         });
264         if (match) {
265           this.triggerHandler(gBrowser.selectedBrowser, match);
266         }
267       },
269       triggerHandler(aBrowser, match) {
270         const updated = this._updateVisits(match.host);
272         // If the previous visit happend less than FEW_MINUTES ago
273         // no updates were made, no need to trigger the handler
274         if (!updated) {
275           return;
276         }
278         this._triggerHandler(aBrowser, {
279           id: this.id,
280           param: match,
281           context: {
282             // Remapped to {host, timestamp} because JEXL operators can only
283             // filter over collections (arrays of objects)
284             recentVisits: this._visits
285               .get(match.host)
286               .map(timestamp => ({ host: match.host, timestamp })),
287           },
288         });
289       },
291       onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) {
292         // Some websites trigger redirect events after they finish loading even
293         // though the location remains the same. This results in onLocationChange
294         // events to be fired twice.
295         const isSameDocument = !!(
296           aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
297         );
298         if (aWebProgress.isTopLevel && !isSameDocument) {
299           const match = checkURLMatch(
300             aLocationURI,
301             { hosts: this._hosts, matchPatternSet: this._matchPatternSet },
302             aRequest
303           );
304           if (match) {
305             this.triggerHandler(aBrowser, match);
306           }
307         }
308       },
310       uninit() {
311         if (this._initialized) {
312           lazy.EveryWindow.unregisterCallback(this.id);
314           this._initialized = false;
315           this._triggerHandler = null;
316           this._hosts = null;
317           this._matchPatternSet = null;
318           this._visits = null;
319         }
320       },
321     },
322   ],
324   /**
325    * Attach listeners to every browser window to detect location changes, and
326    * notify the trigger handler whenever we navigate to a URL with a hostname
327    * we're looking for.
328    */
329   [
330     "openURL",
331     {
332       id: "openURL",
333       _initialized: false,
334       _triggerHandler: null,
335       _hosts: null,
336       _matchPatternSet: null,
337       _visits: null,
339       /*
340        * If the listener is already initialised, `init` will replace the trigger
341        * handler and add any new hosts to `this._hosts`.
342        */
343       init(triggerHandler, hosts = [], patterns) {
344         if (!this._initialized) {
345           this.onLocationChange = this.onLocationChange.bind(this);
346           lazy.EveryWindow.registerCallback(
347             this.id,
348             win => {
349               if (!isPrivateWindow(win)) {
350                 win.gBrowser.addTabsProgressListener(this);
351               }
352             },
353             win => {
354               if (!isPrivateWindow(win)) {
355                 win.gBrowser.removeTabsProgressListener(this);
356               }
357             }
358           );
360           this._visits = new Map();
361           this._initialized = true;
362         }
363         this._triggerHandler = triggerHandler;
364         if (patterns) {
365           this._matchPatternSet = createMatchPatternSet([
366             ...(this._matchPatternSet ? this._matchPatternSet.patterns : []),
367             ...patterns,
368           ]);
369         }
370         if (this._hosts) {
371           hosts.forEach(h => this._hosts.add(h));
372         } else {
373           this._hosts = new Set(hosts); // Clone the hosts to avoid unexpected behaviour
374         }
375       },
377       uninit() {
378         if (this._initialized) {
379           lazy.EveryWindow.unregisterCallback(this.id);
381           this._initialized = false;
382           this._triggerHandler = null;
383           this._hosts = null;
384           this._matchPatternSet = null;
385           this._visits = null;
386         }
387       },
389       onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) {
390         // Some websites trigger redirect events after they finish loading even
391         // though the location remains the same. This results in onLocationChange
392         // events to be fired twice.
393         const isSameDocument = !!(
394           aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
395         );
396         if (aWebProgress.isTopLevel && !isSameDocument) {
397           const match = checkURLMatch(
398             aLocationURI,
399             { hosts: this._hosts, matchPatternSet: this._matchPatternSet },
400             aRequest
401           );
402           if (match) {
403             let visitsCount = (this._visits.get(match.url) || 0) + 1;
404             this._visits.set(match.url, visitsCount);
405             this._triggerHandler(aBrowser, {
406               id: this.id,
407               param: match,
408               context: { visitsCount },
409             });
410           }
411         }
412       },
413     },
414   ],
416   /**
417    * Add an observer notification to notify the trigger handler whenever the user
418    * saves or updates a login via the login capture doorhanger.
419    */
420   [
421     "newSavedLogin",
422     {
423       _initialized: false,
424       _triggerHandler: null,
426       /**
427        * If the listener is already initialised, `init` will replace the trigger
428        * handler.
429        */
430       init(triggerHandler) {
431         if (!this._initialized) {
432           Services.obs.addObserver(this, "LoginStats:NewSavedPassword");
433           Services.obs.addObserver(this, "LoginStats:LoginUpdateSaved");
434           this._initialized = true;
435         }
436         this._triggerHandler = triggerHandler;
437       },
439       uninit() {
440         if (this._initialized) {
441           Services.obs.removeObserver(this, "LoginStats:NewSavedPassword");
442           Services.obs.removeObserver(this, "LoginStats:LoginUpdateSaved");
444           this._initialized = false;
445           this._triggerHandler = null;
446         }
447       },
449       observe(aSubject, aTopic, aData) {
450         if (aSubject.currentURI.asciiHost === "accounts.firefox.com") {
451           // Don't notify about saved logins on the FxA login origin since this
452           // trigger is used to promote login Sync and getting a recommendation
453           // to enable Sync during the sign up process is a bad UX.
454           return;
455         }
457         switch (aTopic) {
458           case "LoginStats:NewSavedPassword": {
459             this._triggerHandler(aSubject, {
460               id: "newSavedLogin",
461               context: { type: "save" },
462             });
463             break;
464           }
465           case "LoginStats:LoginUpdateSaved": {
466             this._triggerHandler(aSubject, {
467               id: "newSavedLogin",
468               context: { type: "update" },
469             });
470             break;
471           }
472           default: {
473             throw new Error(`Unexpected observer notification: ${aTopic}`);
474           }
475         }
476       },
477     },
478   ],
479   [
480     "formAutofill",
481     {
482       id: "formAutofill",
483       _initialized: false,
484       _triggerHandler: null,
485       _triggerDelay: 10000, // 10 second delay before triggering
486       _topic: "formautofill-storage-changed",
487       _events: ["add", "update", "notifyUsed"] /** @see AutofillRecords */,
488       _collections: ["addresses", "creditCards"] /** @see AutofillRecords */,
490       init(triggerHandler) {
491         if (!this._initialized) {
492           Services.obs.addObserver(this, this._topic);
493           this._initialized = true;
494         }
495         this._triggerHandler = triggerHandler;
496       },
498       uninit() {
499         if (this._initialized) {
500           Services.obs.removeObserver(this, this._topic);
501           this._initialized = false;
502           this._triggerHandler = null;
503         }
504       },
506       observe(subject, topic, data) {
507         const browser =
508           Services.wm.getMostRecentBrowserWindow()?.gBrowser.selectedBrowser;
509         if (
510           !browser ||
511           topic !== this._topic ||
512           !subject.wrappedJSObject ||
513           // Ignore changes caused by manual edits in the credit card/address
514           // managers in about:preferences.
515           browser.contentWindow?.gSubDialog?.dialogs.length
516         ) {
517           return;
518         }
519         let { sourceSync, collectionName } = subject.wrappedJSObject;
520         // Ignore changes from sync and changes to untracked collections.
521         if (sourceSync || !this._collections.includes(collectionName)) {
522           return;
523         }
524         if (this._events.includes(data)) {
525           let event = data;
526           let type = collectionName;
527           if (event === "notifyUsed") {
528             event = "use";
529           }
530           if (type === "creditCards") {
531             type = "card";
532           }
533           if (type === "addresses") {
534             type = "address";
535           }
536           lazy.setTimeout(() => {
537             if (
538               this._initialized &&
539               // Make sure the browser still exists and is still selected.
540               browser.isConnectedAndReady &&
541               browser ===
542                 Services.wm.getMostRecentBrowserWindow()?.gBrowser
543                   .selectedBrowser
544             ) {
545               this._triggerHandler(browser, {
546                 id: this.id,
547                 context: { event, type },
548               });
549             }
550           }, this._triggerDelay);
551         }
552       },
553     },
554   ],
556   [
557     "contentBlocking",
558     {
559       _initialized: false,
560       _triggerHandler: null,
561       _events: [],
562       _sessionPageLoad: 0,
563       onLocationChange: null,
565       init(triggerHandler, params, patterns) {
566         params.forEach(p => this._events.push(p));
568         if (!this._initialized) {
569           Services.obs.addObserver(this, "SiteProtection:ContentBlockingEvent");
570           Services.obs.addObserver(
571             this,
572             "SiteProtection:ContentBlockingMilestone"
573           );
574           this.onLocationChange = this._onLocationChange.bind(this);
575           lazy.EveryWindow.registerCallback(
576             this.id,
577             win => {
578               if (!isPrivateWindow(win)) {
579                 win.gBrowser.addTabsProgressListener(this);
580               }
581             },
582             win => {
583               if (!isPrivateWindow(win)) {
584                 win.gBrowser.removeTabsProgressListener(this);
585               }
586             }
587           );
589           this._initialized = true;
590         }
591         this._triggerHandler = triggerHandler;
592       },
594       uninit() {
595         if (this._initialized) {
596           Services.obs.removeObserver(
597             this,
598             "SiteProtection:ContentBlockingEvent"
599           );
600           Services.obs.removeObserver(
601             this,
602             "SiteProtection:ContentBlockingMilestone"
603           );
604           lazy.EveryWindow.unregisterCallback(this.id);
605           this.onLocationChange = null;
606           this._initialized = false;
607         }
608         this._triggerHandler = null;
609         this._events = [];
610         this._sessionPageLoad = 0;
611       },
613       observe(aSubject, aTopic, aData) {
614         switch (aTopic) {
615           case "SiteProtection:ContentBlockingEvent":
616             const { browser, host, event } = aSubject.wrappedJSObject;
617             if (this._events.filter(e => (e & event) === e).length) {
618               this._triggerHandler(browser, {
619                 id: "contentBlocking",
620                 param: {
621                   host,
622                   type: event,
623                 },
624                 context: {
625                   pageLoad: this._sessionPageLoad,
626                 },
627               });
628             }
629             break;
630           case "SiteProtection:ContentBlockingMilestone":
631             if (this._events.includes(aSubject.wrappedJSObject.event)) {
632               this._triggerHandler(
633                 Services.wm.getMostRecentBrowserWindow().gBrowser
634                   .selectedBrowser,
635                 {
636                   id: "contentBlocking",
637                   context: {
638                     pageLoad: this._sessionPageLoad,
639                   },
640                   param: {
641                     type: aSubject.wrappedJSObject.event,
642                   },
643                 }
644               );
645             }
646             break;
647         }
648       },
650       _onLocationChange(
651         aBrowser,
652         aWebProgress,
653         aRequest,
654         aLocationURI,
655         aFlags
656       ) {
657         // Some websites trigger redirect events after they finish loading even
658         // though the location remains the same. This results in onLocationChange
659         // events to be fired twice.
660         const isSameDocument = !!(
661           aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
662         );
663         if (
664           ["http", "https"].includes(aLocationURI.scheme) &&
665           aWebProgress.isTopLevel &&
666           !isSameDocument
667         ) {
668           this._sessionPageLoad += 1;
669         }
670       },
671     },
672   ],
674   [
675     "captivePortalLogin",
676     {
677       id: "captivePortalLogin",
678       _initialized: false,
679       _triggerHandler: null,
681       _shouldShowCaptivePortalVPNPromo() {
682         return lazy.BrowserUtils.shouldShowVPNPromo();
683       },
685       init(triggerHandler) {
686         if (!this._initialized) {
687           Services.obs.addObserver(this, "captive-portal-login-success");
688           this._initialized = true;
689         }
690         this._triggerHandler = triggerHandler;
691       },
693       observe(aSubject, aTopic, aData) {
694         switch (aTopic) {
695           case "captive-portal-login-success":
696             const browser = Services.wm.getMostRecentBrowserWindow();
697             // The check is here rather than in init because some
698             // folks leave their browsers running for a long time,
699             // eg from before leaving on a plane trip to after landing
700             // in the new destination, and the current region may have
701             // changed since init time.
702             if (browser && this._shouldShowCaptivePortalVPNPromo()) {
703               this._triggerHandler(browser.gBrowser.selectedBrowser, {
704                 id: this.id,
705               });
706             }
707             break;
708         }
709       },
711       uninit() {
712         if (this._initialized) {
713           this._triggerHandler = null;
714           this._initialized = false;
715           Services.obs.removeObserver(this, "captive-portal-login-success");
716         }
717       },
718     },
719   ],
721   [
722     "preferenceObserver",
723     {
724       id: "preferenceObserver",
725       _initialized: false,
726       _triggerHandler: null,
727       _observedPrefs: [],
729       init(triggerHandler, prefs) {
730         if (!this._initialized) {
731           this._triggerHandler = triggerHandler;
732           this._initialized = true;
733         }
734         prefs.forEach(pref => {
735           this._observedPrefs.push(pref);
736           Services.prefs.addObserver(pref, this);
737         });
738       },
740       observe(aSubject, aTopic, aData) {
741         switch (aTopic) {
742           case "nsPref:changed":
743             const browser = Services.wm.getMostRecentBrowserWindow();
744             if (browser && this._observedPrefs.includes(aData)) {
745               this._triggerHandler(browser.gBrowser.selectedBrowser, {
746                 id: this.id,
747                 param: {
748                   type: aData,
749                 },
750               });
751             }
752             break;
753         }
754       },
756       uninit() {
757         if (this._initialized) {
758           this._observedPrefs.forEach(pref =>
759             Services.prefs.removeObserver(pref, this)
760           );
761           this._initialized = false;
762           this._triggerHandler = null;
763           this._observedPrefs = [];
764         }
765       },
766     },
767   ],
768   [
769     "nthTabClosed",
770     {
771       id: "nthTabClosed",
772       _initialized: false,
773       _triggerHandler: null,
774       // Number of tabs the user closed this session
775       _closedTabs: 0,
777       init(triggerHandler) {
778         this._triggerHandler = triggerHandler;
779         if (!this._initialized) {
780           lazy.EveryWindow.registerCallback(
781             this.id,
782             win => {
783               win.addEventListener("TabClose", this);
784             },
785             win => {
786               win.removeEventListener("TabClose", this);
787             }
788           );
789           this._initialized = true;
790         }
791       },
792       handleEvent(event) {
793         if (this._initialized) {
794           if (!event.target.ownerGlobal.gBrowser) {
795             return;
796           }
797           const { gBrowser } = event.target.ownerGlobal;
798           this._closedTabs++;
799           this._triggerHandler(gBrowser.selectedBrowser, {
800             id: this.id,
801             context: { tabsClosedCount: this._closedTabs },
802           });
803         }
804       },
805       uninit() {
806         if (this._initialized) {
807           lazy.EveryWindow.unregisterCallback(this.id);
808           this._initialized = false;
809           this._triggerHandler = null;
810           this._closedTabs = 0;
811         }
812       },
813     },
814   ],
815   [
816     "activityAfterIdle",
817     {
818       id: "activityAfterIdle",
819       _initialized: false,
820       _triggerHandler: null,
821       _idleService: null,
822       // Optimization - only report idle state after one minute of idle time.
823       // This represents a minimum idleForMilliseconds of 60000.
824       _idleThreshold: 60,
825       _idleSince: null,
826       _quietSince: null,
827       _awaitingVisibilityChange: false,
828       // Fire the trigger 2 seconds after activity resumes to ensure user is
829       // actively using the browser when it fires.
830       _triggerDelay: 2000,
831       _triggerTimeout: null,
832       // We may get an idle notification immediately after waking from sleep.
833       // The idle time in such a case will be the amount of time since the last
834       // user interaction, which was before the computer went to sleep. We want
835       // to ignore them in that case, so we ignore idle notifications that
836       // happen within 1 second of the last wake notification.
837       _wakeDelay: 1000,
838       _lastWakeTime: null,
839       _listenedEvents: ["visibilitychange", "TabClose", "TabAttrModified"],
840       // When the OS goes to sleep or the process is suspended, we want to drop
841       // the idle time, since the time between sleep and wake is expected to be
842       // very long (e.g. overnight). Otherwise, this would trigger on the first
843       // activity after waking/resuming, counting sleep as idle time. This
844       // basically means each session starts with a fresh idle time.
845       _observedTopics: [
846         "sleep_notification",
847         "suspend_process_notification",
848         "wake_notification",
849         "resume_process_notification",
850         "mac_app_activate",
851       ],
853       get _isVisible() {
854         return [...Services.wm.getEnumerator("navigator:browser")].some(
855           win => !win.closed && !win.document?.hidden
856         );
857       },
858       get _soundPlaying() {
859         return [...Services.wm.getEnumerator("navigator:browser")].some(win =>
860           win.gBrowser?.tabs.some(tab => !tab.closing && tab.soundPlaying)
861         );
862       },
863       init(triggerHandler) {
864         this._triggerHandler = triggerHandler;
865         // Instantiate this here instead of with a lazy service getter so we can
866         // stub it in tests (otherwise we'd have to wait up to 6 minutes for an
867         // idle notification in certain test environments).
868         if (!this._idleService) {
869           this._idleService = Cc[
870             "@mozilla.org/widget/useridleservice;1"
871           ].getService(Ci.nsIUserIdleService);
872         }
873         if (
874           !this._initialized &&
875           !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing
876         ) {
877           this._idleService.addIdleObserver(this, this._idleThreshold);
878           for (let topic of this._observedTopics) {
879             Services.obs.addObserver(this, topic);
880           }
881           lazy.EveryWindow.registerCallback(
882             this.id,
883             win => {
884               for (let ev of this._listenedEvents) {
885                 win.addEventListener(ev, this);
886               }
887             },
888             win => {
889               for (let ev of this._listenedEvents) {
890                 win.removeEventListener(ev, this);
891               }
892             }
893           );
894           if (!this._soundPlaying) {
895             this._quietSince = Date.now();
896           }
897           this._initialized = true;
898           this.log("Initialized: ", {
899             idleTime: this._idleService.idleTime,
900             quietSince: this._quietSince,
901           });
902         }
903       },
904       observe(subject, topic, data) {
905         if (this._initialized) {
906           this.log("Heard observer notification: ", {
907             subject,
908             topic,
909             data,
910             idleTime: this._idleService.idleTime,
911             idleSince: this._idleSince,
912             quietSince: this._quietSince,
913             lastWakeTime: this._lastWakeTime,
914           });
915           switch (topic) {
916             case "idle":
917               const now = Date.now();
918               // If the idle notification is within 1 second of the last wake
919               // notification, ignore it. We do this to avoid counting time the
920               // computer spent asleep as "idle time"
921               const isImmediatelyAfterWake =
922                 this._lastWakeTime &&
923                 now - this._lastWakeTime < this._wakeDelay;
924               if (!isImmediatelyAfterWake) {
925                 this._idleSince = now - subject.idleTime;
926               }
927               break;
928             case "active":
929               // Trigger when user returns from being idle.
930               if (this._isVisible) {
931                 this._onActive();
932                 this._idleSince = null;
933                 this._lastWakeTime = null;
934               } else if (this._idleSince) {
935                 // If the window is not visible, we want to wait until it is
936                 // visible before triggering.
937                 this._awaitingVisibilityChange = true;
938               }
939               break;
940             // OS/process notifications
941             case "wake_notification":
942             case "resume_process_notification":
943             case "mac_app_activate":
944               this._lastWakeTime = Date.now();
945             // Fall through to reset idle time.
946             default:
947               this._idleSince = null;
948           }
949         }
950       },
951       handleEvent(event) {
952         if (this._initialized) {
953           switch (event.type) {
954             case "visibilitychange":
955               if (this._awaitingVisibilityChange && this._isVisible) {
956                 this._onActive();
957                 this._idleSince = null;
958                 this._lastWakeTime = null;
959                 this._awaitingVisibilityChange = false;
960               }
961               break;
962             case "TabAttrModified":
963               // Listen for DOMAudioPlayback* events.
964               if (!event.detail?.changed?.includes("soundplaying")) {
965                 break;
966               }
967             // fall through
968             case "TabClose":
969               this.log("Tab sound changed: ", {
970                 event,
971                 idleTime: this._idleService.idleTime,
972                 idleSince: this._idleSince,
973                 quietSince: this._quietSince,
974               });
975               // Maybe update time if a tab closes with sound playing.
976               if (this._soundPlaying) {
977                 this._quietSince = null;
978               } else if (!this._quietSince) {
979                 this._quietSince = Date.now();
980               }
981           }
982         }
983       },
984       _onActive() {
985         this.log("User is active: ", {
986           idleTime: this._idleService.idleTime,
987           idleSince: this._idleSince,
988           quietSince: this._quietSince,
989           lastWakeTime: this._lastWakeTime,
990         });
991         if (this._idleSince && this._quietSince) {
992           const win = Services.wm.getMostRecentBrowserWindow();
993           if (win && !isPrivateWindow(win) && !this._triggerTimeout) {
994             // Time since the most recent user interaction/audio playback,
995             // reported as the number of milliseconds the user has been idle.
996             const idleForMilliseconds =
997               Date.now() - Math.max(this._idleSince, this._quietSince);
998             this._triggerTimeout = lazy.setTimeout(() => {
999               this._triggerHandler(win.gBrowser.selectedBrowser, {
1000                 id: this.id,
1001                 context: { idleForMilliseconds },
1002               });
1003               this._triggerTimeout = null;
1004             }, this._triggerDelay);
1005           }
1006         }
1007       },
1008       uninit() {
1009         if (this._initialized) {
1010           this._idleService.removeIdleObserver(this, this._idleThreshold);
1011           for (let topic of this._observedTopics) {
1012             Services.obs.removeObserver(this, topic);
1013           }
1014           lazy.EveryWindow.unregisterCallback(this.id);
1015           lazy.clearTimeout(this._triggerTimeout);
1016           this._triggerTimeout = null;
1017           this._initialized = false;
1018           this._triggerHandler = null;
1019           this._idleSince = null;
1020           this._quietSince = null;
1021           this._lastWakeTime = null;
1022           this._awaitingVisibilityChange = false;
1023           this.log("Uninitialized");
1024         }
1025       },
1026       log(...args) {
1027         lazy.log.debug("Idle trigger :>>", ...args);
1028       },
1030       QueryInterface: ChromeUtils.generateQI([
1031         "nsIObserver",
1032         "nsISupportsWeakReference",
1033       ]),
1034     },
1035   ],
1036   [
1037     "cookieBannerDetected",
1038     {
1039       id: "cookieBannerDetected",
1040       _initialized: false,
1041       _triggerHandler: null,
1043       init(triggerHandler) {
1044         this._triggerHandler = triggerHandler;
1045         if (!this._initialized) {
1046           lazy.EveryWindow.registerCallback(
1047             this.id,
1048             win => {
1049               win.addEventListener("cookiebannerdetected", this);
1050             },
1051             win => {
1052               win.removeEventListener("cookiebannerdetected", this);
1053             }
1054           );
1055           this._initialized = true;
1056         }
1057       },
1058       handleEvent(event) {
1059         if (this._initialized) {
1060           const win = event.target || Services.wm.getMostRecentBrowserWindow();
1061           if (!win) {
1062             return;
1063           }
1064           this._triggerHandler(win.gBrowser.selectedBrowser, {
1065             id: this.id,
1066           });
1067         }
1068       },
1069       uninit() {
1070         if (this._initialized) {
1071           lazy.EveryWindow.unregisterCallback(this.id);
1072           this._initialized = false;
1073           this._triggerHandler = null;
1074         }
1075       },
1076     },
1077   ],
1078   [
1079     "cookieBannerHandled",
1080     {
1081       id: "cookieBannerHandled",
1082       _initialized: false,
1083       _triggerHandler: null,
1085       init(triggerHandler) {
1086         this._triggerHandler = triggerHandler;
1087         if (!this._initialized) {
1088           lazy.EveryWindow.registerCallback(
1089             this.id,
1090             win => {
1091               win.addEventListener("cookiebannerhandled", this);
1092             },
1093             win => {
1094               win.removeEventListener("cookiebannerhandled", this);
1095             }
1096           );
1097           this._initialized = true;
1098         }
1099       },
1100       handleEvent(event) {
1101         if (this._initialized) {
1102           const browser =
1103             event.detail.windowContext.rootFrameLoader?.ownerElement;
1104           const win = browser?.ownerGlobal;
1105           // We only want to show messages in the active browser window.
1106           if (
1107             win === Services.wm.getMostRecentBrowserWindow() &&
1108             browser === win.gBrowser.selectedBrowser
1109           ) {
1110             this._triggerHandler(browser, { id: this.id });
1111           }
1112         }
1113       },
1114       uninit() {
1115         if (this._initialized) {
1116           lazy.EveryWindow.unregisterCallback(this.id);
1117           this._initialized = false;
1118           this._triggerHandler = null;
1119         }
1120       },
1121     },
1122   ],
1123   [
1124     "pdfJsFeatureCalloutCheck",
1125     {
1126       id: "pdfJsFeatureCalloutCheck",
1127       _initialized: false,
1128       _triggerHandler: null,
1129       _callouts: new WeakMap(),
1131       init(triggerHandler) {
1132         if (!this._initialized) {
1133           this.onLocationChange = this.onLocationChange.bind(this);
1134           this.onStateChange = this.onLocationChange;
1135           lazy.EveryWindow.registerCallback(
1136             this.id,
1137             win => {
1138               this.onBrowserWindow(win);
1139               win.addEventListener("TabSelect", this);
1140               win.addEventListener("TabClose", this);
1141               win.addEventListener("SSTabRestored", this);
1142               win.gBrowser.addTabsProgressListener(this);
1143             },
1144             win => {
1145               win.removeEventListener("TabSelect", this);
1146               win.removeEventListener("TabClose", this);
1147               win.removeEventListener("SSTabRestored", this);
1148               win.gBrowser.removeTabsProgressListener(this);
1149             }
1150           );
1151           this._initialized = true;
1152         }
1153         this._triggerHandler = triggerHandler;
1154       },
1156       uninit() {
1157         if (this._initialized) {
1158           lazy.EveryWindow.unregisterCallback(this.id);
1159           this._initialized = false;
1160           this._triggerHandler = null;
1161           for (let key of ChromeUtils.nondeterministicGetWeakMapKeys(
1162             this._callouts
1163           )) {
1164             const item = this._callouts.get(key);
1165             if (item) {
1166               item.callout.endTour(true);
1167               item.cleanup();
1168               this._callouts.delete(key);
1169             }
1170           }
1171         }
1172       },
1174       async showFeatureCalloutTour(win, browser, panelId, context) {
1175         const result = await this._triggerHandler(browser, {
1176           id: "pdfJsFeatureCalloutCheck",
1177           context,
1178         });
1179         if (result.message.trigger) {
1180           const callout = lazy.FeatureCalloutBroker.showCustomFeatureCallout(
1181             {
1182               win,
1183               browser,
1184               pref: {
1185                 name:
1186                   result.message.content?.tour_pref_name ??
1187                   "browser.pdfjs.feature-tour",
1188                 defaultValue: result.message.content?.tour_pref_default_value,
1189               },
1190               location: "pdfjs",
1191               theme: { preset: "pdfjs", simulateContent: true },
1192               cleanup: () => {
1193                 this._callouts.delete(win);
1194               },
1195             },
1196             result.message
1197           );
1198           if (callout) {
1199             callout.panelId = panelId;
1200             this._callouts.set(win, callout);
1201           }
1202         }
1203       },
1205       onLocationChange(browser) {
1206         const tabbrowser = browser.getTabBrowser();
1207         if (browser !== tabbrowser.selectedBrowser) {
1208           return;
1209         }
1210         const win = tabbrowser.ownerGlobal;
1211         const tab = tabbrowser.selectedTab;
1212         const existingCallout = this._callouts.get(win);
1213         const isPDFJS =
1214           browser.contentPrincipal.originNoSuffix === "resource://pdf.js";
1215         if (
1216           existingCallout &&
1217           (existingCallout.panelId !== tab.linkedPanel || !isPDFJS)
1218         ) {
1219           existingCallout.callout.endTour(true);
1220           existingCallout.cleanup();
1221         }
1222         if (!this._callouts.has(win) && isPDFJS) {
1223           this.showFeatureCalloutTour(win, browser, tab.linkedPanel, {
1224             source: "open",
1225           });
1226         }
1227       },
1229       handleEvent(event) {
1230         const tab = event.target;
1231         const win = tab.ownerGlobal;
1232         const { gBrowser } = win;
1233         if (!gBrowser) {
1234           return;
1235         }
1236         switch (event.type) {
1237           case "SSTabRestored":
1238             if (tab !== gBrowser.selectedTab) {
1239               return;
1240             }
1241           // fall through
1242           case "TabSelect": {
1243             const browser = gBrowser.getBrowserForTab(tab);
1244             const existingCallout = this._callouts.get(win);
1245             const isPDFJS =
1246               browser.contentPrincipal.originNoSuffix === "resource://pdf.js";
1247             if (
1248               existingCallout &&
1249               (existingCallout.panelId !== tab.linkedPanel || !isPDFJS)
1250             ) {
1251               existingCallout.callout.endTour(true);
1252               existingCallout.cleanup();
1253             }
1254             if (!this._callouts.has(win) && isPDFJS) {
1255               this.showFeatureCalloutTour(win, browser, tab.linkedPanel, {
1256                 source: "open",
1257               });
1258             }
1259             break;
1260           }
1261           case "TabClose": {
1262             const existingCallout = this._callouts.get(win);
1263             if (
1264               existingCallout &&
1265               existingCallout.panelId === tab.linkedPanel
1266             ) {
1267               existingCallout.callout.endTour(true);
1268               existingCallout.cleanup();
1269             }
1270             break;
1271           }
1272         }
1273       },
1275       onBrowserWindow(win) {
1276         this.onLocationChange(win.gBrowser.selectedBrowser);
1277       },
1278     },
1279   ],
1280   [
1281     "newtabFeatureCalloutCheck",
1282     {
1283       id: "newtabFeatureCalloutCheck",
1284       _initialized: false,
1285       _triggerHandler: null,
1286       _callouts: new WeakMap(),
1288       init(triggerHandler) {
1289         if (!this._initialized) {
1290           this.onLocationChange = this.onLocationChange.bind(this);
1291           this.onStateChange = this.onLocationChange;
1292           lazy.EveryWindow.registerCallback(
1293             this.id,
1294             win => {
1295               this.onBrowserWindow(win);
1296               win.addEventListener("TabSelect", this);
1297               win.addEventListener("TabClose", this);
1298               win.addEventListener("SSTabRestored", this);
1299               win.gBrowser.addTabsProgressListener(this);
1300             },
1301             win => {
1302               win.removeEventListener("TabSelect", this);
1303               win.removeEventListener("TabClose", this);
1304               win.removeEventListener("SSTabRestored", this);
1305               win.gBrowser.removeTabsProgressListener(this);
1306             }
1307           );
1308           this._initialized = true;
1309         }
1310         this._triggerHandler = triggerHandler;
1311       },
1313       uninit() {
1314         if (this._initialized) {
1315           lazy.EveryWindow.unregisterCallback(this.id);
1316           this._initialized = false;
1317           this._triggerHandler = null;
1318           for (let key of ChromeUtils.nondeterministicGetWeakMapKeys(
1319             this._callouts
1320           )) {
1321             const item = this._callouts.get(key);
1322             if (item) {
1323               item.callout.endTour(true);
1324               item.cleanup();
1325               this._callouts.delete(key);
1326             }
1327           }
1328         }
1329       },
1331       async showFeatureCalloutTour(win, browser, panelId, context) {
1332         const result = await this._triggerHandler(browser, {
1333           id: "newtabFeatureCalloutCheck",
1334           context,
1335         });
1336         if (result.message.trigger) {
1337           const callout = lazy.FeatureCalloutBroker.showCustomFeatureCallout(
1338             {
1339               win,
1340               browser,
1341               pref: {
1342                 name:
1343                   result.message.content?.tour_pref_name ??
1344                   "browser.newtab.feature-tour",
1345                 defaultValue: result.message.content?.tour_pref_default_value,
1346               },
1347               location: "newtab",
1348               theme: { preset: "newtab", simulateContent: true },
1349               cleanup: () => {
1350                 this._callouts.delete(win);
1351               },
1352             },
1353             result.message
1354           );
1355           if (callout) {
1356             callout.panelId = panelId;
1357             this._callouts.set(win, callout);
1358           }
1359         }
1360       },
1362       onLocationChange(browser) {
1363         const tabbrowser = browser.getTabBrowser();
1364         if (browser !== tabbrowser.selectedBrowser) {
1365           return;
1366         }
1367         const win = tabbrowser.ownerGlobal;
1368         const tab = tabbrowser.selectedTab;
1369         const existingCallout = this._callouts.get(win);
1370         const isNewtabOrHome =
1371           browser.currentURI.spec.startsWith("about:home") ||
1372           browser.currentURI.spec.startsWith("about:newtab");
1373         if (
1374           existingCallout &&
1375           (existingCallout.panelId !== tab.linkedPanel || !isNewtabOrHome)
1376         ) {
1377           existingCallout.callout.endTour(true);
1378           existingCallout.cleanup();
1379         }
1380         if (!this._callouts.has(win) && isNewtabOrHome && tab.linkedPanel) {
1381           this.showFeatureCalloutTour(win, browser, tab.linkedPanel, {
1382             source: "open",
1383           });
1384         }
1385       },
1387       handleEvent(event) {
1388         const tab = event.target;
1389         const win = tab.ownerGlobal;
1390         const { gBrowser } = win;
1391         if (!gBrowser) {
1392           return;
1393         }
1394         switch (event.type) {
1395           case "SSTabRestored":
1396             if (tab !== gBrowser.selectedTab) {
1397               return;
1398             }
1399           // fall through
1400           case "TabSelect": {
1401             const browser = gBrowser.getBrowserForTab(tab);
1402             const existingCallout = this._callouts.get(win);
1403             const isNewtabOrHome =
1404               browser.currentURI.spec.startsWith("about:home") ||
1405               browser.currentURI.spec.startsWith("about:newtab");
1406             if (
1407               existingCallout &&
1408               (existingCallout.panelId !== tab.linkedPanel || !isNewtabOrHome)
1409             ) {
1410               existingCallout.callout.endTour(true);
1411               existingCallout.cleanup();
1412             }
1413             if (!this._callouts.has(win) && isNewtabOrHome) {
1414               this.showFeatureCalloutTour(win, browser, tab.linkedPanel, {
1415                 source: "open",
1416               });
1417             }
1418             break;
1419           }
1420           case "TabClose": {
1421             const existingCallout = this._callouts.get(win);
1422             if (
1423               existingCallout &&
1424               existingCallout.panelId === tab.linkedPanel
1425             ) {
1426               existingCallout.callout.endTour(true);
1427               existingCallout.cleanup();
1428             }
1429             break;
1430           }
1431         }
1432       },
1434       onBrowserWindow(win) {
1435         this.onLocationChange(win.gBrowser.selectedBrowser);
1436       },
1437     },
1438   ],