/* 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/. */ "use strict"; /** * NOTE: If you change the globals in this file, you must check if the globals * list in mobile/android/.eslintrc.js also needs updating. */ ChromeUtils.defineESModuleGetters(this, { GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", }); var { EventDispatcher } = ChromeUtils.importESModule( "resource://gre/modules/Messaging.sys.mjs" ); var { ExtensionCommon } = ChromeUtils.importESModule( "resource://gre/modules/ExtensionCommon.sys.mjs" ); var { ExtensionUtils } = ChromeUtils.importESModule( "resource://gre/modules/ExtensionUtils.sys.mjs" ); var { DefaultWeakMap, ExtensionError } = ExtensionUtils; var { defineLazyGetter } = ExtensionCommon; const BrowserStatusFilter = Components.Constructor( "@mozilla.org/appshell/component/browser-status-filter;1", "nsIWebProgress", "addProgressListener" ); const WINDOW_TYPE = "navigator:geckoview"; // We need let to break cyclic dependency /* eslint-disable-next-line prefer-const */ let windowTracker; /** * A nsIWebProgressListener for a specific XUL browser, which delegates the * events that it receives to a tab progress listener, and prepends the browser * to their arguments list. * * @param {XULElement} browser * A XUL browser element. * @param {object} listener * A tab progress listener object. * @param {integer} flags * The web progress notification flags with which to filter events. */ class BrowserProgressListener { constructor(browser, listener, flags) { this.listener = listener; this.browser = browser; this.filter = new BrowserStatusFilter(this, flags); this.browser.addProgressListener(this.filter, flags); } /** * Destroy the listener, and perform any necessary cleanup. */ destroy() { this.browser.removeProgressListener(this.filter); this.filter.removeProgressListener(this); } /** * Calls the appropriate listener in the wrapped tab progress listener, with * the wrapped XUL browser object as its first argument, and the additional * arguments in `args`. * * @param {string} method * The name of the nsIWebProgressListener method which is being * delegated. * @param {*} args * The arguments to pass to the delegated listener. * @private */ delegate(method, ...args) { if (this.listener[method]) { this.listener[method](this.browser, ...args); } } onLocationChange(webProgress, request, locationURI, flags) { const window = this.browser.ownerGlobal; // GeckoView windows can become popups at any moment, so we need to check // here if (!windowTracker.isBrowserWindow(window)) { return; } this.delegate("onLocationChange", webProgress, request, locationURI, flags); } onStateChange(webProgress, request, stateFlags, status) { this.delegate("onStateChange", webProgress, request, stateFlags, status); } } const PROGRESS_LISTENER_FLAGS = Ci.nsIWebProgress.NOTIFY_STATE_ALL | Ci.nsIWebProgress.NOTIFY_LOCATION; class ProgressListenerWrapper { constructor(window, listener) { this.listener = new BrowserProgressListener( window.browser, listener, PROGRESS_LISTENER_FLAGS ); } destroy() { this.listener.destroy(); } } class WindowTracker extends WindowTrackerBase { constructor(...args) { super(...args); this.progressListeners = new DefaultWeakMap(() => new WeakMap()); } getCurrentWindow(context) { // In GeckoView the popup is on a separate window so getCurrentWindow for // the popup should return whatever is the topWindow. if (context?.viewType === "popup") { return this.topWindow; } return super.getCurrentWindow(context); } get topWindow() { return mobileWindowTracker.topWindow; } get topNonPBWindow() { return mobileWindowTracker.topNonPBWindow; } isBrowserWindow(window) { const { documentElement } = window.document; return documentElement.getAttribute("windowtype") === WINDOW_TYPE; } addProgressListener(window, listener) { const listeners = this.progressListeners.get(window); if (!listeners.has(listener)) { const wrapper = new ProgressListenerWrapper(window, listener); listeners.set(listener, wrapper); } } removeProgressListener(window, listener) { const listeners = this.progressListeners.get(window); const wrapper = listeners.get(listener); if (wrapper) { wrapper.destroy(); listeners.delete(listener); } } } /** * Helper to create an event manager which listens for an event in the Android * global EventDispatcher, and calls the given listener function whenever the * event is received. That listener function receives a `fire` object, * which it can use to dispatch events to the extension, and an object * detailing the EventDispatcher event that was received. * * @param {BaseContext} context * The extension context which the event manager belongs to. * @param {string} name * The API name of the event manager, e.g.,"runtime.onMessage". * @param {string} event * The name of the EventDispatcher event to listen for. * @param {Function} listener * The listener function to call when an EventDispatcher event is * recieved. * * @returns {object} An injectable api for the new event. */ global.makeGlobalEvent = function makeGlobalEvent( context, name, event, listener ) { return new EventManager({ context, name, register: fire => { const listener2 = { onEvent(event, data) { listener(fire, data); }, }; EventDispatcher.instance.registerListener(listener2, [event]); return () => { EventDispatcher.instance.unregisterListener(listener2, [event]); }; }, }).api(); }; class TabTracker extends TabTrackerBase { init() { if (this.initialized) { return; } this.initialized = true; windowTracker.addOpenListener(window => { const nativeTab = window.tab; this.emit("tab-created", { nativeTab }); }); windowTracker.addCloseListener(window => { const { tab: nativeTab, browser } = window; const { windowId, tabId } = this.getBrowserData(browser); this.emit("tab-removed", { nativeTab, tabId, windowId, // In GeckoView, it is not meaningful to speak of "window closed", because a tab is a window. // Until we have a meaningful way to group tabs (and close multiple tabs at once), // let's use isWindowClosing: false isWindowClosing: false, }); }); } getId(nativeTab) { return nativeTab.id; } getTab(id, default_ = undefined) { const windowId = GeckoViewTabBridge.tabIdToWindowId(id); const window = windowTracker.getWindow(windowId, null, false); if (window) { const { tab } = window; if (tab) { return tab; } } if (default_ !== undefined) { return default_; } throw new ExtensionError(`Invalid tab ID: ${id}`); } getBrowserData(browser) { const window = browser.ownerGlobal; const tab = window?.tab; if (!tab) { return { tabId: -1, windowId: -1, }; } const windowId = windowTracker.getId(window); if (!windowTracker.isBrowserWindow(window)) { return { windowId, tabId: -1, }; } return { windowId, tabId: this.getId(tab), }; } getBrowserDataForContext(context) { if (["tab", "background"].includes(context.viewType)) { return this.getBrowserData(context.xulBrowser); } else if (context.viewType === "popup") { const chromeWindow = windowTracker.getCurrentWindow(context); const windowId = chromeWindow ? windowTracker.getId(chromeWindow) : -1; return { tabId: -1, windowId }; } return { tabId: -1, windowId: -1 }; } get activeTab() { const window = windowTracker.topWindow; if (window) { return window.tab; } return null; } } windowTracker = new WindowTracker(); const tabTracker = new TabTracker(); Object.assign(global, { tabTracker, windowTracker }); class Tab extends TabBase { get _favIconUrl() { return undefined; } get attention() { return false; } get audible() { return this.nativeTab.playingAudio; } get browser() { return this.nativeTab.browser; } get discarded() { return this.browser.getAttribute("pending") === "true"; } get cookieStoreId() { return getCookieStoreIdForTab(this, this.nativeTab); } get height() { return this.browser.clientHeight; } get incognito() { return PrivateBrowsingUtils.isBrowserPrivate(this.browser); } get index() { return 0; } get mutedInfo() { return { muted: false }; } get lastAccessed() { return this.nativeTab.lastTouchedAt; } get pinned() { return false; } get active() { return this.nativeTab.getActive(); } get highlighted() { return this.active; } get status() { if (this.browser.webProgress.isLoadingDocument) { return "loading"; } return "complete"; } get successorTabId() { return -1; } get width() { return this.browser.clientWidth; } get window() { return this.browser.ownerGlobal; } get windowId() { return windowTracker.getId(this.window); } // TODO: Just return false for these until properly implemented on Android. // https://bugzilla.mozilla.org/show_bug.cgi?id=1402924 get isArticle() { return false; } get isInReaderMode() { return false; } get hidden() { return false; } get autoDiscardable() { // This property reflects whether the browser is allowed to auto-discard. // Since extensions cannot do so on Android, we return true here. return true; } get sharingState() { return { screen: undefined, microphone: false, camera: false, }; } } // Manages tab-specific context data and dispatches tab select and close events. class TabContext extends EventEmitter { constructor(getDefaultPrototype) { super(); windowTracker.addListener("progress", this); this.getDefaultPrototype = getDefaultPrototype; this.tabData = new Map(); } onLocationChange(browser, webProgress, request, locationURI, flags) { if (!webProgress.isTopLevel) { // Only pageAction and browserAction are consuming the "location-change" event // to update their per-tab status, and they should only do so in response of // location changes related to the top level frame (See Bug 1493470 for a rationale). return; } const { tab } = browser.ownerGlobal; // fromBrowse will be false in case of e.g. a hash change or history.pushState const fromBrowse = !( flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT ); this.emit( "location-change", { id: tab.id, linkedBrowser: browser, // TODO: we don't support selected so we just alway say we are selected: true, }, fromBrowse ); } get(tabId) { if (!this.tabData.has(tabId)) { const data = Object.create(this.getDefaultPrototype(tabId)); this.tabData.set(tabId, data); } return this.tabData.get(tabId); } clear(tabId) { this.tabData.delete(tabId); } shutdown() { windowTracker.removeListener("progress", this); } } class Window extends WindowBase { get focused() { return this.window.document.hasFocus(); } isCurrentFor(context) { // In GeckoView the popup is on a separate window so the current window for // the popup is whatever is the topWindow. if (context?.viewType === "popup") { return mobileWindowTracker.topWindow == this.window; } return super.isCurrentFor(context); } get top() { return this.window.screenY; } get left() { return this.window.screenX; } get width() { return this.window.outerWidth; } get height() { return this.window.outerHeight; } get incognito() { return PrivateBrowsingUtils.isWindowPrivate(this.window); } get alwaysOnTop() { return false; } get isLastFocused() { return this.window === windowTracker.topWindow; } get state() { return "fullscreen"; } *getTabs() { yield this.activeTab; } *getHighlightedTabs() { yield this.activeTab; } get activeTab() { const { tabManager } = this.extension; return tabManager.getWrapper(this.window.tab); } getTabAtIndex(index) { if (index == 0) { return this.activeTab; } } } Object.assign(global, { Tab, TabContext, Window }); class TabManager extends TabManagerBase { get(tabId, default_ = undefined) { const nativeTab = tabTracker.getTab(tabId, default_); if (nativeTab) { return this.getWrapper(nativeTab); } return default_; } addActiveTabPermission(nativeTab = tabTracker.activeTab) { return super.addActiveTabPermission(nativeTab); } revokeActiveTabPermission(nativeTab = tabTracker.activeTab) { return super.revokeActiveTabPermission(nativeTab); } canAccessTab(nativeTab) { return ( this.extension.privateBrowsingAllowed || !PrivateBrowsingUtils.isBrowserPrivate(nativeTab.browser) ); } wrapTab(nativeTab) { return new Tab(this.extension, nativeTab, nativeTab.id); } } class WindowManager extends WindowManagerBase { get(windowId, context) { const window = windowTracker.getWindow(windowId, context); return this.getWrapper(window); } *getAll(context) { for (const window of windowTracker.browserWindows()) { if (!this.canAccessWindow(window, context)) { continue; } const wrapped = this.getWrapper(window); if (wrapped) { yield wrapped; } } } wrapWindow(window) { return new Window(this.extension, window, windowTracker.getId(window)); } } // eslint-disable-next-line mozilla/balanced-listeners extensions.on("startup", (type, extension) => { defineLazyGetter(extension, "tabManager", () => new TabManager(extension)); defineLazyGetter( extension, "windowManager", () => new WindowManager(extension) ); }); /* eslint-disable mozilla/balanced-listeners */ extensions.on("page-shutdown", (type, context) => { if (context.viewType == "tab") { const window = context.xulBrowser.ownerGlobal; if (!windowTracker.isBrowserWindow(window)) { // Content in non-browser window, e.g. ContentPage in xpcshell uses // chrome://extensions/content/dummy.xhtml as the window. return; } GeckoViewTabBridge.closeTab({ window, extensionId: context.extension.id, }); } }); /* eslint-enable mozilla/balanced-listeners */ global.openOptionsPage = async extension => { const { optionsPageProperties } = extension; const extensionId = extension.id; if (optionsPageProperties.open_in_tab) { // Delegate new tab creation and open the options page in the new tab. const tab = await GeckoViewTabBridge.createNewTab({ extensionId, createProperties: { url: optionsPageProperties.page, active: true, }, }); const { browser } = tab; const flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; browser.fixupAndLoadURIString(optionsPageProperties.page, { flags, triggeringPrincipal: extension.principal, }); const newWindow = browser.ownerGlobal; mobileWindowTracker.setTabActive(newWindow, true); return; } // Delegate option page handling to the app. return GeckoViewTabBridge.openOptionsPage(extensionId); };