From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../android/modules/geckoview/AndroidLog.sys.mjs | 82 ++ .../geckoview/BrowserUsageTelemetry.sys.mjs | 21 + .../modules/geckoview/ChildCrashHandler.sys.mjs | 107 ++ .../android/modules/geckoview/DelayedInit.sys.mjs | 174 +++ .../modules/geckoview/GeckoViewActorChild.sys.mjs | 19 + .../geckoview/GeckoViewActorManager.sys.mjs | 27 + .../modules/geckoview/GeckoViewActorParent.sys.mjs | 51 + .../geckoview/GeckoViewAutocomplete.sys.mjs | 730 +++++++++++ .../modules/geckoview/GeckoViewAutofill.sys.mjs | 96 ++ .../modules/geckoview/GeckoViewChildModule.sys.mjs | 81 ++ .../geckoview/GeckoViewClipboardPermission.sys.mjs | 99 ++ .../modules/geckoview/GeckoViewConsole.sys.mjs | 174 +++ .../modules/geckoview/GeckoViewContent.sys.mjs | 762 +++++++++++ .../geckoview/GeckoViewContentBlocking.sys.mjs | 113 ++ .../geckoview/GeckoViewIdentityCredential.sys.mjs | 89 ++ .../geckoview/GeckoViewMediaControl.sys.mjs | 234 ++++ .../modules/geckoview/GeckoViewModule.sys.mjs | 165 +++ .../modules/geckoview/GeckoViewNavigation.sys.mjs | 659 ++++++++++ .../geckoview/GeckoViewProcessHangMonitor.sys.mjs | 210 +++ .../modules/geckoview/GeckoViewProgress.sys.mjs | 636 +++++++++ .../geckoview/GeckoViewPushController.sys.mjs | 70 + .../geckoview/GeckoViewRemoteDebugger.sys.mjs | 141 ++ .../geckoview/GeckoViewSelectionAction.sys.mjs | 36 + .../geckoview/GeckoViewSessionStore.sys.mjs | 187 +++ .../modules/geckoview/GeckoViewSettings.sys.mjs | 182 +++ .../geckoview/GeckoViewStorageController.sys.mjs | 351 +++++ .../android/modules/geckoview/GeckoViewTab.sys.mjs | 219 ++++ .../modules/geckoview/GeckoViewTelemetry.sys.mjs | 44 + .../modules/geckoview/GeckoViewTestUtils.sys.mjs | 62 + .../geckoview/GeckoViewTranslations.sys.mjs | 572 ++++++++ .../modules/geckoview/GeckoViewUtils.sys.mjs | 510 ++++++++ .../geckoview/GeckoViewWebExtension.sys.mjs | 1367 ++++++++++++++++++++ .../modules/geckoview/LoadURIDelegate.sys.mjs | 99 ++ .../android/modules/geckoview/MediaUtils.sys.mjs | 79 ++ mobile/android/modules/geckoview/Messaging.sys.mjs | 319 +++++ mobile/android/modules/geckoview/metrics.yaml | 153 +++ mobile/android/modules/geckoview/moz.build | 45 + .../test/xpcshell/test_ChildCrashHandler.js | 102 ++ .../modules/geckoview/test/xpcshell/xpcshell.toml | 5 + 39 files changed, 9072 insertions(+) create mode 100644 mobile/android/modules/geckoview/AndroidLog.sys.mjs create mode 100644 mobile/android/modules/geckoview/BrowserUsageTelemetry.sys.mjs create mode 100644 mobile/android/modules/geckoview/ChildCrashHandler.sys.mjs create mode 100644 mobile/android/modules/geckoview/DelayedInit.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewActorChild.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewActorManager.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewActorParent.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewAutocomplete.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewAutofill.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewChildModule.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewClipboardPermission.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewConsole.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewContent.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewContentBlocking.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewIdentityCredential.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewMediaControl.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewModule.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewNavigation.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewProgress.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewPushController.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewRemoteDebugger.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewSelectionAction.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewSessionStore.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewSettings.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewStorageController.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewTab.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewTelemetry.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewTestUtils.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewTranslations.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs create mode 100644 mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs create mode 100644 mobile/android/modules/geckoview/LoadURIDelegate.sys.mjs create mode 100644 mobile/android/modules/geckoview/MediaUtils.sys.mjs create mode 100644 mobile/android/modules/geckoview/Messaging.sys.mjs create mode 100644 mobile/android/modules/geckoview/metrics.yaml create mode 100644 mobile/android/modules/geckoview/moz.build create mode 100644 mobile/android/modules/geckoview/test/xpcshell/test_ChildCrashHandler.js create mode 100644 mobile/android/modules/geckoview/test/xpcshell/xpcshell.toml (limited to 'mobile/android/modules/geckoview') diff --git a/mobile/android/modules/geckoview/AndroidLog.sys.mjs b/mobile/android/modules/geckoview/AndroidLog.sys.mjs new file mode 100644 index 0000000000..f24e2bf899 --- /dev/null +++ b/mobile/android/modules/geckoview/AndroidLog.sys.mjs @@ -0,0 +1,82 @@ +/* -*- 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/. */ + +/** + * 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 + * . + * + * // Import it as a ESM: + * let Log = + * ChromeUtils.importESModule("resource://gre/modules/AndroidLog.sys.mjs") + * .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"). + */ + +import { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; + +// From . +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 + +export 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), + }; + }, +}; diff --git a/mobile/android/modules/geckoview/BrowserUsageTelemetry.sys.mjs b/mobile/android/modules/geckoview/BrowserUsageTelemetry.sys.mjs new file mode 100644 index 0000000000..480a54e32d --- /dev/null +++ b/mobile/android/modules/geckoview/BrowserUsageTelemetry.sys.mjs @@ -0,0 +1,21 @@ +/* -*- 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/. */ + +// Used by nsIBrowserUsage +export 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.sys.mjs b/mobile/android/modules/geckoview/ChildCrashHandler.sys.mjs new file mode 100644 index 0000000000..52a929511a --- /dev/null +++ b/mobile/android/modules/geckoview/ChildCrashHandler.sys.mjs @@ -0,0 +1,107 @@ +/* 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 { AppConstants } from "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}`); + }); +} + +export var ChildCrashHandler = { + // Map a child ID to a remote type. + childMap: new Map(), + + // The event listener for this is hooked up in GeckoViewStartup.jsm + observe(aSubject, aTopic, aData) { + const childID = aData; + + switch (aTopic) { + case "process-type-set": + // Intentional fall-through + case "ipc:content-created": { + const pp = aSubject.QueryInterface(Ci.nsIDOMProcessParent); + this.childMap.set(childID, pp.remoteType); + break; + } + + case "ipc:content-shutdown": + // Intentional fall-through + case "compositor:process-aborted": { + 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. This includes most + // "expected" extensions process crashes on Android. + 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); + + let remoteType = this.childMap.get(childID); + this.childMap.delete(childID); + + if (remoteType?.length) { + // Only send the remote type prefix since everything after a "=" is + // dynamic, and used to control the process pool to use. + remoteType = remoteType.split("=")[0]; + } + + // Report GPU and extension process crashes as occuring in a background + // process, and others as foreground. + const processType = + aTopic === "compositor:process-aborted" || remoteType === "extension" + ? "BACKGROUND_CHILD" + : "FOREGROUND_CHILD"; + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:ChildCrashReport", + minidumpPath, + extrasPath, + success: true, + fatal: false, + processType, + remoteType, + }); + + break; + } + } + }, +}; diff --git a/mobile/android/modules/geckoview/DelayedInit.sys.mjs b/mobile/android/modules/geckoview/DelayedInit.sys.mjs new file mode 100644 index 0000000000..db5984c8ec --- /dev/null +++ b/mobile/android/modules/geckoview/DelayedInit.sys.mjs @@ -0,0 +1,174 @@ +/* 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 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"); + */ +export 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. + ChromeUtils.idleDispatch(() => this.onIdle(), { + timeout: 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. + ChromeUtils.idleDispatch(() => this.onIdle(), { timeout: 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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewAutocomplete.sys.mjs new file mode 100644 index 0000000000..2bac20281e --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewAutocomplete.sys.mjs @@ -0,0 +1,730 @@ +/* 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 lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "LoginInfo", () => + Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + "nsILoginInfo", + "init" + ) +); + +export 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; + } +} + +export 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, + }; + } +} + +export 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, + }; + } +} + +export 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 }; + +export 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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewAutofill.sys.mjs new file mode 100644 index 0000000000..1249685aaa --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewAutofill.sys.mjs @@ -0,0 +1,96 @@ +/* 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"; + +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; + } +} + +export var gAutofillManager = new AutofillManager(); + +const { debug, warn } = GeckoViewUtils.initLogging("Autofill"); diff --git a/mobile/android/modules/geckoview/GeckoViewChildModule.sys.mjs b/mobile/android/modules/geckoview/GeckoViewChildModule.sys.mjs new file mode 100644 index 0000000000..68cff76ba7 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewChildModule.sys.mjs @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const { debug, warn } = GeckoViewUtils.initLogging("Module[C]"); + +export 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/GeckoViewClipboardPermission.sys.mjs b/mobile/android/modules/geckoview/GeckoViewClipboardPermission.sys.mjs new file mode 100644 index 0000000000..2c8e55c380 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewClipboardPermission.sys.mjs @@ -0,0 +1,99 @@ +/* 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 lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + PromptUtils: "resource://gre/modules/PromptUtils.sys.mjs", +}); + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const { debug } = GeckoViewUtils.initLogging("GeckoViewClipboardPermission"); + +export var GeckoViewClipboardPermission = { + confirmUserPaste(aWindowContext) { + return new Promise((resolve, reject) => { + if (!aWindowContext) { + reject( + Components.Exception("Null window context.", Cr.NS_ERROR_INVALID_ARG) + ); + return; + } + + const { document } = aWindowContext.browsingContext.topChromeWindow; + if (!document) { + reject( + Components.Exception( + "Unable to get chrome document.", + Cr.NS_ERROR_FAILURE + ) + ); + return; + } + + if (this._pendingRequest) { + reject( + Components.Exception( + "There is an ongoing request.", + Cr.NS_ERROR_FAILURE + ) + ); + return; + } + + this._pendingRequest = { resolve, reject }; + + const mouseXInCSSPixels = {}; + const mouseYInCSSPixels = {}; + const windowUtils = document.ownerGlobal.windowUtils; + windowUtils.getLastOverWindowPointerLocationInCSSPixels( + mouseXInCSSPixels, + mouseYInCSSPixels + ); + const screenRect = windowUtils.toScreenRect( + mouseXInCSSPixels.value, + mouseYInCSSPixels.value, + 0, + 0 + ); + + debug`confirmUserPaste (${screenRect.x}, ${screenRect.y})`; + + document.addEventListener("pointerdown", this); + document.ownerGlobal.WindowEventDispatcher.sendRequestForResult({ + type: "GeckoView:ClipboardPermissionRequest", + screenPoint: { + x: screenRect.x, + y: screenRect.y, + }, + }).then( + allowOrDeny => { + const propBag = lazy.PromptUtils.objectToPropBag({ ok: allowOrDeny }); + this._pendingRequest.resolve(propBag); + this._pendingRequest = null; + document.removeEventListener("pointerdown", this); + }, + error => { + debug`Permission error: ${error}`; + this._pendingRequest.reject(); + this._pendingRequest = null; + document.removeEventListener("pointerdown", this); + } + ); + }); + }, + + // EventListener interface. + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + switch (aEvent.type) { + case "pointerdown": { + aEvent.target.ownerGlobal.WindowEventDispatcher.sendRequestForResult({ + type: "GeckoView:DismissClipboardPermissionRequest", + }); + break; + } + } + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewConsole.sys.mjs b/mobile/android/modules/geckoview/GeckoViewConsole.sys.mjs new file mode 100644 index 0000000000..acf7a12aa2 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewConsole.sys.mjs @@ -0,0 +1,174 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const { debug, warn } = GeckoViewUtils.initLogging("Console"); + +const lazy = {}; + +ChromeUtils.defineLazyGetter( + lazy, + "l10n", + () => new Localization(["mobile/android/geckoViewConsole.ftl"], true) +); + +export 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 args = aMessage.arguments; + const msgDetails = args[0] ?? aMessage; + const filename = this.abbreviateSourceURL(msgDetails.filename); + const functionName = + msgDetails.functionName || + lazy.l10n.formatValueSync("console-stacktrace-anonymous-function"); + + let body = lazy.l10n.formatValueSync("console-stacktrace", { + filename, + functionName, + lineNumber: msgDetails.lineNumber ?? "", + }); + body += "\n"; + for (const aFrame of args) { + const functionName = + aFrame.functionName || + lazy.l10n.formatValueSync("console-stacktrace-anonymous-function"); + body += ` ${aFrame.filename} :: ${functionName} :: ${aFrame.lineNumber}\n`; + } + + Services.console.logStringMessage(body); + } else if (aMessage.level == "time" && aMessage.arguments) { + const body = lazy.l10n.formatValueSync("console-timer-start", { + name: aMessage.arguments.name ?? "", + }); + Services.console.logStringMessage(body); + } else if (aMessage.level == "timeEnd" && aMessage.arguments) { + const body = lazy.l10n.formatValueSync("console-timer-end", { + name: aMessage.arguments.name ?? "", + duration: 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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewContent.sys.mjs new file mode 100644 index 0000000000..b593a6f8e4 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewContent.sys.mjs @@ -0,0 +1,762 @@ +/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + isProductURL: "chrome://global/content/shopping/ShoppingProduct.mjs", + ShoppingProduct: "chrome://global/content/shopping/ShoppingProduct.mjs", +}); + +export class GeckoViewContent extends GeckoViewModule { + onInit() { + this.registerListener([ + "GeckoViewContent:ExitFullScreen", + "GeckoView:ClearMatches", + "GeckoView:DisplayMatches", + "GeckoView:FindInPage", + "GeckoView:HasCookieBannerRuleForBrowsingContextTree", + "GeckoView:RestoreState", + "GeckoView:ContainsFormData", + "GeckoView:RequestCreateAnalysis", + "GeckoView:RequestAnalysisStatus", + "GeckoView:RequestAnalysisCreationStatus", + "GeckoView:PollForAnalysisCompleted", + "GeckoView:SendClickAttributionEvent", + "GeckoView:SendImpressionAttributionEvent", + "GeckoView:SendPlacementAttributionEvent", + "GeckoView:RequestAnalysis", + "GeckoView:RequestRecommendations", + "GeckoView:ReportBackInStock", + "GeckoView:ScrollBy", + "GeckoView:ScrollTo", + "GeckoView:SetActive", + "GeckoView:SetFocused", + "GeckoView:SetPriorityHint", + "GeckoView:UpdateInitData", + "GeckoView:ZoomToInput", + "GeckoView:IsPdfJs", + ]); + } + + 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"); + } + + get isPdfJs() { + return ( + this.browser.contentPrincipal.spec === "resource://pdf.js/web/viewer.html" + ); + } + + // 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; + } + } + + #sendDOMFullScreenEventToAllChildren(aEvent) { + let { browsingContext } = this.actor; + + while (browsingContext) { + if (!browsingContext.currentWindowGlobal) { + break; + } + + const currentPid = browsingContext.currentWindowGlobal.osPid; + const parentPid = browsingContext.parent?.currentWindowGlobal.osPid; + + if (currentPid != parentPid) { + if (!browsingContext.parent) { + // Top level browsing context. Use origin actor (Bug 1505916). + const chromeBC = browsingContext.topChromeWindow?.browsingContext; + const requestOrigin = chromeBC?.fullscreenRequestOrigin?.get(); + if (requestOrigin) { + requestOrigin.browsingContext.currentWindowGlobal + .getActor("GeckoViewContent") + .sendAsyncMessage(aEvent, {}); + delete chromeBC.fullscreenRequestOrigin; + return; + } + } + const actor = + browsingContext.currentWindowGlobal.getActor("GeckoViewContent"); + actor.sendAsyncMessage(aEvent, {}); + } + + 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": { + if (!this.isPdfJs) { + this._clearMatches(); + } + break; + } + case "GeckoView:DisplayMatches": { + if (!this.isPdfJs) { + this._displayMatches(aData); + } + break; + } + case "GeckoView:FindInPage": { + if (!this.isPdfJs) { + this._findInPage(aData, aCallback); + } + break; + } + case "GeckoView:ZoomToInput": + // For ZoomToInput we just need to send the message to the current focused one. + const actor = + Services.focus.focusedContentBrowsingContext.currentWindowGlobal.getActor( + "GeckoViewContent" + ); + actor.sendAsyncMessage(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; + case "GeckoView:RequestAnalysis": + this._requestAnalysis(aData, aCallback); + break; + case "GeckoView:RequestCreateAnalysis": + this._requestCreateAnalysis(aData, aCallback); + break; + case "GeckoView:RequestAnalysisStatus": + this._requestAnalysisStatus(aData, aCallback); + break; + case "GeckoView:RequestAnalysisCreationStatus": + this._requestAnalysisCreationStatus(aData, aCallback); + break; + case "GeckoView:PollForAnalysisCompleted": + this._pollForAnalysisCompleted(aData, aCallback); + break; + case "GeckoView:SendClickAttributionEvent": + this._sendAttributionEvent("click", aData, aCallback); + break; + case "GeckoView:SendImpressionAttributionEvent": + this._sendAttributionEvent("impression", aData, aCallback); + break; + case "GeckoView:SendPlacementAttributionEvent": + this._sendAttributionEvent("placement", aData, aCallback); + break; + case "GeckoView:RequestRecommendations": + this._requestRecommendations(aData, aCallback); + break; + case "GeckoView:ReportBackInStock": + this._reportBackInStock(aData, aCallback); + break; + case "GeckoView:IsPdfJs": + aCallback.onSuccess(this.isPdfJs); + break; + case "GeckoView:HasCookieBannerRuleForBrowsingContextTree": + this._hasCookieBannerRuleForBrowsingContextTree(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.#sendDOMFullScreenEventToAllChildren( + "GeckoView:DOMFullscreenEntered" + ); + } + break; + case "MozDOMFullscreen:Exited": + this.#sendDOMFullScreenEventToAllChildren( + "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()); + } + + async _requestAnalysis(aData, aCallback) { + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + const analysis = { + analysis_url: "https://www.example.com/mock_analysis_url", + product_id: "ABCDEFG123", + grade: "B", + adjusted_rating: 4.5, + needs_analysis: true, + page_not_supported: true, + not_enough_reviews: true, + highlights: null, + last_analysis_time: 12345, + deleted_product_reported: true, + deleted_product: true, + }; + aCallback.onSuccess({ analysis }); + return; + } + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError(`Cannot requestAnalysis on a non-product url.`); + } else { + const product = new lazy.ShoppingProduct(url); + const analysis = await product.requestAnalysis(); + if (!analysis) { + aCallback.onError(`Product analysis returned null.`); + return; + } + aCallback.onSuccess({ analysis }); + } + } + + async _requestCreateAnalysis(aData, aCallback) { + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + const status = "pending"; + aCallback.onSuccess(status); + return; + } + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError(`Cannot requestCreateAnalysis on a non-product url.`); + } else { + const product = new lazy.ShoppingProduct(url); + const status = await product.requestCreateAnalysis(); + if (!status) { + aCallback.onError(`Creation of product analysis returned null.`); + return; + } + aCallback.onSuccess(status.status); + } + } + + async _requestAnalysisCreationStatus(aData, aCallback) { + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + const status = "in_progress"; + aCallback.onSuccess(status); + return; + } + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError( + `Cannot requestAnalysisCreationStatus on a non-product url.` + ); + } else { + const product = new lazy.ShoppingProduct(url); + const status = await product.requestAnalysisCreationStatus(); + if (!status) { + aCallback.onError( + `Status of creation of product analysis returned null.` + ); + return; + } + aCallback.onSuccess(status.status); + } + } + + async _requestAnalysisStatus(aData, aCallback) { + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + const status = { status: "in_progress", progress: 90.9 }; + aCallback.onSuccess({ status }); + return; + } + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError(`Cannot requestAnalysisStatus on a non-product url.`); + } else { + const product = new lazy.ShoppingProduct(url); + const status = await product.requestAnalysisCreationStatus(); + if (!status) { + aCallback.onError(`Status of product analysis returned null.`); + return; + } + aCallback.onSuccess({ status }); + } + } + + async _pollForAnalysisCompleted(aData, aCallback) { + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError( + `Cannot pollForAnalysisCompleted on a non-product url.` + ); + } else { + const product = new lazy.ShoppingProduct(url); + const status = await product.pollForAnalysisCompleted(); + if (!status) { + aCallback.onError( + `Polling the status of creation of product analysis returned null.` + ); + return; + } + aCallback.onSuccess(status.status); + } + } + + async _sendAttributionEvent(aEvent, aData, aCallback) { + let result; + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + result = { TEST_AID: "TEST_AID_RESPONSE" }; + } else { + result = await lazy.ShoppingProduct.sendAttributionEvent( + aEvent, + aData.aid, + "geckoview_android" + ); + } + if (!result || !(aData.aid in result) || !result[aData.aid]) { + aCallback.onSuccess(false); + return; + } + aCallback.onSuccess(true); + } + + async _requestRecommendations(aData, aCallback) { + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + const recommendations = [ + { + name: "Mock Product", + url: "https://example.com/mock_url", + image_url: "https://example.com/mock_image_url", + price: "450", + currency: "USD", + grade: "C", + adjusted_rating: 3.5, + analysis_url: "https://example.com/mock_analysis_url", + sponsored: true, + aid: "mock_aid", + }, + ]; + aCallback.onSuccess({ recommendations }); + return; + } + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError(`Cannot requestRecommendations on a non-product url.`); + } else { + const product = new lazy.ShoppingProduct(url); + const recommendations = await product.requestRecommendations(); + if (!recommendations) { + aCallback.onError(`Product recommendations returned null.`); + return; + } + aCallback.onSuccess({ recommendations }); + } + } + + async _reportBackInStock(aData, aCallback) { + if ( + Services.prefs.getBoolPref("geckoview.shopping.mock_test_response", false) + ) { + const message = "report created"; + aCallback.onSuccess(message); + return; + } + const url = Services.io.newURI(aData.url); + if (!lazy.isProductURL(url)) { + aCallback.onError(`Cannot reportBackInStock on a non-product url.`); + } else { + const product = new lazy.ShoppingProduct(url); + const message = await product.sendReport(); + if (!message) { + aCallback.onError(`Reporting back in stock returned null.`); + return; + } + aCallback.onSuccess(message.message); + } + } + + async _hasCookieBannerRuleForBrowsingContextTree(aCallback) { + const { browsingContext } = this.actor; + aCallback.onSuccess( + Services.cookieBanners.hasRuleForBrowsingContextTree(browsingContext) + ); + } + + _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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewContentBlocking.sys.mjs new file mode 100644 index 0000000000..d5d125444f --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewContentBlocking.sys.mjs @@ -0,0 +1,113 @@ +/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +export 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/GeckoViewIdentityCredential.sys.mjs b/mobile/android/modules/geckoview/GeckoViewIdentityCredential.sys.mjs new file mode 100644 index 0000000000..043415122e --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewIdentityCredential.sys.mjs @@ -0,0 +1,89 @@ +/* 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 lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewPrompter: "resource://gre/modules/GeckoViewPrompter.sys.mjs", +}); + +export const GeckoViewIdentityCredential = { + async onShowProviderPrompt(aBrowser, providers, resolve, reject) { + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + debug`onShowProviderPrompt`; + + prompt.asyncShowPrompt( + { + type: "IdentityCredential:Select:Provider", + providers, + }, + result => { + if (result && result.providerIndex != null) { + debug`onShowProviderPrompt resolve with ${result.providerIndex}`; + resolve(result.providerIndex); + } else { + debug`onShowProviderPrompt rejected`; + reject(); + } + } + ); + }, + async onShowAccountsPrompt(aBrowser, accounts, resolve, reject) { + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + debug`onShowAccountsPrompt`; + + prompt.asyncShowPrompt( + { + type: "IdentityCredential:Select:Account", + accounts, + }, + result => { + if (result && result.accountIndex != null) { + debug`onShowAccountsPrompt resolve with ${result.accountIndex}`; + resolve(result.accountIndex); + } else { + debug`onShowAccountsPrompt rejected`; + reject(); + } + } + ); + }, + async onShowPolicyPrompt( + aBrowser, + privacyPolicyUrl, + termsOfServiceUrl, + providerDomain, + host, + icon, + resolve, + reject + ) { + const prompt = new lazy.GeckoViewPrompter(aBrowser.ownerGlobal); + debug`onShowPolicyPrompt`; + + prompt.asyncShowPrompt( + { + type: "IdentityCredential:Show:Policy", + privacyPolicyUrl, + termsOfServiceUrl, + providerDomain, + host, + icon, + }, + result => { + if (result && result.accept != null) { + debug`onShowPolicyPrompt resolve with ${result.accept}`; + resolve(result.accept); + } else { + debug`onShowPolicyPrompt rejected`; + reject(); + } + } + ); + }, +}; + +const { debug } = GeckoViewUtils.initLogging("GeckoViewIdentityCredential"); diff --git a/mobile/android/modules/geckoview/GeckoViewMediaControl.sys.mjs b/mobile/android/modules/geckoview/GeckoViewMediaControl.sys.mjs new file mode 100644 index 0000000000..1b39125fce --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewMediaControl.sys.mjs @@ -0,0 +1,234 @@ +/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +export class GeckoViewMediaControl extends GeckoViewModule { + onInit() { + debug`onInit`; + } + + onInitBrowser() { + debug`onInitBrowser`; + + 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); + } + + onDestroyBrowser() { + debug`onDestroyBrowser`; + + 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); + } + + onEnable() { + debug`onEnable`; + + if (this.controller.isActive) { + this.handleActivated(); + } + + 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.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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewModule.sys.mjs new file mode 100644 index 0000000000..87ad35d817 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewModule.sys.mjs @@ -0,0 +1,165 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const { debug, warn } = GeckoViewUtils.initLogging("Module"); + +export 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 cleanup when the browser is destroyed. + onDestroyBrowser() {} + + // 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(aEventList) { + this._eventProxy.unregisterListener(aEventList); + } +} + +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(aEventList) { + debug`unregisterListener`; + if (this._registeredEvents.length === 0) { + return; + } + + if (!aEventList) { + this.eventDispatcher.unregisterListener(this, this._registeredEvents); + this._registeredEvents = []; + } else { + this.eventDispatcher.unregisterListener(this, aEventList); + this._registeredEvents = this._registeredEvents.filter( + e => !aEventList.includes(e) + ); + } + } + + 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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewNavigation.sys.mjs new file mode 100644 index 0000000000..287a605dff --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewNavigation.sys.mjs @@ -0,0 +1,659 @@ +/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + GeckoViewUtils: "resource://gre/modules/GeckoViewUtils.sys.mjs", + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + LoadURIDelegate: "resource://gre/modules/LoadURIDelegate.sys.mjs", + isProductURL: "chrome://global/content/shopping/ShoppingProduct.mjs", + TranslationsParent: "resource://gre/actors/TranslationsParent.sys.mjs", +}); + +ChromeUtils.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; +// eslint-disable-next-line no-unused-vars +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; + } + if (aFlags & (1 << 7)) { + navFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_LOAD_URI_DELEGATE; + } + 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. +export 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", + "GeckoView:DotPrintFinish", + ]); + + 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); + 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 || Services.io.newURI(uri).schemeIs("content")) { + // 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.fixupAndLoadURIString(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; + case "GeckoView:DotPrintFinish": + var printActor = this.moduleManager.getActor("GeckoViewPrintDelegate"); + printActor.clearStaticClone(); + 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.window.moduleManager.onPrintWindow(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 || + where === Ci.nsIBrowserDOMWindow.OPEN_NEWTAB_BACKGROUND + ) { + 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, { + 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, + }; + } + + async isProductURL(aLocationURI) { + if (lazy.isProductURL(aLocationURI)) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:OnProductUrl", + }); + } + } + + // 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, + }; + lazy.TranslationsParent.onLocationChange(this.browser); + this.eventDispatcher.sendRequest(message); + + this.isProductURL(aLocationURI); + } +} + +const { debug, warn } = GeckoViewNavigation.initLogging("GeckoViewNavigation"); diff --git a/mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.sys.mjs b/mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.sys.mjs new file mode 100644 index 0000000000..7f6f14a29e --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewProcessHangMonitor.sys.mjs @@ -0,0 +1,210 @@ +/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +export 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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewProgress.sys.mjs new file mode 100644 index 0000000000..66aceb974c --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewProgress.sys.mjs @@ -0,0 +1,636 @@ +/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; +import { XPCOMUtils } from "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", + GleanStopwatch: "resource://gre/modules/GeckoViewTelemetry.sys.mjs", +}); + +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); + + this.pageLoadStopwatch = new lazy.GleanStopwatch( + Glean.geckoview.pageLoadTime + ); + this.pageReloadStopwatch = new lazy.GleanStopwatch( + Glean.geckoview.pageReloadTime + ); + this.pageLoadProgressStopwatch = new lazy.GleanStopwatch( + Glean.geckoview.pageLoadProgressTime + ); + + 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.pageLoadProgressStopwatch.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.pageLoadProgressStopwatch.finish(); + } else { + this.pageLoadProgressStopwatch.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 stopwatch = isPageReload + ? this.pageReloadStopwatch + : this.pageLoadStopwatch; + + 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) { + stopwatch.start(); + this.start(displaySpec); + } else if (isStop && !aWebProgress.isLoadingDocument) { + stopwatch.finish(); + this.stop(aStatus == Cr.NS_OK); + } else if (isRedirecting) { + stopwatch.start(); + this.start(displaySpec); + } + + // During history naviation, global window is recycled, so pagetitlechanged isn't fired + // Although Firefox Desktop always set title by onLocationChange, to reduce title change call, + // we only send title during history navigation. + if ((aStateFlags & Ci.nsIWebProgressListener.STATE_RESTORING) != 0) { + this.eventDispatcher.sendRequest({ + type: "GeckoView:PageTitleChanged", + title: this.browser.contentTitle, + }); + } + } + + 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, + }); + } +} + +export 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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewPushController.sys.mjs new file mode 100644 index 0000000000..da2d7d04e9 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewPushController.sys.mjs @@ -0,0 +1,70 @@ +/* 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 { XPCOMUtils } from "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]; +} + +export 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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewRemoteDebugger.sys.mjs new file mode 100644 index 0000000000..c4d6bf7fb5 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewRemoteDebugger.sys.mjs @@ -0,0 +1,141 @@ +/* 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 lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "require", () => { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + return require; +}); + +ChromeUtils.defineLazyGetter(lazy, "DevToolsServer", () => { + const { DevToolsServer } = lazy.require("devtools/server/devtools-server"); + return DevToolsServer; +}); + +ChromeUtils.defineLazyGetter(lazy, "SocketListener", () => { + const { SocketListener } = lazy.require("devtools/shared/security/socket"); + return SocketListener; +}); + +const { debug, warn } = GeckoViewUtils.initLogging("RemoteDebugger"); + +export 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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewSelectionAction.sys.mjs new file mode 100644 index 0000000000..07498e4b00 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewSelectionAction.sys.mjs @@ -0,0 +1,36 @@ +/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +export 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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewSessionStore.sys.mjs new file mode 100644 index 0000000000..584429295e --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewSessionStore.sys.mjs @@ -0,0 +1,187 @@ +/* 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 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); + } +} + +export var GeckoViewSessionStore = { + // For each 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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewSettings.sys.mjs new file mode 100644 index 0000000000..ec927b0af6 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewSettings.sys.mjs @@ -0,0 +1,182 @@ +/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineLazyGetter(lazy, "MOBILE_USER_AGENT", function () { + return Cc["@mozilla.org/network/protocol;1?name=http"].getService( + Ci.nsIHttpProtocolHandler + ).userAgent; +}); + +ChromeUtils.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"); +}); + +ChromeUtils.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 +// eslint-disable-next-line no-unused-vars +const VIEWPORT_MODE_MOBILE = 0; +const VIEWPORT_MODE_DESKTOP = 1; + +// Handles GeckoSession settings. +export 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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewStorageController.sys.mjs new file mode 100644 index 0000000000..e69ad3b973 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewStorageController.sys.mjs @@ -0,0 +1,351 @@ +/* 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 { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", + PrincipalsCollector: "resource://gre/modules/PrincipalsCollector.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "serviceMode", + "cookiebanners.service.mode", + Ci.nsICookieBannerService.MODE_DISABLED +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "serviceModePBM", + "cookiebanners.service.mode.privateBrowsing", + Ci.nsICookieBannerService.MODE_DISABLED +); + +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 | + Ci.nsIClearDataService.CLEAR_COOKIE_BANNER_EXECUTED_RECORD | + Ci.nsIClearDataService.CLEAR_FINGERPRINTING_PROTECTION_STATE | + Ci.nsIClearDataService.CLEAR_BOUNCE_TRACKING_PROTECTION_STATE, + ], + [ + // 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 | + Ci.nsIClearDataService.CLEAR_COOKIE_BANNER_EXECUTED_RECORD | + Ci.nsIClearDataService.CLEAR_FINGERPRINTING_PROTECTION_STATE | + Ci.nsIClearDataService.CLEAR_BOUNCE_TRACKING_PROTECTION_STATE, + ], + [ + // 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 | + Ci.nsIClearDataService.CLEAR_FINGERPRINTING_PROTECTION_STATE, + ], + [ + // 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; +} + +export 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: lazy.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: lazy.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 = lazy.E10SUtils.deserializePrincipal(aData.principal); + let key = aData.perm; + if (key == "storage-access") { + key = "3rdPartyFrameStorage^" + 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 lazy.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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewTab.sys.mjs new file mode 100644 index 0000000000..53f43f153c --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewTab.sys.mjs @@ -0,0 +1,219 @@ +/* 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 { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; + +const { ExtensionError } = ExtensionUtils; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + mobileWindowTracker: "resource://gre/modules/GeckoViewWebExtension.sys.mjs", +}); + +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; + +export 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} + * 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} + * 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} + * 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); + } + }, +}; + +export 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.sys.mjs b/mobile/android/modules/geckoview/GeckoViewTelemetry.sys.mjs new file mode 100644 index 0000000000..bb7074ced8 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewTelemetry.sys.mjs @@ -0,0 +1,44 @@ +/* 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/. */ + +export var InitializationTracker = { + initialized: false, + onInitialized(profilerTime) { + if (!this.initialized) { + this.initialized = true; + ChromeUtils.addProfilerMarker( + "GeckoView Initialization END", + profilerTime + ); + } + }, +}; + +// A helper for timing_distribution metrics. +export class GleanStopwatch { + constructor(aTimingDistribution) { + this._metric = aTimingDistribution; + } + + isRunning() { + return !!this._timerId; + } + + start() { + if (this.isRunning()) { + this.cancel(); + } + this._timerId = this._metric.start(); + } + + finish() { + this._metric.stopAndAccumulate(this._timerId); + this._timerId = null; + } + + cancel() { + this._metric.cancel(this._timerId); + this._timerId = null; + } +} diff --git a/mobile/android/modules/geckoview/GeckoViewTestUtils.sys.mjs b/mobile/android/modules/geckoview/GeckoViewTestUtils.sys.mjs new file mode 100644 index 0000000000..6c5113cdc2 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewTestUtils.sys.mjs @@ -0,0 +1,62 @@ +/* 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 lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", +}); + +export 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; + + // Immediately load the URI in the browser after creating the new tab to + // load into. This isn't done from the Java side to align with the + // ServiceWorkerOpenWindow infrastructure which this is built on top of. + window.browser.fixupAndLoadURIString(url, { + flags: Ci.nsIWebNavigation.LOAD_FLAGS_NONE, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + + return window.tab; + }, +}; diff --git a/mobile/android/modules/geckoview/GeckoViewTranslations.sys.mjs b/mobile/android/modules/geckoview/GeckoViewTranslations.sys.mjs new file mode 100644 index 0000000000..3db694a64a --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewTranslations.sys.mjs @@ -0,0 +1,572 @@ +/* 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 lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + TranslationsParent: "resource://gre/actors/TranslationsParent.sys.mjs", +}); + +import { GeckoViewModule } from "resource://gre/modules/GeckoViewModule.sys.mjs"; + +export class GeckoViewTranslations extends GeckoViewModule { + onInit() { + debug`onInit`; + this.registerListener([ + "GeckoView:Translations:Translate", + "GeckoView:Translations:RestorePage", + "GeckoView:Translations:GetNeverTranslateSite", + "GeckoView:Translations:SetNeverTranslateSite", + ]); + } + + onEnable() { + debug`onEnable`; + this.window.addEventListener("TranslationsParent:OfferTranslation", this); + this.window.addEventListener("TranslationsParent:LanguageState", this); + } + + onDisable() { + debug`onDisable`; + this.window.removeEventListener( + "TranslationsParent:OfferTranslation", + this + ); + this.window.removeEventListener("TranslationsParent:LanguageState", this); + } + + onEvent(aEvent, aData, aCallback) { + debug`onEvent: event=${aEvent}, data=${aData}`; + switch (aEvent) { + case "GeckoView:Translations:Translate": + try { + const fromLanguage = + GeckoViewTranslationsSettings._checkValidLanguageTagAndMinimize( + aData.fromLanguage + ); + const toLanguage = + GeckoViewTranslationsSettings._checkValidLanguageTagAndMinimize( + aData.toLanguage + ); + try { + this.getActor("Translations").translate(fromLanguage, toLanguage); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError(`Could not translate: ${error}`); + } + } catch (error) { + aCallback.onError( + `The language tag ${aData.fromLanguage} or ${aData.toLanguage} is not valid: ${error}` + ); + } + break; + + case "GeckoView:Translations:RestorePage": + try { + this.getActor("Translations").restorePage(); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError(`Could not restore page: ${error}`); + } + break; + + case "GeckoView:Translations:GetNeverTranslateSite": + try { + var value = this.getActor("Translations").shouldNeverTranslateSite(); + aCallback.onSuccess(value); + } catch (error) { + aCallback.onError(`Could not set site setting: ${error}`); + } + break; + + case "GeckoView:Translations:SetNeverTranslateSite": + try { + this.getActor("Translations").setNeverTranslateSitePermissions( + aData.neverTranslate + ); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError(`Could not set site setting: ${error}`); + } + break; + } + } + + handleEvent(aEvent) { + debug`handleEvent: ${aEvent.type}`; + switch (aEvent.type) { + case "TranslationsParent:OfferTranslation": + this.eventDispatcher.sendRequest({ + type: "GeckoView:Translations:Offer", + }); + break; + case "TranslationsParent:LanguageState": + const { + detectedLanguages, + requestedTranslationPair, + error, + isEngineReady, + } = aEvent.detail.actor.languageState; + + const data = { + detectedLanguages, + requestedTranslationPair, + error, + isEngineReady, + }; + + this.eventDispatcher.sendRequest({ + type: "GeckoView:Translations:StateChange", + data, + }); + break; + } + } +} + +// Runtime functionality +export const GeckoViewTranslationsSettings = { + // Helper method for retrieving language setting state and corresponding string name. + _getLanguageSettingName(langTag) { + const isAlways = lazy.TranslationsParent.shouldAlwaysTranslateLanguage({ + docLangTag: langTag, + userLangTag: new Intl.Locale(Services.locale.appLocaleAsBCP47).language, + }); + const isNever = + lazy.TranslationsParent.shouldNeverTranslateLanguage(langTag); + // Default setting is offer. + var setting = "offer"; + + if (isAlways & !isNever) { + setting = "always"; + } + + if (isNever & !isAlways) { + setting = "never"; + } + return setting; + }, + + // Helper method to validate BCP 47 tags and reduced to only the language portion. For example, en-US will be reduced to en. + _checkValidLanguageTagAndMinimize(langTag) { + // Formats the langTag into a locale, may throw an error + var canonicalTag = new Intl.Locale(Intl.getCanonicalLocales(langTag)[0]); + return canonicalTag.minimize().toString(); + }, + /* eslint-disable complexity */ + async onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:Translations:IsTranslationEngineSupported": { + try { + aCallback.onSuccess( + lazy.TranslationsParent.getIsTranslationsEngineSupported() + ); + } catch (error) { + aCallback.onError( + `An issue occurred while checking the translations engine: ${error}` + ); + } + return; + } + case "GeckoView:Translations:PreferredLanguages": { + aCallback.onSuccess({ + preferredLanguages: lazy.TranslationsParent.getPreferredLanguages(), + }); + return; + } + case "GeckoView:Translations:ManageModel": { + const { language, operation, operationLevel } = aData; + if (operation === "delete") { + if (operationLevel === "all") { + lazy.TranslationsParent.deleteAllLanguageFiles().then( + function (value) { + aCallback.onSuccess(); + }, + function (error) { + aCallback.onError( + `COULD_NOT_DELETE - An issue occurred while deleting all language files: ${error}` + ); + } + ); + return; + } + if (operationLevel === "language") { + if (language === undefined) { + aCallback.onError( + `LANGUAGE_REQUIRED - A specified language is required language level operations.` + ); + return; + } + lazy.TranslationsParent.deleteLanguageFiles(language).then( + function (value) { + aCallback.onSuccess(); + }, + function (error) { + aCallback.onError( + `COULD_NOT_DELETE - An issue occurred while deleting a language file: ${error}` + ); + } + ); + return; + } + } + if (operation === "download") { + if (operationLevel === "all") { + lazy.TranslationsParent.downloadAllFiles().then( + function (value) { + aCallback.onSuccess(); + }, + function (error) { + aCallback.onError( + `COULD_NOT_DOWNLOAD - An issue occurred while downloading all language files: ${error}` + ); + } + ); + return; + } + if (operationLevel === "language") { + if (language === undefined) { + aCallback.onError( + `LANGUAGE_REQUIRED - A specified language is required language level operations.` + ); + return; + } + lazy.TranslationsParent.downloadLanguageFiles(language).then( + function (value) { + aCallback.onSuccess(); + }, + function (error) { + aCallback.onError( + `COULD_NOT_DOWNLOAD - An issue occurred while downloading a language files: ${error}` + ); + } + ); + } + } + break; + } + case "GeckoView:Translations:TranslationInformation": { + if ( + Cu.isInAutomation && + Services.prefs.getBoolPref( + "browser.translations.geckoview.enableAllTestMocks", + false + ) + ) { + const mockResult = { + languagePairs: [ + { fromLang: "en", toLang: "es" }, + { fromLang: "es", toLang: "en" }, + ], + fromLanguages: [ + { langTag: "en", displayName: "English" }, + { langTag: "es", displayName: "Spanish" }, + ], + toLanguages: [ + { langTag: "en", displayName: "English" }, + { langTag: "es", displayName: "Spanish" }, + ], + }; + aCallback.onSuccess(mockResult); + return; + } + + lazy.TranslationsParent.getSupportedLanguages().then( + function (value) { + aCallback.onSuccess(value); + }, + function (error) { + aCallback.onError( + `Could not retrieve requested information: ${error}` + ); + } + ); + break; + } + case "GeckoView:Translations:ModelInformation": { + if ( + Cu.isInAutomation && + Services.prefs.getBoolPref( + "browser.translations.geckoview.enableAllTestMocks", + false + ) + ) { + const mockResult = { + models: [ + { + langTag: "es", + displayName: "Spanish", + isDownloaded: false, + size: 12345, + }, + { + langTag: "de", + displayName: "German", + isDownloaded: false, + size: 12345, + }, + ], + }; + aCallback.onSuccess(mockResult); + return; + } + + // Helper function to process remote server records size and download state for GV use + async function _processLanguageModelData(language, remoteRecords) { + // Aggregate size of downloads, e.g., one language has many model binary files + var size = 0; + remoteRecords.forEach(item => { + size += parseInt(item.attachment.size); + }); + // Check if required files are downloaded + var isDownloaded = + await lazy.TranslationsParent.hasAllFilesForLanguage( + language.langTag + ); + var model = { + langTag: language.langTag, + displayName: language.displayName, + isDownloaded, + size, + }; + return model; + } + + // Main call to toolkit + lazy.TranslationsParent.getSupportedLanguages().then( + // Retrieve supported languages + async function (supportedLanguages) { + // Get language display information, + const languageList = + lazy.TranslationsParent.getLanguageList(supportedLanguages); + var results = []; + // For each language, process the related remote server model records + languageList.forEach(language => { + const recordsResult = + lazy.TranslationsParent.getRecordsForTranslatingToAndFromAppLanguage( + language.langTag, + false + ).then( + async function (records) { + return _processLanguageModelData(language, records); + }, + function (recordError) { + aCallback.onError( + `An issue occurred while aggregating information: ${recordError}` + ); + }, + language + ); + results.push(recordsResult); + }); + // Aggregate records + Promise.all(results).then(models => { + var response = []; + models.forEach(item => { + response.push(item); + }); + aCallback.onSuccess({ models: response }); + }); + }, + function (languageError) { + aCallback.onError( + `An issue occurred while retrieving the supported languages: ${languageError}` + ); + } + ); + break; + } + + case "GeckoView:Translations:GetLanguageSetting": { + if ( + Cu.isInAutomation && + Services.prefs.getBoolPref( + "browser.translations.geckoview.enableAllTestMocks", + false + ) + ) { + aCallback.onSuccess("always"); + return; + } + + try { + var setting = this._getLanguageSettingName(aData.language); + aCallback.onSuccess(setting); + } catch (error) { + aCallback.onError(`Could not get language setting: ${error}`); + } + break; + } + + case "GeckoView:Translations:GetLanguageSettings": { + if ( + Cu.isInAutomation && + Services.prefs.getBoolPref( + "browser.translations.geckoview.enableAllTestMocks", + false + ) + ) { + const mockResult = { + settings: [ + { langTag: "fr", displayName: "French", setting: "always" }, + { langTag: "de", displayName: "German", setting: "offer" }, + { langTag: "es", displayName: "Spanish", setting: "never" }, + ], + }; + aCallback.onSuccess(mockResult); + return; + } + + lazy.TranslationsParent.getSupportedLanguages().then( + function (supportedLanguages) { + const languageList = + lazy.TranslationsParent.getLanguageList(supportedLanguages); + + languageList.forEach(language => { + language.setting = this._getLanguageSettingName(language.langTag); + }); + + aCallback.onSuccess({ settings: languageList }); + }.bind(this), + function (error) { + aCallback.onError( + `Could not retrieve language setting information: ${error}` + ); + } + ); + break; + } + + case "GeckoView:Translations:SetLanguageSettings": { + var { language, languageSetting } = aData; + languageSetting = languageSetting.toLowerCase(); + + try { + language = this._checkValidLanguageTagAndMinimize(language); + } catch (error) { + aCallback.onError( + `The language tag ${language} is not valid: ${error}` + ); + return; + } + + const ALWAYS = lazy.TranslationsParent.ALWAYS_TRANSLATE_LANGS_PREF; + const NEVER = lazy.TranslationsParent.NEVER_TRANSLATE_LANGS_PREF; + + switch (languageSetting) { + case "always": { + try { + lazy.TranslationsParent.removeLangTagFromPref(language, NEVER); + lazy.TranslationsParent.addLangTagToPref(language, ALWAYS); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError( + `Could not set language preference to always: ${error}` + ); + } + break; + } + + case "never": { + try { + lazy.TranslationsParent.removeLangTagFromPref(language, ALWAYS); + lazy.TranslationsParent.addLangTagToPref(language, NEVER); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError( + `Could not set language preference to never: ${error}` + ); + } + break; + } + + case "offer": { + try { + // Reverting to default settings, so ensure nothing is set. + lazy.TranslationsParent.removeLangTagFromPref(language, NEVER); + lazy.TranslationsParent.removeLangTagFromPref(language, ALWAYS); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError( + `Could not set language preference to offer: ${error}` + ); + } + break; + } + } + break; + } + + case "GeckoView:Translations:GetNeverTranslateSpecifiedSites": + try { + const neverTranslateList = + lazy.TranslationsParent.listNeverTranslateSites(); + aCallback.onSuccess({ sites: neverTranslateList }); + } catch (error) { + aCallback.onError( + `Could not get list of never translate sites: ${error}` + ); + } + break; + + case "GeckoView:Translations:SetNeverTranslateSpecifiedSite": + try { + lazy.TranslationsParent.setNeverTranslateSiteByOrigin( + aData.neverTranslate, + aData.origin + ); + aCallback.onSuccess(); + } catch (error) { + aCallback.onError( + `Could not set never translate site setting: ${error}` + ); + } + break; + case "GeckoView:Translations:GetTranslateDownloadSize": { + if ( + Cu.isInAutomation && + Services.prefs.getBoolPref( + "browser.translations.geckoview.enableAllTestMocks", + false + ) + ) { + aCallback.onSuccess({ bytes: 1234567 }); + return; + } + + try { + const fromLanguage = this._checkValidLanguageTagAndMinimize( + aData.fromLanguage + ); + const toLanguage = this._checkValidLanguageTagAndMinimize( + aData.toLanguage + ); + + lazy.TranslationsParent.getExpectedTranslationDownloadSize( + fromLanguage, + toLanguage + ).then( + function (bytes) { + aCallback.onSuccess({ bytes }); + }, + function (error) { + aCallback.onError(`Could not get the download size: ${error}`); + } + ); + } catch (error) { + aCallback.onError( + `The language tag ${aData.fromLanguage} or ${aData.toLanguage} is not valid: ${error}` + ); + } + break; + } + } + }, +}; + +const { debug, warn } = GeckoViewTranslations.initLogging( + "GeckoViewTranslations" +); diff --git a/mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs b/mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs new file mode 100644 index 0000000000..ddfb40c1a1 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewUtils.sys.mjs @@ -0,0 +1,510 @@ +/* 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 { Log } from "resource://gre/modules/Log.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AndroidLog: "resource://gre/modules/AndroidLog.sys.mjs", + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +/** + * 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 } + ) { + ChromeUtils.defineLazyGetter(scope, name, _ => { + let ret = undefined; + if (module) { + ret = ChromeUtils.importESModule(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); + + ChromeUtils.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); + }, +}; + +ChromeUtils.defineLazyGetter( + GeckoViewUtils, + "IS_PARENT_PROCESS", + _ => Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT +); diff --git a/mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs b/mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs new file mode 100644 index 0000000000..ae821a3656 --- /dev/null +++ b/mobile/android/modules/geckoview/GeckoViewWebExtension.sys.mjs @@ -0,0 +1,1367 @@ +/* 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 { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +const PRIVATE_BROWSING_PERMISSION = { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], +}; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs", + AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs", + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", + Extension: "resource://gre/modules/Extension.sys.mjs", + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs", + ExtensionProcessCrashObserver: "resource://gre/modules/Extension.sys.mjs", + GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.sys.mjs", + Management: "resource://gre/modules/Extension.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +const { debug, warn } = GeckoViewUtils.initLogging("Console"); + +export 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 */ +export 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( + "GeckoView:WebExtension:PortMessageFromApp", + null, + aData.message + ); + this.messenger.sendPortMessage(this.id, holder); + break; + } + + case "GeckoView:WebExtension:PortDisconnect": { + this.messenger.sendPortDisconnect(this.id); + this.close(); + break; + } + } + } +} + +export 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 { + amoListingURL, + averageRating, + blocklistState, + creator, + description, + embedderDisabled, + fullDescription, + homepageURL, + icons, + id, + incognito, + isActive, + isBuiltin, + isCorrectlySigned, + isRecommended, + name, + optionsType, + optionsURL, + reviewCount, + reviewURL, + signedState, + sourceURI, + temporarilyInstalled, + userDisabled, + version, + } = 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"); + } + // Add-ons without an `isCorrectlySigned` property are correctly signed as + // they aren't the correct type for signing. + if (lazy.AddonSettings.REQUIRE_SIGNING && isCorrectlySigned === false) { + disabledFlags.push("signatureDisabled"); + } + if (lazy.AddonManager.checkCompatibility && !aAddon.isCompatible) { + disabledFlags.push("appVersionDisabled"); + } + const baseURL = policy ? policy.getURL() : ""; + const privateBrowsingAllowed = policy ? policy.privateBrowsingAllowed : false; + const promptPermissions = aPermissions + ? await filterPromptPermissions(aPermissions.permissions) + : []; + + let updateDate; + try { + updateDate = aAddon.updateDate?.toISOString(); + } catch { + // `installDate` is used as a fallback for `updateDate` but only when the + // add-on is installed. Before that, `installDate` might be undefined, + // which would cause `updateDate` (and `installDate`) to be an "invalid + // date". + updateDate = null; + } + + return { + webExtensionId: id, + locationURI: aSourceURI != null ? aSourceURI.spec : "", + isBuiltIn: isBuiltin, + webExtensionFlags: exportFlags(policy), + metaData: { + amoListingURL, + averageRating, + baseURL, + blocklistState, + creatorName, + creatorURL, + description, + disabledFlags, + downloadUrl: sourceURI?.displaySpec, + enabled: isActive, + fullDescription, + homepageURL, + icons, + incognito, + isRecommended, + name, + openOptionsPageInTab, + optionsPageURL: optionsURL, + origins: aPermissions ? aPermissions.origins : [], + privateBrowsingAllowed, + promptPermissions, + reviewCount, + reviewURL, + signedState, + temporary: temporarilyInstalled, + updateDate, + version, + }, + }; +} + +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 (ex) { + // install may have already failed or been cancelled + debug`Unable to cancel the install installId ${installId}, Error: ${ex}`; + // When we attempt to cancel an install but the cancellation fails for + // some reasons (e.g., because it is too late), we need to revert this + // boolean property to allow another cancellation to be possible. + // Otherwise, events like `onDownloadCancelled` won't resolve and that + // will cause problems in the embedder. + this.cancelling = false; + } + aCallback.onSuccess({ cancelled }); + break; + } + } + } + + onDownloadCancelled(aInstall) { + debug`onDownloadCancelled state=${aInstall.state}`; + // 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) { + debug`onDownloadFailed state=${aInstall.state}`; + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); + } + + onDownloadEnded() { + // Nothing to do + } + + onInstallCancelled(aInstall, aCancelledByUser) { + debug`onInstallCancelled state=${aInstall.state} cancelledByUser=${aCancelledByUser}`; + // Do not resolve we were told to CancelInstall, + // to prevent racing with that handler. + if (!this.cancelling) { + const { error: installError, state } = aInstall; + // An install can be cancelled by the user OR something else, e.g. when + // the blocklist prevents the install of a blocked add-on. + this.resolve({ installError, state, cancelledByUser: aCancelledByUser }); + } + } + + onInstallFailed(aInstall) { + debug`onInstallFailed state=${aInstall.state}`; + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); + } + + onInstallPostponed(aInstall) { + debug`onInstallPostponed state=${aInstall.state}`; + const { error: installError, state } = aInstall; + this.resolve({ installError, state }); + } + + async onInstallEnded(aInstall, aAddon) { + debug`onInstallEnded addonId=${aAddon.id}`; + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + aInstall.sourceURI + ); + this.resolve({ extension }); + } +} + +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; + } + } + } +} + +class AddonInstallObserver { + constructor() { + Services.obs.addObserver(this, "addon-install-failed"); + } + + async onInstallationFailed(aAddon, aAddonName, aError) { + // aAddon could be null if we have a network error where we can't download the xpi file. + // aAddon could also be a valid object without an ID when the xpi file is corrupt. + let extension = null; + if (aAddon?.id) { + extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + } + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnInstallationFailed", + extension, + addonName: aAddonName, + error: aError, + }); + } + + observe(aSubject, aTopic, aData) { + debug`observe ${aTopic}`; + switch (aTopic) { + case "addon-install-failed": { + aSubject.wrappedJSObject.installs.forEach(install => { + const { addon, error, name } = install; + // For some errors, we have a valid `addon` but not the `name` set on + // the `install` object yet so we check both here. + const addonName = name || addon?.name; + + this.onInstallationFailed(addon, addonName, error); + }); + break; + } + } + } +} + +new ExtensionPromptObserver(); +new AddonInstallObserver(); + +class AddonManagerListener { + constructor() { + lazy.AddonManager.addAddonListener(this); + // Some extension properties are not going to be available right away after the extension + // have been installed (e.g. in particular metaData.optionsPageURL), the GeckoView event + // dispatched from onExtensionReady listener will be providing updated extension metadata to + // the GeckoView side when it is actually going to be available. + this.onExtensionReady = this.onExtensionReady.bind(this); + lazy.Management.on("ready", this.onExtensionReady); + } + + async onExtensionReady(name, extInstance) { + // In xpcshell tests there wil be test extensions that trigger this event while the + // AddonManager has not been started at all, on the contrary on a regular browser + // instance the AddonManager is expected to be already fully started for an extension + // for the extension to be able to reach the "ready" state, and so we just silently + // early exit here if the AddonManager is not ready. + if (!lazy.AddonManager.isReady) { + return; + } + + debug`onExtensionReady ${extInstance.id}`; + + const addonWrapper = await lazy.AddonManager.getAddonByID(extInstance.id); + if (!addonWrapper) { + return; + } + + const extension = await exportExtension( + addonWrapper, + addonWrapper.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnReady", + extension, + }); + } + + async onDisabling(aAddon) { + debug`onDisabling ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnDisabling", + extension, + }); + } + + async onDisabled(aAddon) { + debug`onDisabled ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnDisabled", + extension, + }); + } + + async onEnabling(aAddon) { + debug`onEnabling ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnEnabling", + extension, + }); + } + + async onEnabled(aAddon) { + debug`onEnabled ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnEnabled", + extension, + }); + } + + async onUninstalling(aAddon) { + debug`onUninstalling ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnUninstalling", + extension, + }); + } + + async onUninstalled(aAddon) { + debug`onUninstalled ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnUninstalled", + extension, + }); + } + + async onInstalling(aAddon) { + debug`onInstalling ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnInstalling", + extension, + }); + } + + async onInstalled(aAddon) { + debug`onInstalled ${aAddon.id}`; + + const extension = await exportExtension( + aAddon, + aAddon.userPermissions, + /* aSourceURI */ null + ); + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnInstalled", + extension, + }); + } +} + +new AddonManagerListener(); + +class ExtensionProcessListener { + constructor() { + this.onExtensionProcessCrash = this.onExtensionProcessCrash.bind(this); + lazy.Management.on("extension-process-crash", this.onExtensionProcessCrash); + + lazy.EventDispatcher.instance.registerListener(this, [ + "GeckoView:WebExtension:EnableProcessSpawning", + "GeckoView:WebExtension:DisableProcessSpawning", + ]); + } + + async onEvent(aEvent, aData, aCallback) { + debug`onEvent ${aEvent} ${aData}`; + + switch (aEvent) { + case "GeckoView:WebExtension:EnableProcessSpawning": { + debug`Extension process crash -> re-enable process spawning`; + lazy.ExtensionProcessCrashObserver.enableProcessSpawning(); + break; + } + } + } + + async onExtensionProcessCrash(name, { childID, processSpawningDisabled }) { + debug`Extension process crash -> childID=${childID} processSpawningDisabled=${processSpawningDisabled}`; + + // When an extension process has crashed too many times, Gecko will set + // `processSpawningDisabled` and no longer allow the extension process + // spawning. We only want to send a request to the embedder when we are + // disabling the process spawning. If process spawning is still enabled + // then we short circuit and don't notify the embedder. + if (!processSpawningDisabled) { + return; + } + + lazy.EventDispatcher.instance.sendRequest({ + type: "GeckoView:WebExtension:OnDisabledProcessSpawning", + }); + } +} + +new ExtensionProcessListener(); + +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: nativeTab, docShell } = aWindow; + nativeTab.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: nativeTab.id, + isPrivate, + nativeTab, + }); + } + } +} + +export 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."); + } +} + +export 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, installMethod) { + const install = await lazy.AddonManager.getInstallForURL(aUri.spec, { + telemetryInfo: { + source: "geckoview-app", + method: installMethod || undefined, + }, + }); + const promise = new Promise(resolve => { + install.addListener( + new ExtensionInstallListener(resolve, install, aInstallId) + ); + }); + + lazy.AddonManager.installAddonFromAOM(null, aUri, 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) { + // Refresh the cached metadata when necessary. This allows us to always + // export relatively recent metadata to the embedder. + if (lazy.AddonRepository.isMetadataStale()) { + // We use a promise to avoid more than one call to `backgroundUpdateCheck()` + // when `updateWebExtension()` is called for multiple add-ons in parallel. + if (!this._promiseAddonRepositoryUpdate) { + this._promiseAddonRepositoryUpdate = + lazy.AddonRepository.backgroundUpdateCheck().finally(() => { + this._promiseAddonRepositoryUpdate = null; + }); + } + await this._promiseAddonRepositoryUpdate; + } + + // Early-return when extension updates are disabled. + if (!lazy.AddonManager.updateEnabled) { + return null; + } + + 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, installMethod } = 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, + installMethod + ); + 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.sys.mjs b/mobile/android/modules/geckoview/LoadURIDelegate.sys.mjs new file mode 100644 index 0000000000..5769fcafe8 --- /dev/null +++ b/mobile/android/modules/geckoview/LoadURIDelegate.sys.mjs @@ -0,0 +1,99 @@ +// -*- 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/. */ + +import { GeckoViewUtils } from "resource://gre/modules/GeckoViewUtils.sys.mjs"; + +const { debug, warn } = GeckoViewUtils.initLogging("LoadURIDelegate"); + +export 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.sys.mjs b/mobile/android/modules/geckoview/MediaUtils.sys.mjs new file mode 100644 index 0000000000..81dc35a567 --- /dev/null +++ b/mobile/android/modules/geckoview/MediaUtils.sys.mjs @@ -0,0 +1,79 @@ +/* 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/. */ + +export 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/metrics.yaml b/mobile/android/modules/geckoview/metrics.yaml new file mode 100644 index 0000000000..3b8cdd0dc9 --- /dev/null +++ b/mobile/android/modules/geckoview/metrics.yaml @@ -0,0 +1,153 @@ +# 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/. + +# Adding a new metric? We have docs for that! +# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html + +--- +$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0 +$tags: + - "GeckoView :: General" + +geckoview: + page_load_progress_time: + type: timing_distribution + time_unit: millisecond + telemetry_mirror: GV_PAGE_LOAD_PROGRESS_MS + description: > + Time between page load progress starts (0) and completion (100). + (Migrated from the geckoview metric of the same name). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1499418 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1580077 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1580077#c10 + notification_emails: + - android-probes@mozilla.com + expires: never + + page_load_time: + type: timing_distribution + time_unit: millisecond + telemetry_mirror: GV_PAGE_LOAD_MS + description: > + The time taken to load a page. This includes all static contents, no + dynamic content. + Loading of about: pages is not counted. + Back back navigation (sometimes via BFCache) is included which is a + source of bimodality due to the <50ms load times. + (Migrated from the geckoview metric of the same name). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1499418 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1584109 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1584109#c1 + notification_emails: + - android-probes@mozilla.com + expires: never + + page_reload_time: + type: timing_distribution + time_unit: millisecond + telemetry_mirror: GV_PAGE_RELOAD_MS + description: > + Time taken to reload a page. + This includes all static contents, no dynamic content. + Loading of about: pages is not counted. + (Migrated from the geckoview metric of the same name). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1549519 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1580077 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1580077#c10 + notification_emails: + - android-probes@mozilla.com + - sefeng@mozilla.com + - perf-telemetry-alerts@mozilla.com + expires: never + + document_site_origins: + type: custom_distribution + description: > + When a document is loaded, report the + number of [site origins](https://searchfox.org/ + mozilla-central/rev/ + 3300072e993ae05d50d5c63d815260367eaf9179/ + caps/nsIPrincipal.idl#264) of the entire browser + if it has been at least 5 minutes since last + time we collect this data. + (Migrated from the geckoview metric of the same name). + range_min: 0 + range_max: 100 + bucket_count: 50 + histogram_type: exponential + unit: number of site_origin + telemetry_mirror: FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_ALL_TABS + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1589700 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1589700#c5 + notification_emails: + - sefeng@mozilla.com + - perf-telemetry-alerts@mozilla.com + expires: never + + per_document_site_origins: + type: custom_distribution + description: > + When a document is unloaded, report the highest number of + [site origins](https://searchfox.org/ + mozilla-central/rev/ + 3300072e993ae05d50d5c63d815260367eaf9179/ + caps/nsIPrincipal.idl#264) loaded simultaneously in that + document. + (Migrated from the geckoview metric of the same name). + range_min: 0 + range_max: 100 + bucket_count: 50 + histogram_type: exponential + unit: number of site origins per document + telemetry_mirror: FX_NUMBER_OF_UNIQUE_SITE_ORIGINS_PER_DOCUMENT + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1603185 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1877576 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1603185#c13 + notification_emails: + - barret@mozilla.com + - perf-telemetry-alerts@mozilla.com + expires: never + + startup_runtime: + type: timing_distribution + time_unit: millisecond + description: > + The time taken to initialize GeckoRuntime. + (Migrated from the geckoview metric of the same name). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1499418 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1584109 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1584109#c1 + notification_emails: + - android-probes@mozilla.com + expires: never + + content_process_lifetime: + type: timing_distribution + time_unit: millisecond + description: > + The uptime of content processes. + (Migrated from the geckoview metric of the same name). + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1625325 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1625325#c2 + notification_emails: + - android-probes@mozilla.com + expires: never diff --git a/mobile/android/modules/geckoview/moz.build b/mobile/android/modules/geckoview/moz.build new file mode 100644 index 0000000000..9c2693c85e --- /dev/null +++ b/mobile/android/modules/geckoview/moz.build @@ -0,0 +1,45 @@ +# -*- 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.sys.mjs", + "BrowserUsageTelemetry.sys.mjs", + "ChildCrashHandler.sys.mjs", + "DelayedInit.sys.mjs", + "GeckoViewActorChild.sys.mjs", + "GeckoViewActorManager.sys.mjs", + "GeckoViewActorParent.sys.mjs", + "GeckoViewAutocomplete.sys.mjs", + "GeckoViewAutofill.sys.mjs", + "GeckoViewChildModule.sys.mjs", + "GeckoViewClipboardPermission.sys.mjs", + "GeckoViewConsole.sys.mjs", + "GeckoViewContent.sys.mjs", + "GeckoViewContentBlocking.sys.mjs", + "GeckoViewIdentityCredential.sys.mjs", + "GeckoViewMediaControl.sys.mjs", + "GeckoViewModule.sys.mjs", + "GeckoViewNavigation.sys.mjs", + "GeckoViewProcessHangMonitor.sys.mjs", + "GeckoViewProgress.sys.mjs", + "GeckoViewPushController.sys.mjs", + "GeckoViewRemoteDebugger.sys.mjs", + "GeckoViewSelectionAction.sys.mjs", + "GeckoViewSessionStore.sys.mjs", + "GeckoViewSettings.sys.mjs", + "GeckoViewStorageController.sys.mjs", + "GeckoViewTab.sys.mjs", + "GeckoViewTelemetry.sys.mjs", + "GeckoViewTestUtils.sys.mjs", + "GeckoViewTranslations.sys.mjs", + "GeckoViewUtils.sys.mjs", + "GeckoViewWebExtension.sys.mjs", + "LoadURIDelegate.sys.mjs", + "MediaUtils.sys.mjs", + "Messaging.sys.mjs", +] + +XPCSHELL_TESTS_MANIFESTS += ["test/xpcshell/xpcshell.toml"] diff --git a/mobile/android/modules/geckoview/test/xpcshell/test_ChildCrashHandler.js b/mobile/android/modules/geckoview/test/xpcshell/test_ChildCrashHandler.js new file mode 100644 index 0000000000..0e07937ed3 --- /dev/null +++ b/mobile/android/modules/geckoview/test/xpcshell/test_ChildCrashHandler.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ChildCrashHandler: "resource://gre/modules/ChildCrashHandler.sys.mjs", + EventDispatcher: "resource://gre/modules/Messaging.sys.mjs", +}); + +const { makeFakeAppDir } = ChromeUtils.importESModule( + "resource://testing-common/AppData.sys.mjs" +); + +add_setup(async function () { + await makeFakeAppDir(); + // The test harness sets MOZ_CRASHREPORTER_NO_REPORT, which disables crash + // reports. This test needs them enabled. + const noReport = Services.env.get("MOZ_CRASHREPORTER_NO_REPORT"); + Services.env.set("MOZ_CRASHREPORTER_NO_REPORT", ""); + + registerCleanupFunction(function () { + Services.env.set("MOZ_CRASHREPORTER_NO_REPORT", noReport); + }); +}); + +add_task(async function test_remoteType() { + const childID = 123; + const remoteType = "webIsolated=https://example.com"; + // Force-set a remote type for the process that we are going to "crash" next. + lazy.ChildCrashHandler.childMap.set(childID, remoteType); + + // Mock a process crash being notified. + const propertyBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + propertyBag.setPropertyAsBool("abnormal", true); + propertyBag.setPropertyAsAString("dumpID", "a-dump-id"); + + // Set up a listener to receive the crash report event emitted by the handler. + let listener; + const crashReportPromise = new Promise(resolve => { + listener = { + onEvent(aEvent, aData, aCallback) { + resolve([aEvent, aData]); + }, + }; + }); + lazy.EventDispatcher.instance.registerListener(listener, [ + "GeckoView:ChildCrashReport", + ]); + + // Simulate a crash. + lazy.ChildCrashHandler.observe(propertyBag, "ipc:content-shutdown", childID); + + const [aEvent, aData] = await crashReportPromise; + Assert.equal( + "GeckoView:ChildCrashReport", + aEvent, + "expected a child crash report" + ); + Assert.equal("webIsolated", aData?.remoteType, "expected remote type prefix"); +}); + +add_task(async function test_extensions_process_crash() { + const childID = 123; + const remoteType = "extension"; + // Force-set a remote type for the process that we are going to "crash" next. + lazy.ChildCrashHandler.childMap.set(childID, remoteType); + + // Mock a process crash being notified. + const propertyBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + propertyBag.setPropertyAsBool("abnormal", true); + propertyBag.setPropertyAsAString("dumpID", "a-dump-id"); + + // Set up a listener to receive the crash report event emitted by the handler. + let listener; + const crashReportPromise = new Promise(resolve => { + listener = { + onEvent(aEvent, aData, aCallback) { + resolve([aEvent, aData]); + }, + }; + }); + lazy.EventDispatcher.instance.registerListener(listener, [ + "GeckoView:ChildCrashReport", + ]); + + // Simulate a crash. + lazy.ChildCrashHandler.observe(propertyBag, "ipc:content-shutdown", childID); + + const [aEvent, aData] = await crashReportPromise; + Assert.equal( + "GeckoView:ChildCrashReport", + aEvent, + "expected a child crash report" + ); + Assert.equal("extension", aData?.remoteType, "expected remote type"); + Assert.equal("BACKGROUND_CHILD", aData?.processType, "expected process type"); +}); diff --git a/mobile/android/modules/geckoview/test/xpcshell/xpcshell.toml b/mobile/android/modules/geckoview/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..174a258d24 --- /dev/null +++ b/mobile/android/modules/geckoview/test/xpcshell/xpcshell.toml @@ -0,0 +1,5 @@ +[DEFAULT] +firefox-appdir = "browser" +run-if = ["os == 'android'"] + +["test_ChildCrashHandler.js"] -- cgit v1.2.3