diff options
Diffstat (limited to '')
34 files changed, 7608 insertions, 0 deletions
diff --git a/mobile/android/modules/geckoview/.eslintrc.js b/mobile/android/modules/geckoview/.eslintrc.js new file mode 100644 index 0000000000..47d1b91120 --- /dev/null +++ b/mobile/android/modules/geckoview/.eslintrc.js @@ -0,0 +1,12 @@ +/* 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 = { + globals: { + debug: false, + warn: false, + }, +}; diff --git a/mobile/android/modules/geckoview/AndroidLog.jsm b/mobile/android/modules/geckoview/AndroidLog.jsm new file mode 100644 index 0000000000..fb397a2177 --- /dev/null +++ b/mobile/android/modules/geckoview/AndroidLog.jsm @@ -0,0 +1,89 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 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/. */ +"use strict"; + +/** + * Native Android logging for JavaScript. Lets you specify a priority and tag + * in addition to the message being logged. Resembles the android.util.Log API + * <http://developer.android.com/reference/android/util/Log.html>. + * + * // Import it as a JSM: + * let Log = ChromeUtils.import("resource://gre/modules/AndroidLog.jsm") + * .AndroidLog; + * + * // Use Log.i, Log.v, Log.d, Log.w, and Log.e to log verbose, debug, info, + * // warning, and error messages, respectively. + * Log.v("MyModule", "This is a verbose message."); + * Log.d("MyModule", "This is a debug message."); + * Log.i("MyModule", "This is an info message."); + * Log.w("MyModule", "This is a warning message."); + * Log.e("MyModule", "This is an error message."); + * + * // Bind a function with a tag to replace a bespoke dump/log/debug function: + * let debug = Log.d.bind(null, "MyModule"); + * debug("This is a debug message."); + * // Outputs "D/GeckoMyModule(#####): This is a debug message." + * + * // Or "bind" the module object to a tag to automatically tag messages: + * Log = Log.bind("MyModule"); + * Log.d("This is a debug message."); + * // Outputs "D/GeckoMyModule(#####): This is a debug message." + * + * Note: the module automatically prepends "Gecko" to the tag you specify, + * since all tags used by Fennec code should start with that string; and it + * truncates tags longer than MAX_TAG_LENGTH characters (not including "Gecko"). + */ + +const EXPORTED_SYMBOLS = ["AndroidLog"]; +const { ctypes } = ChromeUtils.import("resource://gre/modules/ctypes.jsm"); + +// From <https://android.googlesource.com/platform/system/core/+/master/include/android/log.h>. +const ANDROID_LOG_VERBOSE = 2; +const ANDROID_LOG_DEBUG = 3; +const ANDROID_LOG_INFO = 4; +const ANDROID_LOG_WARN = 5; +const ANDROID_LOG_ERROR = 6; + +// android.util.Log.isLoggable throws IllegalArgumentException if a tag length +// exceeds 23 characters, and we prepend five characters ("Gecko") to every tag. +// However, __android_log_write itself and other android.util.Log methods don't +// seem to mind longer tags. +const MAX_TAG_LENGTH = 18; + +var liblog = ctypes.open("liblog.so"); // /system/lib/liblog.so +var __android_log_write = liblog.declare( + "__android_log_write", + ctypes.default_abi, + ctypes.int, // return value: num bytes logged + ctypes.int, // priority (ANDROID_LOG_* constant) + ctypes.char.ptr, // tag + ctypes.char.ptr +); // message + +var AndroidLog = { + MAX_TAG_LENGTH, + v: (tag, msg) => __android_log_write(ANDROID_LOG_VERBOSE, "Gecko" + tag, msg), + d: (tag, msg) => __android_log_write(ANDROID_LOG_DEBUG, "Gecko" + tag, msg), + i: (tag, msg) => __android_log_write(ANDROID_LOG_INFO, "Gecko" + tag, msg), + w: (tag, msg) => __android_log_write(ANDROID_LOG_WARN, "Gecko" + tag, msg), + e: (tag, msg) => __android_log_write(ANDROID_LOG_ERROR, "Gecko" + tag, msg), + + bind(tag) { + return { + MAX_TAG_LENGTH, + v: AndroidLog.v.bind(null, tag), + d: AndroidLog.d.bind(null, tag), + i: AndroidLog.i.bind(null, tag), + w: AndroidLog.w.bind(null, tag), + e: AndroidLog.e.bind(null, tag), + }; + }, +}; + +if (typeof Components == "undefined") { + // Specify exported symbols for require.js module loader. + // eslint-disable-next-line no-undef + module.exports = AndroidLog; +} diff --git a/mobile/android/modules/geckoview/BrowserUsageTelemetry.jsm b/mobile/android/modules/geckoview/BrowserUsageTelemetry.jsm new file mode 100644 index 0000000000..a6191d4e57 --- /dev/null +++ b/mobile/android/modules/geckoview/BrowserUsageTelemetry.jsm @@ -0,0 +1,25 @@ +/* -*- js-indent-level: 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["getUniqueDomainsVisitedInPast24Hours"]; + +// Used by nsIBrowserUsage +function getUniqueDomainsVisitedInPast24Hours() { + // The prompting heuristic for the storage access API looks at 1% of the + // number of the domains visited in the past 24 hours, with a minimum cap of + // 5 domains, in order to prevent prompts from showing up before a tracker is + // about to obtain tracking power over a significant portion of the user's + // cross-site browsing activity (that is, we do not want to allow automatic + // access grants over 1% of the domains). We have the + // dom.storage_access.max_concurrent_auto_grants which establishes the + // minimum cap here (set to 5 by default) so if we return 0 here the minimum + // cap would always take effect. That would only become inaccurate if the + // user has browsed more than 500 top-level eTLD's in the past 24 hours, + // which should be a very unlikely scenario on mobile anyway. + + return 0; +} diff --git a/mobile/android/modules/geckoview/ChildCrashHandler.jsm b/mobile/android/modules/geckoview/ChildCrashHandler.jsm new file mode 100644 index 0000000000..295485f655 --- /dev/null +++ b/mobile/android/modules/geckoview/ChildCrashHandler.jsm @@ -0,0 +1,87 @@ +/* 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 = ["ChildCrashHandler"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("ChildCrashHandler"); + +function getDir(name) { + const uAppDataPath = Services.dirsvc.get("UAppData", Ci.nsIFile).path; + return PathUtils.join(uAppDataPath, "Crash Reports", name); +} + +function getPendingMinidump(id) { + const pendingDir = getDir("pending"); + + return [".dmp", ".extra"].map(suffix => { + return PathUtils.join(pendingDir, `${id}${suffix}`); + }); +} + +var ChildCrashHandler = { + // The event listener for this is hooked up in GeckoViewStartup.jsm + observe(aSubject, aTopic, aData) { + if ( + aTopic !== "ipc:content-shutdown" && + aTopic !== "compositor:process-aborted" + ) { + return; + } + + aSubject.QueryInterface(Ci.nsIPropertyBag2); + + const disableReporting = Services.env.get("MOZ_CRASHREPORTER_NO_REPORT"); + + if ( + !aSubject.get("abnormal") || + !AppConstants.MOZ_CRASHREPORTER || + disableReporting + ) { + return; + } + + // If dumpID is empty the process was likely killed by the system and we therefore do not want + // to report the crash. + const dumpID = aSubject.get("dumpID"); + if (!dumpID) { + Services.telemetry + .getHistogramById("FX_CONTENT_CRASH_DUMP_UNAVAILABLE") + .add(1); + return; + } + + debug`Notifying child process crash, dump ID ${dumpID}`; + const [minidumpPath, extrasPath] = getPendingMinidump(dumpID); + + // Report GPU process crashes as occuring in a background process, and others as foreground. + const processType = + aTopic === "compositor:process-aborted" + ? "BACKGROUND_CHILD" + : "FOREGROUND_CHILD"; + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:ChildCrashReport", + minidumpPath, + extrasPath, + success: true, + fatal: false, + processType, + }); + }, +}; diff --git a/mobile/android/modules/geckoview/DelayedInit.jsm b/mobile/android/modules/geckoview/DelayedInit.jsm new file mode 100644 index 0000000000..6386fc6cdb --- /dev/null +++ b/mobile/android/modules/geckoview/DelayedInit.jsm @@ -0,0 +1,193 @@ +/* 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"; + +/* globals MessageLoop */ + +var EXPORTED_SYMBOLS = ["DelayedInit"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "MessageLoop", + "@mozilla.org/message-loop;1", + "nsIMessageLoop" +); + +/** + * Use DelayedInit to schedule initializers to run some time after startup. + * Initializers are added to a list of pending inits. Whenever the main thread + * message loop is idle, DelayedInit will start running initializers from the + * pending list. To prevent monopolizing the message loop, every idling period + * has a maximum duration. When that's reached, we give up the message loop and + * wait for the next idle. + * + * DelayedInit is compatible with lazy getters like those from XPCOMUtils. When + * the lazy getter is first accessed, its corresponding initializer is run + * automatically if it hasn't been run already. Each initializer also has a + * maximum wait parameter that specifies a mandatory timeout; when the timeout + * is reached, the initializer is forced to run. + * + * DelayedInit.schedule(() => Foo.init(), null, null, 5000); + * + * In the example above, Foo.init will run automatically when the message loop + * becomes idle, or when 5000ms has elapsed, whichever comes first. + * + * DelayedInit.schedule(() => Foo.init(), this, "Foo", 5000); + * + * In the example above, Foo.init will run automatically when the message loop + * becomes idle, when |this.Foo| is accessed, or when 5000ms has elapsed, + * whichever comes first. + * + * It may be simpler to have a wrapper for DelayedInit.schedule. For example, + * + * function InitLater(fn, obj, name) { + * return DelayedInit.schedule(fn, obj, name, 5000); // constant max wait + * } + * InitLater(() => Foo.init()); + * InitLater(() => Bar.init(), this, "Bar"); + */ +var DelayedInit = { + schedule(fn, object, name, maxWait) { + return Impl.scheduleInit(fn, object, name, maxWait); + }, + + scheduleList(fns, maxWait) { + for (const fn of fns) { + Impl.scheduleInit(fn, null, null, maxWait); + } + }, +}; + +// Maximum duration for each idling period. Pending inits are run until this +// duration is exceeded; then we wait for next idling period. +const MAX_IDLE_RUN_MS = 50; + +var Impl = { + pendingInits: [], + + onIdle() { + const startTime = Cu.now(); + let time = startTime; + let nextDue; + + // Go through all the pending inits. Even if we don't run them, + // we still need to find out when the next timeout should be. + for (const init of this.pendingInits) { + if (init.complete) { + continue; + } + + if (time - startTime < MAX_IDLE_RUN_MS) { + init.maybeInit(); + time = Cu.now(); + } else { + // We ran out of time; find when the next closest due time is. + nextDue = nextDue ? Math.min(nextDue, init.due) : init.due; + } + } + + // Get rid of completed ones. + this.pendingInits = this.pendingInits.filter(init => !init.complete); + + if (nextDue !== undefined) { + // Schedule the next idle, if we still have pending inits. + lazy.MessageLoop.postIdleTask( + () => this.onIdle(), + Math.max(0, nextDue - time) + ); + } + }, + + addPendingInit(fn, wait) { + const init = { + fn, + due: Cu.now() + wait, + complete: false, + maybeInit() { + if (this.complete) { + return false; + } + this.complete = true; + this.fn.call(); + this.fn = null; + return true; + }, + }; + + if (!this.pendingInits.length) { + // Schedule for the first idle. + lazy.MessageLoop.postIdleTask(() => this.onIdle(), wait); + } + this.pendingInits.push(init); + return init; + }, + + scheduleInit(fn, object, name, wait) { + const init = this.addPendingInit(fn, wait); + + if (!object || !name) { + // No lazy getter needed. + return; + } + + // Get any existing information about the property. + let prop = Object.getOwnPropertyDescriptor(object, name) || { + configurable: true, + enumerable: true, + writable: true, + }; + + if (!prop.configurable) { + // Object.defineProperty won't work, so just perform init here. + init.maybeInit(); + return; + } + + // Define proxy getter/setter that will call first initializer first, + // before delegating the get/set to the original target. + Object.defineProperty(object, name, { + get: function proxy_getter() { + init.maybeInit(); + + // If the initializer actually ran, it may have replaced our proxy + // property with a real one, so we need to reload he property. + const newProp = Object.getOwnPropertyDescriptor(object, name); + if (newProp.get !== proxy_getter) { + // Set prop if newProp doesn't refer to our proxy property. + prop = newProp; + } else { + // Otherwise, reset to the original property. + Object.defineProperty(object, name, prop); + } + + if (prop.get) { + return prop.get.call(object); + } + return prop.value; + }, + set(newVal) { + init.maybeInit(); + + // Since our initializer already ran, + // we can get rid of our proxy property. + if (prop.get || prop.set) { + Object.defineProperty(object, name, prop); + prop.set.call(object); + return; + } + + prop.value = newVal; + Object.defineProperty(object, name, prop); + }, + configurable: true, + enumerable: true, + }); + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewActorChild.sys.mjs b/mobile/android/modules/geckoview/GeckoViewActorChild.sys.mjs new file mode 100644 index 0000000000..8b728e7544 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewActorChild.sys.mjs @@ -0,0 +1,19 @@ +/* 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"; +import { EventDispatcher } from "resource://gre/modules/Messaging.sys.mjs"; + +export class GeckoViewActorChild extends JSWindowActorChild { + static initLogging(aModuleName) { + const tag = aModuleName.replace("GeckoView", "") + "[C]"; + return GeckoViewUtils.initLogging(tag); + } + + actorCreated() { + this.eventDispatcher = EventDispatcher.forActor(this); + } +} + +const { debug, warn } = GeckoViewUtils.initLogging("Actor[C]"); diff --git a/mobile/android/modules/geckoview/GeckoViewActorManager.sys.mjs b/mobile/android/modules/geckoview/GeckoViewActorManager.sys.mjs new file mode 100644 index 0000000000..991dfe7581 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewActorManager.sys.mjs @@ -0,0 +1,27 @@ +/* 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 actors = new Set(); + +export var GeckoViewActorManager = { + addJSWindowActors(actors) { + for (const [actorName, actor] of Object.entries(actors)) { + this._register(actorName, actor); + } + }, + + _register(actorName, actor) { + if (actors.has(actorName)) { + // Actor already registered, nothing to do + return; + } + + ChromeUtils.registerWindowActor(actorName, actor); + actors.add(actorName); + }, +}; + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewActorManager"); diff --git a/mobile/android/modules/geckoview/GeckoViewActorParent.sys.mjs b/mobile/android/modules/geckoview/GeckoViewActorParent.sys.mjs new file mode 100644 index 0000000000..c4569fb052 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewActorParent.sys.mjs @@ -0,0 +1,51 @@ +/* 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"; + +export class GeckoViewActorParent extends JSWindowActorParent { + static initLogging(aModuleName) { + const tag = aModuleName.replace("GeckoView", ""); + return GeckoViewUtils.initLogging(tag); + } + + get browser() { + return this.browsingContext.top.embedderElement; + } + + get window() { + const { browsingContext } = this; + // If this is a chrome actor, the chrome window will be at + // browsingContext.window. + if (!browsingContext.isContent && browsingContext.window) { + return browsingContext.window; + } + return this.browser?.ownerGlobal; + } + + get eventDispatcher() { + return this.window?.moduleManager.eventDispatcher; + } + + receiveMessage(aMessage) { + if (!this.window) { + // If we have no window, it means that this browsingContext has been + // destroyed already and there's nothing to do here for us. + debug`receiveMessage window destroyed ${aMessage.name} ${aMessage.data?.type}`; + return null; + } + + switch (aMessage.name) { + case "DispatcherMessage": + return this.eventDispatcher.sendRequest(aMessage.data); + case "DispatcherQuery": + return this.eventDispatcher.sendRequestForResult(aMessage.data); + } + + // By default messages are forwarded to the module. + return this.window.moduleManager.onMessageFromActor(this.name, aMessage); + } +} + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewActorParent"); diff --git a/mobile/android/modules/geckoview/GeckoViewAutocomplete.jsm b/mobile/android/modules/geckoview/GeckoViewAutocomplete.jsm new file mode 100644 index 0000000000..cb59ea23c2 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewAutocomplete.jsm @@ -0,0 +1,749 @@ +/* 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 = [ + "GeckoViewAutocomplete", + "LoginEntry", + "CreditCard", + "Address", + "SelectOption", +]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "LoginInfo", () => + Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + "nsILoginInfo", + "init" + ) +); + +class LoginEntry { + constructor({ + origin, + formActionOrigin, + httpRealm, + username, + password, + guid, + timeCreated, + timeLastUsed, + timePasswordChanged, + timesUsed, + }) { + this.origin = origin ?? ""; + this.formActionOrigin = formActionOrigin ?? null; + this.httpRealm = httpRealm ?? null; + this.username = username ?? ""; + this.password = password ?? ""; + + // Metadata. + this.guid = guid ?? null; + // TODO: Not supported by GV. + this.timeCreated = timeCreated ?? null; + this.timeLastUsed = timeLastUsed ?? null; + this.timePasswordChanged = timePasswordChanged ?? null; + this.timesUsed = timesUsed ?? null; + } + + toLoginInfo() { + const info = new lazy.LoginInfo( + this.origin, + this.formActionOrigin, + this.httpRealm, + this.username, + this.password + ); + + // Metadata. + info.QueryInterface(Ci.nsILoginMetaInfo); + info.guid = this.guid; + info.timeCreated = this.timeCreated; + info.timeLastUsed = this.timeLastUsed; + info.timePasswordChanged = this.timePasswordChanged; + info.timesUsed = this.timesUsed; + + return info; + } + + static parse(aObj) { + const entry = new LoginEntry({}); + Object.assign(entry, aObj); + + return entry; + } + + static fromLoginInfo(aInfo) { + const entry = new LoginEntry({}); + entry.origin = aInfo.origin; + entry.formActionOrigin = aInfo.formActionOrigin; + entry.httpRealm = aInfo.httpRealm; + entry.username = aInfo.username; + entry.password = aInfo.password; + + // Metadata. + aInfo.QueryInterface(Ci.nsILoginMetaInfo); + entry.guid = aInfo.guid; + entry.timeCreated = aInfo.timeCreated; + entry.timeLastUsed = aInfo.timeLastUsed; + entry.timePasswordChanged = aInfo.timePasswordChanged; + entry.timesUsed = aInfo.timesUsed; + + return entry; + } +} + +class Address { + constructor({ + name, + givenName, + additionalName, + familyName, + organization, + streetAddress, + addressLevel1, + addressLevel2, + addressLevel3, + postalCode, + country, + tel, + email, + guid, + timeCreated, + timeLastUsed, + timeLastModified, + timesUsed, + version, + }) { + this.name = name ?? ""; + this.givenName = givenName ?? ""; + this.additionalName = additionalName ?? ""; + this.familyName = familyName ?? ""; + this.organization = organization ?? ""; + this.streetAddress = streetAddress ?? ""; + this.addressLevel1 = addressLevel1 ?? ""; + this.addressLevel2 = addressLevel2 ?? ""; + this.addressLevel3 = addressLevel3 ?? ""; + this.postalCode = postalCode ?? ""; + this.country = country ?? ""; + this.tel = tel ?? ""; + this.email = email ?? ""; + + // Metadata. + this.guid = guid ?? null; + // TODO: Not supported by GV. + this.timeCreated = timeCreated ?? null; + this.timeLastUsed = timeLastUsed ?? null; + this.timeLastModified = timeLastModified ?? null; + this.timesUsed = timesUsed ?? null; + this.version = version ?? null; + } + + isValid() { + return ( + (this.name ?? this.givenName ?? this.familyName) !== "" && + this.streetAddress !== "" && + this.postalCode !== "" + ); + } + + static fromGecko(aObj) { + return new Address({ + version: aObj.version, + name: aObj.name, + givenName: aObj["given-name"], + additionalName: aObj["additional-name"], + familyName: aObj["family-name"], + organization: aObj.organization, + streetAddress: aObj["street-address"], + addressLevel1: aObj["address-level1"], + addressLevel2: aObj["address-level2"], + addressLevel3: aObj["address-level3"], + postalCode: aObj["postal-code"], + country: aObj.country, + tel: aObj.tel, + email: aObj.email, + guid: aObj.guid, + timeCreated: aObj.timeCreated, + timeLastUsed: aObj.timeLastUsed, + timeLastModified: aObj.timeLastModified, + timesUsed: aObj.timesUsed, + }); + } + + static parse(aObj) { + const entry = new Address({}); + Object.assign(entry, aObj); + + return entry; + } + + toGecko() { + return { + version: this.version, + name: this.name, + "given-name": this.givenName, + "additional-name": this.additionalName, + "family-name": this.familyName, + organization: this.organization, + "street-address": this.streetAddress, + "address-level1": this.addressLevel1, + "address-level2": this.addressLevel2, + "address-level3": this.addressLevel3, + "postal-code": this.postalCode, + country: this.country, + tel: this.tel, + email: this.email, + guid: this.guid, + }; + } +} + +class CreditCard { + constructor({ + name, + number, + expMonth, + expYear, + type, + guid, + timeCreated, + timeLastUsed, + timeLastModified, + timesUsed, + version, + }) { + this.name = name ?? ""; + this.number = number ?? ""; + this.expMonth = expMonth ?? ""; + this.expYear = expYear ?? ""; + this.type = type ?? ""; + + // Metadata. + this.guid = guid ?? null; + // TODO: Not supported by GV. + this.timeCreated = timeCreated ?? null; + this.timeLastUsed = timeLastUsed ?? null; + this.timeLastModified = timeLastModified ?? null; + this.timesUsed = timesUsed ?? null; + this.version = version ?? null; + } + + isValid() { + return ( + this.name !== "" && + this.number !== "" && + this.expMonth !== "" && + this.expYear !== "" + ); + } + + static fromGecko(aObj) { + return new CreditCard({ + version: aObj.version, + name: aObj["cc-name"], + number: aObj["cc-number"], + expMonth: aObj["cc-exp-month"]?.toString(), + expYear: aObj["cc-exp-year"]?.toString(), + type: aObj["cc-type"], + guid: aObj.guid, + timeCreated: aObj.timeCreated, + timeLastUsed: aObj.timeLastUsed, + timeLastModified: aObj.timeLastModified, + timesUsed: aObj.timesUsed, + }); + } + + static parse(aObj) { + const entry = new CreditCard({}); + Object.assign(entry, aObj); + + return entry; + } + + toGecko() { + return { + version: this.version, + "cc-name": this.name, + "cc-number": this.number, + "cc-exp-month": this.expMonth, + "cc-exp-year": this.expYear, + "cc-type": this.type, + guid: this.guid, + }; + } +} + +class SelectOption { + // Sync with Autocomplete.SelectOption.Hint in Autocomplete.java. + static Hint = { + NONE: 0, + GENERATED: 1 << 0, + INSECURE_FORM: 1 << 1, + DUPLICATE_USERNAME: 1 << 2, + MATCHING_ORIGIN: 1 << 3, + }; + + constructor({ value, hint }) { + this.value = value ?? null; + this.hint = hint ?? SelectOption.Hint.NONE; + } +} + +// Sync with Autocomplete.UsedField in Autocomplete.java. +const UsedField = { PASSWORD: 1 }; + +const GeckoViewAutocomplete = { + /** current opened prompt */ + _prompt: null, + + /** + * Delegates login entry fetching for the given domain to the attached + * LoginStorage GeckoView delegate. + * + * @param aDomain + * The domain string to fetch login entries for. If null, all logins + * will be fetched. + * @return {Promise} + * Resolves with an array of login objects or null. + * Rejected if no delegate is attached. + * Login object string properties: + * { guid, origin, formActionOrigin, httpRealm, username, password } + */ + fetchLogins(aDomain = null) { + debug`fetchLogins for ${aDomain ?? "All domains"}`; + + return lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:Autocomplete:Fetch:Login", + domain: aDomain, + }); + }, + + /** + * Delegates credit card entry fetching to the attached LoginStorage + * GeckoView delegate. + * + * @return {Promise} + * Resolves with an array of credit card objects or null. + * Rejected if no delegate is attached. + * Login object string properties: + * { guid, name, number, expMonth, expYear, type } + */ + fetchCreditCards() { + debug`fetchCreditCards`; + + return lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:Autocomplete:Fetch:CreditCard", + }); + }, + + /** + * Delegates address entry fetching to the attached LoginStorage + * GeckoView delegate. + * + * @return {Promise} + * Resolves with an array of address objects or null. + * Rejected if no delegate is attached. + * Login object string properties: + * { guid, name, givenName, additionalName, familyName, + * organization, streetAddress, addressLevel1, addressLevel2, + * addressLevel3, postalCode, country, tel, email } + */ + fetchAddresses() { + debug`fetchAddresses`; + + return lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:Autocomplete:Fetch:Address", + }); + }, + + /** + * Delegates credit card entry saving to the attached LoginStorage GeckoView delegate. + * Call this when a new or modified credit card entry has been submitted. + * + * @param aCreditCard The {CreditCard} to be saved. + */ + onCreditCardSave(aCreditCard) { + debug`onCreditCardSave ${aCreditCard}`; + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:Autocomplete:Save:CreditCard", + creditCard: aCreditCard, + }); + }, + + /** + * Delegates address entry saving to the attached LoginStorage GeckoView delegate. + * Call this when a new or modified address entry has been submitted. + * + * @param aAddress The {Address} to be saved. + */ + onAddressSave(aAddress) { + debug`onAddressSave ${aAddress}`; + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:Autocomplete:Save:Address", + address: aAddress, + }); + }, + + /** + * Delegates login entry saving to the attached LoginStorage GeckoView delegate. + * Call this when a new login entry or a new password for an existing login + * entry has been submitted. + * + * @param aLogin The {LoginEntry} to be saved. + */ + onLoginSave(aLogin) { + debug`onLoginSave ${aLogin}`; + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:Autocomplete:Save:Login", + login: aLogin, + }); + }, + + /** + * Delegates login entry password usage to the attached LoginStorage GeckoView + * delegate. + * Call this when the password of an existing login entry, as returned by + * fetchLogins, has been used for autofill. + * + * @param aLogin The {LoginEntry} whose password was used. + */ + onLoginPasswordUsed(aLogin) { + debug`onLoginUsed ${aLogin}`; + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:Autocomplete:Used:Login", + usedFields: UsedField.PASSWORD, + login: aLogin, + }); + }, + + _numActiveSelections: 0, + + /** + * Delegates login entry selection. + * Call this when there are multiple login entry option for a form to delegate + * the selection. + * + * @param aBrowser The browser instance the triggered the selection. + * @param aOptions The list of {SelectOption} depicting viable options. + */ + onLoginSelect(aBrowser, aOptions) { + debug`onLoginSelect ${aOptions}`; + + return new Promise((resolve, reject) => { + if (!aBrowser || !aOptions) { + debug`onLoginSelect Rejecting - no browser or options provided`; + reject(); + return; + } + + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + prompt.asyncShowPrompt( + { + type: "Autocomplete:Select:Login", + options: aOptions, + }, + result => { + if (!result || !result.selection) { + reject(); + return; + } + + const option = new SelectOption({ + value: LoginEntry.parse(result.selection.value), + hint: result.selection.hint, + }); + resolve(option); + } + ); + this._prompt = prompt; + }); + }, + + /** + * Delegates credit card entry selection. + * Call this when there are multiple credit card entry option for a form to delegate + * the selection. + * + * @param aBrowser The browser instance the triggered the selection. + * @param aOptions The list of {SelectOption} depicting viable options. + */ + onCreditCardSelect(aBrowser, aOptions) { + debug`onCreditCardSelect ${aOptions}`; + + return new Promise((resolve, reject) => { + if (!aBrowser || !aOptions) { + debug`onCreditCardSelect Rejecting - no browser or options provided`; + reject(); + return; + } + + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + prompt.asyncShowPrompt( + { + type: "Autocomplete:Select:CreditCard", + options: aOptions, + }, + result => { + if (!result || !result.selection) { + reject(); + return; + } + + const option = new SelectOption({ + value: CreditCard.parse(result.selection.value), + hint: result.selection.hint, + }); + resolve(option); + } + ); + this._prompt = prompt; + }); + }, + + /** + * Delegates address entry selection. + * Call this when there are multiple address entry option for a form to delegate + * the selection. + * + * @param aBrowser The browser instance the triggered the selection. + * @param aOptions The list of {SelectOption} depicting viable options. + */ + onAddressSelect(aBrowser, aOptions) { + debug`onAddressSelect ${aOptions}`; + + return new Promise((resolve, reject) => { + if (!aBrowser || !aOptions) { + debug`onAddressSelect Rejecting - no browser or options provided`; + reject(); + return; + } + + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + prompt.asyncShowPrompt( + { + type: "Autocomplete:Select:Address", + options: aOptions, + }, + result => { + if (!result || !result.selection) { + reject(); + return; + } + + const option = new SelectOption({ + value: Address.parse(result.selection.value), + hint: result.selection.hint, + }); + resolve(option); + } + ); + this._prompt = prompt; + }); + }, + + async delegateSelection({ + browsingContext, + options, + inputElementIdentifier, + formOrigin, + }) { + debug`delegateSelection ${options}`; + + if (!options.length) { + return; + } + + let insecureHint = SelectOption.Hint.NONE; + let loginStyle = null; + + // TODO: Replace this string with more robust mechanics. + let selectionType = null; + const selectOptions = []; + + for (const option of options) { + switch (option.style) { + case "insecureWarning": { + // We depend on the insecure warning to be the first option. + insecureHint = SelectOption.Hint.INSECURE_FORM; + break; + } + case "generatedPassword": { + selectionType = "login"; + const comment = JSON.parse(option.comment); + selectOptions.push( + new SelectOption({ + value: new LoginEntry({ + password: comment.generatedPassword, + }), + hint: SelectOption.Hint.GENERATED | insecureHint, + }) + ); + break; + } + case "login": + // Fallthrough. + case "loginWithOrigin": { + selectionType = "login"; + loginStyle = option.style; + const comment = JSON.parse(option.comment); + + let hint = SelectOption.Hint.NONE | insecureHint; + if (comment.isDuplicateUsername) { + hint |= SelectOption.Hint.DUPLICATE_USERNAME; + } + if (comment.isOriginMatched) { + hint |= SelectOption.Hint.MATCHING_ORIGIN; + } + + selectOptions.push( + new SelectOption({ + value: LoginEntry.parse(comment.login), + hint, + }) + ); + break; + } + case "autofill-profile": { + const comment = JSON.parse(option.comment); + debug`delegateSelection ${comment}`; + const creditCard = CreditCard.fromGecko(comment); + const address = Address.fromGecko(comment); + if (creditCard.isValid()) { + selectionType = "creditCard"; + selectOptions.push( + new SelectOption({ + value: creditCard, + hint: insecureHint, + }) + ); + } else if (address.isValid()) { + selectionType = "address"; + selectOptions.push( + new SelectOption({ + value: address, + hint: insecureHint, + }) + ); + } + break; + } + default: + debug`delegateSelection - ignoring unknown option style ${option.style}`; + } + } + + if (selectOptions.length < 1) { + debug`Abort delegateSelection - no valid options provided`; + return; + } + + if (this._numActiveSelections > 0) { + debug`Abort delegateSelection - there is already one delegation active`; + return; + } + + ++this._numActiveSelections; + + let selectedOption = null; + const browser = browsingContext.top.embedderElement; + if (selectionType === "login") { + selectedOption = await this.onLoginSelect(browser, selectOptions).catch( + _ => { + debug`No GV delegate attached`; + } + ); + } else if (selectionType === "creditCard") { + selectedOption = await this.onCreditCardSelect( + browser, + selectOptions + ).catch(_ => { + debug`No GV delegate attached`; + }); + } else if (selectionType === "address") { + selectedOption = await this.onAddressSelect(browser, selectOptions).catch( + _ => { + debug`No GV delegate attached`; + } + ); + } + + // prompt is closed now. + this._prompt = null; + + --this._numActiveSelections; + + debug`delegateSelection selected option: ${selectedOption}`; + + if (selectionType === "login") { + const selectedLogin = selectedOption?.value?.toLoginInfo(); + + if (!selectedLogin) { + debug`Abort delegateSelection - no login entry selected`; + return; + } + + debug`delegateSelection - filling form`; + + const actor = browsingContext.currentWindowGlobal.getActor( + "LoginManager" + ); + + await actor.fillForm({ + browser, + inputElementIdentifier, + loginFormOrigin: formOrigin, + login: selectedLogin, + style: + selectedOption.hint & SelectOption.Hint.GENERATED + ? "generatedPassword" + : loginStyle, + }); + } else if (selectionType === "creditCard") { + const selectedCreditCard = selectedOption?.value?.toGecko(); + const actor = browsingContext.currentWindowGlobal.getActor( + "FormAutofill" + ); + + actor.sendAsyncMessage("FormAutofill:FillForm", selectedCreditCard); + } else if (selectionType === "address") { + const selectedAddress = selectedOption?.value?.toGecko(); + const actor = browsingContext.currentWindowGlobal.getActor( + "FormAutofill" + ); + + actor.sendAsyncMessage("FormAutofill:FillForm", selectedAddress); + } + + debug`delegateSelection - form filled`; + }, + + delegateDismiss() { + debug`delegateDismiss`; + + this._prompt?.dismiss(); + }, +}; + +const { debug } = GeckoViewUtils.initLogging("GeckoViewAutocomplete"); diff --git a/mobile/android/modules/geckoview/GeckoViewAutofill.jsm b/mobile/android/modules/geckoview/GeckoViewAutofill.jsm new file mode 100644 index 0000000000..b7d0aa39f9 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewAutofill.jsm @@ -0,0 +1,101 @@ +/* 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 { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +var EXPORTED_SYMBOLS = ["gAutofillManager"]; + +class Autofill { + constructor(sessionId, eventDispatcher) { + this.eventDispatcher = eventDispatcher; + this.sessionId = sessionId; + } + + start() { + this.eventDispatcher.sendRequest({ + type: "GeckoView:StartAutofill", + sessionId: this.sessionId, + }); + } + + add(node) { + return this.eventDispatcher.sendRequestForResult({ + type: "GeckoView:AddAutofill", + node, + }); + } + + focus(node) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:OnAutofillFocus", + node, + }); + } + + update(node) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:UpdateAutofill", + node, + }); + } + + commit(node) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:CommitAutofill", + node, + }); + } + + clear() { + this.eventDispatcher.sendRequest({ + type: "GeckoView:ClearAutofill", + }); + } +} + +class AutofillManager { + sessions = new Set(); + autofill = null; + + ensure(sessionId, eventDispatcher) { + if (!this.sessions.has(sessionId)) { + this.autofill = new Autofill(sessionId, eventDispatcher); + this.sessions.add(sessionId); + this.autofill.start(); + } + // This could be called for an outdated session, in which case we will just + // ignore the autofill call. + if (sessionId !== this.autofill.sessionId) { + return null; + } + return this.autofill; + } + + get(sessionId) { + if (!this.autofill || sessionId !== this.autofill.sessionId) { + warn`Disregarding old session ${sessionId}`; + // We disregard old sessions + return null; + } + return this.autofill; + } + + delete(sessionId) { + this.sessions.delete(sessionId); + if (!this.autofill || sessionId !== this.autofill.sessionId) { + // this delete call might happen *after* the next session already + // started, in that case, we can safely ignore this call. + return; + } + this.autofill.clear(); + this.autofill = null; + } +} + +var gAutofillManager = new AutofillManager(); + +const { debug, warn } = GeckoViewUtils.initLogging("Autofill"); diff --git a/mobile/android/modules/geckoview/GeckoViewChildModule.jsm b/mobile/android/modules/geckoview/GeckoViewChildModule.jsm new file mode 100644 index 0000000000..6a81247b36 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewChildModule.jsm @@ -0,0 +1,87 @@ +/* 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 = ["GeckoViewChildModule"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const { debug, warn } = GeckoViewUtils.initLogging("Module[C]"); + +class GeckoViewChildModule { + static initLogging(aModuleName) { + this._moduleName = aModuleName; + const tag = aModuleName.replace("GeckoView", "") + "[C]"; + return GeckoViewUtils.initLogging(tag); + } + + static create(aGlobal, aModuleName) { + return new this(aModuleName || this._moduleName, aGlobal); + } + + constructor(aModuleName, aGlobal) { + this.moduleName = aModuleName; + this.messageManager = aGlobal; + this.enabled = false; + + if (!aGlobal._gvEventDispatcher) { + aGlobal._gvEventDispatcher = GeckoViewUtils.getDispatcherForWindow( + aGlobal.content + ); + aGlobal.addEventListener( + "unload", + event => { + if (event.target === this.messageManager) { + aGlobal._gvEventDispatcher.finalize(); + } + }, + { + mozSystemGroup: true, + } + ); + } + this.eventDispatcher = aGlobal._gvEventDispatcher; + + this.messageManager.addMessageListener( + "GeckoView:UpdateModuleState", + aMsg => { + if (aMsg.data.module !== this.moduleName) { + return; + } + + const { enabled } = aMsg.data; + + if (enabled !== this.enabled) { + if (!enabled) { + this.onDisable(); + } + + this.enabled = enabled; + + if (enabled) { + this.onEnable(); + } + } + } + ); + + this.onInit(); + + this.messageManager.sendAsyncMessage("GeckoView:ContentModuleLoaded", { + module: this.moduleName, + }); + } + + // Override to initialize module. + onInit() {} + + // Override to enable module after setting a Java delegate. + onEnable() {} + + // Override to disable module after clearing the Java delegate. + onDisable() {} +} diff --git a/mobile/android/modules/geckoview/GeckoViewConsole.jsm b/mobile/android/modules/geckoview/GeckoViewConsole.jsm new file mode 100644 index 0000000000..272859f29f --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewConsole.jsm @@ -0,0 +1,188 @@ +/* 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 = ["GeckoViewConsole"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const { debug, warn } = GeckoViewUtils.initLogging("Console"); + +var GeckoViewConsole = { + _isEnabled: false, + + get enabled() { + return this._isEnabled; + }, + + set enabled(aVal) { + debug`enabled = ${aVal}`; + if (!!aVal === this._isEnabled) { + return; + } + + this._isEnabled = !!aVal; + const ConsoleAPIStorage = Cc[ + "@mozilla.org/consoleAPI-storage;1" + ].getService(Ci.nsIConsoleAPIStorage); + if (this._isEnabled) { + this._consoleMessageListener = this._handleConsoleMessage.bind(this); + ConsoleAPIStorage.addLogEventListener( + this._consoleMessageListener, + Cc["@mozilla.org/systemprincipal;1"].createInstance(Ci.nsIPrincipal) + ); + } else if (this._consoleMessageListener) { + ConsoleAPIStorage.removeLogEventListener(this._consoleMessageListener); + delete this._consoleMessageListener; + } + }, + + observe(aSubject, aTopic, aData) { + if (aTopic == "nsPref:changed") { + this.enabled = Services.prefs.getBoolPref(aData, false); + } + }, + + _handleConsoleMessage(aMessage) { + aMessage = aMessage.wrappedJSObject; + + const mappedArguments = Array.from( + aMessage.arguments, + this.formatResult, + this + ); + const joinedArguments = mappedArguments.join(" "); + + if (aMessage.level == "error" || aMessage.level == "warn") { + const flag = + aMessage.level == "error" + ? Ci.nsIScriptError.errorFlag + : Ci.nsIScriptError.warningFlag; + const consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + consoleMsg.init( + joinedArguments, + null, + null, + 0, + 0, + flag, + "content javascript" + ); + Services.console.logMessage(consoleMsg); + } else if (aMessage.level == "trace") { + const bundle = Services.strings.createBundle( + "chrome://browser/locale/browser.properties" + ); + const args = aMessage.arguments; + const msgDetails = args[0] ?? aMessage; + const filename = this.abbreviateSourceURL(msgDetails.filename); + const functionName = + msgDetails.functionName || + bundle.GetStringFromName("stacktrace.anonymousFunction"); + const lineNumber = msgDetails.lineNumber; + + let body = bundle.formatStringFromName("stacktrace.outputMessage", [ + filename, + functionName, + lineNumber, + ]); + body += "\n"; + args.forEach(function(aFrame) { + const functionName = + aFrame.functionName || + bundle.GetStringFromName("stacktrace.anonymousFunction"); + body += + " " + + aFrame.filename + + " :: " + + functionName + + " :: " + + aFrame.lineNumber + + "\n"; + }); + + Services.console.logStringMessage(body); + } else if (aMessage.level == "time" && aMessage.arguments) { + const bundle = Services.strings.createBundle( + "chrome://browser/locale/browser.properties" + ); + const body = bundle.formatStringFromName("timer.start", [ + aMessage.arguments.name, + ]); + Services.console.logStringMessage(body); + } else if (aMessage.level == "timeEnd" && aMessage.arguments) { + const bundle = Services.strings.createBundle( + "chrome://browser/locale/browser.properties" + ); + const body = bundle.formatStringFromName("timer.end", [ + aMessage.arguments.name, + aMessage.arguments.duration, + ]); + Services.console.logStringMessage(body); + } else if ( + ["group", "groupCollapsed", "groupEnd"].includes(aMessage.level) + ) { + // Do nothing yet + } else { + Services.console.logStringMessage(joinedArguments); + } + }, + + getResultType(aResult) { + let type = aResult === null ? "null" : typeof aResult; + if (type == "object" && aResult.constructor && aResult.constructor.name) { + type = aResult.constructor.name; + } + return type.toLowerCase(); + }, + + formatResult(aResult) { + let output = ""; + const type = this.getResultType(aResult); + switch (type) { + case "string": + case "boolean": + case "date": + case "error": + case "number": + case "regexp": + output = aResult.toString(); + break; + case "null": + case "undefined": + output = type; + break; + default: + output = aResult.toString(); + break; + } + + return output; + }, + + abbreviateSourceURL(aSourceURL) { + // Remove any query parameters. + const hookIndex = aSourceURL.indexOf("?"); + if (hookIndex > -1) { + aSourceURL = aSourceURL.substring(0, hookIndex); + } + + // Remove a trailing "/". + if (aSourceURL[aSourceURL.length - 1] == "/") { + aSourceURL = aSourceURL.substring(0, aSourceURL.length - 1); + } + + // Remove all but the last path component. + const slashIndex = aSourceURL.lastIndexOf("/"); + if (slashIndex > -1) { + aSourceURL = aSourceURL.substring(slashIndex + 1); + } + + return aSourceURL; + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewContent.jsm b/mobile/android/modules/geckoview/GeckoViewContent.jsm new file mode 100644 index 0000000000..3235e01ed6 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewContent.jsm @@ -0,0 +1,457 @@ +/* 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 = ["GeckoViewContent"]; + +const { GeckoViewModule } = ChromeUtils.import( + "resource://gre/modules/GeckoViewModule.jsm" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +class GeckoViewContent extends GeckoViewModule { + onInit() { + this.registerListener([ + "GeckoViewContent:ExitFullScreen", + "GeckoView:ClearMatches", + "GeckoView:DisplayMatches", + "GeckoView:FindInPage", + "GeckoView:RestoreState", + "GeckoView:ContainsFormData", + "GeckoView:ScrollBy", + "GeckoView:ScrollTo", + "GeckoView:SetActive", + "GeckoView:SetFocused", + "GeckoView:SetPriorityHint", + "GeckoView:UpdateInitData", + "GeckoView:ZoomToInput", + ]); + } + + onEnable() { + this.window.addEventListener( + "MozDOMFullscreen:Entered", + this, + /* capture */ true, + /* untrusted */ false + ); + this.window.addEventListener( + "MozDOMFullscreen:Exited", + this, + /* capture */ true, + /* untrusted */ false + ); + this.window.addEventListener( + "framefocusrequested", + this, + /* capture */ true, + /* untrusted */ false + ); + + this.window.addEventListener("DOMWindowClose", this); + this.window.addEventListener("pagetitlechanged", this); + this.window.addEventListener("pageinfo", this); + + this.window.addEventListener("cookiebannerdetected", this); + this.window.addEventListener("cookiebannerhandled", this); + + Services.obs.addObserver(this, "oop-frameloader-crashed"); + Services.obs.addObserver(this, "ipc:content-shutdown"); + } + + onDisable() { + this.window.removeEventListener( + "MozDOMFullscreen:Entered", + this, + /* capture */ true + ); + this.window.removeEventListener( + "MozDOMFullscreen:Exited", + this, + /* capture */ true + ); + this.window.removeEventListener( + "framefocusrequested", + this, + /* capture */ true + ); + + this.window.removeEventListener("DOMWindowClose", this); + this.window.removeEventListener("pagetitlechanged", this); + this.window.removeEventListener("pageinfo", this); + + this.window.removeEventListener("cookiebannerdetected", this); + this.window.removeEventListener("cookiebannerhandled", this); + + Services.obs.removeObserver(this, "oop-frameloader-crashed"); + Services.obs.removeObserver(this, "ipc:content-shutdown"); + } + + get actor() { + return this.getActor("GeckoViewContent"); + } + + // Goes up the browsingContext chain and sends the message every time + // we cross the process boundary so that every process in the chain is + // notified. + sendToAllChildren(aEvent, aData) { + let { browsingContext } = this.actor; + + while (browsingContext) { + if (!browsingContext.currentWindowGlobal) { + break; + } + + const currentPid = browsingContext.currentWindowGlobal.osPid; + const parentPid = browsingContext.parent?.currentWindowGlobal.osPid; + + if (currentPid != parentPid) { + const actor = browsingContext.currentWindowGlobal.getActor( + "GeckoViewContent" + ); + actor.sendAsyncMessage(aEvent, aData); + } + + browsingContext = browsingContext.parent; + } + } + + // Bundle event handler. + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + switch (aEvent) { + case "GeckoViewContent:ExitFullScreen": + this.browser.ownerDocument.exitFullscreen(); + break; + case "GeckoView:ClearMatches": { + this._clearMatches(); + break; + } + case "GeckoView:DisplayMatches": { + this._displayMatches(aData); + break; + } + case "GeckoView:FindInPage": { + this._findInPage(aData, aCallback); + break; + } + case "GeckoView:ZoomToInput": + this.sendToAllChildren(aEvent, aData); + break; + case "GeckoView:ScrollBy": + // Unclear if that actually works with oop iframes? + this.sendToAllChildren(aEvent, aData); + break; + case "GeckoView:ScrollTo": + // Unclear if that actually works with oop iframes? + this.sendToAllChildren(aEvent, aData); + break; + case "GeckoView:UpdateInitData": + this.sendToAllChildren(aEvent, aData); + break; + case "GeckoView:SetActive": + this.browser.docShellIsActive = !!aData.active; + break; + case "GeckoView:SetFocused": + if (aData.focused) { + this.browser.focus(); + this.browser.setAttribute("primary", "true"); + } else { + this.browser.removeAttribute("primary"); + this.browser.blur(); + } + break; + case "GeckoView:SetPriorityHint": + if (this.browser.isRemoteBrowser) { + const remoteTab = this.browser.frameLoader?.remoteTab; + if (remoteTab) { + remoteTab.priorityHint = aData.priorityHint; + } + } + break; + case "GeckoView:RestoreState": + this.actor.restoreState(aData); + break; + case "GeckoView:ContainsFormData": + this._containsFormData(aCallback); + break; + } + } + + // DOM event handler + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + + switch (aEvent.type) { + case "framefocusrequested": + if (this.browser != aEvent.target) { + return; + } + if (this.browser.hasAttribute("primary")) { + return; + } + this.eventDispatcher.sendRequest({ + type: "GeckoView:FocusRequest", + }); + aEvent.preventDefault(); + break; + case "MozDOMFullscreen:Entered": + if (this.browser == aEvent.target) { + // Remote browser; dispatch to content process. + this.sendToAllChildren("GeckoView:DOMFullscreenEntered"); + } + break; + case "MozDOMFullscreen:Exited": + this.sendToAllChildren("GeckoView:DOMFullscreenExited"); + break; + case "pagetitlechanged": + this.eventDispatcher.sendRequest({ + type: "GeckoView:PageTitleChanged", + title: this.browser.contentTitle, + }); + break; + case "DOMWindowClose": + // We need this because we want to allow the app + // to close the window itself. If we don't preventDefault() + // here Gecko will close it immediately. + aEvent.preventDefault(); + + this.eventDispatcher.sendRequest({ + type: "GeckoView:DOMWindowClose", + }); + break; + case "pageinfo": + if (aEvent.detail.previewImageURL) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:PreviewImage", + previewImageUrl: aEvent.detail.previewImageURL, + }); + } + break; + case "cookiebannerdetected": + this.eventDispatcher.sendRequest({ + type: "GeckoView:CookieBannerEvent:Detected", + }); + break; + case "cookiebannerhandled": + this.eventDispatcher.sendRequest({ + type: "GeckoView:CookieBannerEvent:Handled", + }); + break; + } + } + + // nsIObserver event handler + observe(aSubject, aTopic, aData) { + debug`observe: ${aTopic}`; + this._contentCrashed = false; + const browser = aSubject.ownerElement; + + switch (aTopic) { + case "oop-frameloader-crashed": { + if (!browser || browser != this.browser) { + return; + } + this.window.setTimeout(() => { + if (this._contentCrashed) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:ContentCrash", + }); + } else { + this.eventDispatcher.sendRequest({ + type: "GeckoView:ContentKill", + }); + } + }, 250); + break; + } + case "ipc:content-shutdown": { + aSubject.QueryInterface(Ci.nsIPropertyBag2); + if (aSubject.get("dumpID")) { + if ( + browser && + aSubject.get("childID") != browser.frameLoader.childID + ) { + return; + } + this._contentCrashed = true; + } + break; + } + } + } + + async _containsFormData(aCallback) { + aCallback.onSuccess(await this.actor.containsFormData()); + } + + _findInPage(aData, aCallback) { + debug`findInPage: data=${aData} callback=${aCallback && "non-null"}`; + + let finder; + try { + finder = this.browser.finder; + } catch (e) { + if (aCallback) { + aCallback.onError(`No finder: ${e}`); + } + return; + } + + if (this._finderListener) { + finder.removeResultListener(this._finderListener); + } + + this._finderListener = { + response: { + found: false, + wrapped: false, + current: 0, + total: -1, + searchString: aData.searchString || finder.searchString, + linkURL: null, + clientRect: null, + flags: { + backwards: !!aData.backwards, + linksOnly: !!aData.linksOnly, + matchCase: !!aData.matchCase, + wholeWord: !!aData.wholeWord, + }, + }, + + onFindResult(aOptions) { + if (!aCallback || aOptions.searchString !== aData.searchString) { + // Result from a previous search. + return; + } + + Object.assign(this.response, { + found: aOptions.result !== Ci.nsITypeAheadFind.FIND_NOTFOUND, + wrapped: aOptions.result !== Ci.nsITypeAheadFind.FIND_FOUND, + linkURL: aOptions.linkURL, + clientRect: aOptions.rect && { + left: aOptions.rect.left, + top: aOptions.rect.top, + right: aOptions.rect.right, + bottom: aOptions.rect.bottom, + }, + flags: { + backwards: aOptions.findBackwards, + linksOnly: aOptions.linksOnly, + matchCase: this.response.flags.matchCase, + wholeWord: this.response.flags.wholeWord, + }, + }); + + if (!this.response.found) { + this.response.current = 0; + this.response.total = 0; + } + + // Only send response if we have a count. + if (!this.response.found || this.response.current !== 0) { + debug`onFindResult: ${this.response}`; + aCallback.onSuccess(this.response); + aCallback = undefined; + } + }, + + onMatchesCountResult(aResult) { + if (!aCallback || finder.searchString !== aData.searchString) { + // Result from a previous search. + return; + } + + Object.assign(this.response, { + current: aResult.current, + total: aResult.total, + }); + + // Only send response if we have a result. `found` and `wrapped` are + // both false only when we haven't received a result yet. + if (this.response.found || this.response.wrapped) { + debug`onMatchesCountResult: ${this.response}`; + aCallback.onSuccess(this.response); + aCallback = undefined; + } + }, + + onCurrentSelection() {}, + + onHighlightFinished() {}, + }; + + finder.caseSensitive = !!aData.matchCase; + finder.entireWord = !!aData.wholeWord; + finder.matchDiacritics = !!aData.matchDiacritics; + finder.addResultListener(this._finderListener); + + const drawOutline = + this._matchDisplayOptions && !!this._matchDisplayOptions.drawOutline; + + if (!aData.searchString || aData.searchString === finder.searchString) { + // Search again. + aData.searchString = finder.searchString; + finder.findAgain( + aData.searchString, + !!aData.backwards, + !!aData.linksOnly, + drawOutline + ); + } else { + finder.fastFind(aData.searchString, !!aData.linksOnly, drawOutline); + } + } + + _clearMatches() { + debug`clearMatches`; + + let finder; + try { + finder = this.browser.finder; + } catch (e) { + return; + } + + finder.removeSelection(); + finder.highlight(false); + + if (this._finderListener) { + finder.removeResultListener(this._finderListener); + this._finderListener = null; + } + } + + _displayMatches(aData) { + debug`displayMatches: data=${aData}`; + + let finder; + try { + finder = this.browser.finder; + } catch (e) { + return; + } + + this._matchDisplayOptions = aData; + finder.onModalHighlightChange(!!aData.dimPage); + finder.onHighlightAllChange(!!aData.highlightAll); + + if (!aData.highlightAll && !aData.dimPage) { + finder.highlight(false); + return; + } + + if (!this._finderListener || !finder.searchString) { + return; + } + const linksOnly = this._finderListener.response.linksOnly; + finder.highlight(true, finder.searchString, linksOnly, !!aData.drawOutline); + } +} + +const { debug, warn } = GeckoViewContent.initLogging("GeckoViewContent"); diff --git a/mobile/android/modules/geckoview/GeckoViewContentBlocking.jsm b/mobile/android/modules/geckoview/GeckoViewContentBlocking.jsm new file mode 100644 index 0000000000..2b556b24e6 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewContentBlocking.jsm @@ -0,0 +1,119 @@ +/* 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 = ["GeckoViewContentBlocking"]; + +const { GeckoViewModule } = ChromeUtils.import( + "resource://gre/modules/GeckoViewModule.jsm" +); + +class GeckoViewContentBlocking extends GeckoViewModule { + onEnable() { + const flags = Ci.nsIWebProgress.NOTIFY_CONTENT_BLOCKING; + this.progressFilter = Cc[ + "@mozilla.org/appshell/component/browser-status-filter;1" + ].createInstance(Ci.nsIWebProgress); + this.progressFilter.addProgressListener(this, flags); + this.browser.addProgressListener(this.progressFilter, flags); + + this.registerListener(["ContentBlocking:RequestLog"]); + } + + onDisable() { + if (this.progressFilter) { + this.progressFilter.removeProgressListener(this); + this.browser.removeProgressListener(this.progressFilter); + delete this.progressFilter; + } + + this.unregisterListener(["ContentBlocking:RequestLog"]); + } + + // Bundle event handler. + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + switch (aEvent) { + case "ContentBlocking:RequestLog": { + let bc = this.browser.browsingContext; + + if (!bc) { + warn`Failed to export content blocking log.`; + break; + } + + // Get the top-level browsingContext. The ContentBlockingLog is located + // in its current window global. + bc = bc.top; + + const topWindowGlobal = bc.currentWindowGlobal; + + if (!topWindowGlobal) { + warn`Failed to export content blocking log.`; + break; + } + + const log = JSON.parse(topWindowGlobal.contentBlockingLog); + const res = Object.keys(log).map(key => { + const blockData = log[key].map(data => { + return { + category: data[0], + blocked: data[1], + count: data[2], + }; + }); + return { + origin: key, + blockData, + }; + }); + + aCallback.onSuccess({ log: res }); + break; + } + } + } + + onContentBlockingEvent(aWebProgress, aRequest, aEvent) { + debug`onContentBlockingEvent ${aEvent.toString(16)}`; + + if (!(aRequest instanceof Ci.nsIClassifiedChannel)) { + return; + } + + const channel = aRequest.QueryInterface(Ci.nsIChannel); + const uri = channel.URI && channel.URI.spec; + + if (!uri) { + return; + } + + const classChannel = aRequest.QueryInterface(Ci.nsIClassifiedChannel); + const blockedList = classChannel.matchedList || null; + let loadedLists = []; + + if (aRequest instanceof Ci.nsIHttpChannel) { + loadedLists = classChannel.matchedTrackingLists || []; + } + + debug`onContentBlockingEvent matchedList: ${blockedList}`; + debug`onContentBlockingEvent matchedTrackingLists: ${loadedLists}`; + + const message = { + type: "GeckoView:ContentBlockingEvent", + uri, + category: aEvent, + blockedList, + loadedLists, + }; + + this.eventDispatcher.sendRequest(message); + } +} + +const { debug, warn } = GeckoViewContentBlocking.initLogging( + "GeckoViewContentBlocking" +); diff --git a/mobile/android/modules/geckoview/GeckoViewMediaControl.jsm b/mobile/android/modules/geckoview/GeckoViewMediaControl.jsm new file mode 100644 index 0000000000..78955ef7c6 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewMediaControl.jsm @@ -0,0 +1,232 @@ +/* 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 = ["GeckoViewMediaControl"]; + +const { GeckoViewModule } = ChromeUtils.import( + "resource://gre/modules/GeckoViewModule.jsm" +); + +class GeckoViewMediaControl extends GeckoViewModule { + onInit() { + debug`onInit`; + } + + onEnable() { + debug`onEnable`; + + if (this.controller.isActive) { + this.handleActivated(); + } + + const options = { + mozSystemGroup: true, + capture: false, + }; + + this.controller.addEventListener("activated", this, options); + this.controller.addEventListener("deactivated", this, options); + this.controller.addEventListener("supportedkeyschange", this, options); + this.controller.addEventListener("positionstatechange", this, options); + this.controller.addEventListener("metadatachange", this, options); + this.controller.addEventListener("playbackstatechange", this, options); + + this.registerListener([ + "GeckoView:MediaSession:Play", + "GeckoView:MediaSession:Pause", + "GeckoView:MediaSession:Stop", + "GeckoView:MediaSession:NextTrack", + "GeckoView:MediaSession:PrevTrack", + "GeckoView:MediaSession:SeekForward", + "GeckoView:MediaSession:SeekBackward", + "GeckoView:MediaSession:SkipAd", + "GeckoView:MediaSession:SeekTo", + "GeckoView:MediaSession:MuteAudio", + ]); + } + + onDisable() { + debug`onDisable`; + + this.controller.removeEventListener("activated", this); + this.controller.removeEventListener("deactivated", this); + this.controller.removeEventListener("supportedkeyschange", this); + this.controller.removeEventListener("positionstatechange", this); + this.controller.removeEventListener("metadatachange", this); + this.controller.removeEventListener("playbackstatechange", this); + + this.unregisterListener(); + } + + get controller() { + return this.browser.browsingContext.mediaController; + } + + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + switch (aEvent) { + case "GeckoView:MediaSession:Play": + this.controller.play(); + break; + case "GeckoView:MediaSession:Pause": + this.controller.pause(); + break; + case "GeckoView:MediaSession:Stop": + this.controller.stop(); + break; + case "GeckoView:MediaSession:NextTrack": + this.controller.nextTrack(); + break; + case "GeckoView:MediaSession:PrevTrack": + this.controller.prevTrack(); + break; + case "GeckoView:MediaSession:SeekForward": + this.controller.seekForward(); + break; + case "GeckoView:MediaSession:SeekBackward": + this.controller.seekBackward(); + break; + case "GeckoView:MediaSession:SkipAd": + this.controller.skipAd(); + break; + case "GeckoView:MediaSession:SeekTo": + this.controller.seekTo(aData.time, aData.fast); + break; + case "GeckoView:MediaSession:MuteAudio": + if (aData.mute) { + this.browser.mute(); + } else { + this.browser.unmute(); + } + break; + } + } + + // eslint-disable-next-line complexity + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + + switch (aEvent.type) { + case "activated": + this.handleActivated(); + break; + case "deactivated": + this.handleDeactivated(); + break; + case "supportedkeyschange": + this.handleSupportedKeysChanged(); + break; + case "positionstatechange": + this.handlePositionStateChanged(aEvent); + break; + case "metadatachange": + this.handleMetadataChanged(); + break; + case "playbackstatechange": + this.handlePlaybackStateChanged(); + break; + default: + warn`Unknown event type ${aEvent.type}`; + break; + } + } + + handleActivated() { + debug`handleActivated`; + + this.eventDispatcher.sendRequest({ + type: "GeckoView:MediaSession:Activated", + }); + } + + handleDeactivated() { + debug`handleDeactivated`; + + this.eventDispatcher.sendRequest({ + type: "GeckoView:MediaSession:Deactivated", + }); + } + + handlePositionStateChanged(aEvent) { + debug`handlePositionStateChanged`; + + this.eventDispatcher.sendRequest({ + type: "GeckoView:MediaSession:PositionState", + state: { + duration: aEvent.duration, + playbackRate: aEvent.playbackRate, + position: aEvent.position, + }, + }); + } + + handleSupportedKeysChanged() { + const supported = this.controller.supportedKeys; + + debug`handleSupportedKeysChanged ${supported}`; + + // Mapping it to a key-value store for compatibility with the JNI + // implementation for now. + const features = new Map(); + supported.forEach(key => { + features[key] = true; + }); + + this.eventDispatcher.sendRequest({ + type: "GeckoView:MediaSession:Features", + features, + }); + } + + handleMetadataChanged() { + let metadata = null; + try { + metadata = this.controller.getMetadata(); + } catch (e) { + warn`Metadata not available`; + } + debug`handleMetadataChanged ${metadata}`; + + if (metadata) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:MediaSession:Metadata", + metadata, + }); + } + } + + handlePlaybackStateChanged() { + const state = this.controller.playbackState; + let type = null; + + debug`handlePlaybackStateChanged ${state}`; + + switch (state) { + case "none": + type = "GeckoView:MediaSession:Playback:None"; + break; + case "paused": + type = "GeckoView:MediaSession:Playback:Paused"; + break; + case "playing": + type = "GeckoView:MediaSession:Playback:Playing"; + break; + } + + if (!type) { + return; + } + + this.eventDispatcher.sendRequest({ + type, + }); + } +} + +const { debug, warn } = GeckoViewMediaControl.initLogging( + "GeckoViewMediaControl" +); diff --git a/mobile/android/modules/geckoview/GeckoViewModule.jsm b/mobile/android/modules/geckoview/GeckoViewModule.jsm new file mode 100644 index 0000000000..9d09fe1873 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewModule.jsm @@ -0,0 +1,160 @@ +/* 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 = ["GeckoViewModule"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const { debug, warn } = GeckoViewUtils.initLogging("Module"); + +class GeckoViewModule { + static initLogging(aModuleName) { + const tag = aModuleName.replace("GeckoView", ""); + return GeckoViewUtils.initLogging(tag); + } + + constructor(aModuleInfo) { + this._info = aModuleInfo; + + this._isContentLoaded = false; + this._eventProxy = new EventProxy(this, this.eventDispatcher); + + this.onInitBrowser(); + } + + get name() { + return this._info.name; + } + + get enabled() { + return this._info.enabled; + } + + get window() { + return this.moduleManager.window; + } + + getActor(aActorName) { + return this.moduleManager.getActor(aActorName); + } + + get browser() { + return this.moduleManager.browser; + } + + get messageManager() { + return this.moduleManager.messageManager; + } + + get eventDispatcher() { + return this.moduleManager.eventDispatcher; + } + + get settings() { + return this.moduleManager.settings; + } + + get moduleManager() { + return this._info.manager; + } + + // Override to initialize the browser before it is bound to the window. + onInitBrowser() {} + + // Override to initialize module. + onInit() {} + + // Override to cleanup when the window is closed + onDestroy() {} + + // Override to detect settings change. Access settings via this.settings. + onSettingsUpdate() {} + + // Override to enable module after setting a Java delegate. + onEnable() {} + + // Override to disable module after clearing the Java delegate. + onDisable() {} + + // Override to perform actions when content module has started loading; + // by default, pause events so events that depend on content modules can work. + onLoadContentModule() { + this._eventProxy.enableQueuing(true); + } + + // Override to perform actions when content module has finished loading; + // by default, un-pause events and flush queued events. + onContentModuleLoaded() { + this._eventProxy.enableQueuing(false); + this._eventProxy.dispatchQueuedEvents(); + } + + registerListener(aEventList) { + this._eventProxy.registerListener(aEventList); + } + + unregisterListener() { + this._eventProxy.unregisterListener(); + } +} + +class EventProxy { + constructor(aListener, aEventDispatcher) { + this.listener = aListener; + this.eventDispatcher = aEventDispatcher; + this._eventQueue = []; + this._registeredEvents = []; + this._enableQueuing = false; + } + + registerListener(aEventList) { + debug`registerListener ${aEventList}`; + this.eventDispatcher.registerListener(this, aEventList); + this._registeredEvents = this._registeredEvents.concat(aEventList); + } + + unregisterListener() { + debug`unregisterListener`; + if (this._registeredEvents.length === 0) { + return; + } + this.eventDispatcher.unregisterListener(this, this._registeredEvents); + this._registeredEvents = []; + } + + onEvent(aEvent, aData, aCallback) { + if (this._enableQueuing) { + debug`queue ${aEvent}, data=${aData}`; + this._eventQueue.unshift(arguments); + } else { + this._dispatch(...arguments); + } + } + + enableQueuing(aEnable) { + debug`enableQueuing ${aEnable}`; + this._enableQueuing = aEnable; + } + + _dispatch(aEvent, aData, aCallback) { + debug`dispatch ${aEvent}, data=${aData}`; + if (this.listener.onEvent) { + this.listener.onEvent(...arguments); + } else { + this.listener(...arguments); + } + } + + dispatchQueuedEvents() { + debug`dispatchQueued`; + while (this._eventQueue.length) { + const args = this._eventQueue.pop(); + this._dispatch(...args); + } + } +} diff --git a/mobile/android/modules/geckoview/GeckoViewNavigation.jsm b/mobile/android/modules/geckoview/GeckoViewNavigation.jsm new file mode 100644 index 0000000000..3dca27707b --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewNavigation.jsm @@ -0,0 +1,653 @@ +/* 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 = ["GeckoViewNavigation"]; + +const { GeckoViewModule } = ChromeUtils.import( + "resource://gre/modules/GeckoViewModule.jsm" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewUtils: "resource://gre/modules/GeckoViewUtils.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + LoadURIDelegate: "resource://gre/modules/LoadURIDelegate.jsm", +}); + +XPCOMUtils.defineLazyGetter(lazy, "ReferrerInfo", () => + Components.Constructor( + "@mozilla.org/referrer-info;1", + "nsIReferrerInfo", + "init" + ) +); + +// Filter out request headers as per discussion in Bug #1567549 +// CONNECTION: Used by Gecko to manage connections +// HOST: Relates to how gecko will ultimately interpret the resulting resource as that +// determines the effective request URI +const BAD_HEADERS = ["connection", "host"]; + +// Headers use |\r\n| as separator so these characters cannot appear +// in the header name or value +const FORBIDDEN_HEADER_CHARACTERS = ["\n", "\r"]; + +// Keep in sync with GeckoSession.java +const HEADER_FILTER_CORS_SAFELISTED = 1; +const HEADER_FILTER_UNRESTRICTED_UNSAFE = 2; + +// Create default ReferrerInfo instance for the given referrer URI string. +const createReferrerInfo = aReferrer => { + let referrerUri; + try { + referrerUri = Services.io.newURI(aReferrer); + } catch (ignored) {} + + return new lazy.ReferrerInfo(Ci.nsIReferrerInfo.EMPTY, true, referrerUri); +}; + +function convertFlags(aFlags) { + let navFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + if (!aFlags) { + return navFlags; + } + // These need to match the values in GeckoSession.LOAD_FLAGS_* + if (aFlags & (1 << 0)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE; + } + if (aFlags & (1 << 1)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY; + } + if (aFlags & (1 << 2)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL; + } + if (aFlags & (1 << 3)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_POPUPS; + } + if (aFlags & (1 << 4)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CLASSIFIER; + } + if (aFlags & (1 << 5)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_FORCE_ALLOW_DATA_URI; + } + if (aFlags & (1 << 6)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY; + } + return navFlags; +} + +// Handles navigation requests between Gecko and a GeckoView. +// Handles GeckoView:GoBack and :GoForward requests dispatched by +// GeckoView.goBack and .goForward. +// Dispatches GeckoView:LocationChange to the GeckoView on location change when +// active. +// Implements nsIBrowserDOMWindow. +class GeckoViewNavigation extends GeckoViewModule { + onInitBrowser() { + this.window.browserDOMWindow = this; + + debug`sessionContextId=${this.settings.sessionContextId}`; + + if (this.settings.sessionContextId !== null) { + // Gecko may have issues with strings containing special characters, + // so we restrict the string format to a specific pattern. + if (!/^gvctx(-)?([a-f0-9]+)$/.test(this.settings.sessionContextId)) { + throw new Error("sessionContextId has illegal format"); + } + + this.browser.setAttribute( + "geckoViewSessionContextId", + this.settings.sessionContextId + ); + } + + // There may be a GeckoViewNavigation module in another window waiting for + // us to create a browser so it can set openWindowInfo, so allow them to do + // that now. + Services.obs.notifyObservers(this.window, "geckoview-window-created"); + } + + onInit() { + debug`onInit`; + + this.registerListener([ + "GeckoView:GoBack", + "GeckoView:GoForward", + "GeckoView:GotoHistoryIndex", + "GeckoView:LoadUri", + "GeckoView:Reload", + "GeckoView:Stop", + "GeckoView:PurgeHistory", + ]); + + this._initialAboutBlank = true; + } + + validateHeader(key, value, filter) { + if (!key) { + // Key cannot be empty + return false; + } + + for (const c of FORBIDDEN_HEADER_CHARACTERS) { + if (key.includes(c) || value?.includes(c)) { + return false; + } + } + + if (BAD_HEADERS.includes(key.toLowerCase().trim())) { + return false; + } + + if ( + filter == HEADER_FILTER_CORS_SAFELISTED && + !this.window.windowUtils.isCORSSafelistedRequestHeader(key, value) + ) { + return false; + } + + return true; + } + + // Bundle event handler. + async onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + switch (aEvent) { + case "GeckoView:GoBack": + this.browser.goBack(aData.userInteraction); + break; + case "GeckoView:GoForward": + this.browser.goForward(aData.userInteraction); + break; + case "GeckoView:GotoHistoryIndex": + this.browser.gotoIndex(aData.index); + break; + case "GeckoView:LoadUri": + const { + uri, + referrerUri, + referrerSessionId, + flags, + headers, + headerFilter, + } = aData; + + let navFlags = convertFlags(flags); + // For performance reasons we don't call the LoadUriDelegate.loadUri + // from Gecko, and instead we call it directly in the loadUri Java API. + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE; + + let triggeringPrincipal, referrerInfo, csp; + if (referrerSessionId) { + const referrerWindow = Services.ww.getWindowByName( + referrerSessionId, + this.window + ); + triggeringPrincipal = referrerWindow.browser.contentPrincipal; + csp = referrerWindow.browser.csp; + + const referrerPolicy = referrerWindow.browser.referrerInfo + ? referrerWindow.browser.referrerInfo.referrerPolicy + : Ci.nsIReferrerInfo.EMPTY; + + referrerInfo = new lazy.ReferrerInfo( + referrerPolicy, + true, + referrerWindow.browser.documentURI + ); + } else if (referrerUri) { + referrerInfo = createReferrerInfo(referrerUri); + } else { + // External apps are treated like web pages, so they should not get + // a privileged principal. + const isExternal = + navFlags & Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL; + if (!isExternal) { + // Always use the system principal as the triggering principal + // for user-initiated (ie. no referrer session and not external) + // loads. See discussion in bug 1573860. + triggeringPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + } + } + + if (!triggeringPrincipal) { + triggeringPrincipal = Services.scriptSecurityManager.createNullPrincipal( + {} + ); + } + + let additionalHeaders = null; + if (headers) { + additionalHeaders = ""; + for (const [key, value] of Object.entries(headers)) { + if (!this.validateHeader(key, value, headerFilter)) { + console.error(`Ignoring invalid header '${key}'='${value}'.`); + continue; + } + + additionalHeaders += `${key}:${value ?? ""}\r\n`; + } + + if (additionalHeaders != "") { + additionalHeaders = lazy.E10SUtils.makeInputStream( + additionalHeaders + ); + } else { + additionalHeaders = null; + } + } + + // For any navigation here, we should have an appropriate triggeringPrincipal: + // + // 1) If we have a referring session, triggeringPrincipal is the contentPrincipal from the + // referring document. + // 2) For certain URI schemes listed above, we will have a codebase principal. + // 3) In all other cases, we create a NullPrincipal. + // + // The navigation flags are driven by the app. We purposely do not propagate these from + // the referring document, but expect that the app will in most cases. + // + // The referrerInfo is derived from the referring document, if present, by propagating any + // referrer policy. If we only have the referrerUri from the app, we create a referrerInfo + // with the specified URI and no policy set. If no referrerUri is present and we have no + // referring session, the referrerInfo is null. + // + // csp is only present if we have a referring document, null otherwise. + this.browser.loadURI(uri, { + flags: navFlags, + referrerInfo, + triggeringPrincipal, + headers: additionalHeaders, + csp, + }); + break; + case "GeckoView:Reload": + // At the moment, GeckoView only supports one reload, which uses + // nsIWebNavigation.LOAD_FLAGS_NONE flag, and the telemetry doesn't + // do anything to differentiate reloads (i.e normal vs skip caches) + // So whenever we add more reload methods, please make sure the + // telemetry probe is adjusted + this.browser.reloadWithFlags(convertFlags(aData.flags)); + break; + case "GeckoView:Stop": + this.browser.stop(); + break; + case "GeckoView:PurgeHistory": + this.browser.purgeSessionHistory(); + break; + } + } + + waitAndSetupWindow(aSessionId, aOpenWindowInfo, aName) { + if (!aSessionId) { + return Promise.resolve(null); + } + + return new Promise(resolve => { + const handler = { + observe(aSubject, aTopic, aData) { + if ( + aTopic === "geckoview-window-created" && + aSubject.name === aSessionId + ) { + // This value will be read by nsFrameLoader while it is being initialized. + aSubject.browser.openWindowInfo = aOpenWindowInfo; + + // Gecko will use this attribute to set the name of the opened window. + if (aName) { + aSubject.browser.setAttribute("name", aName); + } + + if ( + !aOpenWindowInfo.isRemote && + aSubject.browser.hasAttribute("remote") + ) { + // We cannot start in remote mode when we have an opener. + aSubject.browser.setAttribute("remote", "false"); + aSubject.browser.removeAttribute("remoteType"); + } + Services.obs.removeObserver(handler, "geckoview-window-created"); + resolve(aSubject); + } + }, + }; + + // This event is emitted from createBrowser() in geckoview.js + Services.obs.addObserver(handler, "geckoview-window-created"); + }); + } + + handleNewSession(aUri, aOpenWindowInfo, aWhere, aFlags, aName) { + debug`handleNewSession: uri=${aUri && aUri.spec} + where=${aWhere} flags=${aFlags}`; + + if (!this.enabled) { + return null; + } + + const newSessionId = Services.uuid + .generateUUID() + .toString() + .slice(1, -1) + .replace(/-/g, ""); + + const message = { + type: "GeckoView:OnNewSession", + uri: aUri ? aUri.displaySpec : "", + newSessionId, + }; + + // The window might be already open by the time we get the response from + // the Java layer, so we need to start waiting before sending the message. + const setupPromise = this.waitAndSetupWindow( + newSessionId, + aOpenWindowInfo, + aName + ); + + let browser = undefined; + this.eventDispatcher + .sendRequestForResult(message) + .then(didOpenSession => { + if (!didOpenSession) { + return Promise.reject(); + } + return setupPromise; + }) + .then( + window => { + browser = window.browser; + }, + () => { + browser = null; + } + ); + + // Wait indefinitely for app to respond with a browser or null + Services.tm.spinEventLoopUntil( + "GeckoViewNavigation.jsm:handleNewSession", + () => this.window.closed || browser !== undefined + ); + return browser || null; + } + + // nsIBrowserDOMWindow. + createContentWindow( + aUri, + aOpenWindowInfo, + aWhere, + aFlags, + aTriggeringPrincipal, + aCsp + ) { + debug`createContentWindow: uri=${aUri && aUri.spec} + where=${aWhere} flags=${aFlags}`; + + if ( + lazy.LoadURIDelegate.load( + this.window, + this.eventDispatcher, + aUri, + aWhere, + aFlags, + aTriggeringPrincipal + ) + ) { + // The app has handled the load, abort open-window handling. + Components.returnCode = Cr.NS_ERROR_ABORT; + return null; + } + + const browser = this.handleNewSession( + aUri, + aOpenWindowInfo, + aWhere, + aFlags, + null + ); + if (!browser) { + Components.returnCode = Cr.NS_ERROR_ABORT; + return null; + } + + return browser.browsingContext; + } + + // nsIBrowserDOMWindow. + createContentWindowInFrame(aUri, aParams, aWhere, aFlags, aName) { + debug`createContentWindowInFrame: uri=${aUri && aUri.spec} + where=${aWhere} flags=${aFlags} + name=${aName}`; + + if (aWhere === Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) { + return this.moduleManager.onNewPrintWindow(aParams); + } + + if ( + lazy.LoadURIDelegate.load( + this.window, + this.eventDispatcher, + aUri, + aWhere, + aFlags, + aParams.triggeringPrincipal + ) + ) { + // The app has handled the load, abort open-window handling. + Components.returnCode = Cr.NS_ERROR_ABORT; + return null; + } + + const browser = this.handleNewSession( + aUri, + aParams.openWindowInfo, + aWhere, + aFlags, + aName + ); + if (!browser) { + Components.returnCode = Cr.NS_ERROR_ABORT; + return null; + } + + return browser; + } + + handleOpenUri({ + uri, + openWindowInfo, + where, + flags, + triggeringPrincipal, + csp, + referrerInfo = null, + name = null, + }) { + debug`handleOpenUri: uri=${uri && uri.spec} + where=${where} flags=${flags}`; + + if ( + lazy.LoadURIDelegate.load( + this.window, + this.eventDispatcher, + uri, + where, + flags, + triggeringPrincipal + ) + ) { + return null; + } + + let browser = this.browser; + + if ( + where === Ci.nsIBrowserDOMWindow.OPEN_NEWWINDOW || + where === Ci.nsIBrowserDOMWindow.OPEN_NEWTAB + ) { + browser = this.handleNewSession(uri, openWindowInfo, where, flags, name); + } + + if (!browser) { + // Should we throw? + return null; + } + + // 3) We have a new session and a browser element, load the requested URI. + browser.loadURI(uri.spec, { + triggeringPrincipal, + csp, + referrerInfo, + }); + return browser; + } + + // nsIBrowserDOMWindow. + openURI(aUri, aOpenWindowInfo, aWhere, aFlags, aTriggeringPrincipal, aCsp) { + const browser = this.handleOpenUri({ + uri: aUri, + openWindowInfo: aOpenWindowInfo, + where: aWhere, + flags: aFlags, + triggeringPrincipal: aTriggeringPrincipal, + csp: aCsp, + }); + return browser && browser.browsingContext; + } + + // nsIBrowserDOMWindow. + openURIInFrame(aUri, aParams, aWhere, aFlags, aName) { + const browser = this.handleOpenUri({ + uri: aUri, + openWindowInfo: aParams.openWindowInfo, + where: aWhere, + flags: aFlags, + triggeringPrincipal: aParams.triggeringPrincipal, + csp: aParams.csp, + referrerInfo: aParams.referrerInfo, + name: aName, + }); + return browser; + } + + // nsIBrowserDOMWindow. + canClose() { + debug`canClose`; + return true; + } + + onEnable() { + debug`onEnable`; + + const flags = Ci.nsIWebProgress.NOTIFY_LOCATION; + this.progressFilter = Cc[ + "@mozilla.org/appshell/component/browser-status-filter;1" + ].createInstance(Ci.nsIWebProgress); + this.progressFilter.addProgressListener(this, flags); + this.browser.addProgressListener(this.progressFilter, flags); + } + + onDisable() { + debug`onDisable`; + + if (!this.progressFilter) { + return; + } + this.progressFilter.removeProgressListener(this); + this.browser.removeProgressListener(this.progressFilter); + } + + serializePermission({ type, capability, principal }) { + const { URI, originAttributes, privateBrowsingId } = principal; + return { + uri: Services.io.createExposableURI(URI).displaySpec, + principal: lazy.E10SUtils.serializePrincipal(principal), + perm: type, + value: capability, + contextId: originAttributes.geckoViewSessionContextId, + privateMode: privateBrowsingId != 0, + }; + } + + // WebProgress event handler. + onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) { + debug`onLocationChange`; + + let fixedURI = aLocationURI; + + try { + fixedURI = Services.io.createExposableURI(aLocationURI); + } catch (ex) {} + + // We manually fire the initial about:blank messages to make sure that we + // consistently send them so there's nothing to do here. + const ignore = this._initialAboutBlank && fixedURI.spec === "about:blank"; + this._initialAboutBlank = false; + + if (ignore) { + return; + } + + const { contentPrincipal } = this.browser; + let permissions; + if ( + contentPrincipal && + lazy.GeckoViewUtils.isSupportedPermissionsPrincipal(contentPrincipal) + ) { + let rawPerms = []; + try { + rawPerms = Services.perms.getAllForPrincipal(contentPrincipal); + } catch (ex) { + warn`Could not get permissions for principal. ${ex}`; + } + permissions = rawPerms.map(this.serializePermission); + + // The only way for apps to set permissions is to get hold of an existing + // permission and change its value. + // Tracking protection exception permissions are only present when + // explicitly added by the app, so if one is not present, we need to send + // a DENY_ACTION tracking protection permission so that apps can use it + // to add tracking protection exceptions. + const trackingProtectionPermission = + contentPrincipal.privateBrowsingId == 0 + ? "trackingprotection" + : "trackingprotection-pb"; + if ( + contentPrincipal.isContentPrincipal && + rawPerms.findIndex(p => p.type == trackingProtectionPermission) == -1 + ) { + permissions.push( + this.serializePermission({ + type: trackingProtectionPermission, + capability: Ci.nsIPermissionManager.DENY_ACTION, + principal: contentPrincipal, + }) + ); + } + } + + const message = { + type: "GeckoView:LocationChange", + uri: fixedURI.displaySpec, + canGoBack: this.browser.canGoBack, + canGoForward: this.browser.canGoForward, + isTopLevel: aWebProgress.isTopLevel, + permissions, + }; + + this.eventDispatcher.sendRequest(message); + } +} + +const { debug, warn } = GeckoViewNavigation.initLogging("GeckoViewNavigation"); diff --git a/mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.jsm b/mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.jsm new file mode 100644 index 0000000000..e020a2e96e --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.jsm @@ -0,0 +1,216 @@ +/* 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 = ["GeckoViewProcessHangMonitor"]; + +const { GeckoViewModule } = ChromeUtils.import( + "resource://gre/modules/GeckoViewModule.jsm" +); + +class GeckoViewProcessHangMonitor extends GeckoViewModule { + constructor(aModuleInfo) { + super(aModuleInfo); + + /** + * Collection of hang reports that haven't expired or been dismissed + * by the user. These are nsIHangReports. + */ + this._activeReports = new Set(); + + /** + * Collection of hang reports that have been suppressed for a short + * period of time. Keys are nsIHangReports. Values are timeouts for + * when the wait time expires. + */ + this._pausedReports = new Map(); + + /** + * Simple index used for report identification + */ + this._nextIndex = 0; + + /** + * Map of report IDs to report objects. + * Keys are numbers. Values are nsIHangReports. + */ + this._reportIndex = new Map(); + + /** + * Map of report objects to report IDs. + * Keys are nsIHangReports. Values are numbers. + */ + this._reportLookupIndex = new Map(); + } + + onInit() { + debug`onInit`; + Services.obs.addObserver(this, "process-hang-report"); + Services.obs.addObserver(this, "clear-hang-report"); + } + + onDestroy() { + debug`onDestroy`; + Services.obs.removeObserver(this, "process-hang-report"); + Services.obs.removeObserver(this, "clear-hang-report"); + } + + onEnable() { + debug`onEnable`; + this.registerListener([ + "GeckoView:HangReportStop", + "GeckoView:HangReportWait", + ]); + } + + onDisable() { + debug`onDisable`; + this.unregisterListener(); + } + + // Bundle event handler. + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + if (this._reportIndex.has(aData.hangId)) { + const report = this._reportIndex.get(aData.hangId); + switch (aEvent) { + case "GeckoView:HangReportStop": + this.stopHang(report); + break; + case "GeckoView:HangReportWait": + this.pauseHang(report); + break; + } + } else { + debug`Report not found: reportIndex=${this._reportIndex}`; + } + } + + // nsIObserver event handler + observe(aSubject, aTopic, aData) { + debug`observe(aTopic=${aTopic})`; + aSubject.QueryInterface(Ci.nsIHangReport); + if (!aSubject.isReportForBrowserOrChildren(this.browser.frameLoader)) { + return; + } + + switch (aTopic) { + case "process-hang-report": { + this.reportHang(aSubject); + break; + } + case "clear-hang-report": { + this.clearHang(aSubject); + break; + } + } + } + + /** + * This timeout is the wait period applied after a user selects "Wait" in + * an existing notification. + */ + get WAIT_EXPIRATION_TIME() { + try { + return Services.prefs.getIntPref("browser.hangNotification.waitPeriod"); + } catch (ex) { + return 10000; + } + } + + /** + * Terminate whatever is causing this report, be it an add-on or page script. + * This is done without updating any report notifications. + */ + stopHang(report) { + report.terminateScript(); + } + + /** + * + */ + pauseHang(report) { + this._activeReports.delete(report); + + // Create a new timeout with notify callback + const timer = this.window.setTimeout(() => { + for (const [stashedReport, otherTimer] of this._pausedReports) { + if (otherTimer === timer) { + this._pausedReports.delete(stashedReport); + + // We're still hung, so move the report back to the active + // list. + this._activeReports.add(report); + break; + } + } + }, this.WAIT_EXPIRATION_TIME); + + this._pausedReports.set(report, timer); + } + + /** + * construct an information bundle + */ + notifyReport(report) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:HangReport", + hangId: this._reportLookupIndex.get(report), + scriptFileName: report.scriptFileName, + }); + } + + /** + * Handle a potentially new hang report. + */ + reportHang(report) { + // if we aren't enabled then default to stopping the script + if (!this.enabled) { + this.stopHang(report); + return; + } + + // if we have already notified, remind + if (this._activeReports.has(report)) { + this.notifyReport(report); + return; + } + + // If this hang was already reported and paused by the user then ignore it. + if (this._pausedReports.has(report)) { + return; + } + + const index = this._nextIndex++; + this._reportLookupIndex.set(report, index); + this._reportIndex.set(index, report); + this._activeReports.add(report); + + // Actually notify the new report + this.notifyReport(report); + } + + clearHang(report) { + this._activeReports.delete(report); + + const timer = this._pausedReports.get(report); + if (timer) { + this.window.clearTimeout(timer); + } + this._pausedReports.delete(report); + + if (this._reportLookupIndex.has(report)) { + const index = this._reportLookupIndex.get(report); + this._reportIndex.delete(index); + } + this._reportLookupIndex.delete(report); + report.userCanceled(); + } +} + +const { debug, warn } = GeckoViewProcessHangMonitor.initLogging( + "GeckoViewProcessHangMonitor" +); diff --git a/mobile/android/modules/geckoview/GeckoViewProgress.jsm b/mobile/android/modules/geckoview/GeckoViewProgress.jsm new file mode 100644 index 0000000000..746115c6db --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewProgress.jsm @@ -0,0 +1,634 @@ +/* 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 = ["GeckoViewProgress"]; + +const { GeckoViewModule } = ChromeUtils.import( + "resource://gre/modules/GeckoViewModule.jsm" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "OverrideService", + "@mozilla.org/security/certoverride;1", + "nsICertOverrideService" +); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "IDNService", + "@mozilla.org/network/idn-service;1", + "nsIIDNService" +); + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserTelemetryUtils: "resource://gre/modules/BrowserTelemetryUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + HistogramStopwatch: "resource://gre/modules/GeckoViewTelemetry.jsm", +}); + +var IdentityHandler = { + // The definitions below should be kept in sync with those in GeckoView.ProgressListener.SecurityInformation + // No trusted identity information. No site identity icon is shown. + IDENTITY_MODE_UNKNOWN: 0, + + // Domain-Validation SSL CA-signed domain verification (DV). + IDENTITY_MODE_IDENTIFIED: 1, + + // Extended-Validation SSL CA-signed identity information (EV). A more rigorous validation process. + IDENTITY_MODE_VERIFIED: 2, + + // The following mixed content modes are only used if "security.mixed_content.block_active_content" + // is enabled. Our Java frontend coalesces them into one indicator. + + // No mixed content information. No mixed content icon is shown. + MIXED_MODE_UNKNOWN: 0, + + // Blocked active mixed content. + MIXED_MODE_CONTENT_BLOCKED: 1, + + // Loaded active mixed content. + MIXED_MODE_CONTENT_LOADED: 2, + + /** + * Determines the identity mode corresponding to the icon we show in the urlbar. + */ + getIdentityMode: function getIdentityMode(aState) { + if (aState & Ci.nsIWebProgressListener.STATE_IDENTITY_EV_TOPLEVEL) { + return this.IDENTITY_MODE_VERIFIED; + } + + if (aState & Ci.nsIWebProgressListener.STATE_IS_SECURE) { + return this.IDENTITY_MODE_IDENTIFIED; + } + + return this.IDENTITY_MODE_UNKNOWN; + }, + + getMixedDisplayMode: function getMixedDisplayMode(aState) { + if (aState & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_DISPLAY_CONTENT) { + return this.MIXED_MODE_CONTENT_LOADED; + } + + if ( + aState & Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_DISPLAY_CONTENT + ) { + return this.MIXED_MODE_CONTENT_BLOCKED; + } + + return this.MIXED_MODE_UNKNOWN; + }, + + getMixedActiveMode: function getActiveDisplayMode(aState) { + // Only show an indicator for loaded mixed content if the pref to block it is enabled + if ( + aState & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT && + !Services.prefs.getBoolPref("security.mixed_content.block_active_content") + ) { + return this.MIXED_MODE_CONTENT_LOADED; + } + + if (aState & Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_ACTIVE_CONTENT) { + return this.MIXED_MODE_CONTENT_BLOCKED; + } + + return this.MIXED_MODE_UNKNOWN; + }, + + /** + * Determine the identity of the page being displayed by examining its SSL cert + * (if available). Return the data needed to update the UI. + */ + checkIdentity: function checkIdentity(aState, aBrowser) { + const identityMode = this.getIdentityMode(aState); + const mixedDisplay = this.getMixedDisplayMode(aState); + const mixedActive = this.getMixedActiveMode(aState); + const result = { + mode: { + identity: identityMode, + mixed_display: mixedDisplay, + mixed_active: mixedActive, + }, + }; + + if (aBrowser.contentPrincipal) { + result.origin = aBrowser.contentPrincipal.originNoSuffix; + } + + // Don't show identity data for pages with an unknown identity or if any + // mixed content is loaded (mixed display content is loaded by default). + if ( + identityMode === this.IDENTITY_MODE_UNKNOWN || + aState & Ci.nsIWebProgressListener.STATE_IS_BROKEN || + aState & Ci.nsIWebProgressListener.STATE_IS_INSECURE + ) { + result.secure = false; + return result; + } + + result.secure = true; + + let uri = aBrowser.currentURI || {}; + try { + uri = Services.io.createExposableURI(uri); + } catch (e) {} + + try { + result.host = lazy.IDNService.convertToDisplayIDN(uri.host, {}); + } catch (e) { + result.host = uri.host; + } + + const cert = aBrowser.securityUI.secInfo.serverCert; + + result.certificate = aBrowser.securityUI.secInfo.serverCert.getBase64DERString(); + + try { + result.securityException = lazy.OverrideService.hasMatchingOverride( + uri.host, + uri.port, + {}, + cert, + {} + ); + + // If an override exists, the connection is being allowed but should not + // be considered secure. + result.secure = !result.securityException; + } catch (e) {} + + return result; + }, +}; + +class Tracker { + constructor(aModule) { + this._module = aModule; + } + + get eventDispatcher() { + return this._module.eventDispatcher; + } + + get browser() { + return this._module.browser; + } + + QueryInterface = ChromeUtils.generateQI(["nsIWebProgressListener"]); +} + +class ProgressTracker extends Tracker { + constructor(aModule) { + super(aModule); + const window = aModule.browser.ownerGlobal; + this.pageLoadProbe = new lazy.HistogramStopwatch("GV_PAGE_LOAD_MS", window); + this.pageReloadProbe = new lazy.HistogramStopwatch( + "GV_PAGE_RELOAD_MS", + window + ); + this.pageLoadProgressProbe = new lazy.HistogramStopwatch( + "GV_PAGE_LOAD_PROGRESS_MS", + window + ); + + this.clear(); + this._eventReceived = null; + } + + start(aUri) { + debug`ProgressTracker start ${aUri}`; + + if (this._eventReceived) { + // A request was already in process, let's cancel it + this.stop(/* isSuccess */ false); + } + + this._eventReceived = new Set(); + this.clear(); + const data = this._data; + + if (aUri === "about:blank") { + data.uri = null; + return; + } + + this.pageLoadProgressProbe.start(); + + data.uri = aUri; + data.pageStart = true; + this.updateProgress(); + } + + changeLocation(aUri) { + debug`ProgressTracker changeLocation ${aUri}`; + + const data = this._data; + data.locationChange = true; + data.uri = aUri; + } + + stop(aIsSuccess) { + debug`ProgressTracker stop`; + + if (!this._eventReceived) { + // No request in progress + return; + } + + if (aIsSuccess) { + this.pageLoadProgressProbe.finish(); + } else { + this.pageLoadProgressProbe.cancel(); + } + + const data = this._data; + data.pageStop = true; + this.updateProgress(); + this._eventReceived = null; + } + + onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { + debug`ProgressTracker onStateChange: isTopLevel=${aWebProgress.isTopLevel}, + flags=${aStateFlags}, status=${aStatus}`; + + if (!aWebProgress || !aWebProgress.isTopLevel) { + return; + } + + const { displaySpec } = aRequest.QueryInterface(Ci.nsIChannel).URI; + + if (aRequest.URI.schemeIs("about")) { + return; + } + + debug`ProgressTracker onStateChange: uri=${displaySpec}`; + + const isPageReload = + (aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_RELOAD) != 0; + const probe = isPageReload ? this.pageReloadProbe : this.pageLoadProbe; + + const isStart = (aStateFlags & Ci.nsIWebProgressListener.STATE_START) != 0; + const isStop = (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) != 0; + const isRedirecting = + (aStateFlags & Ci.nsIWebProgressListener.STATE_REDIRECTING) != 0; + + if (isStart) { + probe.start(); + this.start(displaySpec); + } else if (isStop && !aWebProgress.isLoadingDocument) { + probe.finish(); + this.stop(aStatus == Cr.NS_OK); + } else if (isRedirecting) { + probe.start(); + this.start(displaySpec); + } + } + + onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) { + if ( + !aWebProgress || + !aWebProgress.isTopLevel || + !aLocationURI || + aLocationURI.schemeIs("about") + ) { + return; + } + + debug`ProgressTracker onLocationChange: location=${aLocationURI.displaySpec}, + flags=${aFlags}`; + + if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) { + this.stop(/* isSuccess */ false); + } else { + this.changeLocation(aLocationURI.displaySpec); + } + } + + handleEvent(aEvent) { + if (!this._eventReceived || this._eventReceived.has(aEvent.name)) { + // Either we're not tracking or we have received this event already + return; + } + + const data = this._data; + + if (!data.uri || data.uri !== aEvent.data?.uri) { + return; + } + + debug`ProgressTracker handleEvent: ${aEvent.name}`; + + let needsUpdate = false; + + switch (aEvent.name) { + case "DOMContentLoaded": + needsUpdate = needsUpdate || !data.parsed; + data.parsed = true; + break; + case "MozAfterPaint": + needsUpdate = needsUpdate || !data.firstPaint; + data.firstPaint = true; + break; + case "pageshow": + needsUpdate = needsUpdate || !data.pageShow; + data.pageShow = true; + break; + } + + this._eventReceived.add(aEvent.name); + + if (needsUpdate) { + this.updateProgress(); + } + } + + clear() { + this._data = { + prev: 0, + uri: null, + locationChange: false, + pageStart: false, + pageStop: false, + firstPaint: false, + pageShow: false, + parsed: false, + }; + } + + _debugData() { + return { + prev: this._data.prev, + uri: this._data.uri, + locationChange: this._data.locationChange, + pageStart: this._data.pageStart, + pageStop: this._data.pageStop, + firstPaint: this._data.firstPaint, + pageShow: this._data.pageShow, + parsed: this._data.parsed, + }; + } + + updateProgress() { + debug`ProgressTracker updateProgress`; + + const data = this._data; + + if (!this._eventReceived || !data.uri) { + return; + } + + let progress = 0; + if (data.pageStop || data.pageShow) { + progress = 100; + } else if (data.firstPaint) { + progress = 80; + } else if (data.parsed) { + progress = 55; + } else if (data.locationChange) { + progress = 30; + } else if (data.pageStart) { + progress = 15; + } + + if (data.prev >= progress) { + return; + } + + debug`ProgressTracker updateProgress data=${this._debugData()} + progress=${progress}`; + + this.eventDispatcher.sendRequest({ + type: "GeckoView:ProgressChanged", + progress, + }); + + data.prev = progress; + } +} + +class StateTracker extends Tracker { + constructor(aModule) { + super(aModule); + this._inProgress = false; + this._uri = null; + } + + start(aUri) { + this._inProgress = true; + this._uri = aUri; + this.eventDispatcher.sendRequest({ + type: "GeckoView:PageStart", + uri: aUri, + }); + } + + stop(aIsSuccess) { + if (!this._inProgress) { + // No request in progress + return; + } + + this._inProgress = false; + this._uri = null; + + this.eventDispatcher.sendRequest({ + type: "GeckoView:PageStop", + success: aIsSuccess, + }); + + lazy.BrowserTelemetryUtils.recordSiteOriginTelemetry( + Services.wm.getEnumerator("navigator:geckoview"), + true + ); + } + + onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { + debug`StateTracker onStateChange: isTopLevel=${aWebProgress.isTopLevel}, + flags=${aStateFlags}, status=${aStatus} + loadType=${aWebProgress.loadType}`; + + if (!aWebProgress.isTopLevel) { + return; + } + + const { displaySpec } = aRequest.QueryInterface(Ci.nsIChannel).URI; + const isStart = (aStateFlags & Ci.nsIWebProgressListener.STATE_START) != 0; + const isStop = (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) != 0; + + if (isStart) { + this.start(displaySpec); + } else if (isStop && !aWebProgress.isLoadingDocument) { + this.stop(aStatus == Cr.NS_OK); + } + } +} + +class SecurityTracker extends Tracker { + constructor(aModule) { + super(aModule); + this._hostChanged = false; + } + + onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) { + debug`SecurityTracker onLocationChange: location=${aLocationURI.displaySpec}, + flags=${aFlags}`; + + this._hostChanged = true; + } + + onSecurityChange(aWebProgress, aRequest, aState) { + debug`onSecurityChange`; + + // Don't need to do anything if the data we use to update the UI hasn't changed + if (this._state === aState && !this._hostChanged) { + return; + } + + this._state = aState; + this._hostChanged = false; + + const identity = IdentityHandler.checkIdentity(aState, this.browser); + + this.eventDispatcher.sendRequest({ + type: "GeckoView:SecurityChanged", + identity, + }); + } +} + +class GeckoViewProgress extends GeckoViewModule { + onEnable() { + debug`onEnable`; + + this._fireInitialLoad(); + this._initialAboutBlank = true; + + this._progressTracker = new ProgressTracker(this); + this._securityTracker = new SecurityTracker(this); + this._stateTracker = new StateTracker(this); + + const flags = + Ci.nsIWebProgress.NOTIFY_STATE_NETWORK | + Ci.nsIWebProgress.NOTIFY_SECURITY | + Ci.nsIWebProgress.NOTIFY_LOCATION; + this.progressFilter = Cc[ + "@mozilla.org/appshell/component/browser-status-filter;1" + ].createInstance(Ci.nsIWebProgress); + this.progressFilter.addProgressListener(this, flags); + this.browser.addProgressListener(this.progressFilter, flags); + Services.obs.addObserver(this, "oop-frameloader-crashed"); + this.registerListener("GeckoView:FlushSessionState"); + } + + onDisable() { + debug`onDisable`; + + if (this.progressFilter) { + this.progressFilter.removeProgressListener(this); + this.browser.removeProgressListener(this.progressFilter); + } + + Services.obs.removeObserver(this, "oop-frameloader-crashed"); + this.unregisterListener("GeckoView:FlushSessionState"); + } + + receiveMessage(aMsg) { + debug`receiveMessage: ${aMsg.name}`; + + switch (aMsg.name) { + case "DOMContentLoaded": // fall-through + case "MozAfterPaint": // fall-through + case "pageshow": { + this._progressTracker?.handleEvent(aMsg); + break; + } + } + } + + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + switch (aEvent) { + case "GeckoView:FlushSessionState": + this.messageManager.sendAsyncMessage("GeckoView:FlushSessionState"); + break; + } + } + + onStateChange(...args) { + // GeckoView never gets PageStart or PageStop for about:blank because we + // set nodefaultsrc to true unconditionally so we can assume here that + // we're starting a page load for a non-blank page (or a consumer-initiated + // about:blank load). + this._initialAboutBlank = false; + + this._progressTracker.onStateChange(...args); + this._stateTracker.onStateChange(...args); + } + + onSecurityChange(...args) { + // We don't report messages about the initial about:blank + if (this._initialAboutBlank) { + return; + } + + this._securityTracker.onSecurityChange(...args); + } + + onLocationChange(...args) { + this._securityTracker.onLocationChange(...args); + this._progressTracker.onLocationChange(...args); + } + + // The initial about:blank load events are unreliable because docShell starts + // up concurrently with loading geckoview.js so we're never guaranteed to get + // the events. + // What we do instead is ignore all initial about:blank events and fire them + // manually once the child process has booted up. + _fireInitialLoad() { + this.eventDispatcher.sendRequest({ + type: "GeckoView:PageStart", + uri: "about:blank", + }); + this.eventDispatcher.sendRequest({ + type: "GeckoView:LocationChange", + uri: "about:blank", + canGoBack: false, + canGoForward: false, + isTopLevel: true, + }); + this.eventDispatcher.sendRequest({ + type: "GeckoView:PageStop", + success: true, + }); + } + + // nsIObserver event handler + observe(aSubject, aTopic, aData) { + debug`observe: topic=${aTopic}`; + + switch (aTopic) { + case "oop-frameloader-crashed": { + const browser = aSubject.ownerElement; + if (!browser || browser != this.browser) { + return; + } + + this._progressTracker?.stop(/* isSuccess */ false); + this._stateTracker?.stop(/* isSuccess */ false); + } + } + } +} + +const { debug, warn } = GeckoViewProgress.initLogging("GeckoViewProgress"); diff --git a/mobile/android/modules/geckoview/GeckoViewPushController.jsm b/mobile/android/modules/geckoview/GeckoViewPushController.jsm new file mode 100644 index 0000000000..57522b80b4 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewPushController.jsm @@ -0,0 +1,77 @@ +/* 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 = ["GeckoViewPushController"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "PushNotifier", + "@mozilla.org/push/Notifier;1", + "nsIPushNotifier" +); + +const { debug, warn } = GeckoViewUtils.initLogging("GeckoViewPushController"); + +function createScopeAndPrincipal(scopeAndAttrs) { + const principal = Services.scriptSecurityManager.createContentPrincipalFromOrigin( + scopeAndAttrs + ); + const scope = principal.URI.spec; + + return [scope, principal]; +} + +const GeckoViewPushController = { + onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:PushEvent": { + const { scope, data } = aData; + + const [url, principal] = createScopeAndPrincipal(scope); + + if ( + Services.perms.testPermissionFromPrincipal( + principal, + "desktop-notification" + ) != Services.perms.ALLOW_ACTION + ) { + return; + } + + if (!data) { + lazy.PushNotifier.notifyPush(url, principal, ""); + return; + } + + const payload = new Uint8Array( + ChromeUtils.base64URLDecode(data, { padding: "ignore" }) + ); + + lazy.PushNotifier.notifyPushWithData(url, principal, "", payload); + break; + } + case "GeckoView:PushSubscriptionChanged": { + const { scope } = aData; + + const [url, principal] = createScopeAndPrincipal(scope); + + lazy.PushNotifier.notifySubscriptionChange(url, principal); + break; + } + } + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewRemoteDebugger.jsm b/mobile/android/modules/geckoview/GeckoViewRemoteDebugger.jsm new file mode 100644 index 0000000000..35f045d149 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewRemoteDebugger.jsm @@ -0,0 +1,151 @@ +/* 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 = ["GeckoViewRemoteDebugger"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "require", () => { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + return require; +}); + +XPCOMUtils.defineLazyGetter(lazy, "DevToolsServer", () => { + const { DevToolsServer } = lazy.require("devtools/server/devtools-server"); + return DevToolsServer; +}); + +XPCOMUtils.defineLazyGetter(lazy, "SocketListener", () => { + const { SocketListener } = lazy.require("devtools/shared/security/socket"); + return SocketListener; +}); + +const { debug, warn } = GeckoViewUtils.initLogging("RemoteDebugger"); + +var GeckoViewRemoteDebugger = { + observe(aSubject, aTopic, aData) { + if (aTopic !== "nsPref:changed") { + return; + } + + if (Services.prefs.getBoolPref(aData, false)) { + this.onEnable(); + } else { + this.onDisable(); + } + }, + + onInit() { + debug`onInit`; + this._isEnabled = false; + this._usbDebugger = new USBRemoteDebugger(); + }, + + onEnable() { + if (this._isEnabled) { + return; + } + + debug`onEnable`; + lazy.DevToolsServer.init(); + lazy.DevToolsServer.registerAllActors(); + const { createRootActor } = lazy.require( + "resource://gre/modules/dbg-browser-actors.js" + ); + lazy.DevToolsServer.setRootActor(createRootActor); + lazy.DevToolsServer.allowChromeProcess = true; + lazy.DevToolsServer.chromeWindowType = "navigator:geckoview"; + // Force the Server to stay alive even if there are no connections at the moment. + lazy.DevToolsServer.keepAlive = true; + + // Socket address for USB remote debugger expects + // @ANDROID_PACKAGE_NAME/firefox-debugger-socket. + // In /proc/net/unix, it will be outputed as + // @org.mozilla.geckoview_example/firefox-debugger-socket + // + // If package name isn't available, it will be "@firefox-debugger-socket". + + let packageName = Services.env.get("MOZ_ANDROID_PACKAGE_NAME"); + if (packageName) { + packageName = packageName + "/"; + } else { + warn`Missing env MOZ_ANDROID_PACKAGE_NAME. Unable to get package name`; + } + + this._isEnabled = true; + this._usbDebugger.stop(); + + const portOrPath = packageName + "firefox-debugger-socket"; + this._usbDebugger.start(portOrPath); + }, + + onDisable() { + if (!this._isEnabled) { + return; + } + + debug`onDisable`; + this._isEnabled = false; + this._usbDebugger.stop(); + }, +}; + +class USBRemoteDebugger { + start(aPortOrPath) { + try { + const AuthenticatorType = lazy.DevToolsServer.Authenticators.get( + "PROMPT" + ); + const authenticator = new AuthenticatorType.Server(); + authenticator.allowConnection = this.allowConnection.bind(this); + const socketOptions = { + authenticator, + portOrPath: aPortOrPath, + }; + this._listener = new lazy.SocketListener( + lazy.DevToolsServer, + socketOptions + ); + this._listener.open(); + debug`USB remote debugger - listening on ${aPortOrPath}`; + } catch (e) { + warn`Unable to start USB debugger server: ${e}`; + } + } + + stop() { + if (!this._listener) { + return; + } + + try { + this._listener.close(); + this._listener = null; + } catch (e) { + warn`Unable to stop USB debugger server: ${e}`; + } + } + + allowConnection(aSession) { + if (!this._listener) { + return lazy.DevToolsServer.AuthenticationResult.DENY; + } + + if (aSession.server.port) { + return lazy.DevToolsServer.AuthenticationResult.DENY; + } + return lazy.DevToolsServer.AuthenticationResult.ALLOW; + } +} diff --git a/mobile/android/modules/geckoview/GeckoViewSelectionAction.jsm b/mobile/android/modules/geckoview/GeckoViewSelectionAction.jsm new file mode 100644 index 0000000000..f118fa8d1d --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewSelectionAction.jsm @@ -0,0 +1,46 @@ +/* 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 = ["GeckoViewSelectionAction"]; + +const { GeckoViewModule } = ChromeUtils.import( + "resource://gre/modules/GeckoViewModule.jsm" +); + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +class GeckoViewSelectionAction extends GeckoViewModule { + onEnable() { + debug`onEnable`; + this.registerListener(["GeckoView:ExecuteSelectionAction"]); + } + + onDisable() { + debug`onDisable`; + this.unregisterListener(); + } + + get actor() { + return this.getActor("SelectionActionDelegate"); + } + + // Bundle event handler. + onEvent(aEvent, aData, aCallback) { + debug`onEvent: ${aEvent}`; + + switch (aEvent) { + case "GeckoView:ExecuteSelectionAction": { + this.actor.executeSelectionAction(aData); + } + } + } +} + +const { debug, warn } = GeckoViewSelectionAction.initLogging( + "GeckoViewSelectionAction" +); diff --git a/mobile/android/modules/geckoview/GeckoViewSessionStore.jsm b/mobile/android/modules/geckoview/GeckoViewSessionStore.jsm new file mode 100644 index 0000000000..20b086078a --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewSessionStore.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 = ["GeckoViewSessionStore"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("SessionStore"); +const kNoIndex = Number.MAX_SAFE_INTEGER; +const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + +class SHistoryListener { + constructor(browsingContext) { + this.QueryInterface = ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]); + + this._browserId = browsingContext.browserId; + this._fromIndex = kNoIndex; + } + + unregister(permanentKey) { + const bc = BrowsingContext.getCurrentTopByBrowserId(this._browserId); + bc?.sessionHistory?.removeSHistoryListener(this); + GeckoViewSessionStore._browserSHistoryListener?.delete(permanentKey); + } + + collect( + permanentKey, // eslint-disable-line no-shadow + browsingContext, // eslint-disable-line no-shadow + { collectFull = true, writeToCache = false } + ) { + // Don't bother doing anything if we haven't seen any navigations. + if (!collectFull && this._fromIndex === kNoIndex) { + return null; + } + + const fromIndex = collectFull ? -1 : this._fromIndex; + this._fromIndex = kNoIndex; + + const historychange = lazy.SessionHistory.collectFromParent( + browsingContext.currentURI?.spec, + true, // Bug 1704574 + browsingContext.sessionHistory, + fromIndex + ); + + if (writeToCache) { + const win = + browsingContext.embedderElement?.ownerGlobal || + browsingContext.currentWindowGlobal?.browsingContext?.window; + + GeckoViewSessionStore.onTabStateUpdate(permanentKey, win, { + data: { historychange }, + }); + } + + return historychange; + } + + collectFrom(index) { + if (this._fromIndex <= index) { + // If we already know that we need to update history from index N we + // can ignore any changes that happened with an element with index + // larger than N. + // + // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which + // means we don't ignore anything here, and in case of navigation in + // the history back and forth cases we use kLastIndex which ignores + // only the subsequent navigations, but not any new elements added. + return; + } + + const bc = BrowsingContext.getCurrentTopByBrowserId(this._browserId); + if (bc?.embedderElement?.frameLoader) { + this._fromIndex = index; + + // Queue a tab state update on the |browser.sessionstore.interval| + // timer. We'll call this.collect() when we receive the update. + bc.embedderElement.frameLoader.requestSHistoryUpdate(); + } + } + + OnHistoryNewEntry(newURI, oldIndex) { + // We use oldIndex - 1 to collect the current entry as well. This makes + // sure to collect any changes that were made to the entry while the + // document was active. + this.collectFrom(oldIndex == -1 ? oldIndex : oldIndex - 1); + } + OnHistoryGotoIndex() { + this.collectFrom(kLastIndex); + } + OnHistoryPurge() { + this.collectFrom(-1); + } + OnHistoryReload() { + this.collectFrom(-1); + return true; + } + OnHistoryReplaceEntry() { + this.collectFrom(-1); + } +} + +var GeckoViewSessionStore = { + // For each <browser> element, records the SHistoryListener. + _browserSHistoryListener: new WeakMap(), + + observe(aSubject, aTopic, aData) { + debug`observe ${aTopic}`; + + switch (aTopic) { + case "browsing-context-did-set-embedder": { + if ( + aSubject && + aSubject === aSubject.top && + aSubject.isContent && + aSubject.embedderElement && + aSubject.embedderElement.permanentKey + ) { + const permanentKey = aSubject.embedderElement.permanentKey; + this._browserSHistoryListener + .get(permanentKey) + ?.unregister(permanentKey); + + this.getOrCreateSHistoryListener(permanentKey, aSubject, true); + } + break; + } + case "browsing-context-discarded": + const permanentKey = aSubject?.embedderElement?.permanentKey; + if (permanentKey) { + this._browserSHistoryListener + .get(permanentKey) + ?.unregister(permanentKey); + } + break; + } + }, + + onTabStateUpdate(permanentKey, win, data) { + win.WindowEventDispatcher.sendRequest({ + type: "GeckoView:StateUpdated", + data: data.data, + }); + }, + + getOrCreateSHistoryListener( + permanentKey, + browsingContext, + collectImmediately = false + ) { + if (!permanentKey || browsingContext !== browsingContext.top) { + return null; + } + + const sessionHistory = browsingContext.sessionHistory; + if (!sessionHistory) { + return null; + } + + let listener = this._browserSHistoryListener.get(permanentKey); + if (listener) { + return listener; + } + + listener = new SHistoryListener(browsingContext); + sessionHistory.addSHistoryListener(listener); + this._browserSHistoryListener.set(permanentKey, listener); + + if ( + collectImmediately && + (!(browsingContext.currentURI?.spec === "about:blank") || + sessionHistory.count !== 0) + ) { + listener.collect(permanentKey, browsingContext, { writeToCache: true }); + } + + return listener; + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewSettings.jsm b/mobile/android/modules/geckoview/GeckoViewSettings.jsm new file mode 100644 index 0000000000..e0256516ec --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewSettings.jsm @@ -0,0 +1,191 @@ +/* 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 = ["GeckoViewSettings"]; + +const { GeckoViewModule } = ChromeUtils.import( + "resource://gre/modules/GeckoViewModule.jsm" +); + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "MOBILE_USER_AGENT", function() { + return Cc["@mozilla.org/network/protocol;1?name=http"].getService( + Ci.nsIHttpProtocolHandler + ).userAgent; +}); + +XPCOMUtils.defineLazyGetter(lazy, "DESKTOP_USER_AGENT", function() { + return lazy.MOBILE_USER_AGENT.replace( + /Android \d.+?; [a-zA-Z]+/, + "X11; Linux x86_64" + ).replace(/Gecko\/[0-9\.]+/, "Gecko/20100101"); +}); + +XPCOMUtils.defineLazyGetter(lazy, "VR_USER_AGENT", function() { + return lazy.MOBILE_USER_AGENT.replace(/Mobile/, "Mobile VR"); +}); + +// This needs to match GeckoSessionSettings.java +const USER_AGENT_MODE_MOBILE = 0; +const USER_AGENT_MODE_DESKTOP = 1; +const USER_AGENT_MODE_VR = 2; + +// This needs to match GeckoSessionSettings.java +const DISPLAY_MODE_BROWSER = 0; +const DISPLAY_MODE_MINIMAL_UI = 1; +const DISPLAY_MODE_STANDALONE = 2; +const DISPLAY_MODE_FULLSCREEN = 3; + +// This needs to match GeckoSessionSettings.java +const VIEWPORT_MODE_MOBILE = 0; +const VIEWPORT_MODE_DESKTOP = 1; + +// Handles GeckoSession settings. +class GeckoViewSettings extends GeckoViewModule { + onInit() { + debug`onInit`; + this._userAgentMode = USER_AGENT_MODE_MOBILE; + this._userAgentOverride = null; + this._sessionContextId = null; + + this.registerListener(["GeckoView:GetUserAgent"]); + } + + onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:GetUserAgent": { + aCallback.onSuccess(this.customUserAgent ?? lazy.MOBILE_USER_AGENT); + } + } + } + + onSettingsUpdate() { + const { settings } = this; + debug`onSettingsUpdate: ${settings}`; + + this.displayMode = settings.displayMode; + this.unsafeSessionContextId = settings.unsafeSessionContextId; + this.userAgentMode = settings.userAgentMode; + this.userAgentOverride = settings.userAgentOverride; + this.sessionContextId = settings.sessionContextId; + this.suspendMediaWhenInactive = settings.suspendMediaWhenInactive; + this.allowJavascript = settings.allowJavascript; + this.viewportMode = settings.viewportMode; + this.useTrackingProtection = !!settings.useTrackingProtection; + + // When the page is loading from the main process (e.g. from an extension + // page) we won't be able to query the actor here. + this.getActor("GeckoViewSettings")?.sendAsyncMessage( + "SettingsUpdate", + settings + ); + } + + get allowJavascript() { + return this.browsingContext.allowJavascript; + } + + set allowJavascript(aAllowJavascript) { + this.browsingContext.allowJavascript = aAllowJavascript; + } + + get customUserAgent() { + if (this.userAgentOverride !== null) { + return this.userAgentOverride; + } + if (this.userAgentMode === USER_AGENT_MODE_DESKTOP) { + return lazy.DESKTOP_USER_AGENT; + } + if (this.userAgentMode === USER_AGENT_MODE_VR) { + return lazy.VR_USER_AGENT; + } + return null; + } + + set useTrackingProtection(aUse) { + this.browsingContext.useTrackingProtection = aUse; + } + + set viewportMode(aViewportMode) { + this.browsingContext.forceDesktopViewport = + aViewportMode == VIEWPORT_MODE_DESKTOP; + } + + get userAgentMode() { + return this._userAgentMode; + } + + set userAgentMode(aMode) { + if (this.userAgentMode === aMode) { + return; + } + this._userAgentMode = aMode; + this.browsingContext.customUserAgent = this.customUserAgent; + } + + get browsingContext() { + return this.browser.browsingContext.top; + } + + get userAgentOverride() { + return this._userAgentOverride; + } + + set userAgentOverride(aUserAgent) { + if (aUserAgent === this.userAgentOverride) { + return; + } + this._userAgentOverride = aUserAgent; + this.browsingContext.customUserAgent = this.customUserAgent; + } + + get suspendMediaWhenInactive() { + return this.browser.suspendMediaWhenInactive; + } + + set suspendMediaWhenInactive(aSuspendMediaWhenInactive) { + if (aSuspendMediaWhenInactive != this.browser.suspendMediaWhenInactive) { + this.browser.suspendMediaWhenInactive = aSuspendMediaWhenInactive; + } + } + + displayModeSettingToValue(aSetting) { + switch (aSetting) { + case DISPLAY_MODE_BROWSER: + return "browser"; + case DISPLAY_MODE_MINIMAL_UI: + return "minimal-ui"; + case DISPLAY_MODE_STANDALONE: + return "standalone"; + case DISPLAY_MODE_FULLSCREEN: + return "fullscreen"; + default: + warn`Invalid displayMode value ${aSetting}.`; + return "browser"; + } + } + + set displayMode(aMode) { + this.browsingContext.displayMode = this.displayModeSettingToValue(aMode); + } + + set sessionContextId(aAttribute) { + this._sessionContextId = aAttribute; + } + + get sessionContextId() { + return this._sessionContextId; + } +} + +const { debug, warn } = GeckoViewSettings.initLogging("GeckoViewSettings"); diff --git a/mobile/android/modules/geckoview/GeckoViewStorageController.jsm b/mobile/android/modules/geckoview/GeckoViewStorageController.jsm new file mode 100644 index 0000000000..02124aa3f1 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewStorageController.jsm @@ -0,0 +1,353 @@ +/* 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 = ["GeckoViewStorageController"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "serviceMode", + "cookiebanners.service.mode", + Ci.nsICookieBannerService.MODE_DISABLED +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "serviceModePBM", + "cookiebanners.service.mode.privateBrowsing", + Ci.nsICookieBannerService.MODE_DISABLED +); +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); +const { PrincipalsCollector } = ChromeUtils.import( + "resource://gre/modules/PrincipalsCollector.jsm" +); +const { E10SUtils } = ChromeUtils.importESModule( + "resource://gre/modules/E10SUtils.sys.mjs" +); + +const { debug, warn } = GeckoViewUtils.initLogging( + "GeckoViewStorageController" +); + +// Keep in sync with StorageController.ClearFlags and nsIClearDataService.idl. +const ClearFlags = [ + [ + // COOKIES + 1 << 0, + Ci.nsIClearDataService.CLEAR_COOKIES | + Ci.nsIClearDataService.CLEAR_MEDIA_DEVICES, + ], + [ + // NETWORK_CACHE + 1 << 1, + Ci.nsIClearDataService.CLEAR_NETWORK_CACHE, + ], + [ + // IMAGE_CACHE + 1 << 2, + Ci.nsIClearDataService.CLEAR_IMAGE_CACHE, + ], + [ + // HISTORY + 1 << 3, + Ci.nsIClearDataService.CLEAR_HISTORY | + Ci.nsIClearDataService.CLEAR_SESSION_HISTORY, + ], + [ + // DOM_STORAGES + 1 << 4, + Ci.nsIClearDataService.CLEAR_DOM_QUOTA | + Ci.nsIClearDataService.CLEAR_DOM_PUSH_NOTIFICATIONS | + Ci.nsIClearDataService.CLEAR_REPORTS, + ], + [ + // AUTH_SESSIONS + 1 << 5, + Ci.nsIClearDataService.CLEAR_AUTH_TOKENS | + Ci.nsIClearDataService.CLEAR_AUTH_CACHE, + ], + [ + // PERMISSIONS + 1 << 6, + Ci.nsIClearDataService.CLEAR_PERMISSIONS, + ], + [ + // SITE_SETTINGS + 1 << 7, + Ci.nsIClearDataService.CLEAR_CONTENT_PREFERENCES | + Ci.nsIClearDataService.CLEAR_DOM_PUSH_NOTIFICATIONS | + // former a part of SECURITY_SETTINGS_CLEANER + Ci.nsIClearDataService.CLEAR_CLIENT_AUTH_REMEMBER_SERVICE, + ], + [ + // SITE_DATA + 1 << 8, + Ci.nsIClearDataService.CLEAR_EME, + // former a part of SECURITY_SETTINGS_CLEANER + Ci.nsIClearDataService.CLEAR_HSTS, + ], + [ + // ALL + 1 << 9, + Ci.nsIClearDataService.CLEAR_ALL, + ], +]; + +function convertFlags(aJavaFlags) { + const flags = ClearFlags.filter(cf => { + return cf[0] & aJavaFlags; + }).reduce((acc, cf) => { + return acc | cf[1]; + }, 0); + return flags; +} + +const GeckoViewStorageController = { + onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:ClearData": { + this.clearData(aData.flags, aCallback); + break; + } + case "GeckoView:ClearSessionContextData": { + this.clearSessionContextData(aData.contextId); + break; + } + case "GeckoView:ClearHostData": { + this.clearHostData(aData.host, aData.flags, aCallback); + break; + } + case "GeckoView:ClearBaseDomainData": { + this.clearBaseDomainData(aData.baseDomain, aData.flags, aCallback); + break; + } + case "GeckoView:GetAllPermissions": { + const rawPerms = Services.perms.all; + const permissions = rawPerms.map(p => { + return { + uri: Services.io.createExposableURI(p.principal.URI).displaySpec, + principal: E10SUtils.serializePrincipal(p.principal), + perm: p.type, + value: p.capability, + contextId: p.principal.originAttributes.geckoViewSessionContextId, + privateMode: p.principal.privateBrowsingId != 0, + }; + }); + aCallback.onSuccess({ permissions }); + break; + } + case "GeckoView:GetPermissionsByURI": { + const uri = Services.io.newURI(aData.uri); + const principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + aData.contextId + ? { + geckoViewSessionContextId: aData.contextId, + privateBrowsingId: aData.privateBrowsingId, + } + : { privateBrowsingId: aData.privateBrowsingId } + ); + const rawPerms = Services.perms.getAllForPrincipal(principal); + const permissions = rawPerms.map(p => { + return { + uri: Services.io.createExposableURI(p.principal.URI).displaySpec, + principal: E10SUtils.serializePrincipal(p.principal), + perm: p.type, + value: p.capability, + contextId: p.principal.originAttributes.geckoViewSessionContextId, + privateMode: p.principal.privateBrowsingId != 0, + }; + }); + aCallback.onSuccess({ permissions }); + break; + } + case "GeckoView:SetPermission": { + const principal = E10SUtils.deserializePrincipal(aData.principal); + let key = aData.perm; + if (key == "storage-access") { + key = "3rdPartyStorage^" + aData.thirdPartyOrigin; + } + if (aData.allowPermanentPrivateBrowsing) { + Services.perms.addFromPrincipalAndPersistInPrivateBrowsing( + principal, + key, + aData.newValue + ); + } else { + const expirePolicy = aData.privateMode + ? Ci.nsIPermissionManager.EXPIRE_SESSION + : Ci.nsIPermissionManager.EXPIRE_NEVER; + Services.perms.addFromPrincipal( + principal, + key, + aData.newValue, + expirePolicy + ); + } + break; + } + case "GeckoView:SetPermissionByURI": { + const uri = Services.io.newURI(aData.uri); + const expirePolicy = aData.privateId + ? Ci.nsIPermissionManager.EXPIRE_SESSION + : Ci.nsIPermissionManager.EXPIRE_NEVER; + const principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + { + geckoViewSessionContextId: aData.contextId ?? undefined, + privateBrowsingId: aData.privateId, + } + ); + Services.perms.addFromPrincipal( + principal, + aData.perm, + aData.newValue, + expirePolicy + ); + break; + } + + case "GeckoView:SetCookieBannerModeForDomain": { + let exceptionLabel = "SetCookieBannerModeForDomain"; + try { + const uri = Services.io.newURI(aData.uri); + if (aData.allowPermanentPrivateBrowsing) { + exceptionLabel = "setDomainPrefAndPersistInPrivateBrowsing"; + Services.cookieBanners.setDomainPrefAndPersistInPrivateBrowsing( + uri, + aData.mode + ); + } else { + Services.cookieBanners.setDomainPref( + uri, + aData.mode, + aData.isPrivateBrowsing + ); + } + aCallback.onSuccess(); + } catch (ex) { + debug`Failed ${exceptionLabel} ${ex}`; + } + break; + } + + case "GeckoView:RemoveCookieBannerModeForDomain": { + try { + const uri = Services.io.newURI(aData.uri); + Services.cookieBanners.removeDomainPref(uri, aData.isPrivateBrowsing); + aCallback.onSuccess(); + } catch (ex) { + debug`Failed RemoveCookieBannerModeForDomain ${ex}`; + } + break; + } + + case "GeckoView:GetCookieBannerModeForDomain": { + try { + let globalMode; + if (aData.isPrivateBrowsing) { + globalMode = lazy.serviceModePBM; + } else { + globalMode = lazy.serviceMode; + } + + if (globalMode === Ci.nsICookieBannerService.MODE_DISABLED) { + aCallback.onSuccess({ mode: globalMode }); + return; + } + + const uri = Services.io.newURI(aData.uri); + const mode = Services.cookieBanners.getDomainPref( + uri, + aData.isPrivateBrowsing + ); + if (mode !== Ci.nsICookieBannerService.MODE_UNSET) { + aCallback.onSuccess({ mode }); + } else { + aCallback.onSuccess({ mode: globalMode }); + } + } catch (ex) { + aCallback.onError(`Unexpected error: ${ex}`); + debug`Failed GetCookieBannerModeForDomain ${ex}`; + } + break; + } + } + }, + + async clearData(aFlags, aCallback) { + const flags = convertFlags(aFlags); + + // storageAccessAPI permissions record every site that the user + // interacted with and thus mirror history quite closely. It makes + // sense to clear them when we clear history. However, since their absence + // indicates that we can purge cookies and site data for tracking origins without + // user interaction, we need to ensure that we only delete those permissions that + // do not have any existing storage. + if (flags & Ci.nsIClearDataService.CLEAR_HISTORY) { + const principalsCollector = new PrincipalsCollector(); + const principals = await principalsCollector.getAllPrincipals(); + await new Promise(resolve => { + Services.clearData.deleteUserInteractionForClearingHistory( + principals, + 0, + resolve + ); + }); + } + + new Promise(resolve => { + Services.clearData.deleteData(flags, resolve); + }).then(resultFlags => { + aCallback.onSuccess(); + }); + }, + + clearHostData(aHost, aFlags, aCallback) { + new Promise(resolve => { + Services.clearData.deleteDataFromHost( + aHost, + /* isUserRequest */ true, + convertFlags(aFlags), + resolve + ); + }).then(resultFlags => { + aCallback.onSuccess(); + }); + }, + + clearBaseDomainData(aBaseDomain, aFlags, aCallback) { + new Promise(resolve => { + Services.clearData.deleteDataFromBaseDomain( + aBaseDomain, + /* isUserRequest */ true, + convertFlags(aFlags), + resolve + ); + }).then(resultFlags => { + aCallback.onSuccess(); + }); + }, + + clearSessionContextData(aContextId) { + const pattern = { geckoViewSessionContextId: aContextId }; + debug`clearSessionContextData ${pattern}`; + Services.clearData.deleteDataFromOriginAttributesPattern(pattern); + // Call QMS explicitly to work around bug 1537882. + Services.qms.clearStoragesForOriginAttributesPattern( + JSON.stringify(pattern) + ); + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewTab.jsm b/mobile/android/modules/geckoview/GeckoViewTab.jsm new file mode 100644 index 0000000000..f753f7519a --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewTab.jsm @@ -0,0 +1,232 @@ +/* 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 = ["GeckoViewTab", "GeckoViewTabBridge"]; + +const { GeckoViewModule } = ChromeUtils.import( + "resource://gre/modules/GeckoViewModule.jsm" +); +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { ExtensionUtils } = ChromeUtils.import( + "resource://gre/modules/ExtensionUtils.jsm" +); + +const { ExtensionError } = ExtensionUtils; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.jsm", +}); + +class Tab { + constructor(window) { + this.id = GeckoViewTabBridge.windowIdToTabId(window.docShell.outerWindowID); + this.browser = window.browser; + this.active = false; + } + + get linkedBrowser() { + return this.browser; + } + + getActive() { + return this.active; + } + + get userContextId() { + return this.browser.ownerGlobal.moduleManager.settings + .unsafeSessionContextId; + } +} + +// Because of bug 1410749, we can't use 0, though, and just to be safe +// we choose a value that is unlikely to overlap with Fennec's tab IDs. +const TAB_ID_BASE = 10000; + +const GeckoViewTabBridge = { + /** + * Converts windowId to tabId as in GeckoView every browser window has exactly one tab. + * + * @param {number} windowId outerWindowId + * + * @returns {number} tabId + */ + windowIdToTabId(windowId) { + return TAB_ID_BASE + windowId; + }, + + /** + * Converts tabId to windowId. + * + * @param {number} tabId + * + * @returns {number} + * outerWindowId of browser window to which the tab belongs. + */ + tabIdToWindowId(tabId) { + return tabId - TAB_ID_BASE; + }, + + /** + * Delegates openOptionsPage handling to the app. + * + * @param {number} extensionId + * The ID of the extension requesting the options menu. + * + * @returns {Promise<Void>} + * A promise resolved after successful handling. + */ + async openOptionsPage(extensionId) { + debug`openOptionsPage for extensionId ${extensionId}`; + + try { + await lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:WebExtension:OpenOptionsPage", + extensionId, + }); + } catch (errorMessage) { + // The error message coming from GeckoView is about :OpenOptionsPage not + // being registered so we need to have one that's extension friendly + // here. + throw new ExtensionError("runtime.openOptionsPage is not supported"); + } + }, + + /** + * Request the GeckoView App to create a new tab (GeckoSession). + * + * @param {object} options + * @param {string} options.extensionId + * The ID of the extension that requested a new tab. + * @param {object} options.createProperties + * The properties for the new tab, see tabs.create reference for details. + * + * @returns {Promise<Tab>} + * A promise resolved to the newly created tab. + * @throws {Error} + * Throws an error if the GeckoView app doesn't support tabs.create or fails to handle the request. + */ + async createNewTab({ extensionId, createProperties } = {}) { + debug`createNewTab`; + + const newSessionId = Services.uuid + .generateUUID() + .toString() + .slice(1, -1) + .replace(/-/g, ""); + + // The window might already be open by the time we get the response, so we + // need to start waiting before we send the message. + const windowPromise = new Promise(resolve => { + const handler = { + observe(aSubject, aTopic, aData) { + if ( + aTopic === "geckoview-window-created" && + aSubject.name === newSessionId + ) { + Services.obs.removeObserver(handler, "geckoview-window-created"); + resolve(aSubject); + } + }, + }; + Services.obs.addObserver(handler, "geckoview-window-created"); + }); + + let didOpenSession = false; + try { + didOpenSession = await lazy.EventDispatcher.instance.sendRequestForResult( + { + type: "GeckoView:WebExtension:NewTab", + extensionId, + createProperties, + newSessionId, + } + ); + } catch (errorMessage) { + // The error message coming from GeckoView is about :NewTab not being + // registered so we need to have one that's extension friendly here. + throw new ExtensionError("tabs.create is not supported"); + } + + if (!didOpenSession) { + throw new ExtensionError("Cannot create new tab"); + } + + const window = await windowPromise; + if (!window.tab) { + window.tab = new Tab(window); + } + return window.tab; + }, + + /** + * Request the GeckoView App to close a tab (GeckoSession). + * + * + * @param {object} options + * @param {Window} options.window The window owning the tab to close + * @param {string} options.extensionId + * + * @returns {Promise<Void>} + * A promise resolved after GeckoSession is closed. + * @throws {Error} + * Throws an error if the GeckoView app doesn't allow extension to close tab. + */ + async closeTab({ window, extensionId } = {}) { + try { + await window.WindowEventDispatcher.sendRequestForResult({ + type: "GeckoView:WebExtension:CloseTab", + extensionId, + }); + } catch (errorMessage) { + throw new ExtensionError(errorMessage); + } + }, + + async updateTab({ window, extensionId, updateProperties } = {}) { + try { + await window.WindowEventDispatcher.sendRequestForResult({ + type: "GeckoView:WebExtension:UpdateTab", + extensionId, + updateProperties, + }); + } catch (errorMessage) { + throw new ExtensionError(errorMessage); + } + }, +}; + +class GeckoViewTab extends GeckoViewModule { + onInit() { + const { window } = this; + if (!window.tab) { + window.tab = new Tab(window); + } + + this.registerListener(["GeckoView:WebExtension:SetTabActive"]); + } + + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + + switch (aEvent) { + case "GeckoView:WebExtension:SetTabActive": { + const { active } = aData; + lazy.mobileWindowTracker.setTabActive(this.window, active); + break; + } + } + } +} + +const { debug, warn } = GeckoViewTab.initLogging("GeckoViewTab"); diff --git a/mobile/android/modules/geckoview/GeckoViewTelemetry.jsm b/mobile/android/modules/geckoview/GeckoViewTelemetry.jsm new file mode 100644 index 0000000000..41760d6373 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewTelemetry.jsm @@ -0,0 +1,50 @@ +/* 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 = ["HistogramStopwatch", "InitializationTracker"]; + +var InitializationTracker = { + initialized: false, + onInitialized(profilerTime) { + if (!this.initialized) { + this.initialized = true; + ChromeUtils.addProfilerMarker( + "GeckoView Initialization END", + profilerTime + ); + } + }, +}; + +// A helper for histogram timer probes. +class HistogramStopwatch { + constructor(aName, aAssociated) { + this._name = aName; + this._obj = aAssociated; + } + + isRunning() { + return TelemetryStopwatch.running(this._name, this._obj); + } + + start() { + if (this.isRunning()) { + this.cancel(); + } + TelemetryStopwatch.start(this._name, this._obj); + } + + finish() { + TelemetryStopwatch.finish(this._name, this._obj); + } + + cancel() { + TelemetryStopwatch.cancel(this._name, this._obj); + } + + timeElapsed() { + return TelemetryStopwatch.timeElapsed(this._name, this._obj, false); + } +} diff --git a/mobile/android/modules/geckoview/GeckoViewTestUtils.jsm b/mobile/android/modules/geckoview/GeckoViewTestUtils.jsm new file mode 100644 index 0000000000..e8cfad4f42 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewTestUtils.jsm @@ -0,0 +1,56 @@ +/* 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 = ["GeckoViewTabUtil"]; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", +}); + +const GeckoViewTabUtil = { + /** + * Creates a new tab through service worker delegate. + * Needs to be ran in a parent process. + * + * @param {string} url + * @returns {Tab} + * @throws {Error} Throws an error if the tab cannot be created. + */ + async createNewTab(url = "about:blank") { + let sessionId = ""; + const windowPromise = new Promise(resolve => { + const openingObserver = (subject, topic, data) => { + if (subject.name === sessionId) { + Services.obs.removeObserver( + openingObserver, + "geckoview-window-created" + ); + resolve(subject); + } + }; + Services.obs.addObserver(openingObserver, "geckoview-window-created"); + }); + + try { + sessionId = await lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:Test:NewTab", + url, + }); + } catch (errorMessage) { + throw new Error( + errorMessage + " GeckoView:Test:NewTab is not supported." + ); + } + + if (!sessionId) { + throw new Error("Could not open a session for the new tab."); + } + + const window = await windowPromise; + return window.tab; + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs b/mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs new file mode 100644 index 0000000000..581892337f --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs @@ -0,0 +1,514 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { Log } from "resource://gre/modules/Log.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + AndroidLog: "resource://gre/modules/AndroidLog.jsm", +}); + +/** + * A formatter that does not prepend time/name/level information to messages, + * because those fields are logged separately when using the Android logger. + */ +class AndroidFormatter extends Log.BasicFormatter { + format(message) { + return this.formatText(message); + } +} + +/* + * AndroidAppender + * Logs to Android logcat using AndroidLog.jsm + */ +class AndroidAppender extends Log.Appender { + constructor(aFormatter) { + super(aFormatter || new AndroidFormatter()); + this._name = "AndroidAppender"; + + // Map log level to AndroidLog.foo method. + this._mapping = { + [Log.Level.Fatal]: "e", + [Log.Level.Error]: "e", + [Log.Level.Warn]: "w", + [Log.Level.Info]: "i", + [Log.Level.Config]: "d", + [Log.Level.Debug]: "d", + [Log.Level.Trace]: "v", + }; + } + + append(aMessage) { + if (!aMessage) { + return; + } + + // AndroidLog.jsm always prepends "Gecko" to the tag, so we strip any + // leading "Gecko" here. Also strip dots to save space. + const tag = aMessage.loggerName.replace(/^Gecko|\./g, ""); + const msg = this._formatter.format(aMessage); + lazy.AndroidLog[this._mapping[aMessage.level]](tag, msg); + } +} + +export var GeckoViewUtils = { + /** + * Define a lazy getter that loads an object from external code, and + * optionally handles observer and/or message manager notifications for the + * object, so the object only loads when a notification is received. + * + * @param scope Scope for holding the loaded object. + * @param name Name of the object to load. + * @param service If specified, load the object from a JS component; the + * component must include the line + * "this.wrappedJSObject = this;" in its constructor. + * @param module If specified, load the object from a JS module. + * @param init Optional post-load initialization function. + * @param observers If specified, listen to specified observer notifications. + * @param ppmm If specified, listen to specified process messages. + * @param mm If specified, listen to specified frame messages. + * @param ged If specified, listen to specified global EventDispatcher events. + * @param once if true, only listen to the specified + * events/messages/notifications once. + */ + addLazyGetter( + scope, + name, + { service, module, handler, observers, ppmm, mm, ged, init, once } + ) { + XPCOMUtils.defineLazyGetter(scope, name, _ => { + let ret = undefined; + if (module) { + ret = ChromeUtils.import(module)[name]; + } else if (service) { + ret = Cc[service].getService(Ci.nsISupports).wrappedJSObject; + } else if (typeof handler === "function") { + ret = { + handleEvent: handler, + observe: handler, + onEvent: handler, + receiveMessage: handler, + }; + } else if (handler) { + ret = handler; + } + if (ret && init) { + init.call(scope, ret); + } + return ret; + }); + + if (observers) { + const observer = (subject, topic, data) => { + Services.obs.removeObserver(observer, topic); + if (!once) { + Services.obs.addObserver(scope[name], topic); + } + scope[name].observe(subject, topic, data); // Explicitly notify new observer + }; + observers.forEach(topic => Services.obs.addObserver(observer, topic)); + } + + if (!this.IS_PARENT_PROCESS) { + // ppmm, mm, and ged are only available in the parent process. + return; + } + + const addMMListener = (target, names) => { + const listener = msg => { + target.removeMessageListener(msg.name, listener); + if (!once) { + target.addMessageListener(msg.name, scope[name]); + } + scope[name].receiveMessage(msg); + }; + names.forEach(msg => target.addMessageListener(msg, listener)); + }; + if (ppmm) { + addMMListener(Services.ppmm, ppmm); + } + if (mm) { + addMMListener(Services.mm, mm); + } + + if (ged) { + const listener = (event, data, callback) => { + lazy.EventDispatcher.instance.unregisterListener(listener, event); + if (!once) { + lazy.EventDispatcher.instance.registerListener(scope[name], event); + } + scope[name].onEvent(event, data, callback); + }; + lazy.EventDispatcher.instance.registerListener(listener, ged); + } + }, + + _addLazyListeners(events, handler, scope, name, addFn, handleFn) { + if (!handler) { + handler = _ => + Array.isArray(name) ? name.map(n => scope[n]) : scope[name]; + } + const listener = (...args) => { + let handlers = handler(...args); + if (!handlers) { + return; + } + if (!Array.isArray(handlers)) { + handlers = [handlers]; + } + handleFn(handlers, listener, args); + }; + if (Array.isArray(events)) { + addFn(events, listener); + } else { + addFn([events], listener); + } + }, + + /** + * Add lazy event listeners that only load the actual handler when an event + * is being handled. + * + * @param target Event target for the event listeners. + * @param events Event name as a string or array. + * @param handler If specified, function that, for a given event, returns the + * actual event handler as an object or an array of objects. + * If handler is not specified, the actual event handler is + * specified using the scope and name pair. + * @param scope See handler. + * @param name See handler. + * @param options Options for addEventListener. + */ + addLazyEventListener(target, events, { handler, scope, name, options }) { + this._addLazyListeners( + events, + handler, + scope, + name, + (events, listener) => { + events.forEach(event => + target.addEventListener(event, listener, options) + ); + }, + (handlers, listener, args) => { + if (!options || !options.once) { + target.removeEventListener(args[0].type, listener, options); + handlers.forEach(handler => + target.addEventListener(args[0].type, handler, options) + ); + } + handlers.forEach(handler => handler.handleEvent(args[0])); + } + ); + }, + + /** + * Add lazy pref observers, and only load the actual handler once the pref + * value changes from default, and every time the pref value changes + * afterwards. + * + * @param aPrefs Prefs as an object or array. Each pref object has fields + * "name" and "default", indicating the name and default value + * of the pref, respectively. + * @param handler If specified, function that, for a given pref, returns the + * actual event handler as an object or an array of objects. + * If handler is not specified, the actual event handler is + * specified using the scope and name pair. + * @param scope See handler. + * @param name See handler. + * @param once If true, only observe the specified prefs once. + */ + addLazyPrefObserver(aPrefs, { handler, scope, name, once }) { + this._addLazyListeners( + aPrefs, + handler, + scope, + name, + (prefs, observer) => { + prefs.forEach(pref => Services.prefs.addObserver(pref.name, observer)); + prefs.forEach(pref => { + if (pref.default === undefined) { + return; + } + let value; + switch (typeof pref.default) { + case "string": + value = Services.prefs.getCharPref(pref.name, pref.default); + break; + case "number": + value = Services.prefs.getIntPref(pref.name, pref.default); + break; + case "boolean": + value = Services.prefs.getBoolPref(pref.name, pref.default); + break; + } + if (pref.default !== value) { + // Notify observer if value already changed from default. + observer(Services.prefs, "nsPref:changed", pref.name); + } + }); + }, + (handlers, observer, args) => { + if (!once) { + Services.prefs.removeObserver(args[2], observer); + handlers.forEach(handler => + Services.prefs.addObserver(args[2], observer) + ); + } + handlers.forEach(handler => handler.observe(...args)); + } + ); + }, + + getRootDocShell(aWin) { + if (!aWin) { + return null; + } + let docShell; + try { + docShell = aWin.QueryInterface(Ci.nsIDocShell); + } catch (e) { + docShell = aWin.docShell; + } + return docShell.rootTreeItem.QueryInterface(Ci.nsIInterfaceRequestor); + }, + + /** + * Return the outermost chrome DOM window (the XUL window) for a given DOM + * window, in the parent process. + * + * @param aWin a DOM window. + */ + getChromeWindow(aWin) { + const docShell = this.getRootDocShell(aWin); + return docShell && docShell.domWindow; + }, + + /** + * Return the content frame message manager (aka the frame script global + * object) for a given DOM window, in a child process. + * + * @param aWin a DOM window. + */ + getContentFrameMessageManager(aWin) { + const docShell = this.getRootDocShell(aWin); + return docShell && docShell.getInterface(Ci.nsIBrowserChild).messageManager; + }, + + /** + * Return the per-nsWindow EventDispatcher for a given DOM window, in either + * the parent process or a child process. + * + * @param aWin a DOM window. + */ + getDispatcherForWindow(aWin) { + try { + if (!this.IS_PARENT_PROCESS) { + const mm = this.getContentFrameMessageManager(aWin.top || aWin); + return mm && lazy.EventDispatcher.forMessageManager(mm); + } + const win = this.getChromeWindow(aWin.top || aWin); + if (!win.closed) { + return win.WindowEventDispatcher || lazy.EventDispatcher.for(win); + } + } catch (e) {} + return null; + }, + + /** + * Return promise for waiting for finishing PanZoomState. + * + * @param aWindow a DOM window. + * @return promise + */ + waitForPanZoomState(aWindow) { + return new Promise((resolve, reject) => { + if ( + !aWindow?.windowUtils.asyncPanZoomEnabled || + !Services.prefs.getBoolPref("apz.zoom-to-focused-input.enabled") + ) { + // No zoomToFocusedInput. + resolve(); + return; + } + + let timerId = 0; + + const panZoomState = (aSubject, aTopic, aData) => { + if (timerId != 0) { + // aWindow may be dead object now. + try { + lazy.clearTimeout(timerId); + } catch (e) {} + timerId = 0; + } + + if (aData === "NOTHING") { + Services.obs.removeObserver(panZoomState, "PanZoom:StateChange"); + resolve(); + } + }; + + Services.obs.addObserver(panZoomState, "PanZoom:StateChange"); + + // "GeckoView:ZoomToInput" has the timeout as 500ms when window isn't + // resized (it means on-screen-keyboard is already shown). + // So after up to 500ms, APZ event is sent. So we need to wait for more + // 500ms. + timerId = lazy.setTimeout(() => { + // PanZoom state isn't changed. zoomToFocusedInput will return error. + Services.obs.removeObserver(panZoomState, "PanZoom:StateChange"); + reject(); + }, 600); + }); + }, + + /** + * Add logging functions to the specified scope that forward to the given + * Log.sys.mjs logger. Currently "debug" and "warn" functions are supported. To + * log something, call the function through a template literal: + * + * function foo(bar, baz) { + * debug `hello world`; + * debug `foo called with ${bar} as bar`; + * warn `this is a warning for ${baz}`; + * } + * + * An inline format can also be used for logging: + * + * let bar = 42; + * do_something(bar); // No log. + * do_something(debug.foo = bar); // Output "foo = 42" to the log. + * + * @param aTag Name of the Log.jsm logger to forward logs to. + * @param aScope Scope to add the logging functions to. + */ + initLogging(aTag, aScope) { + aScope = aScope || {}; + const tag = "GeckoView." + aTag.replace(/^GeckoView\.?/, ""); + + // Only provide two levels for simplicity. + // For "info", use "debug" instead. + // For "error", throw an actual JS error instead. + for (const level of ["DEBUG", "WARN"]) { + const log = (strings, ...exprs) => + this._log(log.logger, level, strings, exprs); + + XPCOMUtils.defineLazyGetter(log, "logger", _ => { + const logger = Log.repository.getLogger(tag); + logger.parent = this.rootLogger; + return logger; + }); + + aScope[level.toLowerCase()] = new Proxy(log, { + set: (obj, prop, value) => obj([prop + " = ", ""], value) || true, + }); + } + return aScope; + }, + + get rootLogger() { + if (!this._rootLogger) { + this._rootLogger = Log.repository.getLogger("GeckoView"); + this._rootLogger.addAppender(new AndroidAppender()); + this._rootLogger.manageLevelFromPref("geckoview.logging"); + } + return this._rootLogger; + }, + + _log(aLogger, aLevel, aStrings, aExprs) { + if (!Array.isArray(aStrings)) { + const [, file, line] = new Error().stack.match(/.*\n.*\n.*@(.*):(\d+):/); + throw Error( + `Expecting template literal: ${aLevel} \`foo \${bar}\``, + file, + +line + ); + } + + if (aLogger.level > Log.Level.Numbers[aLevel]) { + // Log disabled. + return; + } + + // Do some GeckoView-specific formatting: + // * Remove newlines so long log lines can be put into multiple lines: + // debug `foo=${foo} + // bar=${bar}`; + const strs = Array.from(aStrings); + const regex = /\n\s*/g; + for (let i = 0; i < strs.length; i++) { + strs[i] = strs[i].replace(regex, " "); + } + + // * Heuristically format flags as hex. + // * Heuristically format nsresult as string name or hex. + for (let i = 0; i < aExprs.length; i++) { + const expr = aExprs[i]; + switch (typeof expr) { + case "number": + if (expr > 0 && /\ba?[fF]lags?[\s=:]+$/.test(strs[i])) { + // Likely a flag; display in hex. + aExprs[i] = `0x${expr.toString(0x10)}`; + } else if (expr >= 0 && /\b(a?[sS]tatus|rv)[\s=:]+$/.test(strs[i])) { + // Likely an nsresult; display in name or hex. + aExprs[i] = `0x${expr.toString(0x10)}`; + for (const name in Cr) { + if (expr === Cr[name]) { + aExprs[i] = name; + break; + } + } + } + break; + } + } + + aLogger[aLevel.toLowerCase()](strs, ...aExprs); + }, + + /** + * Checks whether the principal is supported for permissions. + * + * @param {nsIPrincipal} principal + * The principal to check. + * + * @return {boolean} if the principal is supported. + */ + isSupportedPermissionsPrincipal(principal) { + if (!principal) { + return false; + } + if (!(principal instanceof Ci.nsIPrincipal)) { + throw new Error( + "Argument passed as principal is not an instance of Ci.nsIPrincipal" + ); + } + return this.isSupportedPermissionsScheme(principal.scheme); + }, + + /** + * Checks whether we support managing permissions for a specific scheme. + * @param {string} scheme - Scheme to test. + * @returns {boolean} Whether the scheme is supported. + */ + isSupportedPermissionsScheme(scheme) { + return ["http", "https", "moz-extension", "file"].includes(scheme); + }, +}; + +XPCOMUtils.defineLazyGetter( + GeckoViewUtils, + "IS_PARENT_PROCESS", + _ => Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT +); diff --git a/mobile/android/modules/geckoview/GeckoViewWebExtension.jsm b/mobile/android/modules/geckoview/GeckoViewWebExtension.jsm new file mode 100644 index 0000000000..5814dbec5f --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewWebExtension.jsm @@ -0,0 +1,1099 @@ +/* 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 = [ + "ExtensionActionHelper", + "GeckoViewConnection", + "GeckoViewWebExtension", + "mobileWindowTracker", + "DownloadTracker", +]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); +const { EventEmitter } = ChromeUtils.importESModule( + "resource://gre/modules/EventEmitter.sys.mjs" +); + +const PRIVATE_BROWSING_PERMISSION = { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], +}; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.jsm", + Extension: "resource://gre/modules/Extension.jsm", + ExtensionData: "resource://gre/modules/Extension.jsm", + ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.jsm", + GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.jsm", + Management: "resource://gre/modules/Extension.jsm", +}); + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "mimeService", + "@mozilla.org/mime;1", + "nsIMIMEService" +); + +const { debug, warn } = GeckoViewUtils.initLogging("Console"); + +const DOWNLOAD_CHANGED_MESSAGE = "GeckoView:WebExtension:DownloadChanged"; + +var DownloadTracker = new (class extends EventEmitter { + constructor() { + super(); + + // maps numeric IDs to DownloadItem objects + this._downloads = new Map(); + } + + onEvent(event, data, callback) { + switch (event) { + case "GeckoView:WebExtension:DownloadChanged": { + const downloadItem = this.getDownloadItemById(data.downloadItemId); + + if (!downloadItem) { + callback.onError("Error: Trying to update unknown download"); + return; + } + + const delta = downloadItem.update(data); + if (delta) { + this.emit("download-changed", { + delta, + downloadItem, + }); + } + } + } + } + + addDownloadItem(item) { + this._downloads.set(item.id, item); + } + + /** + * Finds and returns a DownloadItem with a certain numeric ID + * + * @param {number} id + * @returns {DownloadItem} download item + */ + getDownloadItemById(id) { + return this._downloads.get(id); + } +})(); + +/** Provides common logic between page and browser actions */ +class ExtensionActionHelper { + constructor({ + tabTracker, + windowTracker, + tabContext, + properties, + extension, + }) { + this.tabTracker = tabTracker; + this.windowTracker = windowTracker; + this.tabContext = tabContext; + this.properties = properties; + this.extension = extension; + } + + getTab(aTabId) { + if (aTabId !== null) { + return this.tabTracker.getTab(aTabId); + } + return null; + } + + getWindow(aWindowId) { + if (aWindowId !== null) { + return this.windowTracker.getWindow(aWindowId); + } + return null; + } + + extractProperties(aAction) { + const merged = {}; + for (const p of this.properties) { + merged[p] = aAction[p]; + } + return merged; + } + + eventDispatcherFor(aTabId) { + if (!aTabId) { + return lazy.EventDispatcher.instance; + } + + const windowId = lazy.GeckoViewTabBridge.tabIdToWindowId(aTabId); + const window = this.windowTracker.getWindow(windowId); + return window.WindowEventDispatcher; + } + + sendRequest(aTabId, aData) { + return this.eventDispatcherFor(aTabId).sendRequest({ + ...aData, + aTabId, + extensionId: this.extension.id, + }); + } +} + +class EmbedderPort { + constructor(portId, messenger) { + this.id = portId; + this.messenger = messenger; + this.dispatcher = lazy.EventDispatcher.byName(`port:${portId}`); + this.dispatcher.registerListener(this, [ + "GeckoView:WebExtension:PortMessageFromApp", + "GeckoView:WebExtension:PortDisconnect", + ]); + } + close() { + this.dispatcher.unregisterListener(this, [ + "GeckoView:WebExtension:PortMessageFromApp", + "GeckoView:WebExtension:PortDisconnect", + ]); + } + onPortDisconnect() { + this.dispatcher.sendRequest({ + type: "GeckoView:WebExtension:Disconnect", + sender: this.sender, + }); + this.close(); + } + onPortMessage(holder) { + this.dispatcher.sendRequest({ + type: "GeckoView:WebExtension:PortMessage", + data: holder.deserialize({}), + }); + } + onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:WebExtension:PortMessageFromApp": { + const holder = new StructuredCloneHolder(aData.message); + this.messenger.sendPortMessage(this.id, holder); + break; + } + + case "GeckoView:WebExtension:PortDisconnect": { + this.messenger.sendPortDisconnect(this.id); + this.close(); + break; + } + } + } +} + +class GeckoViewConnection { + constructor(sender, target, nativeApp, allowContentMessaging) { + this.sender = sender; + this.target = target; + this.nativeApp = nativeApp; + this.allowContentMessaging = allowContentMessaging; + + if (!allowContentMessaging && sender.envType !== "addon_child") { + throw new Error(`Unexpected messaging sender: ${JSON.stringify(sender)}`); + } + } + + get dispatcher() { + if (this.sender.envType === "addon_child") { + // If this is a WebExtension Page we will have a GeckoSession associated + // to it and thus a dispatcher. + const dispatcher = GeckoViewUtils.getDispatcherForWindow( + this.target.ownerGlobal + ); + if (dispatcher) { + return dispatcher; + } + + // No dispatcher means this message is coming from a background script, + // use the global event handler + return lazy.EventDispatcher.instance; + } else if ( + this.sender.envType === "content_child" && + this.allowContentMessaging + ) { + // If this message came from a content script, send the message to + // the corresponding tab messenger so that GeckoSession can pick it + // up. + return GeckoViewUtils.getDispatcherForWindow(this.target.ownerGlobal); + } + + throw new Error(`Uknown sender envType: ${this.sender.envType}`); + } + + _sendMessage({ type, portId, data }) { + const message = { + type, + sender: this.sender, + data, + portId, + extensionId: this.sender.id, + nativeApp: this.nativeApp, + }; + + return this.dispatcher.sendRequestForResult(message); + } + + sendMessage(data) { + return this._sendMessage({ + type: "GeckoView:WebExtension:Message", + data: data.deserialize({}), + }); + } + + onConnect(portId, messenger) { + const port = new EmbedderPort(portId, messenger); + + this._sendMessage({ + type: "GeckoView:WebExtension:Connect", + data: {}, + portId: port.id, + }); + + return port; + } +} + +async function filterPromptPermissions(aPermissions) { + if (!aPermissions) { + return []; + } + const promptPermissions = []; + for (const permission of aPermissions) { + if (!(await lazy.Extension.shouldPromptFor(permission))) { + continue; + } + promptPermissions.push(permission); + } + return promptPermissions; +} + +// Keep in sync with WebExtension.java +const FLAG_NONE = 0; +const FLAG_ALLOW_CONTENT_MESSAGING = 1 << 0; + +function exportFlags(aPolicy) { + let flags = FLAG_NONE; + if (!aPolicy) { + return flags; + } + const { extension } = aPolicy; + if (extension.hasPermission("nativeMessagingFromContent")) { + flags |= FLAG_ALLOW_CONTENT_MESSAGING; + } + return flags; +} + +async function exportExtension(aAddon, aPermissions, aSourceURI) { + // First, let's make sure the policy is ready if present + let policy = WebExtensionPolicy.getByID(aAddon.id); + if (policy?.readyPromise) { + policy = await policy.readyPromise; + } + const { + creator, + description, + homepageURL, + signedState, + name, + icons, + version, + optionsURL, + optionsType, + isRecommended, + blocklistState, + userDisabled, + embedderDisabled, + temporarilyInstalled, + isActive, + isBuiltin, + id, + } = aAddon; + let creatorName = null; + let creatorURL = null; + if (creator) { + const { name, url } = creator; + creatorName = name; + creatorURL = url; + } + const openOptionsPageInTab = + optionsType === lazy.AddonManager.OPTIONS_TYPE_TAB; + const disabledFlags = []; + if (userDisabled) { + disabledFlags.push("userDisabled"); + } + if (blocklistState !== Ci.nsIBlocklistService.STATE_NOT_BLOCKED) { + disabledFlags.push("blocklistDisabled"); + } + if (embedderDisabled) { + disabledFlags.push("appDisabled"); + } + const baseURL = policy ? policy.getURL() : ""; + const privateBrowsingAllowed = policy ? policy.privateBrowsingAllowed : false; + const promptPermissions = aPermissions + ? await filterPromptPermissions(aPermissions.permissions) + : []; + return { + webExtensionId: id, + locationURI: aSourceURI != null ? aSourceURI.spec : "", + isBuiltIn: isBuiltin, + webExtensionFlags: exportFlags(policy), + metaData: { + origins: aPermissions ? aPermissions.origins : [], + promptPermissions, + description, + enabled: isActive, + temporary: temporarilyInstalled, + disabledFlags, + version, + creatorName, + creatorURL, + homepageURL, + name, + optionsPageURL: optionsURL, + openOptionsPageInTab, + isRecommended, + blocklistState, + signedState, + icons, + baseURL, + privateBrowsingAllowed, + }, + }; +} + +class ExtensionInstallListener { + constructor(aResolve, aInstall, aInstallId) { + this.install = aInstall; + this.installId = aInstallId; + this.resolve = result => { + aResolve(result); + lazy.EventDispatcher.instance.unregisterListener(this, [ + "GeckoView:WebExtension:CancelInstall", + ]); + }; + lazy.EventDispatcher.instance.registerListener(this, [ + "GeckoView:WebExtension:CancelInstall", + ]); + } + + async onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:WebExtension:CancelInstall": { + const { installId } = aData; + if (this.installId !== installId) { + return; + } + this.cancelling = true; + let cancelled = false; + try { + this.install.cancel(); + cancelled = true; + } catch (_) { + // install may have already failed or been cancelled + } + aCallback.onSuccess({ cancelled }); + break; + } + } + } + + onDownloadCancelled(aInstall) { + // Do not resolve we were told to CancelInstall, + // to prevent racing with that handler. + if (!this.cancelling) { + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); + } + } + + onDownloadFailed(aInstall) { + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); + } + + onDownloadEnded() { + // Nothing to do + } + + onInstallCancelled(aInstall) { + // Do not resolve we were told to CancelInstall, + // to prevent racing with that handler. + if (!this.cancelling) { + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); + } + } + + onInstallFailed(aInstall) { + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); + } + + onInstallPostponed(aInstall) { + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); + } + + async onInstallEnded(aInstall, aAddon) { + const addonId = aAddon.id; + const { sourceURI } = aInstall; + + if (aAddon.userDisabled || aAddon.embedderDisabled) { + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + sourceURI + ); + this.resolve({ extension }); + return; // we don't want to wait until extension is enabled, so return early. + } + + const onReady = async (name, { id }) => { + if (id != addonId) { + return; + } + lazy.Management.off("ready", onReady); + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + sourceURI + ); + this.resolve({ extension }); + }; + lazy.Management.on("ready", onReady); + } +} + +class ExtensionPromptObserver { + constructor() { + Services.obs.addObserver(this, "webextension-permission-prompt"); + Services.obs.addObserver(this, "webextension-optional-permission-prompt"); + } + + async permissionPrompt(aInstall, aAddon, aInfo) { + const { sourceURI } = aInstall; + const { permissions } = aInfo; + const extension = await exportExtension(aAddon, permissions, sourceURI); + const response = await lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:WebExtension:InstallPrompt", + extension, + }); + + if (response.allow) { + aInfo.resolve(); + } else { + aInfo.reject(); + } + } + + async optionalPermissionPrompt(aExtensionId, aPermissions, resolve) { + const response = await lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:WebExtension:OptionalPrompt", + extensionId: aExtensionId, + permissions: aPermissions, + }); + resolve(response.allow); + } + + observe(aSubject, aTopic, aData) { + debug`observe ${aTopic}`; + + switch (aTopic) { + case "webextension-permission-prompt": { + const { info } = aSubject.wrappedJSObject; + const { addon, install } = info; + this.permissionPrompt(install, addon, info); + break; + } + case "webextension-optional-permission-prompt": { + const { id, permissions, resolve } = aSubject.wrappedJSObject; + this.optionalPermissionPrompt(id, permissions, resolve); + break; + } + } + } +} + +new ExtensionPromptObserver(); + +class MobileWindowTracker extends EventEmitter { + constructor() { + super(); + this._topWindow = null; + this._topNonPBWindow = null; + } + + get topWindow() { + if (this._topWindow) { + return this._topWindow.get(); + } + return null; + } + + get topNonPBWindow() { + if (this._topNonPBWindow) { + return this._topNonPBWindow.get(); + } + return null; + } + + setTabActive(aWindow, aActive) { + const { browser, tab, docShell } = aWindow; + tab.active = aActive; + + if (aActive) { + this._topWindow = Cu.getWeakReference(aWindow); + const isPrivate = lazy.PrivateBrowsingUtils.isBrowserPrivate(browser); + if (!isPrivate) { + this._topNonPBWindow = this._topWindow; + } + this.emit("tab-activated", { + windowId: docShell.outerWindowID, + tabId: tab.id, + isPrivate, + }); + } + } +} + +var mobileWindowTracker = new MobileWindowTracker(); + +async function updatePromptHandler(aInfo) { + const oldPerms = aInfo.existingAddon.userPermissions; + if (!oldPerms) { + // Updating from a legacy add-on, let it proceed + return; + } + + const newPerms = aInfo.addon.userPermissions; + + const difference = lazy.Extension.comparePermissions(oldPerms, newPerms); + + // We only care about permissions that we can prompt the user for + const newPermissions = await filterPromptPermissions(difference.permissions); + const { origins: newOrigins } = difference; + + // If there are no new permissions, just proceed + if (!newOrigins.length && !newPermissions.length) { + return; + } + + const currentlyInstalled = await exportExtension( + aInfo.existingAddon, + oldPerms + ); + const updatedExtension = await exportExtension(aInfo.addon, newPerms); + const response = await lazy.EventDispatcher.instance.sendRequestForResult({ + type: "GeckoView:WebExtension:UpdatePrompt", + currentlyInstalled, + updatedExtension, + newPermissions, + newOrigins, + }); + + if (!response.allow) { + throw new Error("Extension update rejected."); + } +} + +var GeckoViewWebExtension = { + observe(aSubject, aTopic, aData) { + debug`observe ${aTopic}`; + + switch (aTopic) { + case "testing-installed-addon": + case "testing-uninstalled-addon": { + // We pretend devtools installed/uninstalled this addon so we don't + // have to add an API just for internal testing. + // TODO: assert this is under a test + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:DebuggerListUpdated", + }); + break; + } + + case "devtools-installed-addon": { + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:DebuggerListUpdated", + }); + break; + } + } + }, + + async extensionById(aId) { + const addon = await lazy.AddonManager.getAddonByID(aId); + if (!addon) { + debug`Could not find extension with id=${aId}`; + return null; + } + return addon; + }, + + async ensureBuiltIn(aUri, aId) { + await lazy.AddonManager.readyPromise; + // Although the add-on is privileged in practice due to it being installed + // as a built-in extension, we pass isPrivileged=false since the exact flag + // doesn't matter as we are only using ExtensionData to read the version. + const extensionData = new lazy.ExtensionData(aUri, false); + const [extensionVersion, extension] = await Promise.all([ + extensionData.getExtensionVersionWithoutValidation(), + this.extensionById(aId), + ]); + + if (!extension || extensionVersion != extension.version) { + return this.installBuiltIn(aUri); + } + + const exported = await exportExtension( + extension, + extension.userPermissions, + aUri + ); + return { extension: exported }; + }, + + async installBuiltIn(aUri) { + await lazy.AddonManager.readyPromise; + const addon = await lazy.AddonManager.installBuiltinAddon(aUri.spec); + const exported = await exportExtension(addon, addon.userPermissions, aUri); + return { extension: exported }; + }, + + async installWebExtension(aInstallId, aUri) { + const install = await lazy.AddonManager.getInstallForURL(aUri.spec, { + telemetryInfo: { + source: "geckoview-app", + }, + }); + const promise = new Promise(resolve => { + install.addListener( + new ExtensionInstallListener(resolve, install, aInstallId) + ); + }); + + const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + const mimeType = lazy.mimeService.getTypeFromURI(aUri); + lazy.AddonManager.installAddonFromWebpage( + mimeType, + null, + systemPrincipal, + install + ); + + return promise; + }, + + async setPrivateBrowsingAllowed(aId, aAllowed) { + if (aAllowed) { + await lazy.ExtensionPermissions.add(aId, PRIVATE_BROWSING_PERMISSION); + } else { + await lazy.ExtensionPermissions.remove(aId, PRIVATE_BROWSING_PERMISSION); + } + + // Reload the extension if it is already enabled. This ensures any change + // on the private browsing permission is properly handled. + const addon = await this.extensionById(aId); + if (addon.isActive) { + await addon.reload(); + } + + return exportExtension(addon, addon.userPermissions, /* aSourceURI */ null); + }, + + async uninstallWebExtension(aId) { + const extension = await this.extensionById(aId); + if (!extension) { + throw new Error(`Could not find an extension with id='${aId}'.`); + } + + return extension.uninstall(); + }, + + async browserActionClick(aId) { + const policy = WebExtensionPolicy.getByID(aId); + if (!policy) { + return undefined; + } + + const browserAction = this.browserActions.get(policy.extension); + if (!browserAction) { + return undefined; + } + + return browserAction.triggerClickOrPopup(); + }, + + async pageActionClick(aId) { + const policy = WebExtensionPolicy.getByID(aId); + if (!policy) { + return undefined; + } + + const pageAction = this.pageActions.get(policy.extension); + if (!pageAction) { + return undefined; + } + + return pageAction.triggerClickOrPopup(); + }, + + async actionDelegateAttached(aId) { + const policy = WebExtensionPolicy.getByID(aId); + if (!policy) { + debug`Could not find extension with id=${aId}`; + return; + } + + const { extension } = policy; + + const browserAction = this.browserActions.get(extension); + if (browserAction) { + // Send information about this action to the delegate + browserAction.updateOnChange(null); + } + + const pageAction = this.pageActions.get(extension); + if (pageAction) { + pageAction.updateOnChange(null); + } + }, + + async enableWebExtension(aId, aSource) { + const extension = await this.extensionById(aId); + if (aSource === "user") { + await extension.enable(); + } else if (aSource === "app") { + await extension.setEmbedderDisabled(false); + } + return exportExtension( + extension, + extension.userPermissions, + /* aSourceURI */ null + ); + }, + + async disableWebExtension(aId, aSource) { + const extension = await this.extensionById(aId); + if (aSource === "user") { + await extension.disable(); + } else if (aSource === "app") { + await extension.setEmbedderDisabled(true); + } + return exportExtension( + extension, + extension.userPermissions, + /* aSourceURI */ null + ); + }, + + /** + * @return A promise resolved with either an AddonInstall object if an update + * is available or null if no update is found. + */ + checkForUpdate(aAddon) { + return new Promise(resolve => { + const listener = { + onUpdateAvailable(aAddon, install) { + install.promptHandler = updatePromptHandler; + resolve(install); + }, + onNoUpdateAvailable() { + resolve(null); + }, + }; + aAddon.findUpdates( + listener, + lazy.AddonManager.UPDATE_WHEN_USER_REQUESTED + ); + }); + }, + + async updateWebExtension(aId) { + const extension = await this.extensionById(aId); + + const install = await this.checkForUpdate(extension); + if (!install) { + return null; + } + const promise = new Promise(resolve => { + install.addListener(new ExtensionInstallListener(resolve)); + }); + install.install(); + return promise; + }, + + validateBuiltInLocation(aLocationUri, aCallback) { + let uri; + try { + uri = Services.io.newURI(aLocationUri); + } catch (ex) { + aCallback.onError(`Could not parse uri: ${aLocationUri}. Error: ${ex}`); + return null; + } + + if (uri.scheme !== "resource" || uri.host !== "android") { + aCallback.onError(`Only resource://android/... URIs are allowed.`); + return null; + } + + if (uri.fileName !== "") { + aCallback.onError( + `This URI does not point to a folder. Note: folders URIs must end with a "/".` + ); + return null; + } + + return uri; + }, + + /* eslint-disable complexity */ + async onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:BrowserAction:Click": { + const popupUrl = await this.browserActionClick(aData.extensionId); + aCallback.onSuccess(popupUrl); + break; + } + case "GeckoView:PageAction:Click": { + const popupUrl = await this.pageActionClick(aData.extensionId); + aCallback.onSuccess(popupUrl); + break; + } + case "GeckoView:WebExtension:MenuClick": { + aCallback.onError(`Not implemented`); + break; + } + case "GeckoView:WebExtension:MenuShow": { + aCallback.onError(`Not implemented`); + break; + } + case "GeckoView:WebExtension:MenuHide": { + aCallback.onError(`Not implemented`); + break; + } + + case "GeckoView:ActionDelegate:Attached": { + this.actionDelegateAttached(aData.extensionId); + break; + } + + case "GeckoView:WebExtension:Get": { + const extension = await this.extensionById(aData.extensionId); + if (!extension) { + aCallback.onError( + `Could not find extension with id: ${aData.extensionId}` + ); + return; + } + + aCallback.onSuccess({ + extension: await exportExtension( + extension, + extension.userPermissions, + /* aSourceURI */ null + ), + }); + break; + } + + case "GeckoView:WebExtension:SetPBAllowed": { + const { extensionId, allowed } = aData; + try { + const extension = await this.setPrivateBrowsingAllowed( + extensionId, + allowed + ); + aCallback.onSuccess({ extension }); + } catch (ex) { + aCallback.onError(`Unexpected error: ${ex}`); + } + break; + } + + case "GeckoView:WebExtension:Install": { + const { locationUri, installId } = aData; + let uri; + try { + uri = Services.io.newURI(locationUri); + } catch (ex) { + aCallback.onError(`Could not parse uri: ${locationUri}`); + return; + } + + try { + const result = await this.installWebExtension(installId, uri); + if (result.extension) { + aCallback.onSuccess(result); + } else { + aCallback.onError(result); + } + } catch (ex) { + debug`Install exception error ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + + break; + } + + case "GeckoView:WebExtension:EnsureBuiltIn": { + const { locationUri, webExtensionId } = aData; + const uri = this.validateBuiltInLocation(locationUri, aCallback); + if (!uri) { + return; + } + + try { + const result = await this.ensureBuiltIn(uri, webExtensionId); + if (result.extension) { + aCallback.onSuccess(result); + } else { + aCallback.onError(result); + } + } catch (ex) { + debug`Install exception error ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + + break; + } + + case "GeckoView:WebExtension:InstallBuiltIn": { + const uri = this.validateBuiltInLocation(aData.locationUri, aCallback); + if (!uri) { + return; + } + + try { + const result = await this.installBuiltIn(uri); + if (result.extension) { + aCallback.onSuccess(result); + } else { + aCallback.onError(result); + } + } catch (ex) { + debug`Install exception error ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + + break; + } + + case "GeckoView:WebExtension:Uninstall": { + try { + await this.uninstallWebExtension(aData.webExtensionId); + aCallback.onSuccess(); + } catch (ex) { + debug`Failed uninstall ${ex}`; + aCallback.onError( + `This extension cannot be uninstalled. Error: ${ex}.` + ); + } + break; + } + + case "GeckoView:WebExtension:Enable": { + try { + const { source, webExtensionId } = aData; + if (source !== "user" && source !== "app") { + throw new Error("Illegal source parameter"); + } + const extension = await this.enableWebExtension( + webExtensionId, + source + ); + aCallback.onSuccess({ extension }); + } catch (ex) { + debug`Failed enable ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + break; + } + + case "GeckoView:WebExtension:Disable": { + try { + const { source, webExtensionId } = aData; + if (source !== "user" && source !== "app") { + throw new Error("Illegal source parameter"); + } + const extension = await this.disableWebExtension( + webExtensionId, + source + ); + aCallback.onSuccess({ extension }); + } catch (ex) { + debug`Failed disable ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + break; + } + + case "GeckoView:WebExtension:List": { + try { + await lazy.AddonManager.readyPromise; + const addons = await lazy.AddonManager.getAddonsByTypes([ + "extension", + ]); + const extensions = await Promise.all( + addons.map(addon => + exportExtension(addon, addon.userPermissions, null) + ) + ); + + aCallback.onSuccess({ extensions }); + } catch (ex) { + debug`Failed list ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + break; + } + + case "GeckoView:WebExtension:Update": { + try { + const { webExtensionId } = aData; + const result = await this.updateWebExtension(webExtensionId); + if (result === null || result.extension) { + aCallback.onSuccess(result); + } else { + aCallback.onError(result); + } + } catch (ex) { + debug`Failed update ${ex}`; + aCallback.onError(`Unexpected error: ${ex}`); + } + break; + } + } + }, +}; + +// WeakMap[Extension -> BrowserAction] +GeckoViewWebExtension.browserActions = new WeakMap(); +// WeakMap[Extension -> PageAction] +GeckoViewWebExtension.pageActions = new WeakMap(); diff --git a/mobile/android/modules/geckoview/LoadURIDelegate.jsm b/mobile/android/modules/geckoview/LoadURIDelegate.jsm new file mode 100644 index 0000000000..86a806ae2e --- /dev/null +++ b/mobile/android/modules/geckoview/LoadURIDelegate.jsm @@ -0,0 +1,104 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 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/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["LoadURIDelegate"]; + +const { GeckoViewUtils } = ChromeUtils.importESModule( + "resource://gre/modules/GeckoViewUtils.sys.mjs" +); + +const { debug, warn } = GeckoViewUtils.initLogging("LoadURIDelegate"); + +const LoadURIDelegate = { + // Delegate URI loading to the app. + // Return whether the loading has been handled. + load(aWindow, aEventDispatcher, aUri, aWhere, aFlags, aTriggeringPrincipal) { + if (!aWindow) { + return false; + } + + const triggerUri = + aTriggeringPrincipal && + (aTriggeringPrincipal.isNullPrincipal ? null : aTriggeringPrincipal.URI); + + const message = { + type: "GeckoView:OnLoadRequest", + uri: aUri ? aUri.displaySpec : "", + where: aWhere, + flags: aFlags, + triggerUri: triggerUri && triggerUri.displaySpec, + hasUserGesture: aWindow.document.hasValidTransientUserGestureActivation, + }; + + let handled = undefined; + aEventDispatcher.sendRequestForResult(message).then( + response => { + handled = response; + }, + () => { + // There was an error or listener was not registered in GeckoSession, + // treat as unhandled. + handled = false; + } + ); + Services.tm.spinEventLoopUntil( + "LoadURIDelegate.jsm:load", + () => aWindow.closed || handled !== undefined + ); + + return handled || false; + }, + + handleLoadError(aWindow, aEventDispatcher, aUri, aError, aErrorModule) { + let errorClass = 0; + try { + const nssErrorsService = Cc[ + "@mozilla.org/nss_errors_service;1" + ].getService(Ci.nsINSSErrorsService); + errorClass = nssErrorsService.getErrorClass(aError); + } catch (e) {} + + const msg = { + type: "GeckoView:OnLoadError", + uri: aUri && aUri.spec, + error: aError, + errorModule: aErrorModule, + errorClass, + }; + + let errorPageURI = undefined; + aEventDispatcher.sendRequestForResult(msg).then( + response => { + try { + errorPageURI = response ? Services.io.newURI(response) : null; + } catch (e) { + warn`Failed to parse URI '${response}`; + errorPageURI = null; + Components.returnCode = Cr.NS_ERROR_ABORT; + } + }, + e => { + errorPageURI = null; + Components.returnCode = Cr.NS_ERROR_ABORT; + } + ); + Services.tm.spinEventLoopUntil( + "LoadURIDelegate.jsm:handleLoadError", + () => aWindow.closed || errorPageURI !== undefined + ); + + return errorPageURI; + }, + + isSafeBrowsingError(aError) { + return ( + aError === Cr.NS_ERROR_PHISHING_URI || + aError === Cr.NS_ERROR_MALWARE_URI || + aError === Cr.NS_ERROR_HARMFUL_URI || + aError === Cr.NS_ERROR_UNWANTED_URI + ); + }, +}; diff --git a/mobile/android/modules/geckoview/MediaUtils.jsm b/mobile/android/modules/geckoview/MediaUtils.jsm new file mode 100644 index 0000000000..277323314a --- /dev/null +++ b/mobile/android/modules/geckoview/MediaUtils.jsm @@ -0,0 +1,83 @@ +/* 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 = ["MediaUtils"]; + +const MediaUtils = { + getMetadata(aElement) { + if (!aElement) { + return null; + } + return { + src: aElement.currentSrc ?? aElement.src, + width: aElement.videoWidth ?? 0, + height: aElement.videoHeight ?? 0, + duration: aElement.duration, + seekable: !!aElement.seekable, + audioTrackCount: + aElement.audioTracks?.length ?? + aElement.mozHasAudio ?? + aElement.webkitAudioDecodedByteCount ?? + MediaUtils.isAudioElement(aElement) + ? 1 + : 0, + videoTrackCount: + aElement.videoTracks?.length ?? MediaUtils.isVideoElement(aElement) + ? 1 + : 0, + }; + }, + + isVideoElement(aElement) { + return ( + aElement && ChromeUtils.getClassName(aElement) === "HTMLVideoElement" + ); + }, + + isAudioElement(aElement) { + return ( + aElement && ChromeUtils.getClassName(aElement) === "HTMLAudioElement" + ); + }, + + isMediaElement(aElement) { + return ( + MediaUtils.isVideoElement(aElement) || MediaUtils.isAudioElement(aElement) + ); + }, + + findMediaElement(aElement) { + return ( + MediaUtils.findVideoElement(aElement) ?? + MediaUtils.findAudioElement(aElement) + ); + }, + + findVideoElement(aElement) { + if (!aElement) { + return null; + } + if (MediaUtils.isVideoElement(aElement)) { + return aElement; + } + const childrenMedia = aElement.getElementsByTagName("video"); + if (childrenMedia && childrenMedia.length) { + return childrenMedia[0]; + } + return null; + }, + + findAudioElement(aElement) { + if (!aElement || MediaUtils.isAudioElement(aElement)) { + return aElement; + } + const childrenMedia = aElement.getElementsByTagName("audio"); + if (childrenMedia && childrenMedia.length) { + return childrenMedia[0]; + } + return null; + }, +}; diff --git a/mobile/android/modules/geckoview/Messaging.sys.mjs b/mobile/android/modules/geckoview/Messaging.sys.mjs new file mode 100644 index 0000000000..e67161fede --- /dev/null +++ b/mobile/android/modules/geckoview/Messaging.sys.mjs @@ -0,0 +1,319 @@ +/* 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/. */ + +const IS_PARENT_PROCESS = + Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT; + +class ChildActorDispatcher { + constructor(actor) { + this._actor = actor; + } + + // TODO: Bug 1658980 + registerListener(aListener, aEvents) { + throw new Error("Cannot registerListener in child actor"); + } + unregisterListener(aListener, aEvents) { + throw new Error("Cannot registerListener in child actor"); + } + + /** + * Sends a request to Java. + * + * @param aMsg Message to send; must be an object with a "type" property + */ + sendRequest(aMsg) { + this._actor.sendAsyncMessage("DispatcherMessage", aMsg); + } + + /** + * Sends a request to Java, returning a Promise that resolves to the response. + * + * @param aMsg Message to send; must be an object with a "type" property + * @return A Promise resolving to the response + */ + sendRequestForResult(aMsg) { + return this._actor.sendQuery("DispatcherQuery", aMsg); + } +} + +function DispatcherDelegate(aDispatcher, aMessageManager) { + this._dispatcher = aDispatcher; + this._messageManager = aMessageManager; + + if (!aDispatcher) { + // Child process. + // TODO: this doesn't work with Fission, remove this code path once every + // consumer has been migrated. Bug 1569360. + this._replies = new Map(); + (aMessageManager || Services.cpmm).addMessageListener( + "GeckoView:MessagingReply", + this + ); + } +} + +DispatcherDelegate.prototype = { + /** + * Register a listener to be notified of event(s). + * + * @param aListener Target listener implementing nsIAndroidEventListener. + * @param aEvents String or array of strings of events to listen to. + */ + registerListener(aListener, aEvents) { + if (!this._dispatcher) { + throw new Error("Can only listen in parent process"); + } + this._dispatcher.registerListener(aListener, aEvents); + }, + + /** + * Unregister a previously-registered listener. + * + * @param aListener Registered listener implementing nsIAndroidEventListener. + * @param aEvents String or array of strings of events to stop listening to. + */ + unregisterListener(aListener, aEvents) { + if (!this._dispatcher) { + throw new Error("Can only listen in parent process"); + } + this._dispatcher.unregisterListener(aListener, aEvents); + }, + + /** + * Dispatch an event to registered listeners for that event, and pass an + * optional data object and/or a optional callback interface to the + * listeners. + * + * @param aEvent Name of event to dispatch. + * @param aData Optional object containing data for the event. + * @param aCallback Optional callback implementing nsIAndroidEventCallback. + * @param aFinalizer Optional finalizer implementing nsIAndroidEventFinalizer. + */ + dispatch(aEvent, aData, aCallback, aFinalizer) { + if (this._dispatcher) { + this._dispatcher.dispatch(aEvent, aData, aCallback, aFinalizer); + return; + } + + const mm = this._messageManager || Services.cpmm; + const forwardData = { + global: !this._messageManager, + event: aEvent, + data: aData, + }; + + if (aCallback) { + const uuid = Services.uuid.generateUUID().toString(); + this._replies.set(uuid, { + callback: aCallback, + finalizer: aFinalizer, + }); + forwardData.uuid = uuid; + } + + mm.sendAsyncMessage("GeckoView:Messaging", forwardData); + }, + + /** + * Sends a request to Java. + * + * @param aMsg Message to send; must be an object with a "type" property + * @param aCallback Optional callback implementing nsIAndroidEventCallback. + */ + sendRequest(aMsg, aCallback) { + const type = aMsg.type; + aMsg.type = undefined; + this.dispatch(type, aMsg, aCallback); + }, + + /** + * Sends a request to Java, returning a Promise that resolves to the response. + * + * @param aMsg Message to send; must be an object with a "type" property + * @return A Promise resolving to the response + */ + sendRequestForResult(aMsg) { + return new Promise((resolve, reject) => { + const type = aMsg.type; + aMsg.type = undefined; + + // Manually release the resolve/reject functions after one callback is + // received, so the JS GC is not tied up with the Java GC. + const onCallback = (callback, ...args) => { + if (callback) { + callback(...args); + } + resolve = undefined; + reject = undefined; + }; + const callback = { + onSuccess: result => onCallback(resolve, result), + onError: error => onCallback(reject, error), + onFinalize: _ => onCallback(reject), + }; + this.dispatch(type, aMsg, callback, callback); + }); + }, + + finalize() { + if (!this._replies) { + return; + } + this._replies.forEach(reply => { + if (typeof reply.finalizer === "function") { + reply.finalizer(); + } else if (reply.finalizer) { + reply.finalizer.onFinalize(); + } + }); + this._replies.clear(); + }, + + receiveMessage(aMsg) { + const { uuid, type } = aMsg.data; + const reply = this._replies.get(uuid); + if (!reply) { + return; + } + + if (type === "success") { + reply.callback.onSuccess(aMsg.data.response); + } else if (type === "error") { + reply.callback.onError(aMsg.data.response); + } else if (type === "finalize") { + if (typeof reply.finalizer === "function") { + reply.finalizer(); + } else if (reply.finalizer) { + reply.finalizer.onFinalize(); + } + this._replies.delete(uuid); + } else { + throw new Error("invalid reply type"); + } + }, +}; + +export var EventDispatcher = { + instance: new DispatcherDelegate( + IS_PARENT_PROCESS ? Services.androidBridge : undefined + ), + + /** + * Return an EventDispatcher instance for a chrome DOM window. In a content + * process, return a proxy through the message manager that automatically + * forwards events to the main process. + * + * To force using a message manager proxy (for example in a frame script + * environment), call forMessageManager. + * + * @param aWindow a chrome DOM window. + */ + for(aWindow) { + const view = + aWindow && + aWindow.arguments && + aWindow.arguments[0] && + aWindow.arguments[0].QueryInterface(Ci.nsIAndroidView); + + if (!view) { + const mm = !IS_PARENT_PROCESS && aWindow && aWindow.messageManager; + if (!mm) { + throw new Error( + "window is not a GeckoView-connected window and does" + + " not have a message manager" + ); + } + return this.forMessageManager(mm); + } + + return new DispatcherDelegate(view); + }, + + /** + * Returns a named EventDispatcher, which can communicate with the + * corresponding EventDispatcher on the java side. + */ + byName(aName) { + if (!IS_PARENT_PROCESS) { + return undefined; + } + const dispatcher = Services.androidBridge.getDispatcherByName(aName); + return new DispatcherDelegate(dispatcher); + }, + + /** + * Return an EventDispatcher instance for a message manager associated with a + * window. + * + * @param aWindow a message manager. + */ + forMessageManager(aMessageManager) { + return new DispatcherDelegate(null, aMessageManager); + }, + + /** + * Return the EventDispatcher instance associated with an actor. + * + * @param aActor an actor + */ + forActor(aActor) { + return new ChildActorDispatcher(aActor); + }, + + receiveMessage(aMsg) { + // aMsg.data includes keys: global, event, data, uuid + let callback; + if (aMsg.data.uuid) { + const reply = (type, response) => { + const mm = aMsg.data.global ? aMsg.target : aMsg.target.messageManager; + if (!mm) { + if (type === "finalize") { + // It's normal for the finalize call to come after the browser has + // been destroyed. We can gracefully handle that case despite + // having no message manager. + return; + } + throw Error( + `No message manager for ${aMsg.data.event}:${type} reply` + ); + } + mm.sendAsyncMessage("GeckoView:MessagingReply", { + type, + response, + uuid: aMsg.data.uuid, + }); + }; + callback = { + onSuccess: response => reply("success", response), + onError: error => reply("error", error), + onFinalize: () => reply("finalize"), + }; + } + + try { + if (aMsg.data.global) { + this.instance.dispatch( + aMsg.data.event, + aMsg.data.data, + callback, + callback + ); + return; + } + + const win = aMsg.target.ownerGlobal; + const dispatcher = win.WindowEventDispatcher || this.for(win); + dispatcher.dispatch(aMsg.data.event, aMsg.data.data, callback, callback); + } catch (e) { + callback?.onError(`Error getting dispatcher: ${e}`); + throw e; + } + }, +}; + +if (IS_PARENT_PROCESS) { + Services.mm.addMessageListener("GeckoView:Messaging", EventDispatcher); + Services.ppmm.addMessageListener("GeckoView:Messaging", EventDispatcher); +} diff --git a/mobile/android/modules/geckoview/moz.build b/mobile/android/modules/geckoview/moz.build new file mode 100644 index 0000000000..c994b57840 --- /dev/null +++ b/mobile/android/modules/geckoview/moz.build @@ -0,0 +1,40 @@ +# -*- 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/. + +EXTRA_JS_MODULES += [ + "AndroidLog.jsm", + "BrowserUsageTelemetry.jsm", + "ChildCrashHandler.jsm", + "DelayedInit.jsm", + "GeckoViewActorChild.sys.mjs", + "GeckoViewActorManager.sys.mjs", + "GeckoViewActorParent.sys.mjs", + "GeckoViewAutocomplete.jsm", + "GeckoViewAutofill.jsm", + "GeckoViewChildModule.jsm", + "GeckoViewConsole.jsm", + "GeckoViewContent.jsm", + "GeckoViewContentBlocking.jsm", + "GeckoViewMediaControl.jsm", + "GeckoViewModule.jsm", + "GeckoViewNavigation.jsm", + "GeckoViewProcessHangMonitor.jsm", + "GeckoViewProgress.jsm", + "GeckoViewPushController.jsm", + "GeckoViewRemoteDebugger.jsm", + "GeckoViewSelectionAction.jsm", + "GeckoViewSessionStore.jsm", + "GeckoViewSettings.jsm", + "GeckoViewStorageController.jsm", + "GeckoViewTab.jsm", + "GeckoViewTelemetry.jsm", + "GeckoViewTestUtils.jsm", + "GeckoViewUtils.sys.mjs", + "GeckoViewWebExtension.jsm", + "LoadURIDelegate.jsm", + "MediaUtils.jsm", + "Messaging.sys.mjs", +] |