/** * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; /** @import { CanonicalURLParent } from "./CanonicalURLParent.sys.mjs" */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs", TabNotes: "moz-src:///browser/components/tabnotes/TabNotes.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { return console.createInstance({ prefix: "TabNotesController", maxLogLevel: Services.prefs.getBoolPref("browser.tabs.notes.debug", false) ? "Debug" : "Warn", }); }); const EVENTS = [ "CanonicalURL:Identified", "TabNote:Created", "TabNote:Edited", "TabNote:Removed", "TabNote:Expand", ]; /** * Orchestrates the tab notes life cycle. * * Singleton class within Firefox that observes tab notes-related topics, * listens for tab notes-related events, and ensures that the state of the * tabbrowser stays in sync with the TabNotes repository. * * Registers with the category manager in order to initialize on Firefox * startup and be notified when windows are opened/closed. * * @see https://firefox-source-docs.mozilla.org/browser/CategoryManagerIndirection.html * * Expected life cycle from Firefox startup: * - One call to `browserWindowDelayedStartup` from the first browser window created. Since * we haven't gotten far enough along in startup yet, we do nothing. * - One call to `browserFirstWindowReady` when the first window has loaded. If tab notes * is enabled, then we initialize TabNotes and the first window. * - As windows are opened (`browserWindowDelayedStartup`) or closed (`browserWindowUnload`), * we register/unregister from canonical URL and tab notes events. * - As the user interacts with the browser, this class mediates between TabNotes storage and * Tabbrowser state to make sure everything stays consistent. * - If the tab notes preference is enabled/disabled, the DesktopActorRegistry will start/stop * the CanonicalURL actor and this class will observe those messages. This class is responsible * for stopping/starting the TabNotes storage connection. * - When the user quits, `browserQuitApplicationGranted` will stop the TabNotes storage * connection. */ class TabNotesControllerClass { /** @type {boolean} */ TAB_NOTES_ENABLED; /** Whether `browser-first-window-ready` has been received on startup yet. */ #isStartupComplete = false; /** Whether the tab notes machinery is currently wired up and running. */ #isInitialized = false; /** * Registered with `browser-first-window-ready` to be notified of * app startup. * * @see tabnotes.manifest */ browserFirstWindowReady() { XPCOMUtils.defineLazyPreferenceGetter( this, "TAB_NOTES_ENABLED", "browser.tabs.notes.enabled", false ); this.#isStartupComplete = true; Services.obs.addObserver(this, "CanonicalURL:ActorRegistered"); Services.obs.addObserver(this, "CanonicalURL:ActorUnregistered"); if (this.TAB_NOTES_ENABLED) { lazy.logConsole.debug("browserFirstWindowReady", "Tab notes enabled"); return this.#init().then(() => { for (const win of lazy.BrowserWindowTracker.orderedWindows) { this.#initWindow(win); } }); } lazy.logConsole.debug("browserFirstWindowReady", "Tab notes disabled"); return Promise.resolve(); } /** * Starts up application-level systems in order to make sure tab notes are usable. * * @returns {Promise} */ async #init() { if (!this.#isInitialized) { this.#isInitialized = true; lazy.logConsole.debug("TabNotes initialized"); return lazy.TabNotes.init(); } return Promise.resolve(); } /** * Registered with `browser-window-delayed-startup` to be notified of new * windows. * * @param {Window} win * @see tabnotes.manifest */ browserWindowDelayedStartup(win) { lazy.logConsole.debug("browserWindowDelayedStartup", win); if (!this.#isStartupComplete) { lazy.logConsole.debug( "browserWindowDelayedStartup", "initialization deferred until startup complete" ); return; } if (this.TAB_NOTES_ENABLED) { this.#initWindow(win); } } /** * Initializes a specific window in order to hook into the life cycle of tabs * and tab notes. * * @param {Window} win */ #initWindow(win) { EVENTS.forEach(eventName => win.addEventListener(eventName, this)); win.gBrowser.addTabsProgressListener(this); // check tabs that may have had canonicalUrl restored from session data for (const tab of win.gBrowser.tabs) { if (tab.canonicalUrl && lazy.TabNotes.isEligible(tab)) { lazy.TabNotes.has(tab).then(hasTabNote => { tab.hasTabNote = hasTabNote; }); } } lazy.logConsole.debug("initWindow", win, EVENTS); } /** * Registered with `browser-window-unload` to be notified of unloaded windows. * * @param {Window} win * @see tabnotes.manifest */ browserWindowUnload(win) { if (this.TAB_NOTES_ENABLED) { this.#unloadWindow(win); lazy.logConsole.debug("browserWindowUnload", EVENTS, win); } } /** * Stops listening to the life cycle of tabs and tab notes for a particular * window. * * @param {Window} win */ #unloadWindow(win) { EVENTS.forEach(eventName => win.removeEventListener(eventName, this)); win.gBrowser.removeTabsProgressListener(this); lazy.logConsole.debug("unloadWindow", win, EVENTS); } /** * Registered with `browser-quit-application-granted` to be notified of * app shutdown. * * @see tabnotes.manifest */ browserQuitApplicationGranted() { return this.#deinit(); } /** * Shuts down application-level systems in order to make sure tab notes are * no longer using system resources. * * @returns {Promise} */ async #deinit() { if (this.#isInitialized) { this.#isInitialized = false; lazy.logConsole.debug("TabNotes deinitialized"); } return lazy.TabNotes.deinit(); } /** * @param {CanonicalURLIdentifiedEvent|TabNoteCreatedEvent|TabNoteEditedEvent|TabNoteRemovedEvent|TabNoteExpandEvent} event */ handleEvent(event) { switch (event.type) { case "CanonicalURL:Identified": { // A browser identified its canonical URL, so we can determine whether // the tab has an associated note and should therefore display a tab // notes icon. const browser = event.target; const { canonicalUrl } = event.detail; const gBrowser = browser.getTabBrowser(); /** @type {MozTabbrowserTab} */ const tab = gBrowser.getTabForBrowser(browser); tab.canonicalUrl = canonicalUrl; lazy.TabNotes.has(tab).then(hasTabNote => { tab.hasTabNote = hasTabNote; lazy.logConsole.debug("TabNote:Determined", tab, hasTabNote); tab.dispatchEvent( new CustomEvent("TabNote:Determined", { detail: { hasTabNote }, }) ); }); lazy.logConsole.debug("CanonicalURL:Identified", tab, canonicalUrl); } break; case "TabNote:Created": { const { note, telemetrySource } = event.detail; if (telemetrySource) { Glean.tabNotes.added.record({ source: telemetrySource, note_length: note.text.length, }); } // A new tab note was created for a specific canonical URL. Ensure that // all tabs with the same canonical URL also indicate that there is a // tab note. const { canonicalUrl } = event.target; for (const win of lazy.BrowserWindowTracker.orderedWindows) { for (const tab of win.gBrowser.tabs) { if (tab.canonicalUrl == canonicalUrl) { tab.hasTabNote = true; } } } lazy.logConsole.debug("TabNote:Created", canonicalUrl); } break; case "TabNote:Edited": { const { canonicalUrl } = event.target; const { note, telemetrySource } = event.detail; if (telemetrySource) { Glean.tabNotes.edited.record({ source: telemetrySource, note_length: note.text.length, }); } lazy.logConsole.debug("TabNote:Edited", canonicalUrl); } break; case "TabNote:Removed": { const { telemetrySource, note } = event.detail; const now = Temporal.Now.instant(); const noteAgeHours = Math.round( now.since(note.created).total("hours") ); if (telemetrySource) { Glean.tabNotes.deleted.record({ source: telemetrySource, note_age_hours: noteAgeHours, }); } // A tab note was removed from a specific canonical URL. Ensure that // all tabs with the same canonical URL also indicate that there is no // longer a tab note. const { canonicalUrl } = event.target; for (const win of lazy.BrowserWindowTracker.orderedWindows) { for (const tab of win.gBrowser.tabs) { if (tab.canonicalUrl == canonicalUrl) { tab.hasTabNote = false; } } } lazy.logConsole.debug("TabNote:Removed", canonicalUrl); } break; case "TabNote:Expand": { const tab = event.target; lazy.TabNotes.get(tab).then(note => { if (note) { lazy.logConsole.debug("TabNote:Expand", note); Glean.tabNotes.expanded.record({ note_length: note.text.length }); } }); } } } /** * Observes messages from the DesktopActorRegistry about the CanonicalURL actor * being registered and unregistered. Instead of observing the tab notes preference * directly, we instead have the DesktopActorRegistry observe the tab notes pref, * manage the CanonicalURL actor, and only then tell us when the CanonicalURL actor * is in a state that we can work with. * * @type {Extract} */ observe(_aSubject, aTopic) { switch (aTopic) { case "CanonicalURL:ActorRegistered": // Tab notes pref was flipped from disabled to enabled while the // browser was running, and now the CanonicalURL actor has been // registered with the browser. // Ask all browsers to report their canonical URLs, if possible. lazy.logConsole.debug( "CanonicalURL actor registered, requesting canonical URLs" ); this.#init() .then(() => { for (const win of lazy.BrowserWindowTracker.orderedWindows) { this.#initWindow(win); for (const tab of win.gBrowser.tabs) { try { /** @type {CanonicalURLParent|undefined} */ let parent = tab.linkedBrowser.browsingContext?.currentWindowGlobal.getActor( "CanonicalURL" ); parent?.sendAsyncMessage("CanonicalURL:Detect"); } catch (e) { if ( DOMException.isInstance(e) && e.message.includes("Window protocol") ) { // Tab is on a URL that doesn't support the CanonicalURL // actor (e.g. about:config) and that's OK. } else { lazy.logConsole.error(e); } } } } }) .then(() => { Services.obs.notifyObservers(null, "TabNote:Enabled"); }); break; case "CanonicalURL:ActorUnregistered": // Tab notes pref was flipped from enabled to disabled while the // browser was running, and now the CanonicalURL actor has been // unregistered with the browser. // Reset the canonical URL and tab notes state for all tabs. lazy.logConsole.debug( "CanonicalURL actor unregistered, clearing all tabs" ); this.#deinit() .then(() => { for (const win of lazy.BrowserWindowTracker.orderedWindows) { this.#unloadWindow(win); for (const tab of win.gBrowser.tabs) { this.#resetTab(tab); } } }) .then(() => { Services.obs.notifyObservers(null, "TabNote:Disabled"); }); break; } } /** * Invoked by Tabbrowser after we register with `Tabbrowser.addProgressListener`. * * Clears the tab note icon and canonical URL from a tab when navigation starts * within a tab. * * The CanonicalURL actor running in this tab's browser will report the canonical * URL of the new destination in the browser, which will determine whether the * new destination has an associated tab note. * * @type {TabbrowserWebProgressListener<"onLocationChange">} */ onLocationChange(aBrowser, aWebProgress, aRequest, aLocation, aFlags) { // Tab notes only apply to the top-level frames loaded in tabs. if (!aWebProgress.isTopLevel) { return; } if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) { if ( aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_RELOAD || aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_HISTORY ) { // User is reloading or returning to the same document via history. We // can count on CanonicalURLChild to listen for `pageshow` (for traditional // web sites) or `popstate` (for single-page applications) and tell us // about the canonical URL at the new location. lazy.logConsole.debug( "reload/history navigation, waiting for pageshow or popstate", aLocation.spec ); return; } if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_HASHCHANGE) { // The web site modified the hash/fragment identifier part of the URL // directly. TODO: determine how and whether to handle `hashchange`. lazy.logConsole.debug("fragment identifier changed", aLocation.spec); return; } if (aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_PUSHSTATE) { // Web page is using `history.pushState()` to change URLs. There isn't // a way for CanonicalURLChild to detect this in the content process, // so we need to ask it to recalculate canonical URLs to see if they // changed. /** @type {CanonicalURLParent|undefined} */ let parent = aBrowser.browsingContext?.currentWindowGlobal.getExistingActor( "CanonicalURL" ); if (parent) { parent.sendAsyncMessage("CanonicalURL:DetectFromPushState", { pushStateUrl: aLocation.spec, }); lazy.logConsole.debug( "requesting CanonicalURL:DetectFromPushState due to history.pushState", aLocation.spec ); } return; } // General same document case: we are navigating in the same document, // so the tab note indicator does not need to change. return; } if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SESSION_STORE) { // Location was changed as part of a session restoration. In this case // the canonical URL was already retrieved from session data. lazy.logConsole.debug("preserving tab note state during session restore"); return; } // General case: we are doing normal navigation to another URL, so we // clear the canonical URL/tab note state on the tab and wait for // `CanonicalURL:Identified` to tell us whether the new location has // a tab note. const tab = aBrowser.documentGlobal.gBrowser.getTabForBrowser(aBrowser); this.#resetTab(tab); lazy.logConsole.debug("clear tab note due to location change", tab); } /** * @param {MozTabbrowserTab} tab */ #resetTab(tab) { delete tab.canonicalUrl; tab.hasTabNote = false; } } export const TabNotesController = new TabNotesControllerClass();