diff options
Diffstat (limited to 'mobile/android/components')
74 files changed, 10119 insertions, 0 deletions
diff --git a/mobile/android/components/extensions/.eslintrc.js b/mobile/android/components/extensions/.eslintrc.js new file mode 100644 index 0000000000..7726338490 --- /dev/null +++ b/mobile/android/components/extensions/.eslintrc.js @@ -0,0 +1,9 @@ +/* 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"; + +module.exports = { + extends: "../../../../toolkit/components/extensions/.eslintrc.js", +}; diff --git a/mobile/android/components/extensions/ExtensionBrowsingData.jsm b/mobile/android/components/extensions/ExtensionBrowsingData.jsm new file mode 100644 index 0000000000..47321afe7a --- /dev/null +++ b/mobile/android/components/extensions/ExtensionBrowsingData.jsm @@ -0,0 +1,64 @@ +/* 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"; + +const EXPORTED_SYMBOLS = ["BrowsingDataDelegate"]; + +const { ExtensionUtils } = ChromeUtils.import( + "resource://gre/modules/ExtensionUtils.jsm" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", +}); + +const { ExtensionError } = ExtensionUtils; + +class BrowsingDataDelegate { + constructor(extension) { + this.extension = extension; + } + + async sendRequestForResult(type, data) { + try { + const result = await lazy.EventDispatcher.instance.sendRequestForResult({ + type, + extensionId: this.extension.id, + ...data, + }); + return result; + } catch (errorMessage) { + throw new ExtensionError(errorMessage); + } + } + + async settings() { + return this.sendRequestForResult("GeckoView:BrowsingData:GetSettings"); + } + + async sendClear(dataType, options) { + const { since } = options; + return this.sendRequestForResult("GeckoView:BrowsingData:Clear", { + dataType, + since, + }); + } + + // This method returns undefined for all data types that are _not_ handled by + // this delegate. + handleRemoval(dataType, options) { + switch (dataType) { + case "downloads": + case "formData": + case "history": + case "passwords": + return this.sendClear(dataType, options); + + default: + return undefined; + } + } +} diff --git a/mobile/android/components/extensions/ext-android.js b/mobile/android/components/extensions/ext-android.js new file mode 100644 index 0000000000..c782c36541 --- /dev/null +++ b/mobile/android/components/extensions/ext-android.js @@ -0,0 +1,637 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +ChromeUtils.defineModuleGetter( + this, + "GeckoViewTabBridge", + "resource://gre/modules/GeckoViewTab.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "mobileWindowTracker", + "resource://gre/modules/GeckoViewWebExtension.jsm" +); + +var { EventDispatcher } = ChromeUtils.importESModule( + "resource://gre/modules/Messaging.sys.mjs" +); + +var { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +var { ExtensionUtils } = ChromeUtils.import( + "resource://gre/modules/ExtensionUtils.jsm" +); + +var { DefaultWeakMap, ExtensionError } = ExtensionUtils; + +var { defineLazyGetter } = ExtensionCommon; + +global.GlobalEventDispatcher = EventDispatcher.instance; + +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. + // TODO: Bug 1651506 use context?.viewType === "popup" instead + if (context?.currentWindow?.moduleManager.settings.isPopup) { + 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, callback) { + listener(fire, data); + }, + }; + + GlobalEventDispatcher.registerListener(listener2, [event]); + return () => { + GlobalEventDispatcher.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, browser } = window; + const { windowId, tabId } = this.getBrowserData(browser); + this.emit("tab-removed", { + tab, + 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; + 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), + }; + } + + 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 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. + // TODO: Bug 1651506 use context?.viewType === "popup" instead + if (context?.currentWindow?.moduleManager.settings.isPopup) { + 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; + GeckoViewTabBridge.closeTab({ + window, + extensionId: context.extension.id, + }); + } +}); +/* eslint-enable mozilla/balanced-listeners */ + +global.openOptionsPage = async extension => { + const { options_ui } = extension.manifest; + const extensionId = extension.id; + + if (options_ui.open_in_tab) { + // Delegate new tab creation and open the options page in the new tab. + const tab = await GeckoViewTabBridge.createNewTab({ + extensionId, + createProperties: { + url: options_ui.page, + active: true, + }, + }); + + const { browser } = tab; + const flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + + browser.loadURI(options_ui.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); +}; diff --git a/mobile/android/components/extensions/ext-android.json b/mobile/android/components/extensions/ext-android.json new file mode 100644 index 0000000000..bafc8fe00a --- /dev/null +++ b/mobile/android/components/extensions/ext-android.json @@ -0,0 +1,34 @@ +{ + "browserAction": { + "url": "chrome://geckoview/content/ext-browserAction.js", + "schema": "chrome://extensions/content/schemas/browser_action.json", + "scopes": ["addon_parent"], + "manifest": ["browser_action", "action"], + "paths": [ + ["browserAction"], + ["action"] + ] + }, + "browsingData": { + "url": "chrome://extensions/content/parent/ext-browsingData.js", + "schema": "chrome://extensions/content/schemas/browsing_data.json", + "scopes": ["addon_parent"], + "paths": [["browsingData"]] + }, + "pageAction": { + "url": "chrome://geckoview/content/ext-pageAction.js", + "schema": "chrome://extensions/content/schemas/page_action.json", + "scopes": ["addon_parent"], + "manifest": ["page_action"], + "paths": [["pageAction"]] + }, + "tabs": { + "url": "chrome://geckoview/content/ext-tabs.js", + "schema": "chrome://geckoview/content/schemas/tabs.json", + "scopes": ["addon_parent"], + "paths": [["tabs"]] + }, + "geckoViewAddons": { + "schema": "chrome://geckoview/content/schemas/gecko_view_addons.json" + } +} diff --git a/mobile/android/components/extensions/ext-browserAction.js b/mobile/android/components/extensions/ext-browserAction.js new file mode 100644 index 0000000000..a0d9428d05 --- /dev/null +++ b/mobile/android/components/extensions/ext-browserAction.js @@ -0,0 +1,194 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-android.js */ + +XPCOMUtils.defineLazyModuleGetters(this, { + GeckoViewWebExtension: "resource://gre/modules/GeckoViewWebExtension.jsm", + ExtensionActionHelper: "resource://gre/modules/GeckoViewWebExtension.jsm", +}); + +const { BrowserActionBase } = ChromeUtils.import( + "resource://gre/modules/ExtensionActions.jsm" +); + +const BROWSER_ACTION_PROPERTIES = [ + "title", + "icon", + "popup", + "badgeText", + "badgeBackgroundColor", + "badgeTextColor", + "enabled", + "patternMatching", +]; + +class BrowserAction extends BrowserActionBase { + constructor(extension, clickDelegate) { + const tabContext = new TabContext(tabId => this.getContextData(null)); + super(tabContext, extension); + this.clickDelegate = clickDelegate; + this.helper = new ExtensionActionHelper({ + extension, + tabTracker, + windowTracker, + tabContext, + properties: BROWSER_ACTION_PROPERTIES, + }); + } + + updateOnChange(tab) { + const tabId = tab ? tab.id : null; + const action = tab + ? this.getContextData(tab) + : this.helper.extractProperties(this.globals); + this.helper.sendRequest(tabId, { + action, + type: "GeckoView:BrowserAction:Update", + }); + } + + openPopup(tab, openPopupWithoutUserInteraction = false) { + const popupUri = openPopupWithoutUserInteraction + ? this.getPopupUrl(tab) + : this.triggerClickOrPopup(tab); + const actionObject = this.getContextData(tab); + const action = this.helper.extractProperties(actionObject); + this.helper.sendRequest(tab.id, { + action, + type: "GeckoView:BrowserAction:OpenPopup", + popupUri, + }); + } + + triggerClickOrPopup(tab = tabTracker.activeTab) { + return super.triggerClickOrPopup(tab); + } + + getTab(tabId) { + return this.helper.getTab(tabId); + } + + getWindow(windowId) { + return this.helper.getWindow(windowId); + } + + dispatchClick() { + this.clickDelegate.onClick(); + } +} + +this.browserAction = class extends ExtensionAPIPersistent { + static for(extension) { + return GeckoViewWebExtension.browserActions.get(extension); + } + + async onManifestEntry(entryName) { + const { extension } = this; + this.action = new BrowserAction(extension, this); + await this.action.loadIconData(); + + GeckoViewWebExtension.browserActions.set(extension, this.action); + + // Notify the embedder of this action + this.action.updateOnChange(null); + } + + onShutdown() { + const { extension } = this; + this.action.onShutdown(); + GeckoViewWebExtension.browserActions.delete(extension); + } + + onClick() { + this.emit("click", tabTracker.activeTab); + } + + PERSISTENT_EVENTS = { + onClicked({ context, fire }) { + const { extension } = this; + const { tabManager } = extension; + async function listener(_event, tab) { + if (fire.wakeup) { + await fire.wakeup(); + } + // TODO: we should double-check if the tab is already being closed by the time + // the background script got started and we converted the primed listener. + fire.sync(tabManager.convert(tab)); + } + this.on("click", listener); + return { + unregister: () => { + this.off("click", listener); + }, + convert(newFire, extContext) { + fire = newFire; + context = extContext; + }, + }; + }, + }; + + getAPI(context) { + const { extension } = context; + const { action } = this; + const namespace = + extension.manifestVersion < 3 ? "browserAction" : "action"; + + return { + [namespace]: { + ...action.api(context), + + onClicked: new EventManager({ + context, + // module name is "browserAction" because it the name used in the + // ext-browser.json, independently from the manifest version. + module: "browserAction", + event: "onClicked", + // NOTE: Firefox Desktop event has inputHandling set to true here. + // inputHandling: true, + extensionApi: this, + }).api(), + + openPopup: options => { + const isHandlingUserInput = + context.callContextData?.isHandlingUserInput; + + if ( + !Services.prefs.getBoolPref( + "extensions.openPopupWithoutUserGesture.enabled" + ) && + !isHandlingUserInput + ) { + throw new ExtensionError("openPopup requires a user gesture"); + } + + const currentWindow = windowTracker.getCurrentWindow(context); + + const window = + typeof options?.windowId === "number" + ? windowTracker.getWindow(options.windowId, context) + : currentWindow; + + if (window !== currentWindow) { + throw new ExtensionError( + "Only the current window is supported on Android." + ); + } + + if (this.action.getPopupUrl(window.tab, true)) { + action.openPopup(window.tab, !isHandlingUserInput); + } + }, + }, + }; + } +}; + +global.browserActionFor = this.browserAction.for; diff --git a/mobile/android/components/extensions/ext-c-android.js b/mobile/android/components/extensions/ext-c-android.js new file mode 100644 index 0000000000..3f2392a9c2 --- /dev/null +++ b/mobile/android/components/extensions/ext-c-android.js @@ -0,0 +1,13 @@ +/* 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"; + +extensions.registerModules({ + tabs: { + url: "chrome://geckoview/content/ext-c-tabs.js", + scopes: ["addon_child"], + paths: [["tabs"]], + }, +}); diff --git a/mobile/android/components/extensions/ext-c-tabs.js b/mobile/android/components/extensions/ext-c-tabs.js new file mode 100644 index 0000000000..cad1e29051 --- /dev/null +++ b/mobile/android/components/extensions/ext-c-tabs.js @@ -0,0 +1,23 @@ +/* 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"; + +this.tabs = class extends ExtensionAPI { + getAPI(context) { + return { + tabs: { + connect(tabId, options) { + const { frameId = null, name = "" } = options || {}; + return context.messenger.connect({ name, tabId, frameId }); + }, + + sendMessage(tabId, message, options, callback) { + const arg = { tabId, frameId: options?.frameId, message, callback }; + return context.messenger.sendRuntimeMessage(arg); + }, + }, + }; + } +}; diff --git a/mobile/android/components/extensions/ext-downloads.js b/mobile/android/components/extensions/ext-downloads.js new file mode 100644 index 0000000000..56fd58c3e2 --- /dev/null +++ b/mobile/android/components/extensions/ext-downloads.js @@ -0,0 +1,300 @@ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs", +}); +XPCOMUtils.defineLazyModuleGetters(this, { + DownloadTracker: "resource://gre/modules/GeckoViewWebExtension.jsm", +}); + +Cu.importGlobalProperties(["PathUtils"]); + +var { ignoreEvent } = ExtensionCommon; + +const REQUEST_DOWNLOAD_MESSAGE = "GeckoView:WebExtension:Download"; + +const FORBIDDEN_HEADERS = [ + "ACCEPT-CHARSET", + "ACCEPT-ENCODING", + "ACCESS-CONTROL-REQUEST-HEADERS", + "ACCESS-CONTROL-REQUEST-METHOD", + "CONNECTION", + "CONTENT-LENGTH", + "COOKIE", + "COOKIE2", + "DATE", + "DNT", + "EXPECT", + "HOST", + "KEEP-ALIVE", + "ORIGIN", + "TE", + "TRAILER", + "TRANSFER-ENCODING", + "UPGRADE", + "VIA", +]; + +const FORBIDDEN_PREFIXES = /^PROXY-|^SEC-/i; + +const State = { + IN_PROGRESS: "in_progress", + INTERRUPTED: "interrupted", + COMPLETE: "complete", +}; + +const STATE_MAP = new Map([ + [0, State.IN_PROGRESS], + [1, State.INTERRUPTED], + [2, State.COMPLETE], +]); + +const INTERRUPT_REASON_MAP = new Map([ + [0, undefined], + [1, "FILE_FAILED"], + [2, "FILE_ACCESS_DENIED"], + [3, "FILE_NO_SPACE"], + [4, "FILE_NAME_TOO_LONG"], + [5, "FILE_TOO_LARGE"], + [6, "FILE_VIRUS_INFECTED"], + [7, "FILE_TRANSIENT_ERROR"], + [8, "FILE_BLOCKED"], + [9, "FILE_SECURITY_CHECK_FAILED"], + [10, "FILE_TOO_SHORT"], + [11, "NETWORK_FAILED"], + [12, "NETWORK_TIMEOUT"], + [13, "NETWORK_DISCONNECTED"], + [14, "NETWORK_SERVER_DOWN"], + [15, "NETWORK_INVALID_REQUEST"], + [16, "SERVER_FAILED"], + [17, "SERVER_NO_RANGE"], + [18, "SERVER_BAD_CONTENT"], + [19, "SERVER_UNAUTHORIZED"], + [20, "SERVER_CERT_PROBLEM"], + [21, "SERVER_FORBIDDEN"], + [22, "USER_CANCELED"], + [23, "USER_SHUTDOWN"], + [24, "CRASH"], +]); + +// TODO Bug 1247794: make id and extension info persistent +class DownloadItem { + /** + * Initializes an object that represents a download + * + * @param {object} downloadInfo - an object from Java when creating a download + * @param {object} options - an object passed in to download() function + * @param {Extension} extension - instance of an extension object + */ + constructor(downloadInfo, options, extension) { + this.id = downloadInfo.id; + this.url = options.url; + this.referrer = downloadInfo.referrer || ""; + this.filename = downloadInfo.filename || ""; + this.incognito = options.incognito; + this.danger = "safe"; // todo; not implemented in desktop either + this.mime = downloadInfo.mime || ""; + this.startTime = downloadInfo.startTime; + this.state = STATE_MAP.get(downloadInfo.state); + this.paused = downloadInfo.paused; + this.canResume = downloadInfo.canResume; + this.bytesReceived = downloadInfo.bytesReceived; + this.totalBytes = downloadInfo.totalBytes; + this.fileSize = downloadInfo.fileSize; + this.exists = downloadInfo.exists; + this.byExtensionId = extension?.id; + this.byExtensionName = extension?.name; + } + + /** + * This function updates the download item it was called on. + * + * @param {object} data that arrived from the app (Java) + * @returns {object | null} an object of <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/onChanged#downloaddelta>downloadDelta type</a> + */ + update(data) { + const { downloadItemId } = data; + const delta = {}; + + data.state = STATE_MAP.get(data.state); + data.error = INTERRUPT_REASON_MAP.get(data.error); + delete data.downloadItemId; + + let changed = false; + for (const prop in data) { + const current = data[prop] ?? null; + const previous = this[prop] ?? null; + if (current !== previous) { + delta[prop] = { current, previous }; + this[prop] = current; + changed = true; + } + } + + // Don't send empty onChange events + if (!changed) { + return null; + } + + delta.id = downloadItemId; + + return delta; + } +} + +this.downloads = class extends ExtensionAPI { + getAPI(context) { + const { extension } = context; + return { + downloads: { + download(options) { + // the validation checks should be kept in sync with the toolkit implementation + const { filename } = options; + if (filename != null) { + if (!filename.length) { + return Promise.reject({ message: "filename must not be empty" }); + } + + if (PathUtils.isAbsolute(filename)) { + return Promise.reject({ + message: "filename must not be an absolute path", + }); + } + + const pathComponents = PathUtils.splitRelative(filename, { + allowEmpty: true, + allowCurrentDir: true, + allowParentDir: true, + }); + + if (pathComponents.some(component => component == "..")) { + return Promise.reject({ + message: "filename must not contain back-references (..)", + }); + } + + if ( + pathComponents.some(component => { + const sanitized = DownloadPaths.sanitize(component, { + compressWhitespaces: false, + }); + return component != sanitized; + }) + ) { + return Promise.reject({ + message: "filename must not contain illegal characters", + }); + } + } + + if (options.incognito && !context.privateBrowsingAllowed) { + return Promise.reject({ + message: "Private browsing access not allowed", + }); + } + + if (options.cookieStoreId != null) { + // https://bugzilla.mozilla.org/show_bug.cgi?id=1721460 + throw new ExtensionError("Not implemented"); + } + + if (options.headers) { + for (const { name } of options.headers) { + if ( + FORBIDDEN_HEADERS.includes(name.toUpperCase()) || + name.match(FORBIDDEN_PREFIXES) + ) { + return Promise.reject({ + message: "Forbidden request header name", + }); + } + } + } + + return EventDispatcher.instance + .sendRequestForResult({ + type: REQUEST_DOWNLOAD_MESSAGE, + options, + extensionId: extension.id, + }) + .then(value => { + const downloadItem = new DownloadItem(value, options, extension); + DownloadTracker.addDownloadItem(downloadItem); + return downloadItem.id; + }); + }, + + removeFile(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + search(query) { + throw new ExtensionError("Not implemented"); + }, + + pause(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + resume(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + cancel(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + showDefaultFolder() { + throw new ExtensionError("Not implemented"); + }, + + erase(query) { + throw new ExtensionError("Not implemented"); + }, + + open(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + show(downloadId) { + throw new ExtensionError("Not implemented"); + }, + + getFileIcon(downloadId, options) { + throw new ExtensionError("Not implemented"); + }, + + onChanged: new EventManager({ + context, + name: "downloads.onChanged", + register: fire => { + const listener = (eventName, event) => { + const { delta, downloadItem } = event; + if (context.privateBrowsingAllowed || !downloadItem.incognito) { + fire.async(delta); + } + }; + + DownloadTracker.on("download-changed", listener); + return () => { + DownloadTracker.off("download-changed", listener); + }; + }, + }).api(), + + onCreated: ignoreEvent(context, "downloads.onCreated"), + + onErased: ignoreEvent(context, "downloads.onErased"), + + onDeterminingFilename: ignoreEvent( + context, + "downloads.onDeterminingFilename" + ), + }, + }; + } +}; diff --git a/mobile/android/components/extensions/ext-pageAction.js b/mobile/android/components/extensions/ext-pageAction.js new file mode 100644 index 0000000000..fceb5e1c0f --- /dev/null +++ b/mobile/android/components/extensions/ext-pageAction.js @@ -0,0 +1,156 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +// The ext-* files are imported into the same scopes. +/* import-globals-from ext-android.js */ + +XPCOMUtils.defineLazyModuleGetters(this, { + GeckoViewWebExtension: "resource://gre/modules/GeckoViewWebExtension.jsm", + ExtensionActionHelper: "resource://gre/modules/GeckoViewWebExtension.jsm", +}); + +const { PageActionBase } = ChromeUtils.import( + "resource://gre/modules/ExtensionActions.jsm" +); + +const PAGE_ACTION_PROPERTIES = [ + "title", + "icon", + "popup", + "badgeText", + "enabled", + "patternMatching", +]; + +class PageAction extends PageActionBase { + constructor(extension, clickDelegate) { + const tabContext = new TabContext(tabId => this.getContextData(null)); + super(tabContext, extension); + this.clickDelegate = clickDelegate; + this.helper = new ExtensionActionHelper({ + extension, + tabTracker, + windowTracker, + tabContext, + properties: PAGE_ACTION_PROPERTIES, + }); + } + + updateOnChange(tab) { + const tabId = tab ? tab.id : null; + // The embedder only gets the override, not the full object + const action = tab + ? this.getContextData(tab) + : this.helper.extractProperties(this.globals); + this.helper.sendRequest(tabId, { + action, + type: "GeckoView:PageAction:Update", + }); + } + + openPopup() { + const tab = tabTracker.activeTab; + const popupUri = this.triggerClickOrPopup(tab); + const actionObject = this.getContextData(tab); + const action = this.helper.extractProperties(actionObject); + this.helper.sendRequest(tab.id, { + action, + type: "GeckoView:PageAction:OpenPopup", + popupUri, + }); + } + + triggerClickOrPopup(tab = tabTracker.activeTab) { + return super.triggerClickOrPopup(tab); + } + + getTab(tabId) { + return this.helper.getTab(tabId); + } + + dispatchClick() { + this.clickDelegate.onClick(); + } +} + +this.pageAction = class extends ExtensionAPIPersistent { + static for(extension) { + return GeckoViewWebExtension.pageActions.get(extension); + } + + async onManifestEntry(entryName) { + const { extension } = this; + const action = new PageAction(extension, this); + await action.loadIconData(); + this.action = action; + + GeckoViewWebExtension.pageActions.set(extension, action); + + // Notify the embedder of this action + action.updateOnChange(null); + } + + onClick() { + this.emit("click", tabTracker.activeTab); + } + + onShutdown() { + const { extension, action } = this; + action.onShutdown(); + GeckoViewWebExtension.pageActions.delete(extension); + } + + PERSISTENT_EVENTS = { + onClicked({ fire }) { + const { extension } = this; + const { tabManager } = extension; + + const listener = async (_event, tab) => { + if (fire.wakeup) { + await fire.wakeup(); + } + // TODO: we should double-check if the tab is already being closed by the time + // the background script got started and we converted the primed listener. + fire.async(tabManager.convert(tab)); + }; + + this.on("click", listener); + return { + unregister: () => { + this.off("click", listener); + }, + convert(newFire, _extContext) { + fire = newFire; + }, + }; + }, + }; + + getAPI(context) { + const { action } = this; + + return { + pageAction: { + ...action.api(context), + + onClicked: new EventManager({ + context, + module: "pageAction", + event: "onClicked", + extensionApi: this, + }).api(), + + openPopup() { + action.openPopup(); + }, + }, + }; + } +}; + +global.pageActionFor = this.pageAction.for; diff --git a/mobile/android/components/extensions/ext-tabs.js b/mobile/android/components/extensions/ext-tabs.js new file mode 100644 index 0000000000..7fd373a8ea --- /dev/null +++ b/mobile/android/components/extensions/ext-tabs.js @@ -0,0 +1,554 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* 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"; + +ChromeUtils.defineESModuleGetters(this, { + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", +}); + +ChromeUtils.defineModuleGetter( + this, + "GeckoViewTabBridge", + "resource://gre/modules/GeckoViewTab.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "mobileWindowTracker", + "resource://gre/modules/GeckoViewWebExtension.jsm" +); + +const getBrowserWindow = window => { + return window.browsingContext.topChromeWindow; +}; + +const tabListener = { + tabReadyInitialized: false, + tabReadyPromises: new WeakMap(), + initializingTabs: new WeakSet(), + + initTabReady() { + if (!this.tabReadyInitialized) { + windowTracker.addListener("progress", this); + + this.tabReadyInitialized = true; + } + }, + + onLocationChange(browser, webProgress, request, locationURI, flags) { + if (webProgress.isTopLevel) { + const { tab } = browser.ownerGlobal; + + // Ignore initial about:blank + if (!request && this.initializingTabs.has(tab)) { + return; + } + + // Now we are certain that the first page in the tab was loaded. + this.initializingTabs.delete(tab); + + // browser.innerWindowID is now set, resolve the promises if any. + const deferred = this.tabReadyPromises.get(tab); + if (deferred) { + deferred.resolve(tab); + this.tabReadyPromises.delete(tab); + } + } + }, + + /** + * Returns a promise that resolves when the tab is ready. + * Tabs created via the `tabs.create` method are "ready" once the location + * changes to the requested URL. Other tabs are assumed to be ready once their + * inner window ID is known. + * + * @param {NativeTab} nativeTab The native tab object. + * @returns {Promise} Resolves with the given tab once ready. + */ + awaitTabReady(nativeTab) { + let deferred = this.tabReadyPromises.get(nativeTab); + if (!deferred) { + deferred = PromiseUtils.defer(); + if ( + !this.initializingTabs.has(nativeTab) && + (nativeTab.browser.innerWindowID || + nativeTab.browser.currentURI.spec === "about:blank") + ) { + deferred.resolve(nativeTab); + } else { + this.initTabReady(); + this.tabReadyPromises.set(nativeTab, deferred); + } + } + return deferred.promise; + }, +}; + +this.tabs = class extends ExtensionAPI { + getAPI(context) { + const { extension } = context; + + const { tabManager } = extension; + + function getTabOrActive(tabId) { + if (tabId !== null) { + return tabTracker.getTab(tabId); + } + return tabTracker.activeTab; + } + + async function promiseTabWhenReady(tabId) { + let tab; + if (tabId !== null) { + tab = tabManager.get(tabId); + } else { + tab = tabManager.getWrapper(tabTracker.activeTab); + } + if (!tab) { + throw new ExtensionError( + tabId == null ? "Cannot access activeTab" : `Invalid tab ID: ${tabId}` + ); + } + + await tabListener.awaitTabReady(tab.nativeTab); + + return tab; + } + + function loadURIInTab(nativeTab, url) { + const { browser } = nativeTab; + + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + let { principal } = context; + const isAboutUrl = url.startsWith("about:"); + if ( + isAboutUrl || + (url.startsWith("moz-extension://") && + !context.checkLoadURL(url, { dontReportErrors: true })) + ) { + // Falling back to content here as about: requires it, however is safe. + principal = Services.scriptSecurityManager.getLoadContextContentPrincipal( + Services.io.newURI(url), + browser.loadContext + ); + } + if (isAboutUrl) { + // Make sure things like about:blank and other about: URIs never + // inherit, and instead always get a NullPrincipal. + flags |= Ci.nsIWebNavigation.LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL; + } + + browser.loadURI(url, { + flags, + triggeringPrincipal: principal, + }); + } + + const self = { + tabs: { + onActivated: new EventManager({ + context, + name: "tabs.onActivated", + register: fire => { + const listener = (eventName, event) => { + const { windowId, tabId, isPrivate } = event; + if (isPrivate && !context.privateBrowsingAllowed) { + return; + } + fire.async({ + windowId, + tabId, + // In GeckoView each window has only one tab, so previousTabId is omitted. + }); + }; + + mobileWindowTracker.on("tab-activated", listener); + return () => { + mobileWindowTracker.off("tab-activated", listener); + }; + }, + }).api(), + + onCreated: new EventManager({ + context, + name: "tabs.onCreated", + register: fire => { + const listener = (eventName, event) => { + fire.async(tabManager.convert(event.nativeTab)); + }; + + tabTracker.on("tab-created", listener); + return () => { + tabTracker.off("tab-created", listener); + }; + }, + }).api(), + + /** + * Since multiple tabs currently can't be highlighted, onHighlighted + * essentially acts an alias for self.tabs.onActivated but returns + * the tabId in an array to match the API. + * + * @see https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/Tabs/onHighlighted + */ + onHighlighted: makeGlobalEvent( + context, + "tabs.onHighlighted", + "Tab:Selected", + (fire, data) => { + const tab = tabManager.get(data.id); + + fire.async({ tabIds: [tab.id], windowId: tab.windowId }); + } + ), + + onAttached: new EventManager({ + context, + name: "tabs.onAttached", + register: fire => { + return () => {}; + }, + }).api(), + + onDetached: new EventManager({ + context, + name: "tabs.onDetached", + register: fire => { + return () => {}; + }, + }).api(), + + onRemoved: new EventManager({ + context, + name: "tabs.onRemoved", + register: fire => { + const listener = (eventName, event) => { + fire.async(event.tabId, { + windowId: event.windowId, + isWindowClosing: event.isWindowClosing, + }); + }; + + tabTracker.on("tab-removed", listener); + return () => { + tabTracker.off("tab-removed", listener); + }; + }, + }).api(), + + onReplaced: new EventManager({ + context, + name: "tabs.onReplaced", + register: fire => { + return () => {}; + }, + }).api(), + + onMoved: new EventManager({ + context, + name: "tabs.onMoved", + register: fire => { + return () => {}; + }, + }).api(), + + onUpdated: new EventManager({ + context, + name: "tabs.onUpdated", + register: fire => { + const restricted = ["url", "favIconUrl", "title"]; + + function sanitize(tab, changeInfo) { + const result = {}; + let nonempty = false; + for (const prop in changeInfo) { + // In practice, changeInfo contains at most one property from + // restricted. Therefore it is not necessary to cache the value + // of tab.hasTabPermission outside the loop. + if (!restricted.includes(prop) || tab.hasTabPermission) { + nonempty = true; + result[prop] = changeInfo[prop]; + } + } + return [nonempty, result]; + } + + const fireForTab = (tab, changed) => { + const [needed, changeInfo] = sanitize(tab, changed); + if (needed) { + fire.async(tab.id, changeInfo, tab.convert()); + } + }; + + const listener = event => { + const needed = []; + let nativeTab; + switch (event.type) { + case "pagetitlechanged": { + const window = getBrowserWindow(event.target.ownerGlobal); + nativeTab = window.tab; + + needed.push("title"); + break; + } + + case "DOMAudioPlaybackStarted": + case "DOMAudioPlaybackStopped": { + const window = event.target.ownerGlobal; + nativeTab = window.tab; + needed.push("audible"); + break; + } + } + + if (!nativeTab) { + return; + } + + const tab = tabManager.getWrapper(nativeTab); + const changeInfo = {}; + for (const prop of needed) { + changeInfo[prop] = tab[prop]; + } + + fireForTab(tab, changeInfo); + }; + + const statusListener = ({ browser, status, url }) => { + const { tab } = browser.ownerGlobal; + if (tab) { + const changed = { status }; + if (url) { + changed.url = url; + } + + fireForTab(tabManager.wrapTab(tab), changed); + } + }; + + windowTracker.addListener("status", statusListener); + windowTracker.addListener("pagetitlechanged", listener); + return () => { + windowTracker.removeListener("status", statusListener); + windowTracker.removeListener("pagetitlechanged", listener); + }; + }, + }).api(), + + async create({ + active, + cookieStoreId, + discarded, + index, + openInReaderMode, + pinned, + title, + url, + } = {}) { + if (active === null) { + active = true; + } + + tabListener.initTabReady(); + + if (url !== null) { + url = context.uri.resolve(url); + + if ( + !url.startsWith("moz-extension://") && + !context.checkLoadURL(url, { dontReportErrors: true }) + ) { + return Promise.reject({ message: `Illegal URL: ${url}` }); + } + } + + if (cookieStoreId) { + cookieStoreId = getUserContextIdForCookieStoreId( + extension, + cookieStoreId, + false // TODO bug 1372178: support creation of private browsing tabs + ); + } + cookieStoreId = cookieStoreId ? cookieStoreId.toString() : undefined; + + const nativeTab = await GeckoViewTabBridge.createNewTab({ + extensionId: context.extension.id, + createProperties: { + active, + cookieStoreId, + discarded, + index, + openInReaderMode, + pinned, + url, + }, + }); + + // Make sure things like about:blank URIs never inherit, + // and instead always get a NullPrincipal. + if (url !== null) { + tabListener.initializingTabs.add(nativeTab); + } else { + url = "about:blank"; + } + + loadURIInTab(nativeTab, url); + + if (active) { + const newWindow = nativeTab.browser.ownerGlobal; + mobileWindowTracker.setTabActive(newWindow, true); + } + + return tabManager.convert(nativeTab); + }, + + async remove(tabs) { + if (!Array.isArray(tabs)) { + tabs = [tabs]; + } + + await Promise.all( + tabs.map(async tabId => { + const windowId = GeckoViewTabBridge.tabIdToWindowId(tabId); + const window = windowTracker.getWindow(windowId, context, false); + if (!window) { + throw new ExtensionError(`Invalid tab ID ${tabId}`); + } + await GeckoViewTabBridge.closeTab({ + window, + extensionId: context.extension.id, + }); + }) + ); + }, + + async update( + tabId, + { active, autoDiscardable, highlighted, muted, pinned, url } = {} + ) { + const nativeTab = getTabOrActive(tabId); + const window = nativeTab.browser.ownerGlobal; + + if (url !== null) { + url = context.uri.resolve(url); + + if ( + !url.startsWith("moz-extension://") && + !context.checkLoadURL(url, { dontReportErrors: true }) + ) { + return Promise.reject({ message: `Illegal URL: ${url}` }); + } + } + + await GeckoViewTabBridge.updateTab({ + window, + extensionId: context.extension.id, + updateProperties: { + active, + autoDiscardable, + highlighted, + muted, + pinned, + url, + }, + }); + + if (url !== null) { + loadURIInTab(nativeTab, url); + } + + // FIXME: openerTabId, successorTabId + if (active) { + mobileWindowTracker.setTabActive(window, true); + } + + return tabManager.convert(nativeTab); + }, + + async reload(tabId, reloadProperties) { + const nativeTab = getTabOrActive(tabId); + + let flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + if (reloadProperties && reloadProperties.bypassCache) { + flags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + } + nativeTab.browser.reloadWithFlags(flags); + }, + + async get(tabId) { + return tabManager.get(tabId).convert(); + }, + + async getCurrent() { + if (context.tabId) { + return tabManager.get(context.tabId).convert(); + } + }, + + async query(queryInfo) { + return Array.from(tabManager.query(queryInfo, context), tab => + tab.convert() + ); + }, + + async captureTab(tabId, options) { + const nativeTab = getTabOrActive(tabId); + await tabListener.awaitTabReady(nativeTab); + + const { browser } = nativeTab; + const window = browser.ownerGlobal; + const zoom = window.windowUtils.fullZoom; + + const tab = tabManager.wrapTab(nativeTab); + return tab.capture(context, zoom, options); + }, + + async captureVisibleTab(windowId, options) { + const window = + windowId == null + ? windowTracker.topWindow + : windowTracker.getWindow(windowId, context); + + const tab = tabManager.wrapTab(window.tab); + await tabListener.awaitTabReady(tab.nativeTab); + const zoom = window.windowUtils.fullZoom; + + return tab.capture(context, zoom, options); + }, + + async executeScript(tabId, details) { + const tab = await promiseTabWhenReady(tabId); + + return tab.executeScript(context, details); + }, + + async insertCSS(tabId, details) { + const tab = await promiseTabWhenReady(tabId); + + return tab.insertCSS(context, details); + }, + + async removeCSS(tabId, details) { + const tab = await promiseTabWhenReady(tabId); + + return tab.removeCSS(context, details); + }, + + goForward(tabId) { + const { browser } = getTabOrActive(tabId); + browser.goForward(); + }, + + goBack(tabId) { + const { browser } = getTabOrActive(tabId); + browser.goBack(); + }, + }, + }; + return self; + } +}; diff --git a/mobile/android/components/extensions/extensions-mobile.manifest b/mobile/android/components/extensions/extensions-mobile.manifest new file mode 100644 index 0000000000..34850bed8b --- /dev/null +++ b/mobile/android/components/extensions/extensions-mobile.manifest @@ -0,0 +1,5 @@ +# modules +category webextension-modules android chrome://geckoview/content/ext-android.json + +category webextension-scripts c-android chrome://geckoview/content/ext-android.js +category webextension-scripts-addon android chrome://geckoview/content/ext-c-android.js diff --git a/mobile/android/components/extensions/jar.mn b/mobile/android/components/extensions/jar.mn new file mode 100644 index 0000000000..29d4cd01cb --- /dev/null +++ b/mobile/android/components/extensions/jar.mn @@ -0,0 +1,14 @@ +# 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/. + +geckoview.jar: + content/ext-android.js + content/ext-android.json + content/ext-browserAction.js + content/ext-c-android.js + content/ext-c-tabs.js + content/ext-pageAction.js + content/ext-tabs.js + content/ext-downloads.js +% override chrome://extensions/content/parent/ext-downloads.js chrome://geckoview/content/ext-downloads.js diff --git a/mobile/android/components/extensions/moz.build b/mobile/android/components/extensions/moz.build new file mode 100644 index 0000000000..acef8642b1 --- /dev/null +++ b/mobile/android/components/extensions/moz.build @@ -0,0 +1,21 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ["jar.mn"] + +EXTRA_JS_MODULES += [ + "ExtensionBrowsingData.jsm", +] + +EXTRA_COMPONENTS += [ + "extensions-mobile.manifest", +] + +DIRS += ["schemas"] + +MOCHITEST_MANIFESTS += ["test/mochitest/mochitest.ini"] +MOCHITEST_CHROME_MANIFESTS += ["test/mochitest/chrome.ini"] +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.ini"] diff --git a/mobile/android/components/extensions/schemas/gecko_view_addons.json b/mobile/android/components/extensions/schemas/gecko_view_addons.json new file mode 100644 index 0000000000..41ee288ef7 --- /dev/null +++ b/mobile/android/components/extensions/schemas/gecko_view_addons.json @@ -0,0 +1,17 @@ +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "PermissionPrivileged", + "choices": [{ + "type": "string", + "enum": [ + "geckoViewAddons", + "nativeMessagingFromContent" + ] + }] + } + ] + } +] diff --git a/mobile/android/components/extensions/schemas/jar.mn b/mobile/android/components/extensions/schemas/jar.mn new file mode 100644 index 0000000000..9f15031cd1 --- /dev/null +++ b/mobile/android/components/extensions/schemas/jar.mn @@ -0,0 +1,7 @@ +# 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/. + +geckoview.jar: + content/schemas/gecko_view_addons.json + content/schemas/tabs.json diff --git a/mobile/android/components/extensions/schemas/moz.build b/mobile/android/components/extensions/schemas/moz.build new file mode 100644 index 0000000000..d988c0ff9b --- /dev/null +++ b/mobile/android/components/extensions/schemas/moz.build @@ -0,0 +1,7 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ["jar.mn"] diff --git a/mobile/android/components/extensions/schemas/tabs.json b/mobile/android/components/extensions/schemas/tabs.json new file mode 100644 index 0000000000..173123a351 --- /dev/null +++ b/mobile/android/components/extensions/schemas/tabs.json @@ -0,0 +1,1263 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +[ + { + "namespace": "manifest", + "types": [ + { + "$extend": "OptionalPermissionNoPrompt", + "choices": [{ + "type": "string", + "enum": [ + "activeTab" + ] + }] + }, + { + "$extend": "OptionalPermission", + "choices": [{ + "type": "string", + "enum": [ + "tabs" + ] + }] + } + ] + }, + { + "namespace": "tabs", + "description": "Use the <code>browser.tabs</code> API to interact with the browser's tab system. You can use this API to create, modify, and rearrange tabs in the browser.", + "types": [ + { "id": "MutedInfoReason", + "type": "string", + "description": "An event that caused a muted state change.", + "enum": [ + {"name": "user", "description": "A user input action has set/overridden the muted state."}, + {"name": "capture", "description": "Tab capture started, forcing a muted state change."}, + {"name": "extension", "description": "An extension, identified by the extensionId field, set the muted state."} + ] + }, + { + "id": "MutedInfo", + "type": "object", + "description": "Tab muted state and the reason for the last state change.", + "properties": { + "muted": { + "type": "boolean", + "description": "Whether the tab is prevented from playing sound (but hasn't necessarily recently produced sound). Equivalent to whether the muted audio indicator is showing." + }, + "reason": { + "$ref": "MutedInfoReason", + "optional": true, + "description": "The reason the tab was muted or unmuted. Not set if the tab's mute state has never been changed." + }, + "extensionId": { + "type": "string", + "optional": true, + "description": "The ID of the extension that changed the muted state. Not set if an extension was not the reason the muted state last changed." + } + } + }, + { + "id": "SharingState", + "type": "object", + "description": "Tab sharing state for screen, microphone and camera. Currently unsupported on Android.", + "properties": { + "screen": { + "type": "string", + "optional": true, + "description": "If the tab is sharing the screen the value will be one of \"Screen\", \"Window\", or \"Application\", or undefined if not screen sharing." + }, + "camera": { + "type": "boolean", + "description": "True if the tab is using the camera." + }, + "microphone": { + "type": "boolean", + "description": "True if the tab is using the microphone." + } + } + }, + { + "id": "Tab", + "type": "object", + "properties": { + "id": {"type": "integer", "minimum": -1, "optional": true, "description": "The ID of the tab. Tab IDs are unique within a browser session. Under some circumstances a Tab may not be assigned an ID, for example when querying foreign tabs using the $(ref:sessions) API, in which case a session ID may be present. Tab ID can also be set to $(ref:tabs.TAB_ID_NONE) for apps and devtools windows."}, + "index": {"type": "integer", "minimum": -1, "description": "The zero-based index of the tab within its window."}, + "windowId": {"type": "integer", "optional": true, "minimum": 0, "description": "The ID of the window the tab is contained within."}, + "openerTabId": {"unsupported": true, "type": "integer", "minimum": 0, "optional": true, "description": "The ID of the tab that opened this tab, if any. This property is only present if the opener tab still exists."}, + "highlighted": {"type": "boolean", "description": "Whether the tab is highlighted. Works as an alias of active."}, + "active": {"type": "boolean", "description": "Whether the tab is active in its window. (Does not necessarily mean the window is focused.)"}, + "pinned": {"type": "boolean", "description": "Whether the tab is pinned."}, + "lastAccessed": {"type": "integer", "optional": true, "description": "The last time the tab was accessed as the number of milliseconds since epoch."}, + "audible": {"type": "boolean", "optional": true, "description": "Whether the tab has produced sound over the past couple of seconds (but it might not be heard if also muted). Equivalent to whether the speaker audio indicator is showing."}, + "mutedInfo": {"$ref": "MutedInfo", "optional": true, "description": "Current tab muted state and the reason for the last state change."}, + "url": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The URL the tab is displaying. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."}, + "title": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The title of the tab. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission."}, + "favIconUrl": {"type": "string", "optional": true, "permissions": ["tabs"], "description": "The URL of the tab's favicon. This property is only present if the extension's manifest includes the <code>\"tabs\"</code> permission. It may also be an empty string if the tab is loading."}, + "status": {"type": "string", "optional": true, "description": "Either <em>loading</em> or <em>complete</em>."}, + "discarded": {"type": "boolean", "optional": true, "description": "True while the tab is not loaded with content."}, + "incognito": {"type": "boolean", "description": "Whether the tab is in an incognito window."}, + "width": {"type": "integer", "optional": true, "description": "The width of the tab in pixels."}, + "height": {"type": "integer", "optional": true, "description": "The height of the tab in pixels."}, + "hidden": {"type": "boolean", "optional": true, "description": "True if the tab is hidden."}, + "sessionId": {"type": "string", "optional": true, "description": "The session ID used to uniquely identify a Tab obtained from the $(ref:sessions) API."}, + "cookieStoreId": {"type": "string", "optional": true, "description": "The CookieStoreId used for the tab."}, + "isArticle": {"type": "boolean", "optional": true, "description": "Whether the document in the tab can be rendered in reader mode."}, + "isInReaderMode": {"type": "boolean", "optional": true, "description": "Whether the document in the tab is being rendered in reader mode."}, + "sharingState": {"$ref": "SharingState", "optional": true, "description": "Current tab sharing state for screen, microphone and camera."}, + "attention": {"type": "boolean", "optional": true, "description": "Whether the tab is drawing attention."}, + "successorTabId": {"type": "integer", "optional": true, "minimum": -1, "description": "The ID of this tab's successor, if any; $(ref:tabs.TAB_ID_NONE) otherwise."} + } + }, + { + "id": "ZoomSettingsMode", + "type": "string", + "description": "Defines how zoom changes are handled, i.e. which entity is responsible for the actual scaling of the page; defaults to <code>automatic</code>.", + "enum": [ + { + "name": "automatic", + "description": "Zoom changes are handled automatically by the browser." + }, + { + "name": "manual", + "description": "Overrides the automatic handling of zoom changes. The <code>onZoomChange</code> event will still be dispatched, and it is the responsibility of the extension to listen for this event and manually scale the page. This mode does not support <code>per-origin</code> zooming, and will thus ignore the <code>scope</code> zoom setting and assume <code>per-tab</code>." + }, + { + "name": "disabled", + "description": "Disables all zooming in the tab. The tab will revert to the default zoom level, and all attempted zoom changes will be ignored." + } + ] + }, + { + "id": "ZoomSettingsScope", + "type": "string", + "description": "Defines whether zoom changes will persist for the page's origin, or only take effect in this tab; defaults to <code>per-origin</code> when in <code>automatic</code> mode, and <code>per-tab</code> otherwise.", + "enum": [ + { + "name": "per-origin", + "description": "Zoom changes will persist in the zoomed page's origin, i.e. all other tabs navigated to that same origin will be zoomed as well. Moreover, <code>per-origin</code> zoom changes are saved with the origin, meaning that when navigating to other pages in the same origin, they will all be zoomed to the same zoom factor. The <code>per-origin</code> scope is only available in the <code>automatic</code> mode." + }, + { + "name": "per-tab", + "description": "Zoom changes only take effect in this tab, and zoom changes in other tabs will not affect the zooming of this tab. Also, <code>per-tab</code> zoom changes are reset on navigation; navigating a tab will always load pages with their <code>per-origin</code> zoom factors." + } + ] + }, + { + "id": "ZoomSettings", + "type": "object", + "description": "Defines how zoom changes in a tab are handled and at what scope.", + "properties": { + "mode": { + "$ref": "ZoomSettingsMode", + "description": "Defines how zoom changes are handled, i.e. which entity is responsible for the actual scaling of the page; defaults to <code>automatic</code>.", + "optional": true + }, + "scope": { + "$ref": "ZoomSettingsScope", + "description": "Defines whether zoom changes will persist for the page's origin, or only take effect in this tab; defaults to <code>per-origin</code> when in <code>automatic</code> mode, and <code>per-tab</code> otherwise.", + "optional": true + }, + "defaultZoomFactor": { + "type": "number", + "optional": true, + "description": "Used to return the default zoom level for the current tab in calls to tabs.getZoomSettings." + } + } + }, + { + "id": "TabStatus", + "type": "string", + "enum": ["loading", "complete"], + "description": "Whether the tabs have completed loading." + }, + { + "id": "WindowType", + "type": "string", + "enum": ["normal", "popup", "panel", "app", "devtools"], + "description": "The type of window." + } + ], + "properties": { + "TAB_ID_NONE": { + "value": -1, + "description": "An ID which represents the absence of a browser tab." + } + }, + "functions": [ + { + "name": "get", + "type": "function", + "description": "Retrieves details about the specified tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0 + }, + { + "type": "function", + "name": "callback", + "parameters": [ + {"name": "tab", "$ref": "Tab"} + ] + } + ] + }, + { + "name": "getCurrent", + "type": "function", + "description": "Gets the tab that this script call is being made from. May be undefined if called from a non-tab context (for example: a background page or popup view).", + "async": "callback", + "parameters": [ + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "tab", + "$ref": "Tab", + "optional": true + } + ] + } + ] + }, + { + "name": "connect", + "type": "function", + "description": "Connects to the content script(s) in the specified tab. The $(ref:runtime.onConnect) event is fired in each content script running in the specified tab for the current extension. For more details, see $(topic:messaging)[Content Script Messaging].", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0 + }, + { + "type": "object", + "name": "connectInfo", + "properties": { + "name": { "type": "string", "optional": true, "description": "Will be passed into onConnect for content scripts that are listening for the connection event." }, + "frameId": { + "type": "integer", + "optional": true, + "minimum": 0, + "description": "Open a port to a specific $(topic:frame_ids)[frame] identified by <code>frameId</code> instead of all frames in the tab." + } + }, + "optional": true + } + ], + "returns": { + "$ref": "runtime.Port", + "description": "A port that can be used to communicate with the content scripts running in the specified tab. The port's $(ref:runtime.Port) event is fired if the tab closes or does not exist. " + } + }, + { + "name": "sendMessage", + "type": "function", + "description": "Sends a single message to the content script(s) in the specified tab, with an optional callback to run when a response is sent back. The $(ref:runtime.onMessage) event is fired in each content script running in the specified tab for the current extension.", + "async": "responseCallback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0 + }, + { + "type": "any", + "name": "message" + }, + { + "type": "object", + "name": "options", + "properties": { + "frameId": { + "type": "integer", + "optional": true, + "minimum": 0, + "description": "Send a message to a specific $(topic:frame_ids)[frame] identified by <code>frameId</code> instead of all frames in the tab." + } + }, + "optional": true + }, + { + "type": "function", + "name": "responseCallback", + "optional": true, + "parameters": [ + { + "name": "response", + "type": "any", + "description": "The JSON response object sent by the handler of the message. If an error occurs while connecting to the specified tab, the callback will be called with no arguments and $(ref:runtime.lastError) will be set to the error message." + } + ] + } + ] + }, + { + "name": "create", + "type": "function", + "description": "Creates a new tab.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "createProperties", + "properties": { + "windowId": { + "type": "integer", + "minimum": -2, + "optional": true, + "description": "The window to create the new tab in. Defaults to the $(topic:current-window)[current window]." + }, + "index": { + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The position the tab should take in the window. The provided value will be clamped to between zero and the number of tabs in the window." + }, + "url": { + "type": "string", + "optional": true, + "description": "The URL to navigate the tab to initially. Fully-qualified URLs must include a scheme (i.e. 'http://www.google.com', not 'www.google.com'). Relative URLs will be relative to the current page within the extension. Defaults to the New Tab Page." + }, + "active": { + "type": "boolean", + "optional": true, + "description": "Whether the tab should become the active tab in the window. Does not affect whether the window is focused (see $(ref:windows.update)). Defaults to <var>true</var>." + }, + "pinned": { + "type": "boolean", + "optional": true, + "description": "Whether the tab should be pinned. Defaults to <var>false</var>" + }, + "openerTabId": { + "unsupported": true, + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as the newly created tab." + }, + "cookieStoreId": { + "type": "string", + "optional": true, + "description": "The CookieStoreId for the tab that opened this tab." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "tab", + "$ref": "Tab", + "optional": true, + "description": "Details about the created tab. Will contain the ID of the new tab." + } + ] + } + ] + }, + { + "name": "duplicate", + "unsupported": true, + "type": "function", + "description": "Duplicates a tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "description": "The ID of the tab which is to be duplicated." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "tab", + "optional": true, + "description": "Details about the duplicated tab. The $(ref:tabs.Tab) object doesn't contain <code>url</code>, <code>title</code> and <code>favIconUrl</code> if the <code>\"tabs\"</code> permission has not been requested.", + "$ref": "Tab" + } + ] + } + ] + }, + { + "name": "query", + "type": "function", + "description": "Gets all tabs that have the specified properties, or all tabs if no properties are specified.", + "async": "callback", + "parameters": [ + { + "type": "object", + "name": "queryInfo", + "properties": { + "active": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are active in their windows." + }, + "pinned": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are pinned." + }, + "audible": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are audible." + }, + "muted": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are muted." + }, + "highlighted": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are highlighted. Works as an alias of active." + }, + "currentWindow": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are in the $(topic:current-window)[current window]." + }, + "lastFocusedWindow": { + "type": "boolean", + "optional": true, + "description": "Whether the tabs are in the last focused window." + }, + "status": { + "$ref": "TabStatus", + "optional": true, + "description": "Whether the tabs have completed loading." + }, + "discarded": { + "type": "boolean", + "optional": true, + "description": "True while the tabs are not loaded with content." + }, + "title": { + "type": "string", + "optional": true, + "description": "Match page titles against a pattern." + }, + "url": { + "choices": [ + {"type": "string"}, + {"type": "array", "items": {"type": "string"}} + ], + "optional": true, + "description": "Match tabs against one or more $(topic:match_patterns)[URL patterns]. Note that fragment identifiers are not matched." + }, + "windowId": { + "type": "integer", + "optional": true, + "minimum": -2, + "description": "The ID of the parent window, or $(ref:windows.WINDOW_ID_CURRENT) for the $(topic:current-window)[current window]." + }, + "windowType": { + "$ref": "WindowType", + "optional": true, + "description": "The type of window the tabs are in." + }, + "index": { + "type": "integer", + "optional": true, + "minimum": 0, + "description": "The position of the tabs within their windows." + }, + "cookieStoreId": { + "choices": [ + { + "type": "array", + "items": { "type": "string" } + }, + { + "type": "string" + } + ], + "optional": true, + "description": "The CookieStoreId used for the tab." + } + } + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "name": "result", + "type": "array", + "items": { + "$ref": "Tab" + } + } + ] + } + ] + }, + { + "name": "highlight", + "type": "function", + "description": "Highlights the given tabs.", + "async": "callback", + "unsupported": true, + "parameters": [ + { + "type": "object", + "name": "highlightInfo", + "properties": { + "windowId": { + "type": "integer", + "optional": true, + "description": "The window that contains the tabs.", + "minimum": -2 + }, + "tabs": { + "description": "One or more tab indices to highlight.", + "choices": [ + {"type": "array", "items": {"type": "integer", "minimum": 0}}, + {"type": "integer"} + ] + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "window", + "$ref": "windows.Window", + "description": "Contains details about the window whose tabs were highlighted." + } + ] + } + ] + }, + { + "name": "update", + "type": "function", + "description": "Modifies the properties of a tab. Properties that are not specified in <var>updateProperties</var> are not modified.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "Defaults to the selected tab of the $(topic:current-window)[current window]." + }, + { + "type": "object", + "name": "updateProperties", + "properties": { + "url": { + "type": "string", + "optional": true, + "description": "A URL to navigate the tab to." + }, + "active": { + "type": "boolean", + "optional": true, + "description": "Whether the tab should be active. Does not affect whether the window is focused (see $(ref:windows.update))." + }, + "highlighted": { + "unsupported": true, + "type": "boolean", + "optional": true, + "description": "Adds or removes the tab from the current selection." + }, + "pinned": { + "type": "boolean", + "unsupported": true, + "optional": true, + "description": "Whether the tab should be pinned." + }, + "muted": { + "type": "boolean", + "unsupported": true, + "optional": true, + "description": "Whether the tab should be muted." + }, + "openerTabId": { + "unsupported": true, + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the tab that opened this tab. If specified, the opener tab must be in the same window as this tab." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "tab", + "$ref": "Tab", + "optional": true, + "description": "Details about the updated tab. The $(ref:tabs.Tab) object doesn't contain <code>url</code>, <code>title</code> and <code>favIconUrl</code> if the <code>\"tabs\"</code> permission has not been requested." + } + ] + } + ] + }, + { + "name": "move", + "unsupported": true, + "type": "function", + "description": "Moves one or more tabs to a new position within its window, or to a new window. Note that tabs can only be moved to and from normal (window.type === \"normal\") windows.", + "async": "callback", + "parameters": [ + { + "name": "tabIds", + "description": "The tab or list of tabs to move.", + "choices": [ + {"type": "integer", "minimum": 0}, + {"type": "array", "items": {"type": "integer", "minimum": 0}} + ] + }, + { + "type": "object", + "name": "moveProperties", + "properties": { + "windowId": { + "type": "integer", + "minimum": -2, + "optional": true, + "description": "Defaults to the window the tab is currently in." + }, + "index": { + "type": "integer", + "minimum": -1, + "description": "The position to move the window to. -1 will place the tab at the end of the window." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [ + { + "name": "tabs", + "description": "Details about the moved tabs.", + "choices": [ + {"$ref": "Tab"}, + {"type": "array", "items": {"$ref": "Tab"}} + ] + } + ] + } + ] + }, + { + "name": "reload", + "type": "function", + "description": "Reload a tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to reload; defaults to the selected tab of the current window." + }, + { + "type": "object", + "name": "reloadProperties", + "optional": true, + "properties": { + "bypassCache": { + "type": "boolean", + "optional": true, + "description": "Whether using any local cache. Default is false." + } + } + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "remove", + "type": "function", + "description": "Closes one or more tabs.", + "async": "callback", + "parameters": [ + { + "name": "tabIds", + "description": "The tab or list of tabs to close.", + "choices": [ + {"type": "integer", "minimum": 0}, + {"type": "array", "items": {"type": "integer", "minimum": 0}} + ] + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "detectLanguage", + "unsupported": true, + "type": "function", + "description": "Detects the primary language of the content in a tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "Defaults to the active tab of the $(topic:current-window)[current window]." + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "type": "string", + "name": "language", + "description": "An ISO language code such as <code>en</code> or <code>fr</code>. For a complete list of languages supported by this method, see <a href='http://src.chromium.org/viewvc/chrome/trunk/src/third_party/cld/languages/internal/languages.cc'>kLanguageInfoTable</a>. The 2nd to 4th columns will be checked and the first non-NULL value will be returned except for Simplified Chinese for which zh-CN will be returned. For an unknown language, <code>und</code> will be returned." + } + ] + } + ] + }, + { + "name": "captureTab", + "type": "function", + "description": "Captures an area of a specified tab. You must have $(topic:declare_permissions)[<all_urls>] permission to use this method.", + "permissions": ["<all_urls>"], + "async": true, + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The tab to capture. Defaults to the active tab of the current window." + }, + { + "$ref": "extensionTypes.ImageDetails", + "name": "options", + "optional": true + } + ] + }, + { + "name": "captureVisibleTab", + "type": "function", + "description": "Captures an area of the currently active tab in the specified window. You must have $(topic:declare_permissions)[<all_urls>] permission to use this method.", + "permissions": ["<all_urls>"], + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "windowId", + "minimum": -2, + "optional": true, + "description": "The target window. Defaults to the $(topic:current-window)[current window]." + }, + { + "$ref": "extensionTypes.ImageDetails", + "name": "options", + "optional": true + }, + { + "type": "function", + "name": "callback", + "parameters": [ + { + "type": "string", + "name": "dataUrl", + "description": "A data URL which encodes an image of the visible area of the captured tab. May be assigned to the 'src' property of an HTML Image element for display." + } + ] + } + ] + }, + { + "name": "executeScript", + "type": "function", + "max_manifest_version": 2, + "description": "Injects JavaScript code into a page. For details, see the $(topic:content_scripts)[programmatic injection] section of the content scripts doc.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab in which to run the script; defaults to the active tab of the current window." + }, + { + "$ref": "extensionTypes.InjectDetails", + "name": "details", + "description": "Details of the script to run." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called after all the JavaScript has been executed.", + "parameters": [ + { + "name": "result", + "optional": true, + "type": "array", + "items": {"type": "any"}, + "description": "The result of the script in every injected frame." + } + ] + } + ] + }, + { + "name": "insertCSS", + "type": "function", + "max_manifest_version": 2, + "description": "Injects CSS into a page. For details, see the $(topic:content_scripts)[programmatic injection] section of the content scripts doc.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab in which to insert the CSS; defaults to the active tab of the current window." + }, + { + "$ref": "extensionTypes.InjectDetails", + "name": "details", + "description": "Details of the CSS text to insert." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called when all the CSS has been inserted.", + "parameters": [] + } + ] + }, + { + "name": "removeCSS", + "type": "function", + "max_manifest_version": 2, + "description": "Removes injected CSS from a page. For details, see the $(topic:content_scripts)[programmatic injection] section of the content scripts doc.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab from which to remove the injected CSS; defaults to the active tab of the current window." + }, + { + "$ref": "extensionTypes.InjectDetails", + "name": "details", + "description": "Details of the CSS text to remove." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called when all the CSS has been removed.", + "parameters": [] + } + ] + }, + { + "name": "setZoom", + "unsupported": true, + "type": "function", + "description": "Zooms a specified tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to zoom; defaults to the active tab of the current window." + }, + { + "type": "number", + "name": "zoomFactor", + "description": "The new zoom factor. Use a value of 0 here to set the tab to its current default zoom factor. Values greater than zero specify a (possibly non-default) zoom factor for the tab." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called after the zoom factor has been changed.", + "parameters": [] + } + ] + }, + { + "name": "getZoom", + "unsupported": true, + "type": "function", + "description": "Gets the current zoom factor of a specified tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to get the current zoom factor from; defaults to the active tab of the current window." + }, + { + "type": "function", + "name": "callback", + "description": "Called with the tab's current zoom factor after it has been fetched.", + "parameters": [ + { + "type": "number", + "name": "zoomFactor", + "description": "The tab's current zoom factor." + } + ] + } + ] + }, + { + "name": "setZoomSettings", + "unsupported": true, + "type": "function", + "description": "Sets the zoom settings for a specified tab, which define how zoom changes are handled. These settings are reset to defaults upon navigating the tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "optional": true, + "minimum": 0, + "description": "The ID of the tab to change the zoom settings for; defaults to the active tab of the current window." + }, + { + "$ref": "ZoomSettings", + "name": "zoomSettings", + "description": "Defines how zoom changes are handled and at what scope." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "description": "Called after the zoom settings have been changed.", + "parameters": [] + } + ] + }, + { + "name": "getZoomSettings", + "unsupported": true, + "type": "function", + "description": "Gets the current zoom settings of a specified tab.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "optional": true, + "minimum": 0, + "description": "The ID of the tab to get the current zoom settings from; defaults to the active tab of the current window." + }, + { + "type": "function", + "name": "callback", + "description": "Called with the tab's current zoom settings.", + "parameters": [ + { + "$ref": "ZoomSettings", + "name": "zoomSettings", + "description": "The tab's current zoom settings." + } + ] + } + ] + }, + { + "name": "goForward", + "type": "function", + "description": "Navigate to next page in tab's history, if available.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to navigate forward." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + }, + { + "name": "goBack", + "type": "function", + "description": "Navigate to previous page in tab's history, if available.", + "async": "callback", + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true, + "description": "The ID of the tab to navigate backward." + }, + { + "type": "function", + "name": "callback", + "optional": true, + "parameters": [] + } + ] + } + ], + "events": [ + { + "name": "onCreated", + "type": "function", + "description": "Fired when a tab is created. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.", + "parameters": [ + { + "$ref": "Tab", + "name": "tab", + "description": "Details of the tab that was created." + } + ] + }, + { + "name": "onUpdated", + "type": "function", + "description": "Fired when a tab is updated.", + "parameters": [ + {"type": "integer", "name": "tabId", "minimum": 0}, + { + "type": "object", + "name": "changeInfo", + "description": "Lists the changes to the state of the tab that was updated.", + "properties": { + "status": { + "type": "string", + "optional": true, + "description": "The status of the tab. Can be either <em>loading</em> or <em>complete</em>." + }, + "discarded": { + "type": "boolean", + "optional": true, + "description": "True while the tab is not loaded with content." + }, + "url": { + "type": "string", + "optional": true, + "description": "The tab's URL if it has changed." + }, + "pinned": { + "type": "boolean", + "optional": true, + "description": "The tab's new pinned state." + }, + "audible": { + "type": "boolean", + "optional": true, + "description": "The tab's new audible state." + }, + "mutedInfo": { + "$ref": "MutedInfo", + "optional": true, + "description": "The tab's new muted state and the reason for the change." + }, + "favIconUrl": { + "type": "string", + "optional": true, + "description": "The tab's new favicon URL." + } + } + }, + { + "$ref": "Tab", + "name": "tab", + "description": "Gives the state of the tab that was updated." + } + ] + }, + { + "name": "onMoved", + "type": "function", + "description": "Fired when a tab is moved within a window. Only one move event is fired, representing the tab the user directly moved. Move events are not fired for the other tabs that must move in response. This event is not fired when a tab is moved between windows. For that, see $(ref:tabs.onDetached).", + "parameters": [ + {"type": "integer", "name": "tabId", "minimum": 0}, + { + "type": "object", + "name": "moveInfo", + "properties": { + "windowId": {"type": "integer", "minimum": 0}, + "fromIndex": {"type": "integer", "minimum": 0}, + "toIndex": {"type": "integer", "minimum": 0} + } + } + ] + }, + { + "name": "onActivated", + "type": "function", + "description": "Fires when the active tab in a window changes. Note that the tab's URL may not be set at the time this event fired, but you can listen to onUpdated events to be notified when a URL is set.", + "parameters": [ + { + "type": "object", + "name": "activeInfo", + "properties": { + "tabId": { + "type": "integer", + "minimum": 0, + "description": "The ID of the tab that has become active." + }, + "previousTabId": { + "type": "integer", + "minimum": 0, + "optional": true, + "description": "The ID of the tab that was previously active, if that tab is still open." + }, + "windowId": { + "type": "integer", + "minimum": 0, + "description": "The ID of the window the active tab changed inside of." + } + } + } + ] + }, + { + "name": "onHighlighted", + "type": "function", + "description": "Fired when the highlighted or selected tabs in a window changes.", + "parameters": [ + { + "type": "object", + "name": "highlightInfo", + "properties": { + "windowId": { + "type": "integer", + "minimum": 0, + "description": "The window whose tabs changed." + }, + "tabIds": { + "type": "array", + "items": {"type": "integer", "minimum": 0}, + "description": "All highlighted tabs in the window." + } + } + } + ] + }, + { + "name": "onDetached", + "type": "function", + "description": "Fired when a tab is detached from a window, for example because it is being moved between windows.", + "parameters": [ + {"type": "integer", "name": "tabId", "minimum": 0}, + { + "type": "object", + "name": "detachInfo", + "properties": { + "oldWindowId": {"type": "integer", "minimum": 0}, + "oldPosition": {"type": "integer", "minimum": 0} + } + } + ] + }, + { + "name": "onAttached", + "type": "function", + "description": "Fired when a tab is attached to a window, for example because it was moved between windows.", + "parameters": [ + {"type": "integer", "name": "tabId", "minimum": 0}, + { + "type": "object", + "name": "attachInfo", + "properties": { + "newWindowId": {"type": "integer", "minimum": 0}, + "newPosition": {"type": "integer", "minimum": 0} + } + } + ] + }, + { + "name": "onRemoved", + "type": "function", + "description": "Fired when a tab is closed.", + "parameters": [ + {"type": "integer", "name": "tabId", "minimum": 0}, + { + "type": "object", + "name": "removeInfo", + "properties": { + "windowId": {"type": "integer", "minimum": 0, "description": "The window whose tab is closed." }, + "isWindowClosing": {"type": "boolean", "description": "True when the tab is being closed because its window is being closed." } + } + } + ] + }, + { + "name": "onReplaced", + "type": "function", + "description": "Fired when a tab is replaced with another tab due to prerendering or instant.", + "parameters": [ + {"type": "integer", "name": "addedTabId", "minimum": 0}, + {"type": "integer", "name": "removedTabId", "minimum": 0} + ] + }, + { + "name": "onZoomChange", + "unsupported": true, + "type": "function", + "description": "Fired when a tab is zoomed.", + "parameters": [{ + "type": "object", + "name": "ZoomChangeInfo", + "properties": { + "tabId": {"type": "integer", "minimum": 0}, + "oldZoomFactor": {"type": "number"}, + "newZoomFactor": {"type": "number"}, + "zoomSettings": {"$ref": "ZoomSettings"} + } + }] + } + ] + } +] diff --git a/mobile/android/components/extensions/test/mochitest/.eslintrc.js b/mobile/android/components/extensions/test/mochitest/.eslintrc.js new file mode 100644 index 0000000000..7d6fe2eb1a --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + extends: + "../../../../../../toolkit/components/extensions/test/mochitest/.eslintrc.js", +}; diff --git a/mobile/android/components/extensions/test/mochitest/chrome.ini b/mobile/android/components/extensions/test/mochitest/chrome.ini new file mode 100644 index 0000000000..901c516d0a --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/chrome.ini @@ -0,0 +1,7 @@ +[DEFAULT] +support-files = + head.js + ../../../../../../toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js +tags = webextensions + +[test_ext_options_ui.html] diff --git a/mobile/android/components/extensions/test/mochitest/context.html b/mobile/android/components/extensions/test/mochitest/context.html new file mode 100644 index 0000000000..1e25c6e851 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/context.html @@ -0,0 +1,24 @@ +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + just some text 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + <img src="ctxmenu-image.png" id="img1"> + + <p> + <a href="some-link" id="link1">Some link</a> + </p> + + <p> + <a href="image-around-some-link"> + <img src="ctxmenu-image.png" id="img-wrapped-in-link"> + </a> + </p> + + <p> + <input type="text" id="edit-me"><br> + <input type="password" id="password"> + </p> + </body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_iframe.html b/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_iframe.html new file mode 100644 index 0000000000..1e2afec6fa --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_iframe.html @@ -0,0 +1,22 @@ +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h3>test iframe</h3> + <script> + "use strict"; + + window.onload = function() { + window.onhashchange = function() { + window.parent.postMessage("updated-iframe-url", "*"); + }; + // NOTE: without the this setTimeout the location change is not fired + // even without the "fire only for top level windows" fix + setTimeout(function() { + window.location.hash = "updated-iframe-url"; + }, 0); + }; + </script> + </body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html b/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html new file mode 100644 index 0000000000..3fa93979fa --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html @@ -0,0 +1,21 @@ +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h3>test page</h3> + <iframe src="about:blank"></iframe> + <script> + "use strict"; + + window.onmessage = function(evt) { + if (evt.data === "updated-iframe-url") { + window.postMessage("frame-updated", "*"); + } + }; + window.onload = function() { + document.querySelector("iframe").setAttribute("src", "context_tabs_onUpdated_iframe.html"); + }; + </script> + </body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/file_bypass_cache.sjs b/mobile/android/components/extensions/test/mochitest/file_bypass_cache.sjs new file mode 100644 index 0000000000..eed8a6ef49 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/file_bypass_cache.sjs @@ -0,0 +1,13 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + + if (request.hasHeader("pragma") && request.hasHeader("cache-control")) { + response.write( + `${request.getHeader("pragma")}:${request.getHeader("cache-control")}` + ); + } +} diff --git a/mobile/android/components/extensions/test/mochitest/file_dummy.html b/mobile/android/components/extensions/test/mochitest/file_dummy.html new file mode 100644 index 0000000000..49ad37128d --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/file_dummy.html @@ -0,0 +1,10 @@ +<html> +<head> +<meta charset="utf-8"> +<title>Dummy test page</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<p>Dummy test page</p> +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/file_iframe_document.html b/mobile/android/components/extensions/test/mochitest/file_iframe_document.html new file mode 100644 index 0000000000..3bb2bd5dcf --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/file_iframe_document.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <title></title> +</head> +<body> + <iframe src="/"></iframe> + <iframe src="about:blank"></iframe> +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/file_slowed_document.sjs b/mobile/android/components/extensions/test/mochitest/file_slowed_document.sjs new file mode 100644 index 0000000000..3816cf045b --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/file_slowed_document.sjs @@ -0,0 +1,49 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +// This script slows the load of an HTML document so that we can reliably test +// all phases of the load cycle supported by the extension API. + +/* eslint-disable no-unused-vars */ + +const URL = "file_slowed_document.sjs"; + +const DELAY = 2 * 1000; // Delay one second before completing the request. + +const nsTimer = Components.Constructor( + "@mozilla.org/timer;1", + "nsITimer", + "initWithCallback" +); + +let timer; + +function handleRequest(request, response) { + response.processAsync(); + + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Cache-Control", "no-cache", false); + response.write(`<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + </head> + <body> + `); + + // Note: We need to store a reference to the timer to prevent it from being + // canceled when it's GCed. + timer = new nsTimer( + () => { + if (request.queryString.includes("with-iframe")) { + response.write(`<iframe src="${URL}?r=${Math.random()}"></iframe>`); + } + response.write(`</body></html>`); + response.finish(); + }, + DELAY, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/mobile/android/components/extensions/test/mochitest/head.js b/mobile/android/components/extensions/test/mochitest/head.js new file mode 100644 index 0000000000..14bb7b663f --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/head.js @@ -0,0 +1,36 @@ +"use strict"; + +/* exported AppConstants, TEST_ICON_ARRAYBUFFER */ + +var { AppConstants } = SpecialPowers.ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); + +var TEST_ICON_DATA = + "iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAC4klEQVRYhdWXLWzbQBSADQtDAwsHC1tUhUxqfL67lk2tdn+OJg0ODU0rLByqgqINBY6tmlbn7LMTJ5FaFVVBk1G0oUGjG2jT2Y7jxmmcbU/6iJ+f36fz+e5sGP9riCGm9hB37RG+scd4Yo/wsDXCZyIE2xuXsce4bY+wXkAsQtzYmExrfFgvkJkRbkzo1ehoxx5iXcgI/9iYUGt8WH9MqDXEcmNChmEYrRCf2SHWeYgQx3x0tLNRIeKQLTtEFyJEep4NTuhk8BC+yMrwEE3+iozo42d8gK7FAOkMsRiiN8QhW2ttSK5QTfRRV4QoymVeJMvPvDp7gCZigD613MN6yRFA3SWarow9QB9LCfG+NeF9qCtjAKOSQjCqVKhfVsiHEQ+grgx/lRGqUihAc1uL8EFD+KCRO+GrF4J61phcoRoPoEzkYhZYpykh5sMb7kOdIeY+jHKur4QI4Feh4AFX1nVeLxrAvQchGsBz5ls6wa2QdwcvIcE2863bTH79KOvsz/uUYJsp+J0pSzNlDckVqqVGUAF+n6uS7txcOl6wot4JVy70ufDLy4pWLUQVPE81pRI0mGe9oxLMHSeohHvMs/STUNaUK6vDPCvOyxMFDx4achehRDJmHnydnkPww5OFfLxrGIZBFDyYl4LpMzlTQFIP6AQx86w2UeYBccFpJrcKv5L9eGDtUAU6RIELqsB74uynjy/UBRF1gS5BTFxwQT1wTiXoUg9MH7m/3NZRRoi5IJytUbMgzv4Wc832+oQkiKgEehmyMkkpKsFkQV11QsRJL5rJYBLItQgRaUZEmnoZXsomz3vGiWw+I9KMF9SVFOqZEemZekli1jN3U/UOqhHHvC6oWWGElhfSpGdOk6+O9prdwvtLj5BjRsQxdRnot+Zeifpy/2/0stktKTRNLmbk0mwXyl8253fyojj+8rxOHNAhjjm5n0/5OOCGOKBzkrMO0Z75lvSAzKlrF32Z/3z8BqLAn+yMV7VhAAAAAElFTkSuQmCC"; + +var TEST_ICON_ARRAYBUFFER = Uint8Array.from(atob(TEST_ICON_DATA), byte => + byte.charCodeAt(0) +).buffer; + +{ + const chromeScript = SpecialPowers.loadChromeScript( + SimpleTest.getTestFileURL("chrome_cleanup_script.js") + ); + + SimpleTest.registerCleanupFunction(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + + chromeScript.sendAsyncMessage("check-cleanup"); + + const results = await chromeScript.promiseOneMessage("cleanup-results"); + chromeScript.destroy(); + + if (results.extraWindows.length || results.extraTabs.length) { + ok( + false, + `Test left extra windows or tabs: ${JSON.stringify(results)}\n` + ); + } + }); +} diff --git a/mobile/android/components/extensions/test/mochitest/mochitest.ini b/mobile/android/components/extensions/test/mochitest/mochitest.ini new file mode 100644 index 0000000000..be07e0f35f --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/mochitest.ini @@ -0,0 +1,38 @@ +[DEFAULT] +support-files = + ../../../../../../toolkit/components/extensions/test/mochitest/test_ext_all_apis.js + ../../../../../../toolkit/components/extensions/test/mochitest/file_sample.html + ../../../../../../toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js + context.html + context_tabs_onUpdated_iframe.html + context_tabs_onUpdated_page.html + file_bypass_cache.sjs + file_dummy.html + file_iframe_document.html + file_slowed_document.sjs + head.js +tags = webextensions +prefs = + javascript.options.asyncstack_capture_debuggee_only=false + +[test_ext_all_apis.html] +[test_ext_tab_runtimeConnect.html] +[test_ext_tabs_create.html] +[test_ext_tabs_events.html] +[test_ext_tabs_executeScript.html] +[test_ext_tabs_executeScript_bad.html] +[test_ext_tabs_executeScript_no_create.html] +[test_ext_tabs_executeScript_runAt.html] +[test_ext_tabs_get.html] +[test_ext_tabs_getCurrent.html] +[test_ext_tabs_goBack_goForward.html] +[test_ext_tabs_insertCSS.html] +[test_ext_tabs_lastAccessed.html] +skip-if = !is_fennec # tab.lastAccessed not implemented +[test_ext_tabs_reload.html] +[test_ext_tabs_reload_bypass_cache.html] +[test_ext_tabs_onUpdated.html] +[test_ext_tabs_query.html] +[test_ext_tabs_sendMessage.html] +[test_ext_tabs_update_url.html] +[test_ext_webNavigation_onCommitted.html] diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html b/mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html new file mode 100644 index 0000000000..c1220ff4a5 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_all_apis.html @@ -0,0 +1,50 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> +<script> +"use strict"; +/* exported expectedContentApisTargetSpecific, expectedBackgroundApisTargetSpecific */ +let expectedContentApisTargetSpecific = []; + +let expectedBackgroundApisTargetSpecific = [ + "tabs.MutedInfoReason", + "tabs.TAB_ID_NONE", + "tabs.TabStatus", + "tabs.WindowType", + "tabs.ZoomSettingsMode", + "tabs.ZoomSettingsScope", + "tabs.connect", + "tabs.create", + "tabs.executeScript", + "tabs.get", + "tabs.getCurrent", + "tabs.goBack", + "tabs.goForward", + "tabs.insertCSS", + "tabs.onActivated", + "tabs.onAttached", + "tabs.onCreated", + "tabs.onDetached", + "tabs.onHighlighted", + "tabs.onMoved", + "tabs.onRemoved", + "tabs.onReplaced", + "tabs.onUpdated", + "tabs.query", + "tabs.reload", + "tabs.remove", + "tabs.removeCSS", + "tabs.sendMessage", + "tabs.update", +]; +</script> +<script src="test_ext_all_apis.js"></script> +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_options_ui.html b/mobile/android/components/extensions/test/mochitest/test_ext_options_ui.html new file mode 100644 index 0000000000..138bb054a9 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_options_ui.html @@ -0,0 +1,498 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>PageAction Test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +async function waitAboutAddonsRendered(addonId) { + await ContentTaskUtils.waitForCondition(() => { + return content.document.querySelector(`div.addon-item[addonID="${addonId}"]`); + }, `wait Addon Item for ${addonId} to be rendered`); +} + +async function navigateToAddonDetails(addonId) { + const item = content.document.querySelector(`div.addon-item[addonID="${addonId}"]`); + const rect = item.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + const domWinUtils = content.window.windowUtils; + + domWinUtils.sendMouseEventToWindow("mousedown", x, y, 0, 1, 0); + domWinUtils.sendMouseEventToWindow("mouseup", x, y, 0, 1, 0); +} + +async function waitAddonOptionsPage([addonId, expectedText]) { + await ContentTaskUtils.waitForCondition(() => { + const optionsIframe = content.document.querySelector(`#addon-options`); + return optionsIframe && optionsIframe.contentDocument.readyState === "complete" && + optionsIframe.contentDocument.body.innerText.includes(expectedText); + }, `wait Addon Options ${expectedText} for ${addonId} to be loaded`); + + const optionsIframe = content.document.querySelector(`#addon-options`); + + return { + iframeHeight: optionsIframe.style.height, + documentHeight: optionsIframe.contentDocument.documentElement.scrollHeight, + bodyHeight: optionsIframe.contentDocument.body.scrollHeight, + }; +} + +async function clickOnLinkInOptionsPage(selector) { + const optionsIframe = content.document.querySelector(`#addon-options`); + optionsIframe.contentDocument.querySelector(selector).click(); +} + +async function clickAddonOptionButton() { + content.document.querySelector(`button#open-addon-options`).click(); +} + +async function navigateBack() { + content.window.history.back(); +} + +function waitDOMContentLoaded(checkUrlCb) { + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + return new Promise(resolve => { + const listener = (event) => { + if (checkUrlCb(event.target.defaultView.location.href)) { + BrowserApp.deck.removeEventListener("DOMContentLoaded", listener); + resolve(); + } + }; + + BrowserApp.deck.addEventListener("DOMContentLoaded", listener); + }); +} + +function waitAboutAddonsLoaded() { + return waitDOMContentLoaded(url => url === "about:addons"); +} + +function clickAddonDisable() { + content.document.querySelector("#disable-btn").click(); +} + +function clickAddonEnable() { + content.document.querySelector("#enable-btn").click(); +} + +add_task(async function test_options_ui_iframe_height() { + const addonID = "test-options-ui@mozilla.org"; + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { + gecko: {id: addonID}, + }, + name: "Options UI Extension", + description: "Longer addon description", + options_ui: { + page: "options.html", + }, + }, + files: { + // An option page with the document element bigger than the body. + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <style> + html { height: 500px; border: 1px solid black; } + body { height: 200px; } + </style> + </head> + <body> + <h1>Options page 1</h1> + <a href="options2.html">go to page 2</a> + </body> + </html> + `, + // A second option page with the body element bigger than the document. + "options2.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <style> + html { height: 200px; border: 1px solid black; } + body { height: 350px; } + </style> + </head> + <body> + <h1>Options page 2</h1> + </body> + </html> + `, + }, + }); + + await extension.startup(); + + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + const onceAboutAddonsLoaded = waitAboutAddonsLoaded(); + + BrowserApp.addTab("about:addons", { + selected: true, + parentId: BrowserApp.selectedTab.id, + }); + + await onceAboutAddonsLoaded; + + is(BrowserApp.selectedTab.currentURI.spec, "about:addons", + "about:addons is the currently selected tab"); + + await SpecialPowers.spawn(BrowserApp.selectedTab.browser, [addonID], waitAboutAddonsRendered); + + await SpecialPowers.spawn(BrowserApp.selectedTab.browser, [addonID], navigateToAddonDetails); + + const optionsSizes = await SpecialPowers.spawn( + BrowserApp.selectedTab.browser, [[addonID, "Options page 1"]], waitAddonOptionsPage + ); + + ok(parseInt(optionsSizes.iframeHeight, 10) >= 500, + "The addon options iframe is at least 500px"); + + is(optionsSizes.iframeHeight, optionsSizes.documentHeight + "px", + "The addon options iframe has the expected height"); + + await SpecialPowers.spawn(BrowserApp.selectedTab.browser, ["a"], clickOnLinkInOptionsPage); + + const options2Sizes = await SpecialPowers.spawn( + BrowserApp.selectedTab.browser, [[addonID, "Options page 2"]], waitAddonOptionsPage + ); + + // The second option page has a body bigger than the document element + // and we expect the iframe to be bigger than that. + ok(parseInt(options2Sizes.iframeHeight, 10) > 200, + `The iframe is bigger then 200px (${options2Sizes.iframeHeight})`); + + // The second option page has a body smaller than the document element of the first + // page and we expect the iframe to be smaller than for the previous options page. + ok(parseInt(options2Sizes.iframeHeight, 10) < 500, + `The iframe is smaller then 500px (${options2Sizes.iframeHeight})`); + + is(options2Sizes.iframeHeight, options2Sizes.documentHeight + "px", + "The second addon options page has the expected height"); + + await SpecialPowers.spawn(BrowserApp.selectedTab.browser, [], navigateBack); + + const backToOptionsSizes = await SpecialPowers.spawn( + BrowserApp.selectedTab.browser, [[addonID, "Options page 1"]], waitAddonOptionsPage + ); + + // After going back to the first options page, + // we expect the iframe to have the same size of the previous load. + is(backToOptionsSizes.iframeHeight, optionsSizes.iframeHeight, + `When navigating back, the old iframe size is restored (${backToOptionsSizes.iframeHeight})`); + + BrowserApp.closeTab(BrowserApp.selectedTab); + + await extension.unload(); +}); + +add_task(async function test_options_ui_open_aboutaddons_details() { + const addonID = "test-options-ui-open-addon-details@mozilla.org"; + + function background() { + browser.test.onMessage.addListener(msg => { + if (msg !== "runtime.openOptionsPage") { + browser.test.fail(`Received unexpected test message: ${msg}`); + return; + } + + browser.runtime.openOptionsPage(); + }); + } + + function optionsScript() { + browser.test.sendMessage("options-page-loaded", window.location.href); + } + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + browser_specific_settings: { + gecko: {id: addonID}, + }, + name: "Options UI open addon details Extension", + description: "Longer addon description", + options_ui: { + page: "options.html", + }, + }, + files: { + "options.js": optionsScript, + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>Options page</h1> + <script src="options.js"><\/script> + </body> + </html> + `, + }, + }); + + await extension.startup(); + + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + const onceAboutAddonsLoaded = waitAboutAddonsLoaded(); + + BrowserApp.addTab("about:addons", { + selected: true, + parentId: BrowserApp.selectedTab.id, + }); + + await onceAboutAddonsLoaded; + + is(BrowserApp.selectedTab.currentURI.spec, "about:addons", + "about:addons is the currently selected tab"); + + info("Wait runtime.openOptionsPage to open the about:addond details in the existent tab"); + extension.sendMessage("runtime.openOptionsPage"); + await extension.awaitMessage("options-page-loaded"); + + is(BrowserApp.selectedTab.currentURI.spec, "about:addons", + "about:addons is still the currently selected tab once the options has been loaded"); + + BrowserApp.closeTab(BrowserApp.selectedTab); + + await extension.unload(); +}); + +add_task(async function test_options_ui_open_in_tab() { + const addonID = "test-options-ui@mozilla.org"; + + function background() { + browser.test.onMessage.addListener(msg => { + if (msg !== "runtime.openOptionsPage") { + browser.test.fail(`Received unexpected test message: ${msg}`); + return; + } + + browser.runtime.openOptionsPage(); + }); + } + + function optionsScript() { + browser.test.sendMessage("options-page-loaded", window.location.href); + } + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + browser_specific_settings: { + gecko: {id: addonID}, + }, + name: "Options UI open_in_tab Extension", + description: "Longer addon description", + options_ui: { + page: "options.html", + open_in_tab: true, + }, + }, + files: { + "options.js": optionsScript, + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>Options page</h1> + <script src="options.js"><\/script> + </body> + </html> + `, + }, + }); + + await extension.startup(); + + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + const onceAboutAddonsLoaded = waitAboutAddonsLoaded(); + + BrowserApp.selectOrAddTab("about:addons", { + selected: true, + parentId: BrowserApp.selectedTab.id, + }); + + await onceAboutAddonsLoaded; + + const aboutAddonsTab = BrowserApp.selectedTab; + + is(aboutAddonsTab.currentURI.spec, "about:addons", + "about:addons is the currently selected tab"); + + await SpecialPowers.spawn(aboutAddonsTab.browser, [addonID], waitAboutAddonsRendered); + await SpecialPowers.spawn(aboutAddonsTab.browser, [addonID], navigateToAddonDetails); + + const onceAddonOptionsLoaded = waitDOMContentLoaded(url => url.endsWith("options.html")); + + info("Click the Options button in the addon details"); + await SpecialPowers.spawn(aboutAddonsTab.browser, [], clickAddonOptionButton); + + info("Waiting that the addon options are loaded in a new tab"); + await onceAddonOptionsLoaded; + + const addonOptionsTab = BrowserApp.selectedTab; + + ok(aboutAddonsTab.id !== addonOptionsTab.id, + "The Addon Options page has been loaded in a new tab"); + + let optionsURL = await extension.awaitMessage("options-page-loaded"); + + is(addonOptionsTab.currentURI.spec, optionsURL, + "Got the expected extension url opened in the addon options tab"); + + const waitTabClosed = (nativeTab) => { + return new Promise(resolve => { + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + const expectedBrowser = nativeTab.browser; + + const tabCloseListener = (event) => { + const browser = event.target; + if (browser !== expectedBrowser) { + return; + } + + BrowserApp.deck.removeEventListener("TabClose", tabCloseListener); + resolve(); + }; + + BrowserApp.deck.addEventListener("TabClose", tabCloseListener); + }); + }; + + const onceOptionsTabClosed = waitTabClosed(addonOptionsTab); + const onceAboutAddonsClosed = waitTabClosed(aboutAddonsTab); + + info("Close the opened about:addons and options tab"); + BrowserApp.closeTab(addonOptionsTab); + BrowserApp.closeTab(aboutAddonsTab); + + info("Wait the tabs to be closed"); + await Promise.all([onceOptionsTabClosed, onceAboutAddonsClosed]); + + const oldSelectedTab = BrowserApp.selectedTab; + info("Call runtime.openOptionsPage"); + extension.sendMessage("runtime.openOptionsPage"); + + info("Wait runtime.openOptionsPage to open the options in a new tab"); + optionsURL = await extension.awaitMessage("options-page-loaded"); + is(BrowserApp.selectedTab.currentURI.spec, optionsURL, + "runtime.openOptionsPage has opened the expected extension page"); + ok(BrowserApp.selectedTab !== oldSelectedTab, + "runtime.openOptionsPage has opened a new tab"); + + BrowserApp.closeTab(BrowserApp.selectedTab); + + await extension.unload(); +}); + +add_task(async function test_options_ui_on_disable_and_enable() { + // Temporarily disabled for races. + /* eslint-disable no-unreachable */ + return; + + const addonID = "test-options-ui-disable-enable@mozilla.org"; + + function optionsScript() { + browser.test.sendMessage("options-page-loaded", window.location.href); + } + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { + gecko: {id: addonID}, + }, + name: "Options UI open addon details Extension", + description: "Longer addon description", + options_ui: { + page: "options.html", + }, + }, + files: { + "options.js": optionsScript, + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>Options page</h1> + <script src="options.js"><\/script> + </body> + </html> + `, + }, + }); + + await extension.startup(); + + const {BrowserApp} = Services.wm.getMostRecentWindow("navigator:browser"); + + const onceAboutAddonsLoaded = waitAboutAddonsLoaded(); + + BrowserApp.addTab("about:addons", { + selected: true, + parentId: BrowserApp.selectedTab.id, + }); + + await onceAboutAddonsLoaded; + + const aboutAddonsTab = BrowserApp.selectedTab; + + is(aboutAddonsTab.currentURI.spec, "about:addons", + "about:addons is the currently selected tab"); + + info("Wait the addon details to have been loaded"); + await SpecialPowers.spawn(aboutAddonsTab.browser, [addonID], waitAboutAddonsRendered); + await SpecialPowers.spawn(aboutAddonsTab.browser, [addonID], navigateToAddonDetails); + + info("Wait the addon options page to have been loaded"); + await extension.awaitMessage("options-page-loaded"); + + info("Click the addon disable button"); + await SpecialPowers.spawn(aboutAddonsTab.browser, [], clickAddonDisable); + + // NOTE: Currently after disabling the addon the extension.awaitMessage seems + // to fail be able to receive events coming from the browser.test.sendMessage API + // (nevertheless `await extension.unload()` seems to be able to remove the extension), + // falling back to wait for the options page to be loaded here. + const onceAddonOptionsLoaded = waitDOMContentLoaded(url => url.endsWith("options.html")); + + info("Click the addon enable button"); + await SpecialPowers.spawn(aboutAddonsTab.browser, [], clickAddonEnable); + + info("Wait the addon options page to have been loaded after clicking the addon enable button"); + await onceAddonOptionsLoaded; + + BrowserApp.closeTab(BrowserApp.selectedTab); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tab_runtimeConnect.html b/mobile/android/components/extensions/test/mochitest/test_ext_tab_runtimeConnect.html new file mode 100644 index 0000000000..48904c2990 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tab_runtimeConnect.html @@ -0,0 +1,89 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs runtimeConnect Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const win = window.open("http://mochi.test:8888/"); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + background: function() { + const messages_received = []; + + let tabId; + + browser.runtime.onConnect.addListener((port) => { + browser.test.assertTrue(!!port, "tab to background port received"); + browser.test.assertEq("tab-connection-name", port.name, "port name should be defined and equal to connectInfo.name"); + browser.test.assertTrue(!!port.sender.tab, "port.sender.tab should be defined"); + browser.test.assertEq(tabId, port.sender.tab.id, "port.sender.tab.id should be equal to the expected tabId"); + + port.onMessage.addListener((msg) => { + messages_received.push(msg); + + if (messages_received.length == 1) { + browser.test.assertEq("tab to background port message", msg, "'tab to background' port message received"); + port.postMessage("background to tab port message"); + } + + if (messages_received.length == 2) { + browser.test.assertTrue(!!msg.tabReceived, "'background to tab' reply port message received"); + browser.test.assertEq("background to tab port message", msg.tabReceived, "reply port content contains the message received"); + + browser.test.notifyPass("tabRuntimeConnect.pass"); + } + }); + }); + + browser.tabs.create({url: "tab.html"}, + (tab) => { tabId = tab.id; }); + }, + + files: { + "tab.js": function() { + const port = browser.runtime.connect({name: "tab-connection-name"}); + port.postMessage("tab to background port message"); + port.onMessage.addListener((msg) => { + port.postMessage({tabReceived: msg}); + }); + }, + "tab.html": ` + <!DOCTYPE html> + <html> + <head> + <title>test tab extension page</title> + <meta charset="utf-8"> + <script src="tab.js" async><\/script> + </head> + <body> + <h1>test tab extension page</h1> + </body> + </html> + `, + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabRuntimeConnect.pass"); + await extension.unload(); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_create.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_create.html new file mode 100644 index 0000000000..027b231fa7 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_create.html @@ -0,0 +1,153 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs create Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.userContext.enabled", true], + ["dom.security.https_first", false], + ], + }); + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs", "cookies"], + + "background": {"page": "bg/background.html"}, + }, + + files: { + "bg/blank.html": `<html><head><meta charset="utf-8"></head></html>`, + + "bg/background.html": `<html><head> + <meta charset="utf-8"> + <script src="background.js"><\/script> + </head></html>`, + + "bg/background.js": function() { + let activeTab; + + function runTests() { + const DEFAULTS = { + active: true, + url: "about:blank", + }; + + const tests = [ + { + create: {url: "http://example.com/"}, + result: {url: "http://example.com/"}, + }, + { + create: {url: "blank.html"}, + result: {url: browser.runtime.getURL("bg/blank.html")}, + }, + { + create: {}, + }, + { + create: {active: false}, + result: {active: false}, + }, + { + create: {active: true}, + result: {active: true}, + }, + { + create: {cookieStoreId: null}, + result: {cookieStoreId: "firefox-default"}, + }, + { + create: {cookieStoreId: "firefox-container-1"}, + result: {cookieStoreId: "firefox-container-1"}, + }, + ]; + + async function nextTest() { + if (!tests.length) { + browser.test.notifyPass("tabs.create"); + return; + } + + const test = tests.shift(); + const expected = Object.assign({}, DEFAULTS, test.result); + + browser.test.log(`Testing tabs.create(${JSON.stringify(test.create)}), expecting ${JSON.stringify(test.result)}`); + + const updatedPromise = new Promise(resolve => { + const onUpdated = (changedTabId, changed) => { + // Loading an extension page causes two `about:blank` messages + // because of the process switch + if (changed.url && (expected.url == "about:blank" || changed.url != "about:blank")) { + browser.tabs.onUpdated.removeListener(onUpdated); + resolve({tabId: changedTabId, url: changed.url}); + } + }; + browser.tabs.onUpdated.addListener(onUpdated); + }); + + const createdPromise = new Promise(resolve => { + const onCreated = tab => { + browser.test.assertTrue("id" in tab, `Expected tabs.onCreated callback to receive tab object`); + resolve(); + }; + browser.tabs.onCreated.addListener(onCreated); + }); + + const [tab] = await Promise.all([ + browser.tabs.create(test.create), + createdPromise, + ]); + const tabId = tab.id; + + for (const key of Object.keys(expected)) { + if (key === "url") { + // FIXME: This doesn't get updated until later in the load cycle. + continue; + } + + browser.test.assertEq(expected[key], tab[key], `Expected value for tab.${key}`); + } + + const updated = await updatedPromise; + browser.test.assertEq(tabId, updated.tabId, `Expected value for tab.id`); + browser.test.assertEq(expected.url, updated.url, `Expected value for tab.url`); + + await browser.tabs.remove(tabId); + await browser.tabs.update(activeTab, {active: true}); + + nextTest(); + } + + nextTest(); + } + + browser.tabs.query({active: true, currentWindow: true}, tabs => { + activeTab = tabs[0].id; + + runTests(); + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.create"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_events.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_events.html new file mode 100644 index 0000000000..d91b8f015f --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_events.html @@ -0,0 +1,239 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs Events Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testTabEvents() { + async function background() { + const events = []; + let eventPromise; + const checkEvents = () => { + if (eventPromise && events.length >= eventPromise.names.length) { + eventPromise.resolve(); + } + }; + + browser.tabs.onCreated.addListener(tab => { + events.push({type: "onCreated", tab}); + checkEvents(); + }); + + browser.tabs.onAttached.addListener((tabId, info) => { + events.push(Object.assign({type: "onAttached", tabId}, info)); + checkEvents(); + }); + + browser.tabs.onDetached.addListener((tabId, info) => { + events.push(Object.assign({type: "onDetached", tabId}, info)); + checkEvents(); + }); + + browser.tabs.onRemoved.addListener((tabId, info) => { + events.push(Object.assign({type: "onRemoved", tabId}, info)); + checkEvents(); + }); + + browser.tabs.onMoved.addListener((tabId, info) => { + events.push(Object.assign({type: "onMoved", tabId}, info)); + checkEvents(); + }); + + async function expectEvents(names) { + browser.test.log(`Expecting events: ${names.join(", ")}`); + + await new Promise(resolve => { + eventPromise = {names, resolve}; + checkEvents(); + }); + + browser.test.assertEq(names.length, events.length, "Got expected number of events"); + for (const [i, name] of names.entries()) { + browser.test.assertEq(name, i in events && events[i].type, + `Got expected ${name} event`); + } + return events.splice(0); + } + + try { + browser.test.log("Create tab"); + const tab = await browser.tabs.create({url: "about:blank"}); + const oldIndex = tab.index; + + const [created] = await expectEvents(["onCreated"]); + browser.test.assertEq(tab.id, created.tab.id, "Got expected tab ID"); + browser.test.assertEq(oldIndex, created.tab.index, "Got expected tab index"); + + + browser.test.log("Remove tab"); + await browser.tabs.remove(tab.id); + const [removed] = await expectEvents(["onRemoved"]); + + browser.test.assertEq(tab.id, removed.tabId, "Expected removed tab ID"); + browser.test.assertEq(tab.windowId, removed.windowId, "Expected removed tab window ID"); + // Note: We want to test for the actual boolean value false here. + browser.test.assertEq(false, removed.isWindowClosing, "Expected isWindowClosing value"); + + browser.test.notifyPass("tabs-events"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function testTabRemovalEvent() { + async function background() { + function awaitLoad(tabId) { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(tabId_, changed, tab) { + if (tabId == tabId_ && changed.status == "complete") { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + } + + chrome.tabs.onRemoved.addListener((tabId, info) => { + browser.test.log("Make sure the removed tab is not available in the tabs.query callback."); + chrome.tabs.query({}, tabs => { + for (const tab of tabs) { + browser.test.assertTrue(tab.id != tabId, "Tab query should not include removed tabId"); + } + browser.test.notifyPass("tabs-events"); + }); + }); + + try { + const url = "http://example.com/mochitest/mobile/android/components/extensions/test/mochitest/context.html"; + const tab = await browser.tabs.create({url: url}); + await awaitLoad(tab.id); + + await browser.tabs.remove(tab.id); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function testTabActivationEvent() { + // TODO bug 1565536: tabs.onActivated is not supported in GeckoView. + if (true) { + todo(false, "skipping testTabActivationEvent"); + return; + } + async function background() { + function makeExpectable() { + let expectation = null, resolver = null; + const expectable = param => { + if (expectation === null) { + browser.test.fail("unexpected call to expectable"); + } else { + try { + resolver(expectation(param)); + } catch (e) { + resolver(Promise.reject(e)); + } finally { + expectation = null; + } + } + }; + expectable.expect = e => { + expectation = e; + return new Promise(r => { resolver = r; }); + }; + return expectable; + } + try { + const listener = makeExpectable(); + browser.tabs.onActivated.addListener(listener); + + const [tab0] = await browser.tabs.query({active: true}); + const [, tab1] = await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab0.id, info.previousTabId, "Got expected previousTabId"); + }), + browser.tabs.create({url: "about:blank"}), + ]); + const [, tab2] = await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab1.id, info.previousTabId, "Got expected previousTabId"); + }), + browser.tabs.create({url: "about:blank"}), + ]); + + await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab1.id, info.tabId, "Got expected tabId"); + browser.test.assertEq(tab2.id, info.previousTabId, "Got expected previousTabId"); + }), + browser.tabs.update(tab1.id, {active: true}), + ]); + + await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab2.id, info.tabId, "Got expected tabId"); + browser.test.assertEq(undefined, info.previousTabId, "previousTabId should not be defined when previous tab was closed"); + }), + browser.tabs.remove(tab1.id), + ]); + + browser.tabs.onActivated.removeListener(listener); + await browser.tabs.remove(tab2.id); + + browser.test.notifyPass("tabs-events"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript.html new file mode 100644 index 0000000000..09e42d73cf --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript.html @@ -0,0 +1,252 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testExecuteScript() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + const BASE = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/"; + const URL = BASE + "file_iframe_document.html"; + + const win = window.open(URL); + await new Promise(resolve => win.addEventListener("load", resolve, {once: true})); + + async function background() { + try { + const [tab] = await browser.tabs.query({active: true, currentWindow: true}); + const frames = await browser.webNavigation.getAllFrames({tabId: tab.id}); + + browser.test.log(`FRAMES: ${frames[1].frameId} ${JSON.stringify(frames)}\n`); + await Promise.all([ + browser.tabs.executeScript({ + code: "42", + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one callback result"); + browser.test.assertEq(42, result[0], "Expected callback result"); + }), + + browser.tabs.executeScript({ + file: "script.js", + code: "42", + }).then(result => { + browser.test.fail("Expected not to be able to execute a script with both file and code"); + }, error => { + browser.test.assertTrue(/a 'code' or a 'file' property, but not both/.test(error.message), + "Got expected error"); + }), + + browser.tabs.executeScript({ + file: "script.js", + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one callback result"); + browser.test.assertEq(undefined, result[0], "Expected callback result"); + }), + + browser.tabs.executeScript({ + file: "script2.js", + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one callback result"); + browser.test.assertEq(27, result[0], "Expected callback result"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + allFrames: true, + }).then(result => { + browser.test.assertTrue(Array.isArray(result), "Result is an array"); + + browser.test.assertEq(2, result.length, "Result has correct length"); + + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "First result is correct"); + browser.test.assertEq("http://mochi.test:8888/", result[1], "Second result is correct"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + allFrames: true, + matchAboutBlank: true, + }).then(result => { + browser.test.assertTrue(Array.isArray(result), "Result is an array"); + + browser.test.assertEq(3, result.length, "Result has correct length"); + + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "First result is correct"); + browser.test.assertEq("http://mochi.test:8888/", result[1], "Second result is correct"); + browser.test.assertEq("about:blank", result[2], "Thirds result is correct"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + runAt: "document_end", + }).then(result => { + browser.test.assertEq(1, result.length, "Expected callback result"); + browser.test.assertEq("string", typeof result[0], "Result is a string"); + + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "Result is correct"); + }), + + browser.tabs.executeScript({ + code: "window", + }).then(result => { + browser.test.fail("Expected error when returning non-structured-clonable object"); + }, error => { + browser.test.assertEq("<anonymous code>", error.fileName, "Got expected fileName"); + browser.test.assertEq("Script '<anonymous code>' result is non-structured-clonable data", + error.message, "Got expected error"); + }), + + browser.tabs.executeScript({ + code: "Promise.resolve(window)", + }).then(result => { + browser.test.fail("Expected error when returning non-structured-clonable object"); + }, error => { + browser.test.assertEq("<anonymous code>", error.fileName, "Got expected fileName"); + browser.test.assertEq("Script '<anonymous code>' result is non-structured-clonable data", + error.message, "Got expected error"); + }), + + browser.tabs.executeScript({ + file: "script3.js", + }).then(result => { + browser.test.fail("Expected error when returning non-structured-clonable object"); + }, error => { + const expected = /Script '.*script3.js' result is non-structured-clonable data/; + browser.test.assertTrue(expected.test(error.message), "Got expected error"); + browser.test.assertTrue(error.fileName.endsWith("script3.js"), "Got expected fileName"); + }), + + browser.tabs.executeScript({ + frameId: Number.MAX_SAFE_INTEGER, + code: "42", + }).then(result => { + browser.test.fail("Expected error when specifying invalid frame ID"); + }, error => { + browser.test.assertEq( + `Invalid frame IDs: [${Number.MAX_SAFE_INTEGER}].`, + error.message, + "Got expected error" + ); + }), + + browser.tabs.create({url: "http://example.net/", active: false}).then(async tab => { + await browser.tabs.executeScript(tab.id, { + code: "42", + }).then(result => { + browser.test.fail("Expected error when trying to execute on invalid domain"); + }, error => { + browser.test.assertEq(`Missing host permission for the tab`, + error.message, "Got expected error"); + }); + + await browser.tabs.remove(tab.id); + }), + + browser.tabs.executeScript({ + code: "Promise.resolve(42)", + }).then(result => { + browser.test.assertEq(42, result[0], "Got expected promise resolution value as result"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + runAt: "document_end", + allFrames: true, + }).then(result => { + browser.test.assertTrue(Array.isArray(result), "Result is an array"); + + browser.test.assertEq(2, result.length, "Result has correct length"); + + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), "First result is correct"); + browser.test.assertEq("http://mochi.test:8888/", result[1], "Second result is correct"); + }), + + browser.tabs.executeScript({ + code: "location.href;", + frameId: frames[0].frameId, + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one result"); + browser.test.assertTrue(/\/file_iframe_document\.html$/.test(result[0]), `Result for frameId[0] is correct: ${result[0]}`); + }), + + browser.tabs.executeScript({ + code: "location.href;", + frameId: frames[1].frameId, + }).then(result => { + browser.test.assertEq(1, result.length, "Expected one result"); + browser.test.assertEq("http://mochi.test:8888/", result[0], "Result for frameId[1] is correct"); + }), + + browser.tabs.create({url: "http://example.com/"}).then(async tab => { + const result = await browser.tabs.executeScript(tab.id, {code: "location.href"}); + + browser.test.assertEq("http://example.com/", result[0], "Script executed correctly in new tab"); + + await browser.tabs.remove(tab.id); + }), + + // This currently does not work on Android. + /* + browser.tabs.create({url: "about:blank"}).then(async tab => { + const result = await browser.tabs.executeScript(tab.id, {code: "location.href", matchAboutBlank: true}); + browser.test.assertEq("about:blank", result[0], "Script executed correctly in new tab"); + await browser.tabs.remove(tab.id); + }), + */ + + new Promise(resolve => { + browser.runtime.onMessage.addListener(message => { + browser.test.assertEq("script ran", message, "Expected runtime message"); + resolve(); + }); + }), + ]); + + browser.test.notifyPass("executeScript"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://mochi.test/", "http://example.com/", "webNavigation"], + }, + + background, + + files: { + "script.js": function() { + browser.runtime.sendMessage("script ran"); + }, + + "script2.js": "27", + + "script3.js": "window", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript"); + + await extension.unload(); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_bad.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_bad.html new file mode 100644 index 0000000000..da645ef738 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_bad.html @@ -0,0 +1,151 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript Bad Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function* testHasNoPermission(params) { + const contentSetup = params.contentSetup || (() => Promise.resolve()); + + async function background(contentSetup) { + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq(msg, "execute-script"); + + await browser.test.assertRejects(browser.tabs.executeScript({ + file: "script.js", + }), /Missing host permission for the tab/); + + browser.test.notifyPass("executeScript"); + }); + + await contentSetup(); + + browser.test.sendMessage("ready"); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: params.manifest, + + background: `(${background})(${contentSetup})`, + + files: { + "script.js": function() { + browser.runtime.sendMessage("first script ran"); + }, + }, + }); + + yield extension.startup(); + yield extension.awaitMessage("ready"); + + if (params.setup) { + yield params.setup(extension); + } + + extension.sendMessage("execute-script"); + + yield extension.awaitFinish("executeScript"); + yield extension.unload(); +} + +add_task(async function testBadPermissions() { + const win = window.open("http://mochi.test:8888/"); + + await new Promise(resolve => setTimeout(resolve, 0)); + + info("Test no special permissions"); + await testHasNoPermission({ + manifest: {"permissions": []}, + }); + + info("Test tabs permissions"); + await testHasNoPermission({ + manifest: {"permissions": ["tabs"]}, + }); + + win.close(); +}); + +add_task(async function testBadURL() { + async function background() { + const promises = [ + new Promise(resolve => { + browser.tabs.executeScript({ + file: "http://example.com/script.js", + }, result => { + browser.test.assertEq(undefined, result, "Result value"); + + browser.test.assertTrue(browser.runtime.lastError instanceof Error, + "runtime.lastError is Error"); + + browser.test.assertTrue(browser.runtime.lastError instanceof Error, + "runtime.lastError is Error"); + + browser.test.assertEq( + "Files to be injected must be within the extension", + browser.runtime.lastError && browser.runtime.lastError.message, + "runtime.lastError value"); + + browser.test.assertEq( + "Files to be injected must be within the extension", + browser.runtime.lastError && browser.runtime.lastError.message, + "runtime.lastError value"); + + resolve(); + }); + }), + + browser.tabs.executeScript({ + file: "http://example.com/script.js", + }).catch(error => { + browser.test.assertTrue(error instanceof Error, "Error is Error"); + + browser.test.assertEq(null, browser.runtime.lastError, + "runtime.lastError value"); + + browser.test.assertEq(null, browser.runtime.lastError, + "runtime.lastError value"); + + browser.test.assertEq( + "Files to be injected must be within the extension", + error && error.message, + "error value"); + }), + ]; + + await Promise.all(promises); + + browser.test.notifyPass("executeScript-lastError"); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["<all_urls>"], + }, + + background, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript-lastError"); + + await extension.unload(); +}); + +// TODO bug 1435100: Test that |executeScript| fails if the tab has navigated +// to a new page, and no longer matches our expected state. This involves +// intentionally trying to trigger a race condition. +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_no_create.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_no_create.html new file mode 100644 index 0000000000..fa25568619 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_no_create.html @@ -0,0 +1,83 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript noCreate Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testExecuteScriptAtOnUpdated() { + const BASE = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/"; + const URL = BASE + "file_iframe_document.html"; + // This is a regression test for bug 1325830. + // The bug (executeScript not completing any more) occurred when executeScript + // was called early at the onUpdated event, unless the tabs.create method is + // called. So this test does not use tabs.create to open new tabs. + // Note that if this test is run together with other tests that do call + // tabs.create, then this test case does not properly test the conditions of + // the regression any more. To verify that the regression has been resolved, + // this test must be run in isolation. + + function background() { + // Using variables to prevent listeners from running more than once, instead + // of removing the listener. This is to minimize any IPC, since the bug that + // is being tested is sensitive to timing. + let ignore = false; + let url; + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (url && changeInfo.status === "loading" && tab.url === url && !ignore) { + ignore = true; + browser.tabs.executeScript(tabId, { + code: "document.URL", + }).then(results => { + browser.test.assertEq(url, results[0], "Content script should run"); + browser.test.notifyPass("executeScript-at-onUpdated"); + }, error => { + browser.test.fail(`Unexpected error: ${error} :: ${error.stack}`); + browser.test.notifyFail("executeScript-at-onUpdated"); + }); + // (running this log call after executeScript to minimize IPC between + // onUpdated and executeScript.) + browser.test.log(`Found expected navigation to ${url}`); + } else { + // The bug occurs when executeScript is called before a tab is + // initialized. + browser.tabs.executeScript(tabId, {code: ""}); + } + }); + browser.test.onMessage.addListener(testUrl => { + url = testUrl; + browser.test.sendMessage("open-test-tab"); + }); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://mochi.test/", "tabs"], + }, + background, + }); + + await extension.startup(); + + extension.sendMessage(URL); + await extension.awaitMessage("open-test-tab"); + + const tab = window.open(URL); + await extension.awaitFinish("executeScript-at-onUpdated"); + + await extension.unload(); + + tab.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_runAt.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_runAt.html new file mode 100644 index 0000000000..2e82320f8c --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_executeScript_runAt.html @@ -0,0 +1,128 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript runAt Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +/** + * These tests ensure that the runAt argument to tabs.executeScript delays + * script execution until the document has reached the correct state. + * + * Since tests of this nature are especially race-prone, it relies on a + * server-JS script to delay the completion of our test page's load cycle long + * enough for us to attempt to load our scripts in the earlies phase we support. + * + * And since we can't actually rely on that timing, it retries any attempts that + * fail to load as early as expected, but don't load at any illegal time. + */ + +add_task(async function testExecuteScript() { + const win = window.open("about:blank"); + + async function background(DEBUG) { + let tab; + + const BASE = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/"; + const URL = BASE + "file_slowed_document.sjs"; + + const MAX_TRIES = 30; + + const onUpdatedPromise = (tabId, url, status) => { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(_, changed, tab) { + if (tabId == tab.id && changed.status == status && tab.url == url) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + }; + + try { + [tab] = await browser.tabs.query({active: true, currentWindow: true}); + + let success = false; + for (let tries = 0; !success && tries < MAX_TRIES; tries++) { + const url = `${URL}?with-iframe&r=${Math.random()}`; + + const loadingPromise = onUpdatedPromise(tab.id, url, "loading"); + const completePromise = onUpdatedPromise(tab.id, url, "complete"); + + // TODO: Test allFrames and frameId. + + await browser.tabs.update({url}); + await loadingPromise; + + const states = await Promise.all([ + // Send the executeScript requests in the reverse order that we expect + // them to execute in, to avoid them passing only because of timing + // races. + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_idle", + }), + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_end", + }), + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_start", + }), + ].reverse()); + + browser.test.log(`Got states: ${states}`); + + // Make sure that none of our scripts executed earlier than expected, + // regardless of retries. + browser.test.assertTrue(states[1] == "interactive" || states[1] == "complete", + `document_end state is valid: ${states[1]}`); + browser.test.assertTrue(states[2] == "interactive" || states[2] == "complete", + `document_idle state is valid: ${states[2]}`); + + // If we have the earliest valid states for each script, we're done. + // Otherwise, try again. + success = ((states[0] == "loading" || DEBUG) && + states[1] == "interactive" && + (states[2] == "interactive" || states[2] == "complete")); + + await completePromise; + } + + browser.test.assertTrue(success, "Got the earliest expected states at least once"); + + browser.test.notifyPass("executeScript-runAt"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript-runAt"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://mochi.test/", "tabs"], + }, + background: `(${background})(${AppConstants.DEBUG})`, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript-runAt"); + + await extension.unload(); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_get.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_get.html new file mode 100644 index 0000000000..109ab8f65c --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_get.html @@ -0,0 +1,36 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs get Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + async background() { + const tab1 = await browser.tabs.create({}); + const tab2 = await browser.tabs.create({}); + browser.test.assertEq(tab1.id, (await browser.tabs.get(tab1.id)).id, "tabs.get should return tab with given id"); + browser.test.assertEq(tab2.id, (await browser.tabs.get(tab2.id)).id, "tabs.get should return tab with given id"); + await browser.tabs.remove(tab1.id); + await browser.tabs.remove(tab2.id); + browser.test.notifyPass("tabs.get"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.get"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_getCurrent.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_getCurrent.html new file mode 100644 index 0000000000..c32f93f44a --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_getCurrent.html @@ -0,0 +1,70 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs getCurrent Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + files: { + "tab.js": function() { + const url = document.location.href; + + browser.tabs.getCurrent().then(currentTab => { + browser.test.assertEq(currentTab.url, url, "getCurrent in non-active background tab"); + + // Activate the tab. + browser.tabs.onActivated.addListener(function listener({tabId}) { + if (tabId == currentTab.id) { + browser.tabs.onActivated.removeListener(listener); + + browser.tabs.getCurrent().then(currentTab => { + browser.test.assertEq(currentTab.id, tabId, "in active background tab"); + browser.test.assertEq(currentTab.url, url, "getCurrent in non-active background tab"); + + browser.test.sendMessage("tab-finished"); + }); + } + }); + browser.tabs.update(currentTab.id, {active: true}); + }); + }, + + "tab.html": `<head><meta charset="utf-8"><script src="tab.js"><\/script></head>`, + }, + + background: function() { + browser.tabs.getCurrent().then(tab => { + browser.test.assertEq(tab, undefined, "getCurrent in background script"); + browser.test.sendMessage("background-finished"); + }); + + browser.tabs.create({url: "tab.html", active: false}); + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background-finished"); + await extension.awaitMessage("tab-finished"); + + // The extension tab is automatically closed when the extension unloads. + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_goBack_goForward.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_goBack_goForward.html new file mode 100644 index 0000000000..0d143e2ac6 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_goBack_goForward.html @@ -0,0 +1,134 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>Tabs goBack and goForward Test</title> + <script + type="text/javascript" + src="/tests/SimpleTest/SimpleTest.js" + ></script> + <script + type="text/javascript" + src="/tests/SimpleTest/ExtensionTestUtils.js" + ></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css" /> + </head> + <body> + <script type="text/javascript"> + "use strict"; + + add_task(async function test_tabs_goBack_goForward() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + files: { + "tab1.html": `<head> + <meta charset=UTF-8"> + <title>tab1</title> + </head>`, + "tab2.html": `<head> + <meta charset=UTF-8"> + <title>tab2</title> + </head>`, + }, + + async background() { + let tabUpdatedCount = 0; + let tab = {}; + + browser.tabs.onUpdated.addListener( + async (tabId, changeInfo, tabInfo) => { + if ( + changeInfo.status !== "complete" || + tabId !== tab.id || + tabInfo.url === "about:blank" + ) { + return; + } + + tabUpdatedCount++; + switch (tabUpdatedCount) { + case 1: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found as expected" + ); + browser.tabs.update(tabId, { url: "tab2.html" }); + break; + + case 2: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found as expected" + ); + browser.tabs.update(tabId, { url: "tab1.html" }); + break; + + case 3: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found as expected" + ); + browser.tabs.goBack(); + break; + + case 4: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found after navigating backward with empty parameter" + ); + browser.tabs.goBack(tabId); + break; + + case 5: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found after navigating backward with tabId as parameter" + ); + browser.tabs.goForward(); + break; + + case 6: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found after navigating forward with empty parameter" + ); + browser.tabs.goForward(tabId); + break; + + case 7: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found after navigating forward with tabId as parameter" + ); + await browser.tabs.remove(tabId); + browser.test.notifyPass("tabs.goBack.goForward"); + break; + + default: + break; + } + } + ); + + tab = await browser.tabs.create({ url: "tab1.html", active: true }); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.goBack.goForward"); + await extension.unload(); + }); + </script> + </body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_insertCSS.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_insertCSS.html new file mode 100644 index 0000000000..26376ebf55 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_insertCSS.html @@ -0,0 +1,117 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testExecuteScript() { + const win = window.open("http://mochi.test:8888/"); + + async function background() { + const tasks = [ + { + background: "rgba(0, 0, 0, 0)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + file: "file2.css", + }); + }, + }, + { + background: "rgb(42, 42, 42)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) }", + }); + }, + }, + { + background: "rgb(42, 42, 42)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + code: "* { background: rgb(100, 100, 100) !important }", + cssOrigin: "author", + }).then(r => browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) !important }", + cssOrigin: "author", + })); + }, + }, + { + background: "rgb(100, 100, 100)", + foreground: "rgb(0, 113, 4)", + promise: () => { + // User has higher importance + return browser.tabs.insertCSS({ + code: "* { background: rgb(100, 100, 100) !important }", + cssOrigin: "user", + }).then(r => browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) !important }", + cssOrigin: "author", + })); + }, + }, + ]; + + function checkCSS() { + const computedStyle = window.getComputedStyle(document.body); + return [computedStyle.backgroundColor, computedStyle.color]; + } + + try { + for (const {promise, background, foreground} of tasks) { + let result = await promise(); + + browser.test.assertEq(undefined, result, "Expected callback result"); + + [result] = await browser.tabs.executeScript({ + code: `(${checkCSS})()`, + }); + + browser.test.assertEq(background, result[0], "Expected background color"); + browser.test.assertEq(foreground, result[1], "Expected foreground color"); + } + + browser.test.notifyPass("insertCSS"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFailure("insertCSS"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://mochi.test/"], + }, + + background, + + files: { + "file2.css": "* { color: rgb(0, 113, 4) }", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("insertCSS"); + + await extension.unload(); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_lastAccessed.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_lastAccessed.html new file mode 100644 index 0000000000..5bb44ab645 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_lastAccessed.html @@ -0,0 +1,64 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs lastAccessed Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testLastAccessed() { + const past = Date.now(); + + window.open("https://example.com/?1"); + window.open("https://example.com/?2"); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + browser.test.onMessage.addListener(async function(msg, past) { + if (msg !== "past") { + return; + } + + const [tab1] = await browser.tabs.query({url: "https://example.com/?1"}); + const [tab2] = await browser.tabs.query({url: "https://example.com/?2"}); + + browser.test.assertTrue(tab1 && tab2, "Expected tabs were found"); + + const now = Date.now(); + + browser.test.assertTrue(typeof tab1.lastAccessed == "number", + "tab1 lastAccessed should be a number"); + + browser.test.assertTrue(typeof tab2.lastAccessed == "number", + "tab2 lastAccessed should be a number"); + + browser.test.assertTrue(past <= tab1.lastAccessed && + tab1.lastAccessed <= tab2.lastAccessed && + tab2.lastAccessed <= now, + "lastAccessed timestamps are recent and in the right order"); + + await browser.tabs.remove([tab1.id, tab2.id]); + + browser.test.notifyPass("tabs.lastAccessed"); + }); + }, + }); + + await extension.startup(); + await extension.sendMessage("past", past); + await extension.awaitFinish("tabs.lastAccessed"); + await extension.unload(); +}); +</script> + +</body> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_onUpdated.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_onUpdated.html new file mode 100644 index 0000000000..8d96e79cc2 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_onUpdated.html @@ -0,0 +1,187 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs onUpdated Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_onUpdated() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://mochi.test/*/context_tabs_onUpdated_page.html"], + "js": ["content-script.js"], + "run_at": "document_start", + }], + }, + + background: function() { + const pageURL = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html"; + + const expectedSequence = [ + {status: "loading"}, + {status: "loading", url: pageURL}, + {status: "complete"}, + ]; + + const collectedSequence = []; + + let tabId; + browser.tabs.onUpdated.addListener(function(tabId, updatedInfo) { + // onUpdated also fires with updatedInfo.faviconUrl, so explicitly + // check for updatedInfo.status before recording the event. + if ("status" in updatedInfo) { + collectedSequence.push(updatedInfo); + } + }); + + browser.runtime.onMessage.addListener(async msg => { + if (collectedSequence.length !== expectedSequence.length) { + browser.test.assertEq( + JSON.stringify(expectedSequence), + JSON.stringify(collectedSequence), + "got unexpected number of updateInfo data" + ); + } else { + for (let i = 0; i < expectedSequence.length; i++) { + browser.test.assertEq( + expectedSequence[i].status, + collectedSequence[i].status, + "check updatedInfo status" + ); + if (expectedSequence[i].url || collectedSequence[i].url) { + browser.test.assertEq( + expectedSequence[i].url, + collectedSequence[i].url, + "check updatedInfo url" + ); + } + } + } + + await browser.tabs.remove(tabId); + browser.test.notifyPass("tabs.onUpdated"); + }); + + browser.tabs.create({url: pageURL}).then(tab => { + tabId = tab.id; + }); + }, + files: { + "content-script.js": ` + window.addEventListener("message", function(evt) { + if (evt.data == "frame-updated") { + browser.runtime.sendMessage("load-completed"); + } + }, true); + `, + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitFinish("tabs.onUpdated"), + ]); + + await extension.unload(); +}); + +async function do_test_update(background, withPermissions = true) { + const manifest = {}; + if (withPermissions) { + manifest.permissions = ["tabs", "http://mochi.test/"]; + } + const extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + }); + + await extension.startup(); + await extension.awaitFinish("finish"); + + await extension.unload(); +} + +add_task(async function test_url() { + await do_test_update(function background() { + // Create a new tab for testing update. + browser.tabs.create({}, function(tab) { + browser.tabs.onUpdated.addListener(async function onUpdated(tabId, changeInfo) { + // Check callback + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log("onUpdate: " + JSON.stringify(changeInfo)); + if ("url" in changeInfo) { + browser.test.assertEq("about:blank", changeInfo.url, + "Check changeInfo.url"); + browser.tabs.onUpdated.removeListener(onUpdated); + // Remove created tab. + await browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + }); + browser.tabs.update(tab.id, {url: "about:blank"}); + }); + }); +}); + +add_task(async function test_title() { + await do_test_update(async function background() { + const url = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html"; + const tab = await browser.tabs.create({url}); + + browser.tabs.onUpdated.addListener(async function onUpdated(tabId, changeInfo) { + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log(`onUpdated: ${JSON.stringify(changeInfo)}`); + if ("title" in changeInfo && changeInfo.title === "New Message (1)") { + browser.test.log("changeInfo.title is correct"); + browser.tabs.onUpdated.removeListener(onUpdated); + await browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + }); + + browser.tabs.executeScript(tab.id, {code: "document.title = 'New Message (1)'"}); + }); +}); + +add_task(async function test_without_tabs_permission() { + await do_test_update(async function background() { + const url = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html"; + const tab = await browser.tabs.create({url}); + let count = 0; + + browser.tabs.onUpdated.addListener(async function onUpdated(tabId, changeInfo) { + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log(`onUpdated: ${JSON.stringify(changeInfo)}`); + + browser.test.assertFalse("url" in changeInfo, "url should not be included without tabs permission"); + browser.test.assertFalse("favIconUrl" in changeInfo, "favIconUrl should not be included without tabs permission"); + browser.test.assertFalse("title" in changeInfo, "title should not be included without tabs permission"); + + if (changeInfo.status == "complete") { + count++; + if (count === 2) { + browser.test.log("Reload complete"); + browser.tabs.onUpdated.removeListener(onUpdated); + await browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + } + }); + + browser.tabs.reload(tab.id); + }, false /* withPermissions */); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_query.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_query.html new file mode 100644 index 0000000000..9a907f47de --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_query.html @@ -0,0 +1,46 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs create Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_query_index() { + const extension = ExtensionTestUtils.loadExtension({ + background: function() { + browser.tabs.onCreated.addListener(async function({index, windowId, id}) { + browser.test.assertThrows( + () => browser.tabs.query({index: -1}), + /-1 is too small \(must be at least 0\)/, + "tab indices must be non-negative"); + + let tabs = await browser.tabs.query({index, windowId}); + browser.test.assertEq(tabs.length, 1, `Got one tab at index ${index}`); + browser.test.assertEq(tabs[0].id, id, "The tab is the right one"); + + tabs = await browser.tabs.query({index: 1e5, windowId}); + browser.test.assertEq(tabs.length, 0, "There is no tab at this index"); + + browser.test.notifyPass("tabs.query"); + }); + }, + }); + + await extension.startup(); + const win = window.open("http://example.com"); + await extension.awaitFinish("tabs.query"); + win.close(); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload.html new file mode 100644 index 0000000000..30379f02a1 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload.html @@ -0,0 +1,66 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs reload Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + files: { + "tab.js": function() { + browser.runtime.sendMessage("tab-loaded"); + }, + "tab.html": + `<head> + <meta charset="utf-8"> + <script src="tab.js"><\/script> + </head>`, + }, + + async background() { + let tabLoadedCount = 0; + // eslint-disable-next-line prefer-const + let tab; + + browser.runtime.onMessage.addListener(msg => { + if (msg == "tab-loaded") { + tabLoadedCount++; + + if (tabLoadedCount == 1) { + // Reload the tab once passing no arguments. + return browser.tabs.reload(); + } + + if (tabLoadedCount == 2) { + // Reload the tab again with explicit arguments. + return browser.tabs.reload(tab.id, { + bypassCache: false, + }); + } + + if (tabLoadedCount == 3) { + browser.test.notifyPass("tabs.reload"); + } + } + }); + tab = await browser.tabs.create({url: "tab.html", active: true}); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.reload"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload_bypass_cache.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload_bypass_cache.html new file mode 100644 index 0000000000..87f90ad855 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_reload_bypass_cache.html @@ -0,0 +1,87 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs executeScript bypassCache Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs", "<all_urls>"], + }, + + async background() { + const BASE = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/"; + const URL = BASE + "file_bypass_cache.sjs"; + + let tabId = null; + let loadPromise, resolveLoad; + function resetLoad() { + loadPromise = new Promise(resolve => { + resolveLoad = resolve; + }); + } + function awaitLoad() { + return loadPromise.then(() => { + resetLoad(); + }); + } + resetLoad(); + + browser.tabs.onUpdated.addListener(function listener( + tabId_, + changed, + tab + ) { + if (tabId == tabId_ && changed.status == "complete" && tab.url == URL) { + resolveLoad(); + } + }); + + try { + const tab = await browser.tabs.create({url: URL}); + tabId = tab.id; + await awaitLoad(); + + await browser.tabs.reload(tab.id, {bypassCache: false}); + await awaitLoad(); + + let [textContent] = await browser.tabs.executeScript(tab.id, {code: "document.body.textContent"}); + browser.test.assertEq("", textContent, "`textContent` should be empty when bypassCache=false"); + + await browser.tabs.reload(tab.id, {bypassCache: true}); + await awaitLoad(); + + [textContent] = await browser.tabs.executeScript(tab.id, {code: "document.body.textContent"}); + + const [pragma, cacheControl] = textContent.split(":"); + browser.test.assertEq("no-cache", pragma, "`pragma` should be set to `no-cache` when bypassCache is true"); + browser.test.assertEq("no-cache", cacheControl, "`cacheControl` should be set to `no-cache` when bypassCache is true"); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("tabs.reload_bypass_cache"); + } catch (error) { + browser.test.fail(`${error} :: ${error.stack}`); + browser.test.notifyFail("tabs.reload_bypass_cache"); + } + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.reload_bypass_cache"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html new file mode 100644 index 0000000000..320ce4dde6 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html @@ -0,0 +1,277 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs sendMessage Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function tabsSendMessageReply() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://example.com/"], + "js": ["content-script.js"], + "run_at": "document_start", + }], + }, + + background: async function() { + // eslint-disable-next-line prefer-const + let firstTab; + const promiseResponse = new Promise(resolve => { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "content-script-ready") { + const tabId = sender.tab.id; + + Promise.all([ + promiseResponse, + + browser.tabs.sendMessage(tabId, "respond-now"), + browser.tabs.sendMessage(tabId, "respond-now-2"), + new Promise(resolve => browser.tabs.sendMessage(tabId, "respond-soon", resolve)), + browser.tabs.sendMessage(tabId, "respond-promise"), + browser.tabs.sendMessage(tabId, "respond-promise-false"), + browser.tabs.sendMessage(tabId, "respond-false"), + browser.tabs.sendMessage(tabId, "respond-never"), + new Promise(resolve => { + browser.runtime.sendMessage("respond-never", response => { resolve(response); }); + }), + + browser.tabs.sendMessage(tabId, "respond-error").catch(error => Promise.resolve({error})), + browser.tabs.sendMessage(tabId, "throw-error").catch(error => Promise.resolve({error})), + + browser.tabs.sendMessage(tabId, "respond-uncloneable").catch(error => Promise.resolve({ error })), + browser.tabs.sendMessage(tabId, "reject-uncloneable").catch(error => Promise.resolve({ error })), + browser.tabs.sendMessage(tabId, "reject-undefined").catch(error => Promise.resolve({ error })), + browser.tabs.sendMessage(tabId, "throw-undefined").catch(error => Promise.resolve({ error })), + + browser.tabs.sendMessage(firstTab, "no-listener").catch(error => Promise.resolve({error})), + ]).then(([response, respondNow, respondNow2, respondSoon, respondPromise, respondPromiseFalse, respondFalse, respondNever, respondNever2, respondError, throwError, respondUncloneable, rejectUncloneable, rejectUndefined, throwUndefined, noListener]) => { + browser.test.assertEq("expected-response", response, "Content script got the expected response"); + + browser.test.assertEq("respond-now", respondNow, "Got the expected immediate response"); + browser.test.assertEq("respond-now-2", respondNow2, "Got the expected immediate response from the second listener"); + browser.test.assertEq("respond-soon", respondSoon, "Got the expected delayed response"); + browser.test.assertEq("respond-promise", respondPromise, "Got the expected promise response"); + browser.test.assertEq(false, respondPromiseFalse, "Got the expected false value as a promise result"); + browser.test.assertEq(undefined, respondFalse, "Got the expected no-response when onMessage returns false"); + browser.test.assertEq(undefined, respondNever, "Got the expected no-response resolution"); + browser.test.assertEq(undefined, respondNever2, "Got the expected no-response resolution"); + + browser.test.assertEq("respond-error", respondError.error.message, "Got the expected error response"); + browser.test.assertEq("throw-error", throwError.error.message, "Got the expected thrown error response"); + + browser.test.assertEq("Could not establish connection. Receiving end does not exist.", respondUncloneable.error.message, "An uncloneable response should be ignored"); + browser.test.assertEq("An unexpected error occurred", rejectUncloneable.error.message, "Got the expected error for a rejection with an uncloneable value"); + browser.test.assertEq("An unexpected error occurred", rejectUndefined.error.message, "Got the expected error for a void rejection"); + browser.test.assertEq("An unexpected error occurred", throwUndefined.error.message, "Got the expected error for a void throw"); + + browser.test.assertEq("Could not establish connection. Receiving end does not exist.", + noListener.error.message, + "Got the expected no listener response"); + + return browser.tabs.remove(tabId); + }).then(() => { + browser.test.notifyPass("sendMessage"); + }); + + return Promise.resolve("expected-response"); + } else if (msg[0] == "got-response") { + resolve(msg[1]); + } + }); + }); + + const tabs = await browser.tabs.query({currentWindow: true, active: true}); + firstTab = tabs[0].id; + browser.tabs.create({url: "http://example.com/"}); + }, + + files: { + "content-script.js": async function() { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond(msg); + } else if (msg == "respond-soon") { + setTimeout(() => { respond(msg); }, 0); + return true; + } else if (msg == "respond-promise") { + return Promise.resolve(msg); + } else if (msg == "respond-promise-false") { + return Promise.resolve(false); + } else if (msg == "respond-false") { + // return false means that respond() is not expected to be called. + setTimeout(() => respond("should be ignored")); + return false; + } else if (msg == "respond-never") { + return undefined; + } else if (msg == "respond-error") { + return Promise.reject(new Error(msg)); + } else if (msg === "respond-uncloneable") { + return Promise.resolve(window); + } else if (msg === "reject-uncloneable") { + return Promise.reject(window); + } else if (msg == "reject-undefined") { + return Promise.reject(); + } else if (msg == "throw-undefined") { + throw undefined; // eslint-disable-line no-throw-literal + } else if (msg == "throw-error") { + throw new Error(msg); + } + }); + + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond("hello"); + } else if (msg == "respond-now-2") { + respond(msg); + } + }); + + const response = await browser.runtime.sendMessage("content-script-ready"); + browser.runtime.sendMessage(["got-response", response]); + }, + }, + }); + + await extension.startup(); + + await extension.awaitFinish("sendMessage"); + + await extension.unload(); +}); + + +add_task(async function tabsSendHidden() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://example.com/content*"], + "js": ["content-script.js"], + "run_at": "document_start", + }], + }, + + background: async function() { + let resolveContent; + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg[0] == "content-ready") { + resolveContent(msg[1]); + } + }); + + const awaitContent = url => { + return new Promise(resolve => { + resolveContent = resolve; + }).then(result => { + browser.test.assertEq(url, result, "Expected content script URL"); + }); + }; + + try { + const URL1 = "http://example.com/content1.html"; + const URL2 = "http://example.com/content2.html"; + + const tab = await browser.tabs.create({url: URL1}); + await awaitContent(URL1); + + let url = await browser.tabs.sendMessage(tab.id, URL1); + browser.test.assertEq(URL1, url, "Should get response from expected content window"); + + await browser.tabs.update(tab.id, {url: URL2}); + await awaitContent(URL2); + + url = await browser.tabs.sendMessage(tab.id, URL2); + browser.test.assertEq(URL2, url, "Should get response from expected content window"); + + // Repeat once just to be sure the first message was processed by all + // listeners before we exit the test. + url = await browser.tabs.sendMessage(tab.id, URL2); + browser.test.assertEq(URL2, url, "Should get response from expected content window"); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("contentscript-bfcache-window"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("contentscript-bfcache-window"); + } + }, + + files: { + "content-script.js": function() { + // Store this in a local variable to make sure we don't touch any + // properties of the possibly-hidden content window. + const href = window.location.href; + + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.assertEq(href, msg, "Should be in the expected content window"); + + return Promise.resolve(href); + }); + + browser.runtime.sendMessage(["content-ready", href]); + }, + }, + }); + + await extension.startup(); + + await extension.awaitFinish("contentscript-bfcache-window"); + + await extension.unload(); +}); + + +add_task(async function tabsSendMessageNoExceptionOnNonExistentTab() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.security.https_first", false]], + }); + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + async background() { + const url = "http://example.com/mochitest/tests/mobile/android/components/extensions/test/mochitest/file_dummy.html"; + const tab = await browser.tabs.create({url}); + + try { + browser.tabs.sendMessage(tab.id, "message"); + browser.tabs.sendMessage(tab.id + 100, "message"); + } catch (e) { + browser.test.fail("no exception should be raised on tabs.sendMessage to nonexistent tabs"); + } + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("tabs.sendMessage"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.sendMessage"); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_tabs_update_url.html b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_update_url.html new file mode 100644 index 0000000000..9332efd516 --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_tabs_update_url.html @@ -0,0 +1,125 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tabs update Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +async function testTabsUpdateURL(existentTabURL, tabsUpdateURL, isErrorExpected) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["tabs"], + }, + + files: { + "tab.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>tab page</h1> + </body> + </html> + `.trim(), + }, + background: function() { + browser.test.sendMessage("ready", browser.runtime.getURL("tab.html")); + + browser.test.onMessage.addListener(async (msg, tabsUpdateURL, isErrorExpected) => { + const tabs = await browser.tabs.query({lastFocusedWindow: true}); + + try { + const tab = await browser.tabs.update(tabs[0].id, {url: tabsUpdateURL}); + + browser.test.assertFalse(isErrorExpected, `tabs.update with URL ${tabsUpdateURL} should be rejected`); + browser.test.assertTrue(tab, "on success the tab should be defined"); + } catch (error) { + browser.test.assertTrue(isErrorExpected, `tabs.update with URL ${tabsUpdateURL} should not be rejected`); + browser.test.assertTrue(/^Illegal URL/.test(error.message), + "tabs.update should be rejected with the expected error message"); + } + + browser.test.sendMessage("done"); + }); + }, + }); + + await extension.startup(); + + const mozExtTabURL = await extension.awaitMessage("ready"); + + if (tabsUpdateURL == "self") { + tabsUpdateURL = mozExtTabURL; + } + + info(`tab.update URL "${tabsUpdateURL}" on tab with URL "${existentTabURL}"`); + + const tab1 = window.open(existentTabURL); + + extension.sendMessage("start", tabsUpdateURL, isErrorExpected); + await extension.awaitMessage("done"); + + tab1.close(); + await extension.unload(); +} + +add_task(async function() { + info("Start testing tabs.update on javascript URLs"); + + const dataURLPage = `data:text/html, + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>data url page</h1> + </body> + </html>`; + + const checkList = [ + { + tabsUpdateURL: "http://example.net", + isErrorExpected: false, + }, + { + tabsUpdateURL: "self", + isErrorExpected: false, + }, + { + tabsUpdateURL: "about:addons", + isErrorExpected: true, + }, + { + tabsUpdateURL: "javascript:console.log('tabs.update execute javascript')", + isErrorExpected: true, + }, + { + tabsUpdateURL: dataURLPage, + isErrorExpected: true, + }, + ]; + + const testCases = checkList + .map((check) => Object.assign({}, check, {existentTabURL: "about:blank"})); + + for (const {existentTabURL, tabsUpdateURL, isErrorExpected} of testCases) { + await testTabsUpdateURL(existentTabURL, tabsUpdateURL, isErrorExpected); + } + + info("done"); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/mochitest/test_ext_webNavigation_onCommitted.html b/mobile/android/components/extensions/test/mochitest/test_ext_webNavigation_onCommitted.html new file mode 100644 index 0000000000..33f178492d --- /dev/null +++ b/mobile/android/components/extensions/test/mochitest/test_ext_webNavigation_onCommitted.html @@ -0,0 +1,50 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>WebNavigation onCommitted Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation", "tabs"], + }, + async background() { + const url = "http://mochi.test:8888/"; + const [tab, tabDetails] = await Promise.all([ + browser.tabs.create({url}), + new Promise(resolve => { + browser.webNavigation.onCommitted.addListener(details => { + if (details.url === "about:blank") { + // skip initial about:blank + return; + } + resolve(details); + }); + }), + ]); + + browser.test.assertEq(url, tabDetails.url, "webNavigation.onCommitted detects correct url"); + browser.test.assertEq(tab.id, tabDetails.tabId, "webNavigation.onCommitted fire for proper tabId"); + await browser.tabs.remove(tab.id); + browser.test.notifyPass("webNavigation.onCommitted"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("webNavigation.onCommitted"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/mobile/android/components/extensions/test/xpcshell/head.js b/mobile/android/components/extensions/test/xpcshell/head.js new file mode 100644 index 0000000000..a07a90cff9 --- /dev/null +++ b/mobile/android/components/extensions/test/xpcshell/head.js @@ -0,0 +1,30 @@ +"use strict"; + +/* exported createHttpServer */ + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +// eslint-disable-next-line no-unused-vars +XPCOMUtils.defineLazyModuleGetters(this, { + AddonTestUtils: "resource://testing-common/AddonTestUtils.jsm", + ExtensionTestUtils: "resource://testing-common/ExtensionXPCShellUtils.jsm", +}); + +// Remove this pref once bug 1535365 is fixed. +Services.prefs.setBoolPref("extensions.webextensions.remote", false); + +// https_first automatically upgrades http to https, but the tests are not +// designed to expect that. And it is not easy to change that because +// nsHttpServer does not support https (bug 1742061). So disable https_first. +Services.prefs.setBoolPref("dom.security.https_first", false); + +ExtensionTestUtils.init(this); + +Services.io.offline = true; + +var createHttpServer = (...args) => { + AddonTestUtils.maybeInit(this); + return AddonTestUtils.createHttpServer(...args); +}; diff --git a/mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_permissions.js b/mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_permissions.js new file mode 100644 index 0000000000..2c7ac3cc2f --- /dev/null +++ b/mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_permissions.js @@ -0,0 +1,165 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/dum", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +async function testNativeMessaging({ + isPrivileged = false, + permissions, + testBackground, + testContent, +}) { + async function runTest(testFn, completionMessage) { + try { + dump(`Running test before sending ${completionMessage}\n`); + await testFn(); + } catch (e) { + browser.test.fail(`Unexpected error: ${e}`); + } + browser.test.sendMessage(completionMessage); + } + const extension = ExtensionTestUtils.loadExtension({ + isPrivileged, + background: `(${runTest})(${testBackground}, "background_done");`, + manifest: { + content_scripts: [ + { + run_at: "document_end", + js: ["test.js"], + matches: ["http://example.com/dummy"], + }, + ], + permissions, + }, + files: { + "test.js": `(${runTest})(${testContent}, "content_done");`, + }, + }); + + // Run background script. + await extension.startup(); + await extension.awaitMessage("background_done"); + + // Run content script. + const page = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("content_done"); + await page.close(); + + await extension.unload(); +} + +// Checks that unprivileged extensions cannot use any of the nativeMessaging +// APIs on Android. +add_task(async function test_nativeMessaging_unprivileged() { + function testScript() { + browser.test.assertEq( + browser.runtime.connectNative, + undefined, + "connectNative should not be available in unprivileged extensions" + ); + browser.test.assertEq( + browser.runtime.sendNativeMessage, + undefined, + "sendNativeMessage should not be available in unprivileged extensions" + ); + } + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await testNativeMessaging({ + isPrivileged: false, + permissions: [ + "geckoViewAddons", + "nativeMessaging", + "nativeMessagingFromContent", + ], + testBackground: testScript, + testContent: testScript, + }); + }); + AddonTestUtils.checkMessages(messages, { + expected: [ + { message: /Invalid extension permission: geckoViewAddons/ }, + { message: /Invalid extension permission: nativeMessaging/ }, + { message: /Invalid extension permission: nativeMessagingFromContent/ }, + ], + }); +}); + +// Checks that privileged extensions can still not use native messaging without +// the geckoViewAddons permission. +add_task(async function test_geckoViewAddons_missing() { + const ERROR_NATIVE_MESSAGE_FROM_BACKGROUND = + "Native manifests are not supported on android"; + const ERROR_NATIVE_MESSAGE_FROM_CONTENT = /^Native messaging not allowed: \{.*"envType":"content_child","url":"http:\/\/example\.com\/dummy"\}$/; + + async function testBackground() { + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("dummy_nativeApp", "DummyMsg"), + // Redacted error: ERROR_NATIVE_MESSAGE_FROM_BACKGROUND + "An unexpected error occurred", + "Background script cannot use nativeMessaging without geckoViewAddons" + ); + } + async function testContent() { + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("dummy_nativeApp", "DummyMsg"), + // Redacted error: ERROR_NATIVE_MESSAGE_FROM_CONTENT + "An unexpected error occurred", + "Content script cannot use nativeMessaging without geckoViewAddons" + ); + } + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await testNativeMessaging({ + isPrivileged: true, + permissions: ["nativeMessaging", "nativeMessagingFromContent"], + testBackground, + testContent, + }); + }); + AddonTestUtils.checkMessages(messages, { + expected: [ + { errorMessage: ERROR_NATIVE_MESSAGE_FROM_BACKGROUND }, + { errorMessage: ERROR_NATIVE_MESSAGE_FROM_CONTENT }, + ], + }); +}); + +// Checks that privileged extensions cannot use native messaging from content +// without the nativeMessagingFromContent permission. +add_task(async function test_nativeMessagingFromContent_missing() { + const ERROR_NATIVE_MESSAGE_FROM_CONTENT_NO_PERM = /^Unexpected messaging sender: \{.*"envType":"content_child","url":"http:\/\/example\.com\/dummy"\}$/; + function testBackground() { + // sendNativeMessage / connectNative are expected to succeed, but we + // are not testing that here because XpcshellTestRunnerService does not + // have a WebExtension.MessageDelegate that handles the message. + // There are plenty of mochitests that rely on connectNative, so we are + // not testing that here. + } + async function testContent() { + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("dummy_nativeApp", "DummyMsg"), + // Redacted error: ERROR_NATIVE_MESSAGE_FROM_CONTENT_NO_PERM + "An unexpected error occurred", + "Trying to get through to native messaging but without luck" + ); + } + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await testNativeMessaging({ + isPrivileged: true, + permissions: ["geckoViewAddons", "nativeMessaging"], + testBackground, + testContent, + }); + }); + AddonTestUtils.checkMessages(messages, { + expected: [{ errorMessage: ERROR_NATIVE_MESSAGE_FROM_CONTENT_NO_PERM }], + }); +}); diff --git a/mobile/android/components/extensions/test/xpcshell/xpcshell.ini b/mobile/android/components/extensions/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..f004140076 --- /dev/null +++ b/mobile/android/components/extensions/test/xpcshell/xpcshell.ini @@ -0,0 +1,7 @@ +[DEFAULT] +head = head.js +firefox-appdir = browser +tags = webextensions in-process-webextensions +skip-if = os != "android" + +[test_ext_native_messaging_permissions.js] diff --git a/mobile/android/components/geckoview/ColorPickerDelegate.jsm b/mobile/android/components/geckoview/ColorPickerDelegate.jsm new file mode 100644 index 0000000000..b72662a316 --- /dev/null +++ b/mobile/android/components/geckoview/ColorPickerDelegate.jsm @@ -0,0 +1,43 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["ColorPickerDelegate"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("ColorPickerDelegate"); + +class ColorPickerDelegate { + // TODO(bug 1805397): Implement default colors + init(aParent, aTitle, aInitialColor, aDefaultColors) { + this._prompt = new lazy.GeckoViewPrompter(aParent); + this._msg = { + type: "color", + title: aTitle, + value: aInitialColor, + predefinedValues: aDefaultColors, + }; + } + + open(aColorPickerShownCallback) { + this._prompt.asyncShowPrompt(this._msg, result => { + // OK: result + // Cancel: !result + aColorPickerShownCallback.done((result && result.color) || ""); + }); + } +} + +ColorPickerDelegate.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIColorPicker", +]); diff --git a/mobile/android/components/geckoview/FilePickerDelegate.jsm b/mobile/android/components/geckoview/FilePickerDelegate.jsm new file mode 100644 index 0000000000..32966b65e6 --- /dev/null +++ b/mobile/android/components/geckoview/FilePickerDelegate.jsm @@ -0,0 +1,194 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["FilePickerDelegate"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("FilePickerDelegate"); + +class FilePickerDelegate { + /* ---------- nsIFilePicker ---------- */ + init(aParent, aTitle, aMode) { + if ( + aMode === Ci.nsIFilePicker.modeGetFolder || + aMode === Ci.nsIFilePicker.modeSave + ) { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + this._prompt = new lazy.GeckoViewPrompter(aParent); + this._msg = { + type: "file", + title: aTitle, + mode: aMode === Ci.nsIFilePicker.modeOpenMultiple ? "multiple" : "single", + }; + this._mode = aMode; + this._mimeTypes = []; + this._capture = 0; + } + + get mode() { + return this._mode; + } + + appendRawFilter(aFilter) { + this._mimeTypes.push(aFilter); + } + + show() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + open(aFilePickerShownCallback) { + this._msg.mimeTypes = this._mimeTypes; + this._msg.capture = this._capture; + this._prompt.asyncShowPrompt(this._msg, result => { + // OK: result + // Cancel: !result + if (!result || !result.files || !result.files.length) { + aFilePickerShownCallback.done(Ci.nsIFilePicker.returnCancel); + } else { + this._resolveFiles(result.files, aFilePickerShownCallback); + } + }); + } + + async _resolveFiles(aFiles, aCallback) { + const fileData = []; + + try { + for (const file of aFiles) { + const domFile = await this._getDOMFile(file); + fileData.push({ + file, + domFile, + }); + } + } catch (ex) { + warn`Error resolving files from file picker: ${ex}`; + aCallback.done(Ci.nsIFilePicker.returnCancel); + return; + } + + this._fileData = fileData; + aCallback.done(Ci.nsIFilePicker.returnOK); + } + + get file() { + if (!this._fileData) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + const fileData = this._fileData[0]; + if (!fileData) { + return null; + } + return new lazy.FileUtils.File(fileData.file); + } + + get fileURL() { + return Services.io.newFileURI(this.file); + } + + *_getEnumerator(aDOMFile) { + if (!this._fileData) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + for (const fileData of this._fileData) { + if (aDOMFile) { + yield fileData.domFile; + } + yield new lazy.FileUtils.File(fileData.file); + } + } + + get files() { + return this._getEnumerator(/* aDOMFile */ false); + } + + _getDOMFile(aPath) { + if (this._prompt.domWin) { + return this._prompt.domWin.File.createFromFileName(aPath); + } + return File.createFromFileName(aPath); + } + + get domFileOrDirectory() { + if (!this._fileData) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + return this._fileData[0] ? this._fileData[0].domFile : null; + } + + get domFileOrDirectoryEnumerator() { + return this._getEnumerator(/* aDOMFile */ true); + } + + get defaultString() { + return ""; + } + + set defaultString(aValue) {} + + get defaultExtension() { + return ""; + } + + set defaultExtension(aValue) {} + + get filterIndex() { + return 0; + } + + set filterIndex(aValue) {} + + get displayDirectory() { + return null; + } + + set displayDirectory(aValue) {} + + get displaySpecialDirectory() { + return ""; + } + + set displaySpecialDirectory(aValue) {} + + get addToRecentDocs() { + return false; + } + + set addToRecentDocs(aValue) {} + + get okButtonLabel() { + return ""; + } + + set okButtonLabel(aValue) {} + + get capture() { + return this._capture; + } + + set capture(aValue) { + this._capture = aValue; + } +} + +FilePickerDelegate.prototype.classID = Components.ID( + "{e4565e36-f101-4bf5-950b-4be0887785a9}" +); +FilePickerDelegate.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIFilePicker", +]); diff --git a/mobile/android/components/geckoview/GeckoView.manifest b/mobile/android/components/geckoview/GeckoView.manifest new file mode 100644 index 0000000000..a26ffe2a05 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoView.manifest @@ -0,0 +1,4 @@ +# GeckoViewStartup.js +category app-startup GeckoViewStartup service,@mozilla.org/geckoview/startup;1 process=main +category content-process-ready-for-script GeckoViewStartup @mozilla.org/geckoview/startup;1 process=content +category profile-after-change GeckoViewStartup @mozilla.org/geckoview/startup;1 process=main diff --git a/mobile/android/components/geckoview/GeckoViewExternalAppService.cpp b/mobile/android/components/geckoview/GeckoViewExternalAppService.cpp new file mode 100644 index 0000000000..30dedc9251 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewExternalAppService.cpp @@ -0,0 +1,100 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * 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/. */ + +#include "GeckoViewExternalAppService.h" + +#include "mozilla/dom/BrowsingContext.h" +#include "mozilla/dom/CanonicalBrowsingContext.h" +#include "mozilla/dom/WindowGlobalParent.h" +#include "nsIChannel.h" + +#include "mozilla/widget/EventDispatcher.h" +#include "mozilla/widget/nsWindow.h" +#include "GeckoViewStreamListener.h" + +#include "JavaBuiltins.h" + +class StreamListener final : public mozilla::GeckoViewStreamListener { + public: + explicit StreamListener(nsWindow* aWindow) + : GeckoViewStreamListener(), mWindow(aWindow) {} + + void SendWebResponse(mozilla::java::WebResponse::Param aResponse) { + mWindow->PassExternalResponse(aResponse); + } + + void CompleteWithError(nsresult aStatus, nsIChannel* aChannel) { + // Currently we don't do anything about errors here + } + + virtual ~StreamListener() {} + + private: + RefPtr<nsWindow> mWindow; +}; + +mozilla::StaticRefPtr<GeckoViewExternalAppService> + GeckoViewExternalAppService::sService; + +/* static */ +already_AddRefed<GeckoViewExternalAppService> +GeckoViewExternalAppService::GetSingleton() { + if (!sService) { + sService = new GeckoViewExternalAppService(); + } + RefPtr<GeckoViewExternalAppService> service = sService; + return service.forget(); +} + +GeckoViewExternalAppService::GeckoViewExternalAppService() {} + +NS_IMPL_ISUPPORTS(GeckoViewExternalAppService, nsIExternalHelperAppService); + +NS_IMETHODIMP GeckoViewExternalAppService::DoContent( + const nsACString& aMimeContentType, nsIRequest* aRequest, + nsIInterfaceRequestor* aContentContext, bool aForceSave, + nsIInterfaceRequestor* aWindowContext, + nsIStreamListener** aStreamListener) { + return NS_ERROR_FAILURE; +} + +NS_IMETHODIMP GeckoViewExternalAppService::CreateListener( + const nsACString& aMimeContentType, nsIRequest* aRequest, + mozilla::dom::BrowsingContext* aContentContext, bool aForceSave, + nsIInterfaceRequestor* aWindowContext, + nsIStreamListener** aStreamListener) { + using namespace mozilla; + using namespace mozilla::dom; + MOZ_ASSERT(XRE_IsParentProcess()); + + nsresult rv; + nsCOMPtr<nsIChannel> channel(do_QueryInterface(aRequest, &rv)); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<nsIWidget> widget = + aContentContext->Canonical()->GetParentProcessWidgetContaining(); + if (!widget) { + return NS_ERROR_ABORT; + } + + RefPtr<nsWindow> window = nsWindow::From(widget); + MOZ_ASSERT(window); + + RefPtr<StreamListener> listener = new StreamListener(window); + + rv = channel->SetNotificationCallbacks(listener); + NS_ENSURE_SUCCESS(rv, rv); + + listener.forget(aStreamListener); + return NS_OK; +} + +NS_IMETHODIMP GeckoViewExternalAppService::ApplyDecodingForExtension( + const nsACString& aExtension, const nsACString& aEncodingType, + bool* aApplyDecoding) { + // This currently doesn't matter, because we never read the stream. + *aApplyDecoding = true; + return NS_OK; +} diff --git a/mobile/android/components/geckoview/GeckoViewExternalAppService.h b/mobile/android/components/geckoview/GeckoViewExternalAppService.h new file mode 100644 index 0000000000..1dfb7c9491 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewExternalAppService.h @@ -0,0 +1,26 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- + * 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/. */ + +#ifndef GeckoViewExternalAppService_h__ +#define GeckoViewExternalAppService_h__ + +#include "nsIExternalHelperAppService.h" +#include "mozilla/StaticPtr.h" + +class GeckoViewExternalAppService : public nsIExternalHelperAppService { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIEXTERNALHELPERAPPSERVICE + + GeckoViewExternalAppService(); + + static already_AddRefed<GeckoViewExternalAppService> GetSingleton(); + + private: + virtual ~GeckoViewExternalAppService() {} + static mozilla::StaticRefPtr<GeckoViewExternalAppService> sService; +}; + +#endif diff --git a/mobile/android/components/geckoview/GeckoViewHistory.cpp b/mobile/android/components/geckoview/GeckoViewHistory.cpp new file mode 100644 index 0000000000..c69e419b18 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewHistory.cpp @@ -0,0 +1,508 @@ +/* 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/. */ + +#include "GeckoViewHistory.h" + +#include "JavaBuiltins.h" +#include "jsapi.h" +#include "js/Array.h" // JS::GetArrayLength, JS::IsArrayObject +#include "js/PropertyAndElement.h" // JS_GetElement +#include "nsIURI.h" +#include "nsXULAppAPI.h" + +#include "mozilla/ClearOnShutdown.h" +#include "mozilla/ResultExtensions.h" +#include "mozilla/StaticPrefs_layout.h" + +#include "mozilla/dom/ContentParent.h" +#include "mozilla/dom/Element.h" +#include "mozilla/dom/Link.h" +#include "mozilla/dom/BrowserChild.h" + +#include "mozilla/ipc/URIUtils.h" + +#include "mozilla/widget/EventDispatcher.h" +#include "mozilla/widget/nsWindow.h" + +using namespace mozilla; +using namespace mozilla::dom; +using namespace mozilla::ipc; +using namespace mozilla::widget; + +static const char16_t kOnVisitedMessage[] = u"GeckoView:OnVisited"; +static const char16_t kGetVisitedMessage[] = u"GeckoView:GetVisited"; + +// Keep in sync with `GeckoSession.HistoryDelegate.VisitFlags`. +enum class GeckoViewVisitFlags : int32_t { + VISIT_TOP_LEVEL = 1 << 0, + VISIT_REDIRECT_TEMPORARY = 1 << 1, + VISIT_REDIRECT_PERMANENT = 1 << 2, + VISIT_REDIRECT_SOURCE = 1 << 3, + VISIT_REDIRECT_SOURCE_PERMANENT = 1 << 4, + VISIT_UNRECOVERABLE_ERROR = 1 << 5, +}; + +GeckoViewHistory::GeckoViewHistory() {} + +GeckoViewHistory::~GeckoViewHistory() {} + +NS_IMPL_ISUPPORTS(GeckoViewHistory, IHistory) + +StaticRefPtr<GeckoViewHistory> GeckoViewHistory::sHistory; + +/* static */ +already_AddRefed<GeckoViewHistory> GeckoViewHistory::GetSingleton() { + if (!sHistory) { + sHistory = new GeckoViewHistory(); + ClearOnShutdown(&sHistory); + } + RefPtr<GeckoViewHistory> history = sHistory; + return history.forget(); +} + +// Handles a request to fetch visited statuses for new tracked URIs in the +// content process (e10s). +void GeckoViewHistory::QueryVisitedStateInContentProcess( + const PendingVisitedQueries& aQueries) { + // Holds an array of new tracked URIs for a tab in the content process. + struct NewURIEntry { + explicit NewURIEntry(BrowserChild* aBrowserChild, nsIURI* aURI) + : mBrowserChild(aBrowserChild) { + AddURI(aURI); + } + + void AddURI(nsIURI* aURI) { mURIs.AppendElement(aURI); } + + BrowserChild* mBrowserChild; + nsTArray<RefPtr<nsIURI>> mURIs; + }; + + MOZ_ASSERT(XRE_IsContentProcess()); + + // First, serialize all the new URIs that we need to look up. Note that this + // could be written as `nsTHashMap<nsUint64HashKey, + // nsTArray<URIParams>` instead, but, since we don't expect to have many tab + // children, we can avoid the cost of hashing. + AutoTArray<NewURIEntry, 8> newEntries; + for (auto& query : aQueries) { + nsIURI* uri = query.GetKey(); + MOZ_ASSERT(query.GetData().IsEmpty(), + "Shouldn't have parents to notify in child processes"); + auto entry = mTrackedURIs.Lookup(uri); + if (!entry) { + continue; + } + ObservingLinks& links = entry.Data(); + for (Link* link : links.mLinks.BackwardRange()) { + nsIWidget* widget = nsContentUtils::WidgetForContent(link->GetElement()); + if (!widget) { + continue; + } + BrowserChild* browserChild = widget->GetOwningBrowserChild(); + if (!browserChild) { + continue; + } + // Add to the list of new URIs for this document, or make a new entry. + bool hasEntry = false; + for (NewURIEntry& entry : newEntries) { + if (entry.mBrowserChild == browserChild) { + entry.AddURI(uri); + hasEntry = true; + break; + } + } + if (!hasEntry) { + newEntries.AppendElement(NewURIEntry(browserChild, uri)); + } + } + } + + // Send the request to the parent process, one message per tab child. + for (const NewURIEntry& entry : newEntries) { + Unused << NS_WARN_IF( + !entry.mBrowserChild->SendQueryVisitedState(entry.mURIs)); + } +} + +// Handles a request to fetch visited statuses for new tracked URIs in the +// parent process (non-e10s). +void GeckoViewHistory::QueryVisitedStateInParentProcess( + const PendingVisitedQueries& aQueries) { + // Holds an array of new URIs for a window in the parent process. Unlike + // the content process case, we don't need to track tab children, since we + // have the outer window and can send the request directly to Java. + struct NewURIEntry { + explicit NewURIEntry(nsIWidget* aWidget, nsIURI* aURI) : mWidget(aWidget) { + AddURI(aURI); + } + + void AddURI(nsIURI* aURI) { mURIs.AppendElement(aURI); } + + nsCOMPtr<nsIWidget> mWidget; + nsTArray<RefPtr<nsIURI>> mURIs; + }; + + MOZ_ASSERT(XRE_IsParentProcess()); + + nsTArray<NewURIEntry> newEntries; + for (const auto& query : aQueries) { + nsIURI* uri = query.GetKey(); + auto entry = mTrackedURIs.Lookup(uri); + if (!entry) { + continue; // Nobody cares about this uri anymore. + } + + ObservingLinks& links = entry.Data(); + nsTObserverArray<Link*>::BackwardIterator linksIter(links.mLinks); + while (linksIter.HasMore()) { + Link* link = linksIter.GetNext(); + + nsIWidget* widget = nsContentUtils::WidgetForContent(link->GetElement()); + if (!widget) { + continue; + } + + bool hasEntry = false; + for (NewURIEntry& entry : newEntries) { + if (entry.mWidget != widget) { + continue; + } + entry.AddURI(uri); + hasEntry = true; + } + if (!hasEntry) { + newEntries.AppendElement(NewURIEntry(widget, uri)); + } + } + } + + for (NewURIEntry& entry : newEntries) { + QueryVisitedState(entry.mWidget, nullptr, std::move(entry.mURIs)); + } +} + +void GeckoViewHistory::StartPendingVisitedQueries( + PendingVisitedQueries&& aQueries) { + if (XRE_IsContentProcess()) { + QueryVisitedStateInContentProcess(aQueries); + } else { + QueryVisitedStateInParentProcess(aQueries); + } +} + +/** + * Called from the session handler for the history delegate, after the new + * visit is recorded. + */ +class OnVisitedCallback final : public nsIAndroidEventCallback { + public: + explicit OnVisitedCallback(GeckoViewHistory* aHistory, nsIURI* aURI) + : mHistory(aHistory), mURI(aURI) {} + + NS_DECL_ISUPPORTS + + NS_IMETHOD + OnSuccess(JS::Handle<JS::Value> aData, JSContext* aCx) override { + Maybe<bool> visitedState = GetVisitedValue(aCx, aData); + JS_ClearPendingException(aCx); + if (visitedState) { + AutoTArray<VisitedURI, 1> visitedURIs; + visitedURIs.AppendElement(VisitedURI{mURI.get(), *visitedState}); + mHistory->HandleVisitedState(visitedURIs, nullptr); + } + return NS_OK; + } + + NS_IMETHOD + OnError(JS::Handle<JS::Value> aData, JSContext* aCx) override { + return NS_OK; + } + + private: + virtual ~OnVisitedCallback() {} + + Maybe<bool> GetVisitedValue(JSContext* aCx, JS::Handle<JS::Value> aData) { + if (NS_WARN_IF(!aData.isBoolean())) { + return Nothing(); + } + return Some(aData.toBoolean()); + } + + RefPtr<GeckoViewHistory> mHistory; + nsCOMPtr<nsIURI> mURI; +}; + +NS_IMPL_ISUPPORTS(OnVisitedCallback, nsIAndroidEventCallback) + +NS_IMETHODIMP +GeckoViewHistory::VisitURI(nsIWidget* aWidget, nsIURI* aURI, + nsIURI* aLastVisitedURI, uint32_t aFlags, + uint64_t aBrowserId) { + if (!aURI) { + return NS_OK; + } + + if (XRE_IsContentProcess()) { + // If we're in the content process, send the visit to the parent. The parent + // will find the matching chrome window for the content process and tab, + // then forward the visit to Java. + if (NS_WARN_IF(!aWidget)) { + return NS_OK; + } + BrowserChild* browserChild = aWidget->GetOwningBrowserChild(); + if (NS_WARN_IF(!browserChild)) { + return NS_OK; + } + Unused << NS_WARN_IF( + !browserChild->SendVisitURI(aURI, aLastVisitedURI, aFlags, aBrowserId)); + return NS_OK; + } + + // Otherwise, we're in the parent process. Wrap the URIs up in a bundle, and + // send them to Java. + MOZ_ASSERT(XRE_IsParentProcess()); + RefPtr<nsWindow> window = nsWindow::From(aWidget); + if (NS_WARN_IF(!window)) { + return NS_OK; + } + widget::EventDispatcher* dispatcher = window->GetEventDispatcher(); + if (NS_WARN_IF(!dispatcher)) { + return NS_OK; + } + + // If nobody is listening for this, we can stop now. + if (!dispatcher->HasListener(kOnVisitedMessage)) { + return NS_OK; + } + + AutoTArray<jni::String::LocalRef, 3> keys; + AutoTArray<jni::Object::LocalRef, 3> values; + + nsAutoCString uriSpec; + if (NS_WARN_IF(NS_FAILED(aURI->GetSpec(uriSpec)))) { + return NS_OK; + } + keys.AppendElement(jni::StringParam(u"url"_ns)); + values.AppendElement(jni::StringParam(uriSpec)); + + if (aLastVisitedURI) { + nsAutoCString lastVisitedURISpec; + if (NS_WARN_IF(NS_FAILED(aLastVisitedURI->GetSpec(lastVisitedURISpec)))) { + return NS_OK; + } + keys.AppendElement(jni::StringParam(u"lastVisitedURL"_ns)); + values.AppendElement(jni::StringParam(lastVisitedURISpec)); + } + + int32_t flags = 0; + if (aFlags & TOP_LEVEL) { + flags |= static_cast<int32_t>(GeckoViewVisitFlags::VISIT_TOP_LEVEL); + } + if (aFlags & REDIRECT_TEMPORARY) { + flags |= + static_cast<int32_t>(GeckoViewVisitFlags::VISIT_REDIRECT_TEMPORARY); + } + if (aFlags & REDIRECT_PERMANENT) { + flags |= + static_cast<int32_t>(GeckoViewVisitFlags::VISIT_REDIRECT_PERMANENT); + } + if (aFlags & REDIRECT_SOURCE) { + flags |= static_cast<int32_t>(GeckoViewVisitFlags::VISIT_REDIRECT_SOURCE); + } + if (aFlags & REDIRECT_SOURCE_PERMANENT) { + flags |= static_cast<int32_t>( + GeckoViewVisitFlags::VISIT_REDIRECT_SOURCE_PERMANENT); + } + if (aFlags & UNRECOVERABLE_ERROR) { + flags |= + static_cast<int32_t>(GeckoViewVisitFlags::VISIT_UNRECOVERABLE_ERROR); + } + keys.AppendElement(jni::StringParam(u"flags"_ns)); + values.AppendElement(java::sdk::Integer::ValueOf(flags)); + + MOZ_ASSERT(keys.Length() == values.Length()); + + auto bundleKeys = jni::ObjectArray::New<jni::String>(keys.Length()); + auto bundleValues = jni::ObjectArray::New<jni::Object>(values.Length()); + for (size_t i = 0; i < keys.Length(); ++i) { + bundleKeys->SetElement(i, keys[i]); + bundleValues->SetElement(i, values[i]); + } + auto bundle = java::GeckoBundle::New(bundleKeys, bundleValues); + + nsCOMPtr<nsIAndroidEventCallback> callback = + new OnVisitedCallback(this, aURI); + + Unused << NS_WARN_IF( + NS_FAILED(dispatcher->Dispatch(kOnVisitedMessage, bundle, callback))); + + return NS_OK; +} + +NS_IMETHODIMP +GeckoViewHistory::SetURITitle(nsIURI* aURI, const nsAString& aTitle) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +/** + * Called from the session handler for the history delegate, with visited + * statuses for all requested URIs. + */ +class GetVisitedCallback final : public nsIAndroidEventCallback { + public: + explicit GetVisitedCallback(GeckoViewHistory* aHistory, + ContentParent* aInterestedProcess, + nsTArray<RefPtr<nsIURI>>&& aURIs) + : mHistory(aHistory), + mInterestedProcess(aInterestedProcess), + mURIs(std::move(aURIs)) {} + + NS_DECL_ISUPPORTS + + NS_IMETHOD + OnSuccess(JS::Handle<JS::Value> aData, JSContext* aCx) override { + nsTArray<VisitedURI> visitedURIs; + if (!ExtractVisitedURIs(aCx, aData, visitedURIs)) { + JS_ClearPendingException(aCx); + return NS_ERROR_FAILURE; + } + IHistory::ContentParentSet interestedProcesses; + if (mInterestedProcess) { + interestedProcesses.Insert(mInterestedProcess); + } + mHistory->HandleVisitedState(visitedURIs, &interestedProcesses); + return NS_OK; + } + + NS_IMETHOD + OnError(JS::Handle<JS::Value> aData, JSContext* aCx) override { + return NS_OK; + } + + private: + virtual ~GetVisitedCallback() {} + + /** + * Unpacks an array of Boolean visited statuses from the session handler into + * an array of `VisitedURI` structs. Each element in the array corresponds to + * a URI in `mURIs`. + * + * Returns `false` on error, `true` if the array is `null` or was successfully + * unpacked. + * + * TODO (bug 1503482): Remove this unboxing. + */ + bool ExtractVisitedURIs(JSContext* aCx, JS::Handle<JS::Value> aData, + nsTArray<VisitedURI>& aVisitedURIs) { + if (aData.isNull()) { + return true; + } + bool isArray = false; + if (NS_WARN_IF(!JS::IsArrayObject(aCx, aData, &isArray))) { + return false; + } + if (NS_WARN_IF(!isArray)) { + return false; + } + JS::Rooted<JSObject*> visited(aCx, &aData.toObject()); + uint32_t length = 0; + if (NS_WARN_IF(!JS::GetArrayLength(aCx, visited, &length))) { + return false; + } + if (NS_WARN_IF(length != mURIs.Length())) { + return false; + } + if (!aVisitedURIs.SetCapacity(length, mozilla::fallible)) { + return false; + } + for (uint32_t i = 0; i < length; ++i) { + JS::Rooted<JS::Value> value(aCx); + if (NS_WARN_IF(!JS_GetElement(aCx, visited, i, &value))) { + JS_ClearPendingException(aCx); + aVisitedURIs.AppendElement(VisitedURI{mURIs[i].get(), false}); + continue; + } + if (NS_WARN_IF(!value.isBoolean())) { + aVisitedURIs.AppendElement(VisitedURI{mURIs[i].get(), false}); + continue; + } + aVisitedURIs.AppendElement(VisitedURI{mURIs[i].get(), value.toBoolean()}); + } + return true; + } + + RefPtr<GeckoViewHistory> mHistory; + RefPtr<ContentParent> mInterestedProcess; + nsTArray<RefPtr<nsIURI>> mURIs; +}; + +NS_IMPL_ISUPPORTS(GetVisitedCallback, nsIAndroidEventCallback) + +/** + * Queries the history delegate to find which URIs have been visited. This + * is always called in the parent process: from `GetVisited` in non-e10s, and + * from `ContentParent::RecvGetVisited` in e10s. + */ +void GeckoViewHistory::QueryVisitedState(nsIWidget* aWidget, + ContentParent* aInterestedProcess, + nsTArray<RefPtr<nsIURI>>&& aURIs) { + MOZ_ASSERT(XRE_IsParentProcess()); + RefPtr<nsWindow> window = nsWindow::From(aWidget); + if (NS_WARN_IF(!window)) { + return; + } + widget::EventDispatcher* dispatcher = window->GetEventDispatcher(); + if (NS_WARN_IF(!dispatcher)) { + return; + } + + // If nobody is listening for this we can stop now + if (!dispatcher->HasListener(kGetVisitedMessage)) { + return; + } + + // Assemble a bundle like `{ urls: ["http://example.com/1", ...] }`. + auto uris = jni::ObjectArray::New<jni::String>(aURIs.Length()); + for (size_t i = 0; i < aURIs.Length(); ++i) { + nsAutoCString uriSpec; + if (NS_WARN_IF(NS_FAILED(aURIs[i]->GetSpec(uriSpec)))) { + continue; + } + jni::String::LocalRef value{jni::StringParam(uriSpec)}; + uris->SetElement(i, value); + } + + auto bundleKeys = jni::ObjectArray::New<jni::String>(1); + jni::String::LocalRef key(jni::StringParam(u"urls"_ns)); + bundleKeys->SetElement(0, key); + + auto bundleValues = jni::ObjectArray::New<jni::Object>(1); + jni::Object::LocalRef value(uris); + bundleValues->SetElement(0, value); + + auto bundle = java::GeckoBundle::New(bundleKeys, bundleValues); + + nsCOMPtr<nsIAndroidEventCallback> callback = + new GetVisitedCallback(this, aInterestedProcess, std::move(aURIs)); + + Unused << NS_WARN_IF( + NS_FAILED(dispatcher->Dispatch(kGetVisitedMessage, bundle, callback))); +} + +/** + * Updates link states for all tracked links, forwarding the visited statuses to + * the content process in e10s. This is always called in the parent process, + * from `VisitedCallback::OnSuccess` and `GetVisitedCallback::OnSuccess`. + */ +void GeckoViewHistory::HandleVisitedState( + const nsTArray<VisitedURI>& aVisitedURIs, + ContentParentSet* aInterestedProcesses) { + MOZ_ASSERT(XRE_IsParentProcess()); + + for (const VisitedURI& visitedURI : aVisitedURIs) { + auto status = + visitedURI.mVisited ? VisitedStatus::Visited : VisitedStatus::Unvisited; + NotifyVisited(visitedURI.mURI, status, aInterestedProcesses); + } +} diff --git a/mobile/android/components/geckoview/GeckoViewHistory.h b/mobile/android/components/geckoview/GeckoViewHistory.h new file mode 100644 index 0000000000..a3b96ba58f --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewHistory.h @@ -0,0 +1,60 @@ +/* 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/. */ + +#ifndef GECKOVIEWHISTORY_H +#define GECKOVIEWHISTORY_H + +#include "mozilla/BaseHistory.h" +#include "nsTObserverArray.h" +#include "nsURIHashKey.h" +#include "nsINamed.h" +#include "nsITimer.h" +#include "nsIURI.h" + +#include "mozilla/StaticPtr.h" + +class nsIWidget; + +namespace mozilla { +namespace dom { +class Document; +} +} // namespace mozilla + +struct VisitedURI { + nsCOMPtr<nsIURI> mURI; + bool mVisited = false; +}; + +class GeckoViewHistory final : public mozilla::BaseHistory { + public: + NS_DECL_ISUPPORTS + + // IHistory + NS_IMETHOD VisitURI(nsIWidget*, nsIURI*, nsIURI* aLastVisitedURI, + uint32_t aFlags, uint64_t aBrowserId) final; + NS_IMETHOD SetURITitle(nsIURI*, const nsAString&) final; + + static already_AddRefed<GeckoViewHistory> GetSingleton(); + + void StartPendingVisitedQueries(PendingVisitedQueries&&) final; + + GeckoViewHistory(); + + void QueryVisitedState(nsIWidget* aWidget, + mozilla::dom::ContentParent* aInterestedProcess, + nsTArray<RefPtr<nsIURI>>&& aURIs); + void HandleVisitedState(const nsTArray<VisitedURI>& aVisitedURIs, + ContentParentSet* aInterestedProcesses); + + private: + virtual ~GeckoViewHistory(); + + void QueryVisitedStateInContentProcess(const PendingVisitedQueries&); + void QueryVisitedStateInParentProcess(const PendingVisitedQueries&); + + static mozilla::StaticRefPtr<GeckoViewHistory> sHistory; +}; + +#endif diff --git a/mobile/android/components/geckoview/GeckoViewOutputStream.cpp b/mobile/android/components/geckoview/GeckoViewOutputStream.cpp new file mode 100644 index 0000000000..3744b8e82d --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewOutputStream.cpp @@ -0,0 +1,56 @@ +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 2; indent-tabs-mode: nil; -*- + * 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/. */ + +#include "GeckoViewOutputStream.h" +#include "mozilla/fallible.h" + +using namespace mozilla; + +NS_IMPL_ISUPPORTS(GeckoViewOutputStream, nsIOutputStream); + +NS_IMETHODIMP +GeckoViewOutputStream::Close() { + mStream->SendEof(); + return NS_OK; +} + +NS_IMETHODIMP +GeckoViewOutputStream::Flush() { return NS_ERROR_NOT_IMPLEMENTED; } + +NS_IMETHODIMP +GeckoViewOutputStream::Write(const char* buf, uint32_t count, + uint32_t* retval) { + jni::ByteArray::LocalRef buffer = jni::ByteArray::New( + reinterpret_cast<const int8_t*>(buf), count, fallible); + if (!buffer) { + return NS_ERROR_OUT_OF_MEMORY; + } + if (NS_FAILED(mStream->AppendBuffer(buffer))) { + // The stream was closed, abort reading this channel. + return NS_BASE_STREAM_CLOSED; + } + // Return amount of bytes written + *retval = count; + + return NS_OK; +} + +NS_IMETHODIMP +GeckoViewOutputStream::WriteFrom(nsIInputStream* fromStream, uint32_t count, + uint32_t* retval) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +GeckoViewOutputStream::WriteSegments(nsReadSegmentFun reader, void* closure, + uint32_t count, uint32_t* retval) { + return NS_ERROR_NOT_IMPLEMENTED; +} + +NS_IMETHODIMP +GeckoViewOutputStream::IsNonBlocking(bool* retval) { + *retval = true; + return NS_OK; +} diff --git a/mobile/android/components/geckoview/GeckoViewOutputStream.h b/mobile/android/components/geckoview/GeckoViewOutputStream.h new file mode 100644 index 0000000000..70ab8a9198 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewOutputStream.h @@ -0,0 +1,29 @@ + +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 2; indent-tabs-mode: nil; -*- + * 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/. */ + +#ifndef GeckoViewOutputStream_h__ +#define GeckoViewOutputStream_h__ + +#include "mozilla/java/GeckoInputStreamNatives.h" +#include "mozilla/java/GeckoInputStreamWrappers.h" + +#include "nsIOutputStream.h" +#include "nsIRequest.h" + +class GeckoViewOutputStream : public nsIOutputStream { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIOUTPUTSTREAM + explicit GeckoViewOutputStream( + mozilla::java::GeckoInputStream::GlobalRef aStream) + : mStream(aStream) {} + + private: + const mozilla::java::GeckoInputStream::GlobalRef mStream; + virtual ~GeckoViewOutputStream() = default; +}; + +#endif // GeckoViewOutputStream_h__ diff --git a/mobile/android/components/geckoview/GeckoViewPermission.jsm b/mobile/android/components/geckoview/GeckoViewPermission.jsm new file mode 100644 index 0000000000..88ab64ab9d --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewPermission.jsm @@ -0,0 +1,41 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["GeckoViewPermission"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +class GeckoViewPermission { + constructor() { + this.wrappedJSObject = this; + } + + async prompt(aRequest) { + const window = aRequest.window + ? aRequest.window + : aRequest.element.ownerGlobal; + + const actor = window.windowGlobalChild.getActor("GeckoViewPermission"); + const result = await actor.promptPermission(aRequest); + if (!result.allow) { + aRequest.cancel(); + } else { + // Note: permission could be undefined, that's what aRequest expects. + const { permission } = result; + aRequest.allow(permission); + } + } +} + +GeckoViewPermission.prototype.classID = Components.ID( + "{42f3c238-e8e8-4015-9ca2-148723a8afcf}" +); +GeckoViewPermission.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIContentPermissionPrompt", +]); + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPermission"); diff --git a/mobile/android/components/geckoview/GeckoViewPrompt.jsm b/mobile/android/components/geckoview/GeckoViewPrompt.jsm new file mode 100644 index 0000000000..6e8d5a5984 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewPrompt.jsm @@ -0,0 +1,820 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["PromptFactory"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPrompt"); + +class PromptFactory { + constructor() { + this.wrappedJSObject = this; + } + + handleEvent(aEvent) { + switch (aEvent.type) { + case "mozshowdropdown": + case "mozshowdropdown-sourcetouch": + this._handleSelect(aEvent.composedTarget, /* aIsDropDown = */ true); + break; + case "MozOpenDateTimePicker": + this._handleDateTime(aEvent.composedTarget); + break; + case "click": + this._handleClick(aEvent); + break; + case "DOMPopupBlocked": + this._handlePopupBlocked(aEvent); + break; + } + } + + _handleClick(aEvent) { + const target = aEvent.composedTarget; + const className = ChromeUtils.getClassName(target); + if (className !== "HTMLInputElement" && className !== "HTMLSelectElement") { + return; + } + + if ( + target.isContentEditable || + target.disabled || + target.readOnly || + !target.willValidate + ) { + // target.willValidate is false when any associated fieldset is disabled, + // in which case this element is treated as disabled per spec. + return; + } + + if (className === "HTMLSelectElement") { + if (!target.isCombobox) { + this._handleSelect(target, /* aIsDropDown = */ false); + return; + } + // combobox select is handled by mozshowdropdown. + return; + } + + const type = target.type; + if (type === "month" || type === "week") { + // If there's a shadow root, the MozOpenDateTimePicker event takes care + // of this. Right now for these input types there's never a shadow root. + // Once we support UA widgets for month/week inputs (see bug 888320), we + // can remove this. + if (!target.openOrClosedShadowRoot) { + this._handleDateTime(target); + aEvent.preventDefault(); + } + } + } + + _generateSelectItems(aElement) { + const win = aElement.ownerGlobal; + let id = 0; + const map = {}; + + const items = (function enumList(elem, disabled) { + const items = []; + const children = elem.children; + for (let i = 0; i < children.length; i++) { + const child = children[i]; + if (win.getComputedStyle(child).display === "none") { + continue; + } + const item = { + id: String(id), + disabled: disabled || child.disabled, + }; + if (win.HTMLOptGroupElement.isInstance(child)) { + item.label = child.label; + item.items = enumList(child, item.disabled); + } else if (win.HTMLOptionElement.isInstance(child)) { + item.label = child.label || child.text; + item.selected = child.selected; + } else { + continue; + } + items.push(item); + map[id++] = child; + } + return items; + })(aElement); + + return [items, map, id]; + } + + _handleSelect(aElement, aIsDropDown) { + const win = aElement.ownerGlobal; + const [items] = this._generateSelectItems(aElement); + + if (aIsDropDown) { + aElement.openInParentProcess = true; + } + + const prompt = new lazy.GeckoViewPrompter(win); + + // Something changed the <select> while it was open. + const deferredUpdate = new lazy.DeferredTask(() => { + // Inner contents in choice prompt are updated. + const [newItems] = this._generateSelectItems(aElement); + prompt.update({ + type: "choice", + mode: aElement.multiple ? "multiple" : "single", + choices: newItems, + }); + }, 0); + const mut = new win.MutationObserver(() => { + deferredUpdate.arm(); + }); + mut.observe(aElement, { + childList: true, + subtree: true, + attributes: true, + }); + + const dismissPrompt = () => prompt.dismiss(); + aElement.addEventListener("blur", dismissPrompt, { mozSystemGroup: true }); + const hidedropdown = event => { + if (aElement === event.target) { + prompt.dismiss(); + } + }; + const chromeEventHandler = aElement.ownerGlobal.docShell.chromeEventHandler; + chromeEventHandler.addEventListener("mozhidedropdown", hidedropdown, { + mozSystemGroup: true, + }); + + prompt.asyncShowPrompt( + { + type: "choice", + mode: aElement.multiple ? "multiple" : "single", + choices: items, + }, + result => { + deferredUpdate.disarm(); + mut.disconnect(); + aElement.removeEventListener("blur", dismissPrompt, { + mozSystemGroup: true, + }); + chromeEventHandler.removeEventListener( + "mozhidedropdown", + hidedropdown, + { mozSystemGroup: true } + ); + + if (aIsDropDown) { + aElement.openInParentProcess = false; + } + // OK: result + // Cancel: !result + if (!result || result.choices === undefined) { + return; + } + + const [, map, id] = this._generateSelectItems(aElement); + let dispatchEvents = false; + if (!aElement.multiple) { + const elem = map[result.choices[0]]; + if (elem && win.HTMLOptionElement.isInstance(elem)) { + dispatchEvents = !elem.selected; + elem.selected = true; + } else { + console.error("Invalid id for select result: " + result.choices[0]); + } + } else { + for (let i = 0; i < id; i++) { + const elem = map[i]; + const index = result.choices.indexOf(String(i)); + if ( + win.HTMLOptionElement.isInstance(elem) && + elem.selected !== index >= 0 + ) { + // Current selected is not the same as the new selected state. + dispatchEvents = true; + elem.selected = !elem.selected; + } + result.choices[index] = undefined; + } + for (let i = 0; i < result.choices.length; i++) { + if (result.choices[i] !== undefined && result.choices[i] !== null) { + console.error( + "Invalid id for select result: " + result.choices[i] + ); + break; + } + } + } + + if (dispatchEvents) { + this._dispatchEvents(aElement); + } + } + ); + } + + _handleDateTime(aElement) { + const prompt = new lazy.GeckoViewPrompter(aElement.ownerGlobal); + + const chromeEventHandler = aElement.ownerGlobal.docShell.chromeEventHandler; + const dismissPrompt = () => prompt.dismiss(); + // Some controls don't have UA widget (bug 888320) + if ( + ["month", "week"].includes(aElement.type) && + !aElement.openOrClosedShadowRoot + ) { + aElement.addEventListener("blur", dismissPrompt, { + mozSystemGroup: true, + }); + } else { + chromeEventHandler.addEventListener( + "MozCloseDateTimePicker", + dismissPrompt + ); + } + + prompt.asyncShowPrompt( + { + type: "datetime", + mode: aElement.type, + value: aElement.value, + min: aElement.min, + max: aElement.max, + step: aElement.step, + }, + result => { + // Some controls don't have UA widget (bug 888320) + if ( + ["month", "week"].includes(aElement.type) && + !aElement.openOrClosedShadowRoot + ) { + aElement.removeEventListener("blur", dismissPrompt, { + mozSystemGroup: true, + }); + } else { + chromeEventHandler.removeEventListener( + "MozCloseDateTimePicker", + dismissPrompt + ); + } + + // OK: result + // Cancel: !result + if ( + !result || + result.datetime === undefined || + result.datetime === aElement.value + ) { + return; + } + aElement.value = result.datetime; + this._dispatchEvents(aElement); + } + ); + } + + _dispatchEvents(aElement) { + // Fire both "input" and "change" events for <select> and <input> for + // date/time. + aElement.dispatchEvent( + new aElement.ownerGlobal.Event("input", { bubbles: true, composed: true }) + ); + aElement.dispatchEvent( + new aElement.ownerGlobal.Event("change", { bubbles: true }) + ); + } + + _handlePopupBlocked(aEvent) { + const dwi = aEvent.requestingWindow; + const popupWindowURISpec = aEvent.popupWindowURI + ? aEvent.popupWindowURI.displaySpec + : "about:blank"; + + const prompt = new lazy.GeckoViewPrompter(aEvent.requestingWindow); + prompt.asyncShowPrompt( + { + type: "popup", + targetUri: popupWindowURISpec, + }, + ({ response }) => { + if (response && dwi) { + dwi.open( + popupWindowURISpec, + aEvent.popupWindowName, + aEvent.popupWindowFeatures + ); + } + } + ); + } + + /* ---------- nsIPromptFactory ---------- */ + getPrompt(aDOMWin, aIID) { + // Delegated to login manager here, which in turn calls back into us via nsIPromptService. + if (aIID.equals(Ci.nsIAuthPrompt2) || aIID.equals(Ci.nsIAuthPrompt)) { + try { + const pwmgr = Cc[ + "@mozilla.org/passwordmanager/authpromptfactory;1" + ].getService(Ci.nsIPromptFactory); + return pwmgr.getPrompt(aDOMWin, aIID); + } catch (e) { + console.error("Delegation to password manager failed: " + e); + } + } + + const p = new PromptDelegate(aDOMWin); + p.QueryInterface(aIID); + return p; + } + + /* ---------- private memebers ---------- */ + + // nsIPromptService methods proxy to our Prompt class + callProxy(aMethod, aArguments) { + const prompt = new PromptDelegate(aArguments[0]); + let promptArgs; + if (BrowsingContext.isInstance(aArguments[0])) { + // Called by BrowsingContext prompt method, strip modalType. + [, , /*browsingContext*/ /*modalType*/ ...promptArgs] = aArguments; + } else { + [, /*domWindow*/ ...promptArgs] = aArguments; + } + return prompt[aMethod].apply(prompt, promptArgs); + } + + /* ---------- nsIPromptService ---------- */ + + alert() { + return this.callProxy("alert", arguments); + } + alertBC() { + return this.callProxy("alert", arguments); + } + alertCheck() { + return this.callProxy("alertCheck", arguments); + } + alertCheckBC() { + return this.callProxy("alertCheck", arguments); + } + confirm() { + return this.callProxy("confirm", arguments); + } + confirmBC() { + return this.callProxy("confirm", arguments); + } + confirmCheck() { + return this.callProxy("confirmCheck", arguments); + } + confirmCheckBC() { + return this.callProxy("confirmCheck", arguments); + } + confirmEx() { + return this.callProxy("confirmEx", arguments); + } + confirmExBC() { + return this.callProxy("confirmEx", arguments); + } + prompt() { + return this.callProxy("prompt", arguments); + } + promptBC() { + return this.callProxy("prompt", arguments); + } + promptUsernameAndPassword() { + return this.callProxy("promptUsernameAndPassword", arguments); + } + promptUsernameAndPasswordBC() { + return this.callProxy("promptUsernameAndPassword", arguments); + } + promptPassword() { + return this.callProxy("promptPassword", arguments); + } + promptPasswordBC() { + return this.callProxy("promptPassword", arguments); + } + select() { + return this.callProxy("select", arguments); + } + selectBC() { + return this.callProxy("select", arguments); + } + promptAuth() { + return this.callProxy("promptAuth", arguments); + } + promptAuthBC() { + return this.callProxy("promptAuth", arguments); + } + asyncPromptAuth() { + return this.callProxy("asyncPromptAuth", arguments); + } +} + +PromptFactory.prototype.classID = Components.ID( + "{076ac188-23c1-4390-aa08-7ef1f78ca5d9}" +); +PromptFactory.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIPromptFactory", + "nsIPromptService", +]); + +class PromptDelegate { + constructor(aParent) { + this._prompter = new lazy.GeckoViewPrompter(aParent); + } + + BUTTON_TYPE_POSITIVE = 0; + BUTTON_TYPE_NEUTRAL = 1; + BUTTON_TYPE_NEGATIVE = 2; + + /* ---------- internal methods ---------- */ + + _addText(aTitle, aText, aMsg) { + return Object.assign(aMsg, { + title: aTitle, + msg: aText, + }); + } + + _addCheck(aCheckMsg, aCheckState, aMsg) { + return Object.assign(aMsg, { + hasCheck: !!aCheckMsg, + checkMsg: aCheckMsg, + checkValue: aCheckState && aCheckState.value, + }); + } + + /* ---------- nsIPrompt ---------- */ + + alert(aTitle, aText) { + this.alertCheck(aTitle, aText); + } + + alertCheck(aTitle, aText, aCheckMsg, aCheckState) { + const result = this._prompter.showPrompt( + this._addText( + aTitle, + aText, + this._addCheck(aCheckMsg, aCheckState, { + type: "alert", + }) + ) + ); + if (result && aCheckState) { + aCheckState.value = !!result.checkValue; + } + } + + confirm(aTitle, aText) { + // Button 0 is OK. + return this.confirmCheck(aTitle, aText); + } + + confirmCheck(aTitle, aText, aCheckMsg, aCheckState) { + // Button 0 is OK. + return ( + this.confirmEx( + aTitle, + aText, + Ci.nsIPrompt.STD_OK_CANCEL_BUTTONS, + /* aButton0 */ null, + /* aButton1 */ null, + /* aButton2 */ null, + aCheckMsg, + aCheckState + ) == 0 + ); + } + + confirmEx( + aTitle, + aText, + aButtonFlags, + aButton0, + aButton1, + aButton2, + aCheckMsg, + aCheckState + ) { + const btnMap = Array(3).fill(null); + const btnTitle = Array(3).fill(null); + const btnCustomTitle = Array(3).fill(null); + const savedButtonId = []; + for (let i = 0; i < 3; i++) { + const btnFlags = aButtonFlags >> (i * 8); + switch (btnFlags & 0xff) { + case Ci.nsIPrompt.BUTTON_TITLE_OK: + btnMap[this.BUTTON_TYPE_POSITIVE] = i; + btnTitle[this.BUTTON_TYPE_POSITIVE] = "ok"; + break; + case Ci.nsIPrompt.BUTTON_TITLE_CANCEL: + btnMap[this.BUTTON_TYPE_NEGATIVE] = i; + btnTitle[this.BUTTON_TYPE_NEGATIVE] = "cancel"; + break; + case Ci.nsIPrompt.BUTTON_TITLE_YES: + btnMap[this.BUTTON_TYPE_POSITIVE] = i; + btnTitle[this.BUTTON_TYPE_POSITIVE] = "yes"; + break; + case Ci.nsIPrompt.BUTTON_TITLE_NO: + btnMap[this.BUTTON_TYPE_NEGATIVE] = i; + btnTitle[this.BUTTON_TYPE_NEGATIVE] = "no"; + break; + case Ci.nsIPrompt.BUTTON_TITLE_IS_STRING: + // We don't know if this is positive/negative/neutral, so save for later. + savedButtonId.push(i); + break; + case Ci.nsIPrompt.BUTTON_TITLE_SAVE: + case Ci.nsIPrompt.BUTTON_TITLE_DONT_SAVE: + case Ci.nsIPrompt.BUTTON_TITLE_REVERT: + // Not supported; fall-through. + default: + break; + } + } + + // Put saved buttons into available slots. + for (let i = 0; i < 3 && savedButtonId.length; i++) { + if (btnMap[i] === null) { + btnMap[i] = savedButtonId.shift(); + btnTitle[i] = "custom"; + btnCustomTitle[i] = [aButton0, aButton1, aButton2][btnMap[i]]; + } + } + + const result = this._prompter.showPrompt( + this._addText( + aTitle, + aText, + this._addCheck(aCheckMsg, aCheckState, { + type: "button", + btnTitle, + btnCustomTitle, + }) + ) + ); + if (result && aCheckState) { + aCheckState.value = !!result.checkValue; + } + return result && result.button in btnMap ? btnMap[result.button] : -1; + } + + prompt(aTitle, aText, aValue, aCheckMsg, aCheckState) { + const result = this._prompter.showPrompt( + this._addText( + aTitle, + aText, + this._addCheck(aCheckMsg, aCheckState, { + type: "text", + value: aValue.value, + }) + ) + ); + // OK: result && result.text !== undefined + // Cancel: result && result.text === undefined + // Error: !result + if (result && aCheckState) { + aCheckState.value = !!result.checkValue; + } + if (!result || result.text === undefined) { + return false; + } + aValue.value = result.text || ""; + return true; + } + + promptPassword(aTitle, aText, aPassword) { + return this._promptUsernameAndPassword( + aTitle, + aText, + /* aUsername */ undefined, + aPassword + ); + } + + promptUsernameAndPassword(aTitle, aText, aUsername, aPassword) { + const msg = { + type: "auth", + mode: aUsername ? "auth" : "password", + options: { + flags: aUsername ? 0 : Ci.nsIAuthInformation.ONLY_PASSWORD, + username: aUsername ? aUsername.value : undefined, + password: aPassword.value, + }, + }; + const result = this._prompter.showPrompt(this._addText(aTitle, aText, msg)); + // OK: result && result.password !== undefined + // Cancel: result && result.password === undefined + // Error: !result + if (!result || result.password === undefined) { + return false; + } + if (aUsername) { + aUsername.value = result.username || ""; + } + aPassword.value = result.password || ""; + return true; + } + + select(aTitle, aText, aSelectList, aOutSelection) { + const choices = Array.prototype.map.call(aSelectList, (item, index) => ({ + id: String(index), + label: item, + disabled: false, + selected: false, + })); + const result = this._prompter.showPrompt( + this._addText(aTitle, aText, { + type: "choice", + mode: "single", + choices, + }) + ); + // OK: result + // Cancel: !result + if (!result || result.choices === undefined) { + return false; + } + aOutSelection.value = Number(result.choices[0]); + return true; + } + + _getAuthMsg(aChannel, aLevel, aAuthInfo) { + let username; + if ( + aAuthInfo.flags & Ci.nsIAuthInformation.NEED_DOMAIN && + aAuthInfo.domain + ) { + username = aAuthInfo.domain + "\\" + aAuthInfo.username; + } else { + username = aAuthInfo.username; + } + return this._addText( + /* title */ null, + this._getAuthText(aChannel, aAuthInfo), + { + type: "auth", + mode: + aAuthInfo.flags & Ci.nsIAuthInformation.ONLY_PASSWORD + ? "password" + : "auth", + options: { + flags: aAuthInfo.flags, + uri: aChannel && aChannel.URI.displaySpec, + level: aLevel, + username, + password: aAuthInfo.password, + }, + } + ); + } + + _fillAuthInfo(aAuthInfo, aResult) { + if (!aResult || aResult.password === undefined) { + return false; + } + + aAuthInfo.password = aResult.password || ""; + if (aAuthInfo.flags & Ci.nsIAuthInformation.ONLY_PASSWORD) { + return true; + } + + const username = aResult.username || ""; + if (aAuthInfo.flags & Ci.nsIAuthInformation.NEED_DOMAIN) { + // Domain is separated from username by a backslash + var idx = username.indexOf("\\"); + if (idx >= 0) { + aAuthInfo.domain = username.substring(0, idx); + aAuthInfo.username = username.substring(idx + 1); + return true; + } + } + aAuthInfo.username = username; + return true; + } + + promptAuth(aChannel, aLevel, aAuthInfo) { + const result = this._prompter.showPrompt( + this._getAuthMsg(aChannel, aLevel, aAuthInfo) + ); + // OK: result && result.password !== undefined + // Cancel: result && result.password === undefined + // Error: !result + return this._fillAuthInfo(aAuthInfo, result); + } + + async asyncPromptAuth(aChannel, aLevel, aAuthInfo) { + const result = await this._prompter.asyncShowPromptPromise( + this._getAuthMsg(aChannel, aLevel, aAuthInfo) + ); + // OK: result && result.password !== undefined + // Cancel: result && result.password === undefined + // Error: !result + return this._fillAuthInfo(aAuthInfo, result); + } + + _getAuthText(aChannel, aAuthInfo) { + const isProxy = aAuthInfo.flags & Ci.nsIAuthInformation.AUTH_PROXY; + const isPassOnly = aAuthInfo.flags & Ci.nsIAuthInformation.ONLY_PASSWORD; + const isCrossOrig = + aAuthInfo.flags & Ci.nsIAuthInformation.CROSS_ORIGIN_SUB_RESOURCE; + + const username = aAuthInfo.username; + const authTarget = this._getAuthTarget(aChannel, aAuthInfo); + const { displayHost } = authTarget; + let { realm } = authTarget; + + // Suppress "the site says: $realm" when we synthesized a missing realm. + if (!aAuthInfo.realm && !isProxy) { + realm = ""; + } + + // Trim obnoxiously long realms. + if (realm.length > 50) { + realm = realm.substring(0, 50) + "\u2026"; + } + + const bundle = Services.strings.createBundle( + "chrome://global/locale/commonDialogs.properties" + ); + let text; + if (isProxy) { + text = bundle.formatStringFromName("EnterLoginForProxy3", [ + realm, + displayHost, + ]); + } else if (isPassOnly) { + text = bundle.formatStringFromName("EnterPasswordFor", [ + username, + displayHost, + ]); + } else if (isCrossOrig) { + text = bundle.formatStringFromName("EnterUserPasswordForCrossOrigin2", [ + displayHost, + ]); + } else if (!realm) { + text = bundle.formatStringFromName("EnterUserPasswordFor2", [ + displayHost, + ]); + } else { + text = bundle.formatStringFromName("EnterLoginForRealm3", [ + realm, + displayHost, + ]); + } + + return text; + } + + _getAuthTarget(aChannel, aAuthInfo) { + // If our proxy is demanding authentication, don't use the + // channel's actual destination. + if (aAuthInfo.flags & Ci.nsIAuthInformation.AUTH_PROXY) { + if (!(aChannel instanceof Ci.nsIProxiedChannel)) { + throw new Error("proxy auth needs nsIProxiedChannel"); + } + const info = aChannel.proxyInfo; + if (!info) { + throw new Error("proxy auth needs nsIProxyInfo"); + } + // Proxies don't have a scheme, but we'll use "moz-proxy://" + // so that it's more obvious what the login is for. + const idnService = Cc["@mozilla.org/network/idn-service;1"].getService( + Ci.nsIIDNService + ); + const displayHost = + "moz-proxy://" + + idnService.convertUTF8toACE(info.host) + + ":" + + info.port; + let realm = aAuthInfo.realm; + if (!realm) { + realm = displayHost; + } + return { displayHost, realm }; + } + + const displayHost = + aChannel.URI.scheme + "://" + aChannel.URI.displayHostPort; + // If a HTTP WWW-Authenticate header specified a realm, that value + // will be available here. If it wasn't set or wasn't HTTP, we'll use + // the formatted hostname instead. + let realm = aAuthInfo.realm; + if (!realm) { + realm = displayHost; + } + return { displayHost, realm }; + } +} + +PromptDelegate.prototype.QueryInterface = ChromeUtils.generateQI(["nsIPrompt"]); diff --git a/mobile/android/components/geckoview/GeckoViewPrompter.sys.mjs b/mobile/android/components/geckoview/GeckoViewPrompter.sys.mjs new file mode 100644 index 0000000000..e6e97822d6 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewPrompter.sys.mjs @@ -0,0 +1,208 @@ +/* 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 { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPrompter"); + +export class GeckoViewPrompter { + constructor(aParent) { + this.id = Services.uuid + .generateUUID() + .toString() + .slice(1, -1); // Discard surrounding braces + + if (aParent) { + if (Window.isInstance(aParent)) { + this._domWin = aParent; + } else if (aParent.window) { + this._domWin = aParent.window; + } else { + this._domWin = + aParent.embedderElement && aParent.embedderElement.ownerGlobal; + } + } + + if (!this._domWin) { + this._domWin = Services.wm.getMostRecentWindow("navigator:geckoview"); + } + + this._innerWindowId = this._domWin?.browsingContext.currentWindowContext.innerWindowId; + } + + get domWin() { + return this._domWin; + } + + get prompterActor() { + const actor = this.domWin?.windowGlobalChild.getActor("GeckoViewPrompter"); + return actor; + } + + _changeModalState(aEntering) { + if (!this._domWin) { + // Allow not having a DOM window. + return true; + } + // Accessing the document object can throw if this window no longer exists. See bug 789888. + try { + const winUtils = this._domWin.windowUtils; + if (!aEntering) { + winUtils.leaveModalState(); + } + + const event = this._domWin.document.createEvent("Events"); + event.initEvent( + aEntering ? "DOMWillOpenModalDialog" : "DOMModalDialogClosed", + true, + true + ); + winUtils.dispatchEventToChromeOnly(this._domWin, event); + + if (aEntering) { + winUtils.enterModalState(); + } + return true; + } catch (ex) { + console.error("Failed to change modal state: " + ex); + } + return false; + } + + _dismissUi() { + this.prompterActor?.dismissPrompt(this); + } + + accept(aInputText = this.inputText) { + if (this.callback) { + let acceptMsg = {}; + switch (this.message.type) { + case "alert": + acceptMsg = null; + break; + case "button": + acceptMsg.button = 0; + break; + case "text": + acceptMsg.text = aInputText; + break; + default: + acceptMsg = null; + break; + } + this.callback(acceptMsg); + // Notify the UI that this prompt should be hidden. + this._dismissUi(); + } + } + + dismiss() { + this.callback(null); + // Notify the UI that this prompt should be hidden. + this._dismissUi(); + } + + getPromptType() { + switch (this.message.type) { + case "alert": + return this.message.checkValue ? "alertCheck" : "alert"; + case "button": + return this.message.checkValue ? "confirmCheck" : "confirm"; + case "text": + return this.message.checkValue ? "promptCheck" : "prompt"; + default: + return this.message.type; + } + } + + getPromptText() { + return this.message.msg; + } + + getInputText() { + return this.inputText; + } + + setInputText(aInput) { + this.inputText = aInput; + } + + /** + * Shows a native prompt, and then spins the event loop for this thread while we wait + * for a response + */ + showPrompt(aMsg) { + let result = undefined; + if (!this._domWin || !this._changeModalState(/* aEntering */ true)) { + return result; + } + try { + this.asyncShowPrompt(aMsg, res => (result = res)); + + // Spin this thread while we wait for a result + Services.tm.spinEventLoopUntil( + "GeckoViewPrompter.jsm:showPrompt", + () => this._domWin.closed || result !== undefined + ); + } finally { + this._changeModalState(/* aEntering */ false); + } + return result; + } + + checkInnerWindow() { + // Checks that the innerWindow where this prompt was created still matches + // the current innerWindow. + // This checks will fail if the page navigates away, making this prompt + // obsolete. + return ( + this._innerWindowId === + this._domWin.browsingContext.currentWindowContext.innerWindowId + ); + } + + asyncShowPromptPromise(aMsg) { + return new Promise(resolve => { + this.asyncShowPrompt(aMsg, resolve); + }); + } + + async asyncShowPrompt(aMsg, aCallback) { + this.message = aMsg; + this.inputText = aMsg.value; + this.callback = aCallback; + + aMsg.id = this.id; + + let response = null; + try { + if (this.checkInnerWindow()) { + response = await this.prompterActor.prompt(this, aMsg); + } + } catch (error) { + // Nothing we can do really, we will treat this as a dismiss. + warn`Error while prompting: ${error}`; + } + + if (!this.checkInnerWindow()) { + // Page has navigated away, let's dismiss the prompt + aCallback(null); + } else { + aCallback(response); + } + // This callback object is tied to the Java garbage collector because + // it is invoked from Java. Manually release the target callback + // here; otherwise we may hold onto resources for too long, because + // we would be relying on both the Java and the JS garbage collectors + // to run. + aMsg = undefined; + aCallback = undefined; + } + + update(aMsg) { + this.message = aMsg; + aMsg.id = this.id; + this.prompterActor?.updatePrompt(aMsg); + } +} diff --git a/mobile/android/components/geckoview/GeckoViewPush.jsm b/mobile/android/components/geckoview/GeckoViewPush.jsm new file mode 100644 index 0000000000..5899bfd3d8 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewPush.jsm @@ -0,0 +1,257 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["PushService"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPush"); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", +}); + +// Observer notification topics for push messages and subscription status +// changes. These are duplicated and used in `nsIPushNotifier`. They're exposed +// on `nsIPushService` so that JS callers only need to import this service. +const OBSERVER_TOPIC_PUSH = "push-message"; +const OBSERVER_TOPIC_SUBSCRIPTION_CHANGE = "push-subscription-change"; +const OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED = "push-subscription-modified"; + +function createSubscription({ + scope, + principal, + browserPublicKey, + authSecret, + endpoint, + appServerKey, +}) { + const decodedBrowserKey = ChromeUtils.base64URLDecode(browserPublicKey, { + padding: "ignore", + }); + const decodedAuthSecret = ChromeUtils.base64URLDecode(authSecret, { + padding: "ignore", + }); + + return new PushSubscription({ + endpoint, + scope, + p256dhKey: decodedBrowserKey, + authenticationSecret: decodedAuthSecret, + appServerKey, + }); +} + +function scopeWithAttrs(scope, attrs) { + return scope + ChromeUtils.originAttributesToSuffix(attrs); +} + +class PushService { + constructor() { + this.wrappedJSObject = this; + } + + pushTopic = OBSERVER_TOPIC_PUSH; + subscriptionChangeTopic = OBSERVER_TOPIC_SUBSCRIPTION_CHANGE; + subscriptionModifiedTopic = OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED; + + // nsIObserver methods + + observe(subject, topic, data) {} + + // nsIPushService methods + + subscribe(scope, principal, callback) { + this.subscribeWithKey(scope, principal, null, callback); + } + + async subscribeWithKey(scope, principal, appServerKey, callback) { + try { + const response = await lazy.EventDispatcher.instance.sendRequestForResult( + { + type: "GeckoView:PushSubscribe", + scope: scopeWithAttrs(scope, principal.originAttributes), + appServerKey: appServerKey + ? ChromeUtils.base64URLEncode(new Uint8Array(appServerKey), { + pad: true, + }) + : null, + } + ); + + let subscription = null; + if (response) { + subscription = createSubscription({ + ...response, + scope, + principal, + appServerKey, + }); + } + + callback.onPushSubscription(Cr.NS_OK, subscription); + } catch (e) { + callback.onPushSubscription(Cr.NS_ERROR_FAILURE, null); + } + } + + async unsubscribe(scope, principal, callback) { + try { + await lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:PushUnsubscribe", + scope: scopeWithAttrs(scope, principal.originAttributes), + }); + + callback.onUnsubscribe(Cr.NS_OK, true); + } catch (e) { + callback.onUnsubscribe(Cr.NS_ERROR_FAILURE, false); + } + } + + async getSubscription(scope, principal, callback) { + try { + const response = await lazy.EventDispatcher.instance.sendRequestForResult( + { + type: "GeckoView:PushGetSubscription", + scope: scopeWithAttrs(scope, principal.originAttributes), + } + ); + + let subscription = null; + if (response) { + subscription = createSubscription({ + ...response, + scope, + principal, + }); + } + + callback.onPushSubscription(Cr.NS_OK, subscription); + } catch (e) { + callback.onPushSubscription(Cr.NS_ERROR_FAILURE, null); + } + } + + clearForDomain(domain, callback) { + callback.onClear(Cr.NS_OK); + } + + // nsIPushQuotaManager methods + + notificationForOriginShown(origin) {} + + notificationForOriginClosed(origin) {} + + // nsIPushErrorReporter methods + + reportDeliveryError(messageId, reason) {} +} + +PushService.prototype.classID = Components.ID( + "{a54d84d7-98a4-4fec-b664-e42e512ae9cc}" +); +PushService.prototype.contractID = "@mozilla.org/push/Service;1"; +PushService.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + "nsIPushService", + "nsIPushQuotaManager", + "nsIPushErrorReporter", +]); + +/** `PushSubscription` instances are passed to all subscription callbacks. */ +class PushSubscription { + constructor(props) { + this._props = props; + } + + /** The URL for sending messages to this subscription. */ + get endpoint() { + return this._props.endpoint; + } + + /** The last time a message was sent to this subscription. */ + get lastPush() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** The total number of messages sent to this subscription. */ + get pushCount() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** + * The app will take care of throttling, so we don't + * care about the quota stuff here. + */ + get quota() { + return -1; + } + + /** + * Indicates whether this subscription was created with the system principal. + * System subscriptions are exempt from the background message quota and + * permission checks. + */ + get isSystemSubscription() { + return false; + } + + /** The private key used to decrypt incoming push messages, in JWK format */ + get p256dhPrivateKey() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + /** + * Indicates whether this subscription is subject to the background message + * quota. + */ + quotaApplies() { + return false; + } + + /** + * Indicates whether this subscription exceeded the background message quota, + * or the user revoked the notification permission. The caller must request a + * new subscription to continue receiving push messages. + */ + isExpired() { + return false; + } + + /** + * Returns a key for encrypting messages sent to this subscription. JS + * callers receive the key buffer as a return value, while C++ callers + * receive the key size and buffer as out parameters. + */ + getKey(name) { + switch (name) { + case "p256dh": + return this._getRawKey(this._props.p256dhKey); + + case "auth": + return this._getRawKey(this._props.authenticationSecret); + + case "appServer": + return this._getRawKey(this._props.appServerKey); + } + return []; + } + + _getRawKey(key) { + if (!key) { + return []; + } + return new Uint8Array(key); + } +} + +PushSubscription.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIPushSubscription", +]); diff --git a/mobile/android/components/geckoview/GeckoViewStartup.jsm b/mobile/android/components/geckoview/GeckoViewStartup.jsm new file mode 100644 index 0000000000..9f0d3934bd --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewStartup.jsm @@ -0,0 +1,333 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["GeckoViewStartup"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ActorManagerParent: "resource://gre/modules/ActorManagerParent.sys.mjs", + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + PdfJs: "resource://pdf.js/PdfJs.jsm", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("Startup"); + +var { DelayedInit } = ChromeUtils.import( + "resource://gre/modules/DelayedInit.jsm" +); + +function InitLater(fn, object, name) { + return DelayedInit.schedule(fn, object, name, 15000 /* 15s max wait */); +} + +const JSPROCESSACTORS = { + GeckoViewPermissionProcess: { + parent: { + moduleURI: "resource:///actors/GeckoViewPermissionProcessParent.jsm", + }, + child: { + moduleURI: "resource:///actors/GeckoViewPermissionProcessChild.jsm", + observers: [ + "getUserMedia:ask-device-permission", + "getUserMedia:request", + "recording-device-events", + "PeerConnection:request", + ], + }, + }, +}; + +const JSWINDOWACTORS = { + LoadURIDelegate: { + parent: { + moduleURI: "resource:///actors/LoadURIDelegateParent.jsm", + }, + child: { + moduleURI: "resource:///actors/LoadURIDelegateChild.jsm", + }, + messageManagerGroups: ["browsers"], + }, + GeckoViewPermission: { + parent: { + moduleURI: "resource:///actors/GeckoViewPermissionParent.jsm", + }, + child: { + moduleURI: "resource:///actors/GeckoViewPermissionChild.jsm", + }, + allFrames: true, + includeChrome: true, + }, + GeckoViewPrompt: { + child: { + moduleURI: "resource:///actors/GeckoViewPromptChild.jsm", + events: { + click: { capture: false, mozSystemGroup: true }, + contextmenu: { capture: false, mozSystemGroup: true }, + mozshowdropdown: {}, + "mozshowdropdown-sourcetouch": {}, + MozOpenDateTimePicker: {}, + DOMPopupBlocked: { capture: false, mozSystemGroup: true }, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], + }, + GeckoViewFormValidation: { + child: { + moduleURI: "resource:///actors/GeckoViewFormValidationChild.jsm", + events: { + MozInvalidForm: {}, + }, + }, + allFrames: true, + messageManagerGroups: ["browsers"], + }, + GeckoViewClipboardPermission: { + parent: { + moduleURI: "resource:///actors/GeckoViewClipboardPermissionParent.jsm", + }, + child: { + moduleURI: "resource:///actors/GeckoViewClipboardPermissionChild.jsm", + events: { + MozClipboardReadPaste: {}, + deactivate: { mozSystemGroup: true }, + mousedown: { capture: true, mozSystemGroup: true }, + mozvisualscroll: { mozSystemGroup: true }, + pagehide: { capture: true, mozSystemGroup: true }, + }, + }, + allFrames: true, + }, +}; + +class GeckoViewStartup { + /* ---------- nsIObserver ---------- */ + observe(aSubject, aTopic, aData) { + debug`observe: ${aTopic}`; + switch (aTopic) { + case "content-process-ready-for-script": + case "app-startup": { + GeckoViewUtils.addLazyGetter(this, "GeckoViewConsole", { + module: "resource://gre/modules/GeckoViewConsole.jsm", + }); + + GeckoViewUtils.addLazyGetter(this, "GeckoViewStorageController", { + module: "resource://gre/modules/GeckoViewStorageController.jsm", + ged: [ + "GeckoView:ClearData", + "GeckoView:ClearSessionContextData", + "GeckoView:ClearHostData", + "GeckoView:ClearBaseDomainData", + "GeckoView:GetAllPermissions", + "GeckoView:GetPermissionsByURI", + "GeckoView:SetPermission", + "GeckoView:SetPermissionByURI", + "GeckoView:GetCookieBannerModeForDomain", + "GeckoView:SetCookieBannerModeForDomain", + "GeckoView:RemoveCookieBannerModeForDomain", + ], + }); + + GeckoViewUtils.addLazyGetter(this, "GeckoViewPushController", { + module: "resource://gre/modules/GeckoViewPushController.jsm", + ged: ["GeckoView:PushEvent", "GeckoView:PushSubscriptionChanged"], + }); + + GeckoViewUtils.addLazyPrefObserver( + { + name: "geckoview.console.enabled", + default: false, + }, + { + handler: _ => this.GeckoViewConsole, + } + ); + + // Parent process only + if ( + Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT + ) { + lazy.ActorManagerParent.addJSWindowActors(JSWINDOWACTORS); + lazy.ActorManagerParent.addJSProcessActors(JSPROCESSACTORS); + + if (Services.appinfo.sessionHistoryInParent) { + GeckoViewUtils.addLazyGetter(this, "GeckoViewSessionStore", { + module: "resource://gre/modules/GeckoViewSessionStore.jsm", + observers: [ + "browsing-context-did-set-embedder", + "browsing-context-discarded", + ], + }); + } + + GeckoViewUtils.addLazyGetter(this, "GeckoViewWebExtension", { + module: "resource://gre/modules/GeckoViewWebExtension.jsm", + ged: [ + "GeckoView:ActionDelegate:Attached", + "GeckoView:BrowserAction:Click", + "GeckoView:PageAction:Click", + "GeckoView:RegisterWebExtension", + "GeckoView:UnregisterWebExtension", + "GeckoView:WebExtension:CancelInstall", + "GeckoView:WebExtension:Disable", + "GeckoView:WebExtension:Enable", + "GeckoView:WebExtension:EnsureBuiltIn", + "GeckoView:WebExtension:Get", + "GeckoView:WebExtension:Install", + "GeckoView:WebExtension:InstallBuiltIn", + "GeckoView:WebExtension:List", + "GeckoView:WebExtension:PortDisconnect", + "GeckoView:WebExtension:PortMessageFromApp", + "GeckoView:WebExtension:SetPBAllowed", + "GeckoView:WebExtension:Uninstall", + "GeckoView:WebExtension:Update", + ], + observers: [ + "devtools-installed-addon", + "testing-installed-addon", + "testing-uninstalled-addon", + ], + }); + + GeckoViewUtils.addLazyGetter(this, "ChildCrashHandler", { + module: "resource://gre/modules/ChildCrashHandler.jsm", + observers: ["ipc:content-shutdown", "compositor:process-aborted"], + }); + + lazy.EventDispatcher.instance.registerListener(this, [ + "GeckoView:StorageDelegate:Attached", + ]); + } + break; + } + + case "profile-after-change": { + GeckoViewUtils.addLazyGetter(this, "GeckoViewRemoteDebugger", { + module: "resource://gre/modules/GeckoViewRemoteDebugger.jsm", + init: gvrd => gvrd.onInit(), + }); + + GeckoViewUtils.addLazyPrefObserver( + { + name: "devtools.debugger.remote-enabled", + default: false, + }, + { + handler: _ => this.GeckoViewRemoteDebugger, + } + ); + + GeckoViewUtils.addLazyGetter(this, "DownloadTracker", { + module: "resource://gre/modules/GeckoViewWebExtension.jsm", + ged: ["GeckoView:WebExtension:DownloadChanged"], + }); + + ChromeUtils.import("resource://gre/modules/NotificationDB.jsm"); + + // Listen for global EventDispatcher messages + lazy.EventDispatcher.instance.registerListener(this, [ + "GeckoView:ResetUserPrefs", + "GeckoView:SetDefaultPrefs", + "GeckoView:SetLocale", + ]); + + Services.obs.addObserver(this, "browser-idle-startup-tasks-finished"); + Services.obs.addObserver(this, "handlersvc-store-initialized"); + + Services.obs.notifyObservers(null, "geckoview-startup-complete"); + break; + } + case "browser-idle-startup-tasks-finished": { + // TODO bug 1730026: when an alternative is introduced that runs once, + // replace this observer topic with that alternative. + // This only needs to happen once during startup. + Services.obs.removeObserver(this, aTopic); + // Notify the start up crash tracker that the browser has successfully + // started up so the startup cache isn't rebuilt on next startup. + Services.startup.trackStartupCrashEnd(); + break; + } + case "handlersvc-store-initialized": { + // Initialize PdfJs when running in-process and remote. This only + // happens once since PdfJs registers global hooks. If the PdfJs + // extension is installed the init method below will be overridden + // leaving initialization to the extension. + // parent only: configure default prefs, set up pref observers, register + // pdf content handler, and initializes parent side message manager + // shim for privileged api access. + try { + lazy.PdfJs.init(this._isNewProfile); + } catch {} + break; + } + } + } + + onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent}`; + + switch (aEvent) { + case "GeckoView:ResetUserPrefs": { + const prefs = new lazy.Preferences(); + prefs.reset(aData.names); + break; + } + case "GeckoView:SetDefaultPrefs": { + const prefs = new lazy.Preferences({ defaultBranch: true }); + for (const name of Object.keys(aData)) { + try { + prefs.set(name, aData[name]); + } catch (e) { + warn`Failed to set preference ${name}: ${e}`; + } + } + break; + } + case "GeckoView:SetLocale": + if (aData.requestedLocales) { + Services.locale.requestedLocales = aData.requestedLocales; + } + const pls = Cc["@mozilla.org/pref-localizedstring;1"].createInstance( + Ci.nsIPrefLocalizedString + ); + pls.data = aData.acceptLanguages; + Services.prefs.setComplexValue( + "intl.accept_languages", + Ci.nsIPrefLocalizedString, + pls + ); + break; + + case "GeckoView:StorageDelegate:Attached": + InitLater(() => { + const loginDetection = Cc[ + "@mozilla.org/login-detection-service;1" + ].createInstance(Ci.nsILoginDetectionService); + loginDetection.init(); + }); + break; + } + } +} + +GeckoViewStartup.prototype.classID = Components.ID( + "{8e993c34-fdd6-432c-967e-f995d888777f}" +); +GeckoViewStartup.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIObserver", +]); diff --git a/mobile/android/components/geckoview/GeckoViewStreamListener.cpp b/mobile/android/components/geckoview/GeckoViewStreamListener.cpp new file mode 100644 index 0000000000..71b9fadeb5 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewStreamListener.cpp @@ -0,0 +1,298 @@ +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 2; indent-tabs-mode: nil; -*- + * 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/. */ + +#include "GeckoViewStreamListener.h" + +#include "mozilla/fallible.h" +#include "nsIAsyncVerifyRedirectCallback.h" +#include "nsIChannelEventSink.h" +#include "nsIHttpChannel.h" +#include "nsIHttpHeaderVisitor.h" +#include "nsIInputStream.h" +#include "nsINSSErrorsService.h" +#include "nsITransportSecurityInfo.h" +#include "nsIWebProgressListener.h" +#include "nsIX509Cert.h" +#include "nsPrintfCString.h" + +#include "nsNetUtil.h" + +#include "JavaBuiltins.h" + +using namespace mozilla; + +NS_IMPL_ISUPPORTS(GeckoViewStreamListener, nsIStreamListener, + nsIInterfaceRequestor, nsIChannelEventSink) + +class HeaderVisitor final : public nsIHttpHeaderVisitor { + public: + NS_DECL_THREADSAFE_ISUPPORTS + + explicit HeaderVisitor(java::WebResponse::Builder::Param aBuilder) + : mBuilder(aBuilder) {} + + NS_IMETHOD + VisitHeader(const nsACString& aHeader, const nsACString& aValue) override { + mBuilder->Header(aHeader, aValue); + return NS_OK; + } + + private: + virtual ~HeaderVisitor() {} + + const java::WebResponse::Builder::GlobalRef mBuilder; +}; + +NS_IMPL_ISUPPORTS(HeaderVisitor, nsIHttpHeaderVisitor) + +class StreamSupport final + : public java::GeckoInputStream::Support::Natives<StreamSupport> { + public: + typedef java::GeckoInputStream::Support::Natives<StreamSupport> Base; + using Base::AttachNative; + using Base::GetNative; + + explicit StreamSupport(java::GeckoInputStream::Support::Param aInstance, + nsIRequest* aRequest) + : mInstance(aInstance), mRequest(aRequest) {} + + void Close() { + mRequest->Cancel(NS_ERROR_ABORT); + mRequest->Resume(); + + // This is basically `delete this`, so don't run anything else! + Base::DisposeNative(mInstance); + } + + void Resume() { mRequest->Resume(); } + + private: + java::GeckoInputStream::Support::GlobalRef mInstance; + nsCOMPtr<nsIRequest> mRequest; +}; + +NS_IMETHODIMP +GeckoViewStreamListener::OnStartRequest(nsIRequest* aRequest) { + MOZ_ASSERT(!mStream); + + nsresult status; + aRequest->GetStatus(&status); + if (NS_FAILED(status)) { + nsCOMPtr<nsIChannel> channel = do_QueryInterface(aRequest); + CompleteWithError(status, channel); + return NS_OK; + } + + // We're expecting data later via OnDataAvailable, so create the stream now. + InitializeStreamSupport(aRequest); + + mStream = java::GeckoInputStream::New(mSupport); + + // Suspend the request immediately. It will be resumed when (if) someone + // tries to read the Java stream. + aRequest->Suspend(); + + nsresult rv = HandleWebResponse(aRequest); + if (NS_FAILED(rv)) { + nsCOMPtr<nsIChannel> channel = do_QueryInterface(aRequest); + CompleteWithError(rv, channel); + return NS_OK; + } + + return NS_OK; +} + +NS_IMETHODIMP +GeckoViewStreamListener::OnStopRequest(nsIRequest* aRequest, + nsresult aStatusCode) { + if (mStream) { + if (NS_FAILED(aStatusCode)) { + mStream->SendError(); + } else { + mStream->SendEof(); + } + } + return NS_OK; +} + +NS_IMETHODIMP GeckoViewStreamListener::OnDataAvailable( + nsIRequest* aRequest, nsIInputStream* aInputStream, uint64_t aOffset, + uint32_t aCount) { + MOZ_ASSERT(mStream); + + // We only need this for the ReadSegments call, the value is unused. + uint32_t countRead; + nsresult rv = + aInputStream->ReadSegments(WriteSegment, this, aCount, &countRead); + NS_ENSURE_SUCCESS(rv, rv); + return rv; +} + +NS_IMETHODIMP +GeckoViewStreamListener::GetInterface(const nsIID& aIID, void** aResultOut) { + if (aIID.Equals(NS_GET_IID(nsIChannelEventSink))) { + *aResultOut = static_cast<nsIChannelEventSink*>(this); + NS_ADDREF_THIS(); + return NS_OK; + } + + return NS_ERROR_NO_INTERFACE; +} + +NS_IMETHODIMP +GeckoViewStreamListener::AsyncOnChannelRedirect( + nsIChannel* aOldChannel, nsIChannel* aNewChannel, uint32_t flags, + nsIAsyncVerifyRedirectCallback* callback) { + callback->OnRedirectVerifyCallback(NS_OK); + return NS_OK; +} + +/* static */ +nsresult GeckoViewStreamListener::WriteSegment( + nsIInputStream* aInputStream, void* aClosure, const char* aFromSegment, + uint32_t aToOffset, uint32_t aCount, uint32_t* aWriteCount) { + GeckoViewStreamListener* self = + static_cast<GeckoViewStreamListener*>(aClosure); + MOZ_ASSERT(self); + MOZ_ASSERT(self->mStream); + + *aWriteCount = aCount; + + jni::ByteArray::LocalRef buffer = jni::ByteArray::New( + reinterpret_cast<signed char*>(const_cast<char*>(aFromSegment)), + *aWriteCount, fallible); + if (!buffer) { + return NS_ERROR_OUT_OF_MEMORY; + } + + if (NS_FAILED(self->mStream->AppendBuffer(buffer))) { + // The stream was closed or something, abort reading this channel. + return NS_ERROR_ABORT; + } + + return NS_OK; +} + +nsresult GeckoViewStreamListener::HandleWebResponse(nsIRequest* aRequest) { + nsresult rv; + + nsCOMPtr<nsIChannel> channel = do_QueryInterface(aRequest, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + // URI + nsCOMPtr<nsIURI> uri; + rv = channel->GetURI(getter_AddRefs(uri)); + NS_ENSURE_SUCCESS(rv, rv); + + nsAutoCString uriSpec; + rv = uri->GetSpec(uriSpec); + NS_ENSURE_SUCCESS(rv, rv); + + java::WebResponse::Builder::LocalRef builder = + java::WebResponse::Builder::New(uriSpec); + + // Body stream + if (mStream) { + builder->Body(mStream); + } + + // Redirected + nsCOMPtr<nsILoadInfo> loadInfo = channel->LoadInfo(); + builder->Redirected(!loadInfo->RedirectChain().IsEmpty()); + + // Secure status + auto [certBytes, isSecure] = CertificateFromChannel(channel); + builder->IsSecure(isSecure); + if (certBytes) { + rv = builder->CertificateBytes(certBytes); + NS_ENSURE_SUCCESS(rv, rv); + } + + // We might need some additional info for response to http/https request + nsCOMPtr<nsIHttpChannel> httpChannel(do_QueryInterface(channel, &rv)); + if (httpChannel) { + // Status code + uint32_t statusCode; + rv = httpChannel->GetResponseStatus(&statusCode); + NS_ENSURE_SUCCESS(rv, rv); + builder->StatusCode(statusCode); + + // Headers + RefPtr<HeaderVisitor> visitor = new HeaderVisitor(builder); + rv = httpChannel->VisitResponseHeaders(visitor); + NS_ENSURE_SUCCESS(rv, rv); + } else { + // Headers for other responses + // try to provide some basic metadata about the response + nsString filename; + if (NS_SUCCEEDED(channel->GetContentDispositionFilename(filename))) { + builder->Header(jni::StringParam(u"content-disposition"_ns), + nsPrintfCString("attachment; filename=\"%s\"", + NS_ConvertUTF16toUTF8(filename).get())); + } + + nsCString contentType; + if (NS_SUCCEEDED(channel->GetContentType(contentType))) { + builder->Header(jni::StringParam(u"content-type"_ns), contentType); + } + + int64_t contentLength = 0; + if (NS_SUCCEEDED(channel->GetContentLength(&contentLength))) { + nsString contentLengthString; + contentLengthString.AppendInt(contentLength); + builder->Header(jni::StringParam(u"content-length"_ns), + contentLengthString); + } + } + + java::WebResponse::GlobalRef response = builder->Build(); + + SendWebResponse(response); + return NS_OK; +} + +void GeckoViewStreamListener::InitializeStreamSupport(nsIRequest* aRequest) { + StreamSupport::Init(); + + mSupport = java::GeckoInputStream::Support::New(); + StreamSupport::AttachNative( + mSupport, mozilla::MakeUnique<StreamSupport>(mSupport, aRequest)); +} + +std::tuple<jni::ByteArray::LocalRef, java::sdk::Boolean::LocalRef> +GeckoViewStreamListener::CertificateFromChannel(nsIChannel* aChannel) { + MOZ_ASSERT(aChannel); + + nsCOMPtr<nsITransportSecurityInfo> securityInfo; + aChannel->GetSecurityInfo(getter_AddRefs(securityInfo)); + if (!securityInfo) { + return std::make_tuple((jni::ByteArray::LocalRef) nullptr, + (java::sdk::Boolean::LocalRef) nullptr); + } + + uint32_t securityState = 0; + securityInfo->GetSecurityState(&securityState); + auto isSecure = securityState == nsIWebProgressListener::STATE_IS_SECURE + ? java::sdk::Boolean::TRUE() + : java::sdk::Boolean::FALSE(); + + nsCOMPtr<nsIX509Cert> cert; + securityInfo->GetServerCert(getter_AddRefs(cert)); + if (!cert) { + return std::make_tuple((jni::ByteArray::LocalRef) nullptr, + (java::sdk::Boolean::LocalRef) nullptr); + } + + nsTArray<uint8_t> derBytes; + nsresult rv = cert->GetRawDER(derBytes); + NS_ENSURE_SUCCESS(rv, + std::make_tuple((jni::ByteArray::LocalRef) nullptr, + (java::sdk::Boolean::LocalRef) nullptr)); + + auto certBytes = jni::ByteArray::New( + reinterpret_cast<const int8_t*>(derBytes.Elements()), derBytes.Length()); + + return std::make_tuple(certBytes, isSecure); +} diff --git a/mobile/android/components/geckoview/GeckoViewStreamListener.h b/mobile/android/components/geckoview/GeckoViewStreamListener.h new file mode 100644 index 0000000000..b42249f458 --- /dev/null +++ b/mobile/android/components/geckoview/GeckoViewStreamListener.h @@ -0,0 +1,57 @@ +/* -*- Mode: c++; c-basic-offset: 2; tab-width: 2; indent-tabs-mode: nil; -*- + * 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/. */ + +#ifndef GeckoViewStreamListener_h__ +#define GeckoViewStreamListener_h__ + +#include "nsIStreamListener.h" +#include "nsIInterfaceRequestor.h" +#include "nsIChannelEventSink.h" + +#include "mozilla/widget/EventDispatcher.h" +#include "mozilla/java/GeckoInputStreamNatives.h" +#include "mozilla/java/WebResponseWrappers.h" + +#include "JavaBuiltins.h" + +namespace mozilla { + +class GeckoViewStreamListener : public nsIStreamListener, + public nsIInterfaceRequestor, + public nsIChannelEventSink { + public: + NS_DECL_THREADSAFE_ISUPPORTS + NS_DECL_NSIREQUESTOBSERVER + NS_DECL_NSISTREAMLISTENER + NS_DECL_NSIINTERFACEREQUESTOR + NS_DECL_NSICHANNELEVENTSINK + + explicit GeckoViewStreamListener() {} + + static std::tuple<jni::ByteArray::LocalRef, java::sdk::Boolean::LocalRef> + CertificateFromChannel(nsIChannel* aChannel); + + protected: + virtual ~GeckoViewStreamListener() {} + + java::GeckoInputStream::GlobalRef mStream; + java::GeckoInputStream::Support::GlobalRef mSupport; + + void InitializeStreamSupport(nsIRequest* aRequest); + + static nsresult WriteSegment(nsIInputStream* aInputStream, void* aClosure, + const char* aFromSegment, uint32_t aToOffset, + uint32_t aCount, uint32_t* aWriteCount); + + virtual nsresult HandleWebResponse(nsIRequest* aRequest); + + virtual void SendWebResponse(java::WebResponse::Param aResponse) = 0; + + virtual void CompleteWithError(nsresult aStatus, nsIChannel* aChannel) = 0; +}; + +} // namespace mozilla + +#endif // GeckoViewStreamListener_h__ diff --git a/mobile/android/components/geckoview/LoginStorageDelegate.jsm b/mobile/android/components/geckoview/LoginStorageDelegate.jsm new file mode 100644 index 0000000000..c5d3b118f4 --- /dev/null +++ b/mobile/android/components/geckoview/LoginStorageDelegate.jsm @@ -0,0 +1,138 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["LoginStorageDelegate"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.jsm", + LoginEntry: "resource://gre/modules/GeckoViewAutocomplete.jsm", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("LoginStorageDelegate"); + +// Sync with LoginSaveOption.Hint in Autocomplete.java. +const LoginStorageHint = { + NONE: 0, + GENERATED: 1 << 0, + LOW_CONFIDENCE: 1 << 1, +}; + +class LoginStorageDelegate { + _createMessage({ dismissed, autoSavedLoginGuid }, aLogins) { + let hint = LoginStorageHint.NONE; + if (dismissed) { + hint |= LoginStorageHint.LOW_CONFIDENCE; + } + if (autoSavedLoginGuid) { + hint |= LoginStorageHint.GENERATED; + } + return { + // Sync with PromptController + type: "Autocomplete:Save:Login", + hint, + logins: aLogins, + }; + } + + promptToSavePassword( + aBrowser, + aLogin, + dismissed = false, + notifySaved = false + ) { + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + prompt.asyncShowPrompt( + this._createMessage({ dismissed }, [ + lazy.LoginEntry.fromLoginInfo(aLogin), + ]), + result => { + const selectedLogin = result?.selection?.value; + + if (!selectedLogin) { + return; + } + + const loginInfo = lazy.LoginEntry.parse(selectedLogin).toLoginInfo(); + Services.obs.notifyObservers(loginInfo, "passwordmgr-prompt-save"); + + lazy.GeckoViewAutocomplete.onLoginSave(selectedLogin); + } + ); + + return { + dismiss() { + prompt.dismiss(); + }, + }; + } + + promptToChangePassword( + aBrowser, + aOldLogin, + aNewLogin, + dismissed = false, + notifySaved = false, + autoSavedLoginGuid = "" + ) { + const newLogin = lazy.LoginEntry.fromLoginInfo(aOldLogin || aNewLogin); + const oldGuid = (aOldLogin && newLogin.guid) || null; + newLogin.origin = aNewLogin.origin; + newLogin.formActionOrigin = aNewLogin.formActionOrigin; + newLogin.password = aNewLogin.password; + newLogin.username = aNewLogin.username; + + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + prompt.asyncShowPrompt( + this._createMessage({ dismissed, autoSavedLoginGuid }, [newLogin]), + result => { + const selectedLogin = result?.selection?.value; + + if (!selectedLogin) { + return; + } + + lazy.GeckoViewAutocomplete.onLoginSave(selectedLogin); + + const loginInfo = lazy.LoginEntry.parse(selectedLogin).toLoginInfo(); + Services.obs.notifyObservers( + loginInfo, + "passwordmgr-prompt-change", + oldGuid + ); + } + ); + + return { + dismiss() { + prompt.dismiss(); + }, + }; + } + + promptToChangePasswordWithUsernames(aBrowser, aLogins, aNewLogin) { + this.promptToChangePassword(aBrowser, null /* oldLogin */, aNewLogin); + } +} + +LoginStorageDelegate.prototype.classID = Components.ID( + "{3d765750-1c3d-11ea-aaef-0800200c9a66}" +); +LoginStorageDelegate.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsILoginManagerPrompter", +]); diff --git a/mobile/android/components/geckoview/PromptCollection.jsm b/mobile/android/components/geckoview/PromptCollection.jsm new file mode 100644 index 0000000000..16ec60fd2e --- /dev/null +++ b/mobile/android/components/geckoview/PromptCollection.jsm @@ -0,0 +1,48 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["PromptCollection"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("PromptCollection"); + +class PromptCollection { + confirmRepost(browsingContext) { + const msg = { + type: "repost", + }; + const prompter = new lazy.GeckoViewPrompter(browsingContext); + const result = prompter.showPrompt(msg); + return !!result?.allow; + } + + asyncBeforeUnloadCheck(browsingContext) { + return new Promise(resolve => { + const msg = { + type: "beforeUnload", + }; + const prompter = new lazy.GeckoViewPrompter(browsingContext); + prompter.asyncShowPrompt(msg, resolve); + }).then(result => !!result?.allow); + } + + confirmFolderUpload() { + // Folder upload is not supported by GeckoView yet, see Bug 1674428. + return false; + } +} + +PromptCollection.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIPromptCollection", +]); diff --git a/mobile/android/components/geckoview/ShareDelegate.jsm b/mobile/android/components/geckoview/ShareDelegate.jsm new file mode 100644 index 0000000000..1e3a133953 --- /dev/null +++ b/mobile/android/components/geckoview/ShareDelegate.jsm @@ -0,0 +1,82 @@ +/* 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"; + +var EXPORTED_SYMBOLS = ["ShareDelegate"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +const domBundle = Services.strings.createBundle( + "chrome://global/locale/dom/dom.properties" +); + +const { debug, warn } = GeckoViewUtils.initLogging("ShareDelegate"); + +class ShareDelegate { + init(aParent) { + this._openerWindow = aParent; + } + + get openerWindow() { + return this._openerWindow; + } + + async share(aTitle, aText, aUri) { + const ABORT = 2; + const FAILURE = 1; + const SUCCESS = 0; + + const msg = { + type: "share", + title: aTitle, + text: aText, + uri: aUri ? aUri.displaySpec : null, + }; + const prompt = new lazy.GeckoViewPrompter(this._openerWindow); + const result = await new Promise(resolve => { + prompt.asyncShowPrompt(msg, resolve); + }); + + if (!result) { + // A null result is treated as a dismissal in GeckoViewPrompter. + throw new DOMException( + domBundle.GetStringFromName("WebShareAPI_Aborted"), + "AbortError" + ); + } + + const res = result && result.response; + switch (res) { + case FAILURE: + throw new DOMException( + domBundle.GetStringFromName("WebShareAPI_Failed"), + "DataError" + ); + case ABORT: // Handle aborted attempt and invalid responses the same. + throw new DOMException( + domBundle.GetStringFromName("WebShareAPI_Aborted"), + "AbortError" + ); + case SUCCESS: + return; + default: + throw new DOMException("Unknown error.", "UnknownError"); + } + } +} + +ShareDelegate.prototype.classID = Components.ID( + "{1201d357-8417-4926-a694-e6408fbedcf8}" +); +ShareDelegate.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsISharePicker", +]); diff --git a/mobile/android/components/geckoview/components.conf b/mobile/android/components/geckoview/components.conf new file mode 100644 index 0000000000..d33567bb74 --- /dev/null +++ b/mobile/android/components/geckoview/components.conf @@ -0,0 +1,93 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +Classes = [ + { + 'cid': '{3e30d2a0-9934-11ea-bb37-0242ac130002}', + 'contract_ids': ['@mozilla.org/embedcomp/prompt-collection;1'], + 'jsm': 'resource://gre/modules/PromptCollection.jsm', + 'constructor': 'PromptCollection', + }, + { + 'js_name': 'prompt', + 'cid': '{076ac188-23c1-4390-aa08-7ef1f78ca5d9}', + 'contract_ids': [ + '@mozilla.org/prompter;1', + ], + 'interfaces': ['nsIPromptService'], + 'jsm': 'resource://gre/modules/GeckoViewPrompt.jsm', + 'constructor': 'PromptFactory', + }, + { + 'cid': '{8e993c34-fdd6-432c-967e-f995d888777f}', + 'contract_ids': ['@mozilla.org/geckoview/startup;1'], + 'jsm': 'resource://gre/modules/GeckoViewStartup.jsm', + 'constructor': 'GeckoViewStartup', + }, + { + 'cid': '{42f3c238-e8e8-4015-9ca2-148723a8afcf}', + 'contract_ids': ['@mozilla.org/content-permission/prompt;1'], + 'jsm': 'resource://gre/modules/GeckoViewPermission.jsm', + 'constructor': 'GeckoViewPermission', + }, + { + 'cid': '{a54d84d7-98a4-4fec-b664-e42e512ae9cc}', + 'contract_ids': ['@mozilla.org/push/Service;1'], + 'jsm': 'resource://gre/modules/GeckoViewPush.jsm', + 'constructor': 'PushService', + }, + { + 'cid': '{fc4bec74-ddd0-4ea8-9a66-9a5081258e32}', + 'contract_ids': ['@mozilla.org/parent/colorpicker;1'], + 'jsm': 'resource://gre/modules/ColorPickerDelegate.jsm', + 'constructor': 'ColorPickerDelegate', + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + }, + { + 'cid': '{25fdbae6-f684-4bf0-b773-ff2b7a6273c8}', + 'contract_ids': ['@mozilla.org/parent/filepicker;1'], + 'jsm': 'resource://gre/modules/FilePickerDelegate.jsm', + 'constructor': 'FilePickerDelegate', + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + }, + { + 'cid': '{1201d357-8417-4926-a694-e6408fbedcf8}', + 'contract_ids': ['@mozilla.org/sharepicker;1'], + 'jsm': 'resource://gre/modules/ShareDelegate.jsm', + 'constructor': 'ShareDelegate', + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + }, + { + 'cid': '{3d765750-1c3d-11ea-aaef-0800200c9a66}', + 'contract_ids': ['@mozilla.org/login-manager/prompter;1'], + 'jsm': 'resource://gre/modules/LoginStorageDelegate.jsm', + 'constructor': 'LoginStorageDelegate', + 'processes': ProcessSelector.MAIN_PROCESS_ONLY, + }, + { + 'cid': '{91455c77-64a1-4c37-be00-f94eb9c7b8e1}', + 'contract_ids': [ + '@mozilla.org/uriloader/external-helper-app-service;1', + ], + 'type': 'GeckoViewExternalAppService', + 'constructor': 'GeckoViewExternalAppService::GetSingleton', + 'headers': ['GeckoViewExternalAppService.h'], + 'processes': ProcessSelector.ALLOW_IN_SOCKET_PROCESS, + }, +] + +if defined('MOZ_ANDROID_HISTORY'): + Classes += [ + { + 'name': 'History', + 'cid': '{0937a705-91a6-417a-8292-b22eb10da86c}', + 'contract_ids': ['@mozilla.org/browser/history;1'], + 'singleton': True, + 'type': 'GeckoViewHistory', + 'headers': ['GeckoViewHistory.h'], + 'constructor': 'GeckoViewHistory::GetSingleton', + }, + ] diff --git a/mobile/android/components/geckoview/moz.build b/mobile/android/components/geckoview/moz.build new file mode 100644 index 0000000000..607b02f367 --- /dev/null +++ b/mobile/android/components/geckoview/moz.build @@ -0,0 +1,49 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +SOURCES += [ + "GeckoViewExternalAppService.cpp", + "GeckoViewOutputStream.cpp", + "GeckoViewStreamListener.cpp", +] + +EXPORTS += [ + "GeckoViewExternalAppService.h", + "GeckoViewOutputStream.h", + "GeckoViewStreamListener.h", +] + +if CONFIG["MOZ_ANDROID_HISTORY"]: + EXPORTS += [ + "GeckoViewHistory.h", + ] + SOURCES += [ + "GeckoViewHistory.cpp", + ] + include("/ipc/chromium/chromium-config.mozbuild") + +XPCOM_MANIFESTS += [ + "components.conf", +] + +EXTRA_COMPONENTS += [ + "GeckoView.manifest", +] + +EXTRA_JS_MODULES += [ + "ColorPickerDelegate.jsm", + "FilePickerDelegate.jsm", + "GeckoViewPermission.jsm", + "GeckoViewPrompt.jsm", + "GeckoViewPrompter.sys.mjs", + "GeckoViewPush.jsm", + "GeckoViewStartup.jsm", + "LoginStorageDelegate.jsm", + "PromptCollection.jsm", + "ShareDelegate.jsm", +] + +FINAL_LIBRARY = "xul" diff --git a/mobile/android/components/moz.build b/mobile/android/components/moz.build new file mode 100644 index 0000000000..a6300de349 --- /dev/null +++ b/mobile/android/components/moz.build @@ -0,0 +1,16 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +with Files("**"): + BUG_COMPONENT = ("GeckoView", "General") + +with Files("extensions/**"): + BUG_COMPONENT = ("WebExtensions", "Android") + +DIRS += [ + "extensions", + "geckoview", +] |