Bug 1599580 - Implement install/uninstall extension. r=snorp,esawin

Differential Revision: https://phabricator.services.mozilla.com/D55730

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Agi Sferro 2019-12-05 23:16:10 +00:00
parent b6568ba03f
commit d87305d23c
10 changed files with 937 additions and 137 deletions

View File

@ -74,6 +74,7 @@ GeckoViewStartup.prototype = {
"GeckoView:PageAction:Click",
"GeckoView:RegisterWebExtension",
"GeckoView:UnregisterWebExtension",
"GeckoView:WebExtension:Get",
"GeckoView:WebExtension:Disable",
"GeckoView:WebExtension:Enable",
"GeckoView:WebExtension:Install",

View File

@ -1395,6 +1395,7 @@ package org.mozilla.geckoview {
field public final long flags;
field @NonNull public final String id;
field @NonNull public final String location;
field @Nullable public final WebExtension.MetaData metaData;
}
@AnyThread public static class WebExtension.Action {
@ -1416,6 +1417,16 @@ package org.mozilla.geckoview {
method @UiThread @Nullable default public GeckoResult<GeckoSession> onTogglePopup(@NonNull WebExtension, @NonNull WebExtension.Action);
}
public static class WebExtension.BlocklistStateFlags {
ctor public BlocklistStateFlags();
field public static final int BLOCKED = 2;
field public static final int NOT_BLOCKED = 0;
field public static final int OUTDATED = 3;
field public static final int SOFTBLOCKED = 1;
field public static final int VULNERABLE_NO_UPDATE = 5;
field public static final int VULNERABLE_UPDATE_AVAILABLE = 4;
}
public static class WebExtension.Flags {
ctor protected Flags();
field public static final long ALLOW_CONTENT_MESSAGING = 1L;
@ -1427,6 +1438,22 @@ package org.mozilla.geckoview {
method @AnyThread @NonNull public GeckoResult<Bitmap> get(int);
}
public static class WebExtension.InstallException extends Exception {
ctor protected InstallException();
field public final int code;
}
public static class WebExtension.InstallException.ErrorCodes {
ctor protected ErrorCodes();
field public static final int ERROR_CORRUPT_FILE = -3;
field public static final int ERROR_FILE_ACCESS = -4;
field public static final int ERROR_INCORRECT_HASH = -2;
field public static final int ERROR_INCORRECT_ID = -7;
field public static final int ERROR_NETWORK_FAILURE = -1;
field public static final int ERROR_SIGNEDSTATE_REQUIRED = -5;
field public static final int ERROR_UNEXPECTED_ADDON_TYPE = -6;
}
@UiThread public static interface WebExtension.MessageDelegate {
method @Nullable default public void onConnect(@NonNull WebExtension.Port);
method @Nullable default public GeckoResult<Object> onMessage(@NonNull String, @NonNull Object, @NonNull WebExtension.MessageSender);
@ -1443,6 +1470,22 @@ package org.mozilla.geckoview {
field @NonNull public final WebExtension webExtension;
}
public class WebExtension.MetaData {
ctor protected MetaData();
field public final int blocklistState;
field @Nullable public final String creatorName;
field @Nullable public final String creatorUrl;
field @Nullable public final String description;
field @Nullable public final String homepageUrl;
field @NonNull public final WebExtension.Icon icon;
field public final boolean isRecommended;
field @Nullable public final String name;
field @NonNull public final String[] origins;
field @NonNull public final String[] permissions;
field public final int signedState;
field @NonNull public final String version;
}
@UiThread public static class WebExtension.Port {
ctor protected Port();
method public void disconnect();
@ -1457,9 +1500,27 @@ package org.mozilla.geckoview {
method default public void onPortMessage(@NonNull Object, @NonNull WebExtension.Port);
}
public static class WebExtension.SignedStateFlags {
ctor public SignedStateFlags();
field public static final int MISSING = 0;
field public static final int PRELIMINARY = 1;
field public static final int PRIVILEGED = 4;
field public static final int SIGNED = 2;
field public static final int SYSTEM = 3;
field public static final int UNKNOWN = -1;
}
public class WebExtensionController {
method @UiThread @Nullable public WebExtensionController.PromptDelegate getPromptDelegate();
method @UiThread @Nullable public WebExtensionController.TabDelegate getTabDelegate();
method @NonNull @AnyThread public GeckoResult<WebExtension> install(@NonNull String);
method @UiThread public void setPromptDelegate(@Nullable WebExtensionController.PromptDelegate);
method @UiThread public void setTabDelegate(@Nullable WebExtensionController.TabDelegate);
method @NonNull @AnyThread public GeckoResult<Void> uninstall(@NonNull WebExtension);
}
@UiThread public static interface WebExtensionController.PromptDelegate {
method @Nullable default public GeckoResult<AllowOrDeny> onInstallPrompt(@NonNull WebExtension);
}
public static interface WebExtensionController.TabDelegate {

View File

@ -12,8 +12,11 @@ import org.hamcrest.core.StringEndsWith.endsWith
import org.hamcrest.core.IsEqual.equalTo
import org.json.JSONObject
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.geckoview.*
@ -37,6 +40,16 @@ class WebExtensionTest : BaseSessionTest() {
val MESSAGING_CONTENT: String = "resource://android/assets/web_extensions/messaging-content/"
}
@Before
fun setup() {
sessionRule.addExternalDelegateUntilTestEnd(
WebExtensionController.PromptDelegate::class,
sessionRule.runtime.webExtensionController::setPromptDelegate,
{ sessionRule.runtime.webExtensionController.promptDelegate = null },
object : WebExtensionController.PromptDelegate {}
)
}
@Test
fun registerWebExtension() {
mainSession.loadUri("example.com")
@ -73,6 +86,130 @@ class WebExtensionTest : BaseSessionTest() {
colorAfter as String, equalTo(""))
}
@Test
fun installWebExtension() {
mainSession.loadUri("example.com")
sessionRule.waitForPageStop()
// First let's check that the color of the border is empty before loading
// the WebExtension
val colorBefore = mainSession.evaluateJS("document.body.style.borderColor")
assertThat("The border color should be empty when loading without extensions.",
colorBefore as String, equalTo(""))
sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
@AssertCalled
override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
assertEquals(extension.metaData!!.description,
"Adds a red border to all webpages matching example.com.")
assertEquals(extension.metaData!!.name, "Borderify")
assertEquals(extension.metaData!!.version, "1.0")
// TODO: Bug 1601067
// assertEquals(extension.isBuiltIn, false)
// TODO: Bug 1599585
// assertEquals(extension.isEnabled, false)
assertEquals(extension.metaData!!.signedState,
WebExtension.SignedStateFlags.SIGNED)
assertEquals(extension.metaData!!.blocklistState,
WebExtension.BlocklistStateFlags.NOT_BLOCKED)
return GeckoResult.fromValue(AllowOrDeny.ALLOW)
}
})
val borderify = sessionRule.waitForResult(
sessionRule.runtime.webExtensionController.install(
"resource://android/assets/web_extensions/borderify.xpi"))
mainSession.reload()
sessionRule.waitForPageStop()
// Check that the WebExtension was applied by checking the border color
val color = mainSession.evaluateJS("document.body.style.borderColor")
assertThat("Content script should have been applied",
color as String, equalTo("red"))
// Unregister WebExtension and check again
sessionRule.waitForResult(sessionRule.runtime.webExtensionController.uninstall(borderify))
mainSession.reload()
sessionRule.waitForPageStop()
// Check that the WebExtension was not applied after being unregistered
val colorAfter = mainSession.evaluateJS("document.body.style.borderColor")
assertThat("Content script should have been applied",
colorAfter as String, equalTo(""))
}
private fun testInstallError(name: String, expectedError: Int) {
sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
@AssertCalled(count = 0)
override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
return GeckoResult.fromValue(AllowOrDeny.ALLOW)
}
})
sessionRule.waitForResult(
sessionRule.runtime.webExtensionController.install(
"resource://android/assets/web_extensions/$name")
.accept({
// We should not be able to install unsigned extensions
assertTrue(false)
}, { exception ->
val installException = exception as WebExtension.InstallException
assertEquals(installException.code, expectedError)
}))
}
@Test
fun installUnsignedExtensionSignatureNotRequired() {
sessionRule.setPrefsUntilTestEnd(mapOf(
"xpinstall.signatures.required" to false
))
sessionRule.delegateDuringNextWait(object : WebExtensionController.PromptDelegate {
override fun onInstallPrompt(extension: WebExtension): GeckoResult<AllowOrDeny> {
return GeckoResult.fromValue(AllowOrDeny.ALLOW)
}
})
val borderify = sessionRule.waitForResult(
sessionRule.runtime.webExtensionController.install(
"resource://android/assets/web_extensions/borderify-unsigned.xpi")
.then { extension ->
assertEquals(extension!!.metaData!!.signedState,
WebExtension.SignedStateFlags.MISSING)
assertEquals(extension.metaData!!.blocklistState,
WebExtension.BlocklistStateFlags.NOT_BLOCKED)
assertEquals(extension.metaData!!.name, "Borderify")
GeckoResult.fromValue(extension)
})
sessionRule.waitForResult(
sessionRule.runtime.webExtensionController.uninstall(borderify))
}
@Test
fun installUnsignedExtensionSignatureRequired() {
sessionRule.setPrefsUntilTestEnd(mapOf(
"xpinstall.signatures.required" to true
))
testInstallError("borderify-unsigned.xpi",
WebExtension.InstallException.ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED)
}
@Test
fun installExtensionFileNotFound() {
testInstallError("file-not-found.xpi",
WebExtension.InstallException.ErrorCodes.ERROR_NETWORK_FAILURE)
}
@Test
fun installExtensionMissingId() {
testInstallError("borderify-missing-id.xpi",
WebExtension.InstallException.ErrorCodes.ERROR_CORRUPT_FILE)
}
// This test
// - Registers a web extension
// - Listens for messages and waits for a message

View File

@ -54,8 +54,9 @@ public class WebExtension {
*/
public final @WebExtensionFlags long flags;
// TODO: make public
final MetaData metaData;
/** Provides information about this {@link WebExtension}. */
// TODO: move to @NonNull when we remove registerWebExtension
public final @Nullable MetaData metaData;
// TODO: make public
final boolean isBuiltIn;
@ -63,6 +64,26 @@ public class WebExtension {
// TODO: make public
final boolean isEnabled;
/** Called whenever a delegate is set or unset on this {@link WebExtension} instance.
/* package */ interface DelegateObserver {
void onMessageDelegate(final String nativeApp, final MessageDelegate delegate);
void onActionDelegate(final ActionDelegate delegate);
}
private WeakReference<DelegateObserver> mDelegateObserver = new WeakReference<>(null);
/* package */ void setDelegateObserver(final DelegateObserver observer) {
mDelegateObserver = new WeakReference<>(observer);
if (observer != null) {
// Notify observers of already attached delegates
for (final Map.Entry<String, MessageDelegate> entry : messageDelegates.entrySet()) {
observer.onMessageDelegate(entry.getKey(), entry.getValue());
}
observer.onActionDelegate(actionDelegate);
}
}
/**
* Delegates that handle messaging between this WebExtension and the app.
*/
@ -219,6 +240,10 @@ public class WebExtension {
@UiThread
public void setMessageDelegate(final @Nullable MessageDelegate messageDelegate,
final @NonNull String nativeApp) {
final DelegateObserver observer = mDelegateObserver.get();
if (observer != null) {
observer.onMessageDelegate(nativeApp, messageDelegate);
}
if (messageDelegate == null) {
messageDelegates.remove(nativeApp);
return;
@ -1022,6 +1047,58 @@ public class WebExtension {
}
}
/** Extension thrown when an error occurs during extension installation. */
public static class InstallException extends Exception {
public static class ErrorCodes {
/** The download failed due to network problems. */
public static final int ERROR_NETWORK_FAILURE = -1;
/** The downloaded file did not match the provided hash. */
public static final int ERROR_INCORRECT_HASH = -2;
/** The downloaded file seems to be corrupted in some way. */
public static final int ERROR_CORRUPT_FILE = -3;
/** An error occurred trying to write to the filesystem. */
public static final int ERROR_FILE_ACCESS = -4;
/** The extension must be signed and isn't. */
public static final int ERROR_SIGNEDSTATE_REQUIRED = -5;
/** The downloaded extension had a different type than expected. */
public static final int ERROR_UNEXPECTED_ADDON_TYPE = -6;
/** The extension did not have the expected ID. */
public static final int ERROR_INCORRECT_ID = -7;
/** For testing. */
protected ErrorCodes() {}
}
@Retention(RetentionPolicy.SOURCE)
@IntDef(value = {
ErrorCodes.ERROR_NETWORK_FAILURE,
ErrorCodes.ERROR_INCORRECT_HASH,
ErrorCodes.ERROR_CORRUPT_FILE,
ErrorCodes.ERROR_FILE_ACCESS,
ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED,
ErrorCodes.ERROR_UNEXPECTED_ADDON_TYPE,
ErrorCodes.ERROR_INCORRECT_ID
})
/* package */ @interface Codes {}
/** One of {@link ErrorCodes} that provides more information about this exception. */
public final @Codes int code;
/** For testing */
protected InstallException() {
this.code = ErrorCodes.ERROR_NETWORK_FAILURE;
}
@Override
public String toString() {
return "InstallException: " + code;
}
/* package */ InstallException(final @Codes int code) {
this.code = code;
}
}
/**
* Set the Action delegate for this WebExtension.
*
@ -1035,6 +1112,11 @@ public class WebExtension {
*/
@AnyThread
public void setActionDelegate(final @Nullable ActionDelegate delegate) {
final DelegateObserver observer = mDelegateObserver.get();
if (observer != null) {
observer.onActionDelegate(delegate);
}
actionDelegate = delegate;
final GeckoBundle bundle = new GeckoBundle(1);
@ -1044,15 +1126,27 @@ public class WebExtension {
"GeckoView:ActionDelegate:Attached", bundle);
}
// TODO: make public
// Keep in sync with AddonManager.jsm
static class SignedStateFlags {
final static int UNKNOWN = -1;
final static int MISSING = 0;
final static int PRELIMINARY = 1;
final static int SIGNED = 2;
final static int SYSTEM = 3;
final static int PRIVILEGED = 4;
/** Describes the signed status for a {@link WebExtension}.
*
* See <a href="https://support.mozilla.org/en-US/kb/add-on-signing-in-firefox">
* Add-on signing in Firefox.
* </a>
*/
public static class SignedStateFlags {
// Keep in sync with AddonManager.jsm
/** This extension may be signed but by a certificate that doesn't
* chain to our our trusted certificate. */
public final static int UNKNOWN = -1;
/** This extension is unsigned. */
public final static int MISSING = 0;
/** This extension has been preliminarily reviewed. */
public final static int PRELIMINARY = 1;
/** This extension has been fully reviewed. */
public final static int SIGNED = 2;
/** This extension is a system add-on. */
public final static int SYSTEM = 3;
/** This extension is signed with a "Mozilla Extensions" certificate. */
public final static int PRIVILEGED = 4;
/* package */ final static int LAST = PRIVILEGED;
}
@ -1062,15 +1156,27 @@ public class WebExtension {
SignedStateFlags.SIGNED, SignedStateFlags.SYSTEM, SignedStateFlags.PRIVILEGED})
@interface SignedState {}
// TODO: make public
// Keep in sync with nsIBlocklistService.idl
static class BlocklistStateFlags {
final static int NOT_BLOCKED = 0;
final static int SOFTBLOCKED = 1;
final static int BLOCKED = 2;
final static int OUTDATED = 3;
final static int VULNERABLE_UPDATE_AVAILABLE = 4;
final static int VULNERABLE_NO_UPDATE = 5;
/** Describes the blocklist state for a {@link WebExtension}.
* See <a href="https://support.mozilla.org/en-US/kb/add-ons-cause-issues-are-on-blocklist">
* Add-ons that cause stability or security issues are put on a blocklist
* </a>.
*/
public static class BlocklistStateFlags {
// Keep in sync with nsIBlocklistService.idl
/** This extension does not appear in the blocklist. */
public final static int NOT_BLOCKED = 0;
/** This extension is in the blocklist but the problem is not severe
* enough to warant forcibly blocking. */
public final static int SOFTBLOCKED = 1;
/** This extension should be blocked and never used. */
public final static int BLOCKED = 2;
/** This extension is considered outdated, and there is a known update
* available. */
public final static int OUTDATED = 3;
/** This extension is vulnerable and there is an update. */
public final static int VULNERABLE_UPDATE_AVAILABLE = 4;
/** This extension is vulnerable and there is no update. */
public final static int VULNERABLE_NO_UPDATE = 5;
}
@Retention(RetentionPolicy.SOURCE)
@ -1080,22 +1186,106 @@ public class WebExtension {
BlocklistStateFlags.VULNERABLE_NO_UPDATE})
@interface BlocklistState {}
// TODO: make public
class MetaData {
final Icon icon;
final String[] permissions;
final String[] origins;
final String name;
final String description;
final String version;
final String creatorName;
final String creatorUrl;
final String homepageUrl;
final String optionsPageUrl;
/** Provides information about a {@link WebExtension}. */
public class MetaData {
/** Main {@link Icon} branding for this {@link WebExtension}.
* Can be used when displaying prompts. */
public final @NonNull Icon icon;
/** API permissions requested or granted to this extension.
*
* Permission identifiers match entries in the manifest, see
* <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#API_permissions">
* API permissions
* </a>.
*/
public final @NonNull String[] permissions;
/** Host permissions requested or granted to this extension.
*
* See <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#Host_permissions">
* Host permissions
* </a>.
*/
public final @NonNull String[] origins;
/** Branding name for this extension.
*
* See <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/name">
* manifest.json/name
* </a>
*/
public final @Nullable String name;
/** Branding description for this extension. This string will be
* localized using the current GeckoView language setting.
*
* See <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/description">
* manifest.json/description
* </a>
*/
public final @Nullable String description;
/** Version string for this extension.
*
* See <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version">
* manifest.json/version
* </a>
*/
public final @NonNull String version;
/** Creator name as provided in the manifest.
*
* See <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer">
* manifest.json/developer
* </a>
*/
public final @Nullable String creatorName;
/** Creator url as provided in the manifest.
*
* See <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer">
* manifest.json/developer
* </a>
*/
public final @Nullable String creatorUrl;
/** Homepage url as provided in the manifest.
*
* See <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/homepage_url">
* manifest.json/homepage_url
* </a>
*/
public final @Nullable String homepageUrl;
/** Options page as provided in the manifest.
*
* See <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui">
* manifest.json/options_ui
* </a>
*/
// TODO: Bug 1598792
final @Nullable String optionsPageUrl;
/** Whether the options page should be open in a Tab or not.
*
* See <a href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui#Syntax">
* manifest.json/options_ui#Syntax
* </a>
*/
// TODO: Bug 1598792
final boolean openOptionsPageInTab;
final boolean isRecommended;
final @BlocklistState int blocklistState;
final @SignedState int signedState;
/** Whether or not this is a recommended extension.
*
* See <a href="https://blog.mozilla.org/firefox/firefox-recommended-extensions/">
* Recommended Extensions program
* </a>
*/
public final boolean isRecommended;
/** Blocklist status for this extension.
*
* See <a href="https://support.mozilla.org/en-US/kb/add-ons-cause-issues-are-on-blocklist">
* Add-ons that cause stability or security issues are put on a blocklist
* </a>.
*/
public final @BlocklistState int blocklistState;
/** Signed status for this extension.
*
* See <a href="https://support.mozilla.org/en-US/kb/add-on-signing-in-firefox">
* Add-on signing in Firefox.
* </a>.
*/
public final @SignedState int signedState;
/** Override for testing. */
protected MetaData() {

View File

@ -1,5 +1,6 @@
package org.mozilla.geckoview;
import android.support.annotation.AnyThread;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.UiThread;
@ -22,8 +23,6 @@ public class WebExtensionController {
private GeckoRuntime mRuntime;
private boolean mHandlerRegistered = false;
private TabDelegate mTabDelegate;
private PromptDelegate mPromptDelegate;
@ -33,12 +32,18 @@ public class WebExtensionController {
public GeckoResult<WebExtension> get(final String id) {
final WebExtension extension = mData.get(id);
if (extension == null) {
if (BuildConfig.DEBUG) {
// TODO: Bug 1582185 Some gecko tests install WebExtensions that we
// don't know about and cause this to trigger.
// throw new RuntimeException("Could not find extension: " + extensionId);
}
Log.e(LOGTAG, "Could not find extension: " + id);
final WebExtensionResult result = new WebExtensionResult("extension");
final GeckoBundle bundle = new GeckoBundle(1);
bundle.putString("extensionId", id);
EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:Get",
bundle, result);
return result.then(ext -> {
mData.put(ext.id, ext);
return GeckoResult.fromValue(ext);
});
}
return GeckoResult.fromValue(extension);
@ -48,7 +53,6 @@ public class WebExtensionController {
mData.remove(id);
}
// TODO: remove once registerWebExtension is removed
public void put(final String id, final WebExtension extension) {
mData.put(id, extension);
}
@ -62,19 +66,54 @@ public class WebExtensionController {
// Avoids exposing listeners to the API
private class Internals implements BundleEventListener,
WebExtension.Port.DisconnectDelegate {
WebExtension.Port.DisconnectDelegate,
WebExtension.DelegateObserver {
private boolean mMessageListenersAttached = false;
private boolean mActionListenersAttached = false;
@Override
// BundleEventListener
public void handleMessage(final String event, final GeckoBundle message,
final EventCallback callback) {
WebExtensionController.this.handleMessage(event, message, callback, null);
}
@Override
// WebExtension.Port.DisconnectDelegate
public void onDisconnectFromApp(final WebExtension.Port port) {
// If the port has been disconnected from the app side, we don't need to notify anyone and
// we just need to remove it from our list of ports.
mPorts.remove(port.id);
}
@Override
// WebExtension.DelegateObserver
public void onMessageDelegate(final String nativeApp,
final WebExtension.MessageDelegate delegate) {
if (delegate != null && !mMessageListenersAttached) {
EventDispatcher.getInstance().registerUiThreadListener(
this,
"GeckoView:WebExtension:Message",
"GeckoView:WebExtension:PortMessage",
"GeckoView:WebExtension:Connect",
"GeckoView:WebExtension:Disconnect");
mMessageListenersAttached = true;
}
}
@Override
// WebExtension.DelegateObserver
public void onActionDelegate(final WebExtension.ActionDelegate delegate) {
if (delegate != null && !mActionListenersAttached) {
EventDispatcher.getInstance().registerUiThreadListener(
this,
"GeckoView:BrowserAction:Update",
"GeckoView:BrowserAction:OpenPopup",
"GeckoView:PageAction:Update",
"GeckoView:PageAction:OpenPopup");
mActionListenersAttached = true;
}
}
}
public interface TabDelegate {
@ -139,31 +178,64 @@ public class WebExtensionController {
mTabDelegate = delegate;
}
// TODO: make public
interface PromptDelegate {
default GeckoResult<AllowOrDeny> onInstallPrompt(WebExtension extension) {
/**
* This delegate will be called whenever an extension is about to be installed or it needs
* new permissions, e.g during an update or because it called <code>permissions.request</code>
*/
@UiThread
public interface PromptDelegate {
/**
* Called whenever a new extension is being installed. This is intended as an
* opportunity for the app to prompt the user for the permissions required by
* this extension.
*
* @param extension The {@link WebExtension} that is about to be installed.
* You can use {@link WebExtension#metaData} to gather information
* about this extension when building the user prompt dialog.
* @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW}
* if this extension should be installed or {@link AllowOrDeny#DENY DENY} if
* this extension should not be installed. A null value will be interpreted as
* {@link AllowOrDeny#DENY DENY}.
*/
@Nullable
default GeckoResult<AllowOrDeny> onInstallPrompt(final @NonNull WebExtension extension) {
return null;
}
/*
TODO: Bug 1599581
default GeckoResult<AllowOrDeny> onUpdatePrompt(
WebExtension currentlyInstalled,
WebExtension updatedExtension,
String[] newPermissions) {
return null;
}
TODO: Bug 1601420
default GeckoResult<AllowOrDeny> onOptionalPrompt(
WebExtension extension,
String[] optionalPermissions) {
return null;
}
} */
}
// TODO: make public
PromptDelegate getPromptDelegate() {
/**
* @return the current {@link PromptDelegate} instance.
* @see PromptDelegate
*/
@UiThread
@Nullable
public PromptDelegate getPromptDelegate() {
return mPromptDelegate;
}
// TODO: make public
void setPromptDelegate(final PromptDelegate delegate) {
/** Set the {@link PromptDelegate} for this instance. This delegate will be used
* to be notified whenever an extension is being installed or needs new permissions.
*
* @param delegate the delegate instance.
* @see PromptDelegate
*/
@UiThread
public void setPromptDelegate(final @Nullable PromptDelegate delegate) {
if (delegate == null && mPromptDelegate != null) {
EventDispatcher.getInstance().unregisterUiThreadListener(
mInternals,
@ -183,7 +255,8 @@ public class WebExtensionController {
mPromptDelegate = delegate;
}
private static class WebExtensionResult extends CallbackResult<WebExtension> {
private static class WebExtensionResult extends GeckoResult<WebExtension>
implements EventCallback {
private final String mFieldName;
public WebExtensionResult(final String fieldName) {
@ -195,11 +268,59 @@ public class WebExtensionController {
final GeckoBundle bundle = (GeckoBundle) response;
complete(new WebExtension(bundle.getBundle(mFieldName)));
}
@Override
public void sendError(final Object response) {
if (response instanceof GeckoBundle
&& ((GeckoBundle) response).containsKey("installError")) {
final GeckoBundle bundle = (GeckoBundle) response;
final int errorCode = bundle.getInt("installError");
completeExceptionally(new WebExtension.InstallException(errorCode));
} else {
completeExceptionally(new Exception(response.toString()));
}
}
}
// TODO: make public
GeckoResult<WebExtension> install(final String uri) {
final CallbackResult<WebExtension> result = new WebExtensionResult("extension");
/**
* Install an extension.
*
* An installed extension will persist and will be available even when restarting the
* {@link GeckoRuntime}.
*
* Installed extensions through this method need to be signed by Mozilla, see
* <a href="https://extensionworkshop.com/documentation/publish/signing-and-distribution-overview/#distributing-your-addon">
* Distributing your add-on
* </a>.
*
* When calling this method, the GeckoView library will download the extension, validate
* its manifest and signature, and give you an opportunity to verify its permissions through
* {@link PromptDelegate#installPrompt}, you can use this method to prompt the user if
* appropriate.
*
* @param uri URI to the extension's <code>.xpi</code> package. This can be a remote
* <code>https:</code> URI or a local <code>file:</code> or <code>resource:</code>
* URI. Note: the app needs the appropriate permissions for local URIs.
*
* @return A {@link GeckoResult} that will complete when the installation process finishes.
* For successful installations, the GeckoResult will return the {@link WebExtension}
* object that you can use to set delegates and retrieve information about the
* WebExtension using {@link WebExtension#metaData}.
*
* If an error occurs during the installation process, the GeckoResult will complete
* exceptionally with a
* {@link WebExtension.InstallException InstallException} that will contain
* the relevant error code in
* {@link WebExtension.InstallException#code InstallException#code}.
*
* @see PromptDelegate#installPrompt
* @see WebExtension.InstallException.ErrorCodes
* @see WebExtension#metaData
*/
@NonNull
@AnyThread
public GeckoResult<WebExtension> install(final @NonNull String uri) {
final WebExtensionResult result = new WebExtensionResult("extension");
final GeckoBundle bundle = new GeckoBundle(1);
bundle.putString("locationUri", uri);
@ -207,12 +328,15 @@ public class WebExtensionController {
EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:Install",
bundle, result);
return result;
return result.then(extension -> {
registerWebExtension(extension);
return GeckoResult.fromValue(extension);
});
}
// TODO: make public
// TODO: Bug 1601067 make public
GeckoResult<WebExtension> installBuiltIn(final String uri) {
final CallbackResult<WebExtension> result = new WebExtensionResult("extension");
final WebExtensionResult result = new WebExtensionResult("extension");
final GeckoBundle bundle = new GeckoBundle(1);
bundle.putString("locationUri", uri);
@ -220,11 +344,25 @@ public class WebExtensionController {
EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:InstallBuiltIn",
bundle, result);
return result;
return result.then(extension -> {
registerWebExtension(extension);
return GeckoResult.fromValue(extension);
});
}
// TODO: make public
GeckoResult<Void> uninstall(final WebExtension extension) {
/**
* Uninstall an extension.
*
* Uninstalling an extension will remove it from the current {@link GeckoRuntime} instance,
* delete all its data and trigger a request to close all extension pages currently open.
*
* @param extension The {@link WebExtension} to be uninstalled.
*
* @return A {@link GeckoResult} that will complete when the uninstall process is completed.
*/
@NonNull
@AnyThread
public GeckoResult<Void> uninstall(final @NonNull WebExtension extension) {
final CallbackResult<Void> result = new CallbackResult<Void>() {
@Override
public void sendSuccess(final Object response) {
@ -232,6 +370,8 @@ public class WebExtensionController {
}
};
unregisterWebExtension(extension);
final GeckoBundle bundle = new GeckoBundle(1);
bundle.putString("webExtensionId", extension.id);
@ -241,9 +381,9 @@ public class WebExtensionController {
return result;
}
// TODO: make public
// TODO: Bug 1599585 make public
GeckoResult<WebExtension> enable(final WebExtension extension) {
final CallbackResult<WebExtension> result = new WebExtensionResult("extension");
final WebExtensionResult result = new WebExtensionResult("extension");
final GeckoBundle bundle = new GeckoBundle(1);
bundle.putString("webExtensionId", extension.id);
@ -251,12 +391,15 @@ public class WebExtensionController {
EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:Enable",
bundle, result);
return result;
return result.then(newExtension -> {
registerWebExtension(newExtension);
return GeckoResult.fromValue(newExtension);
});
}
// TODO: make public
// TODO: Bug 1599585 make public
GeckoResult<WebExtension> disable(final WebExtension extension) {
final CallbackResult<WebExtension> result = new WebExtensionResult("extension");
final WebExtensionResult result = new WebExtensionResult("extension");
final GeckoBundle bundle = new GeckoBundle(1);
bundle.putString("webExtensionId", extension.id);
@ -264,10 +407,13 @@ public class WebExtensionController {
EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:Disable",
bundle, result);
return result;
return result.then(newExtension -> {
registerWebExtension(newExtension);
return GeckoResult.fromValue(newExtension);
});
}
// TODO: make public
// TODO: Bug 1600742 make public
GeckoResult<List<WebExtension>> listInstalled() {
final CallbackResult<List<WebExtension>> result = new CallbackResult<List<WebExtension>>() {
@Override
@ -276,7 +422,9 @@ public class WebExtensionController {
.getBundleArray("extensions");
final List<WebExtension> list = new ArrayList<>(bundles.length);
for (GeckoBundle bundle : bundles) {
list.add(new WebExtension(bundle));
final WebExtension extension = new WebExtension(bundle);
registerWebExtension(extension);
list.add(extension);
}
complete(list);
@ -289,9 +437,9 @@ public class WebExtensionController {
return result;
}
// TODO: make public
// TODO: Bug 1599581 make public
GeckoResult<WebExtension> update(final WebExtension extension) {
final CallbackResult<WebExtension> result = new WebExtensionResult("extension");
final WebExtensionResult result = new WebExtensionResult("extension");
final GeckoBundle bundle = new GeckoBundle(1);
bundle.putString("webExtensionId", extension.id);
@ -299,7 +447,10 @@ public class WebExtensionController {
EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:Update",
bundle, result);
return result;
return result.then(newExtension -> {
registerWebExtension(newExtension);
return GeckoResult.fromValue(newExtension);
});
}
/* package */ WebExtensionController(final GeckoRuntime runtime) {
@ -307,23 +458,7 @@ public class WebExtensionController {
}
/* package */ void registerWebExtension(final WebExtension webExtension) {
if (!mHandlerRegistered) {
EventDispatcher.getInstance().registerUiThreadListener(
mInternals,
"GeckoView:WebExtension:Message",
"GeckoView:WebExtension:PortMessage",
"GeckoView:WebExtension:Connect",
"GeckoView:WebExtension:Disconnect",
// {Browser,Page}Actions
"GeckoView:BrowserAction:Update",
"GeckoView:BrowserAction:OpenPopup",
"GeckoView:PageAction:Update",
"GeckoView:PageAction:OpenPopup"
);
mHandlerRegistered = true;
}
webExtension.setDelegateObserver(mInternals);
mExtensions.put(webExtension.id, webExtension);
}
@ -350,6 +485,9 @@ public class WebExtensionController {
} else if ("GeckoView:PageAction:OpenPopup".equals(event)) {
openPopup(message, session, WebExtension.Action.TYPE_PAGE_ACTION);
return;
} else if ("GeckoView:WebExtension:InstallPrompt".equals(event)) {
installPrompt(message, callback);
return;
}
final String nativeApp = message.getString("nativeApp");
@ -381,6 +519,42 @@ public class WebExtensionController {
});
}
private void installPrompt(final GeckoBundle message, final EventCallback callback) {
final GeckoBundle extensionBundle = message.getBundle("extension");
if (extensionBundle == null || !extensionBundle.containsKey("webExtensionId")
|| !extensionBundle.containsKey("locationURI")) {
if (BuildConfig.DEBUG) {
throw new RuntimeException("Missing webExtensionId or locationURI");
}
Log.e(LOGTAG, "Missing webExtensionId or locationURI");
return;
}
final WebExtension extension = new WebExtension(extensionBundle);
if (mPromptDelegate == null) {
Log.e(LOGTAG, "Tried to install extension " + extension.id +
" but no delegate is registered");
return;
}
final GeckoResult<AllowOrDeny> promptResponse = mPromptDelegate.onInstallPrompt(extension);
if (promptResponse == null) {
return;
}
promptResponse.accept(allowOrDeny -> {
GeckoBundle response = new GeckoBundle(1);
if (AllowOrDeny.ALLOW.equals(allowOrDeny)) {
response.putBoolean("allow", true);
} else {
response.putBoolean("allow", false);
}
callback.sendSuccess(response);
});
}
private void newTab(final GeckoBundle message, final EventCallback callback) {
if (mTabDelegate == null) {
callback.sendSuccess(null);
@ -411,8 +585,12 @@ public class WebExtensionController {
return;
}
mExtensions.get(message.getString("extensionId")).then(extension ->
mTabDelegate.onCloseTab(extension, session)
mExtensions.get(message.getString("extensionId")).then(
extension -> mTabDelegate.onCloseTab(extension, session),
// On uninstall, we close all extension pages, in that case
// the extension object may be gone already so we can't
// send it to the delegate
exception -> mTabDelegate.onCloseTab(null, session)
).accept(value -> {
if (value == AllowOrDeny.ALLOW) {
callback.sendSuccess(null);
@ -425,6 +603,7 @@ public class WebExtensionController {
/* package */ void unregisterWebExtension(final WebExtension webExtension) {
mExtensions.remove(webExtension.id);
webExtension.setDelegateObserver(null);
// Some ports may still be open so we need to go through the list and close all of the
// ports tied to this web extension

View File

@ -13,6 +13,13 @@ exclude: true
⚠️ breaking change
## v73
- Added [`WebExtensionController.install`][73.1] and [`uninstall`][73.2] to
manage installed extensions
[73.1]: {{javadoc_uri}}/WebExtensionController.html#install-java.lang.String-
[73.2]: {{javadoc_uri}}/WebExtensionController.html#uninstall-org.mozilla.geckoview.WebExtension-
## v72
- Added [`GeckoSession.NavigationDelegate.LoadRequest#hasUserGesture`][72.1]. This indicates
if a load was requested while a user gesture was active (e.g., a tap).
@ -476,4 +483,4 @@ exclude: true
[65.24]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport-android.content.Context-android.os.Bundle-java.lang.String-
[65.25]: {{javadoc_uri}}/GeckoResult.html
[api-version]: 4c9f04038d8478206efac05b518920819faeacea
[api-version]: 5856cdf682140fafdd09d74dbc004bf0b6bb7398

View File

@ -19,12 +19,20 @@ const { GeckoViewUtils } = ChromeUtils.import(
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyModuleGetters(this, {
AddonManager: "resource://gre/modules/AddonManager.jsm",
EventDispatcher: "resource://gre/modules/Messaging.jsm",
Extension: "resource://gre/modules/Extension.jsm",
ExtensionChild: "resource://gre/modules/ExtensionChild.jsm",
GeckoViewTabBridge: "resource://gre/modules/GeckoViewTab.jsm",
});
XPCOMUtils.defineLazyServiceGetter(
this,
"mimeService",
"@mozilla.org/mime;1",
"nsIMIMEService"
);
const { debug, warn } = GeckoViewUtils.initLogging("Console"); // eslint-disable-line no-unused-vars
/** Provides common logic between page and browser actions */
@ -231,6 +239,133 @@ class GeckoViewConnection {
}
}
function exportExtension(aAddon, aPermissions, aSourceURI) {
const { origins, permissions } = aPermissions;
const {
creator,
description,
homepageURL,
signedState,
name,
icons,
version,
optionsURL,
optionsBrowserStyle,
isRecommended,
blocklistState,
isActive,
isBuiltin,
id,
} = aAddon;
let creatorName = null;
let creatorURL = null;
if (creator) {
const { name, url } = creator;
creatorName = name;
creatorURL = url;
}
const openOptionsPageInTab =
optionsBrowserStyle === AddonManager.OPTIONS_TYPE_TAB;
return {
webExtensionId: id,
locationURI: aSourceURI != null ? aSourceURI.spec : "",
isEnabled: isActive,
isBuiltIn: isBuiltin,
metaData: {
permissions,
origins,
description,
version,
creatorName,
creatorURL,
homepageURL,
name,
optionsPageURL: optionsURL,
openOptionsPageInTab,
isRecommended,
blocklistState,
signedState,
icons,
},
};
}
class ExtensionInstallListener {
constructor(aResolve) {
this.resolve = aResolve;
}
onDownloadCancelled(aInstall) {
const { error: installError } = aInstall;
this.resolve({ installError });
}
onDownloadFailed(aInstall) {
const { error: installError } = aInstall;
this.resolve({ installError });
}
onDownloadEnded() {
// Nothing to do
}
onInstallCancelled(aInstall) {
const { error: installError } = aInstall;
this.resolve({ installError });
}
onInstallFailed(aInstall) {
const { error: installError } = aInstall;
this.resolve({ installError });
}
onInstallEnded(aInstall, aAddon) {
const extension = exportExtension(
aAddon,
aAddon.userPermissions,
aInstall.sourceURI
);
this.resolve({ extension });
}
}
class ExtensionPromptObserver {
constructor() {
Services.obs.addObserver(this, "webextension-permission-prompt");
}
async permissionPrompt(aInstall, aAddon, aInfo) {
const { sourceURI } = aInstall;
const { permissions } = aInfo;
const extension = exportExtension(aAddon, permissions, sourceURI);
const response = await EventDispatcher.instance.sendRequestForResult({
type: "GeckoView:WebExtension:InstallPrompt",
extension,
});
if (response.allow) {
aInfo.resolve();
} else {
aInfo.reject();
}
}
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;
}
}
}
}
new ExtensionPromptObserver();
var GeckoViewWebExtension = {
async registerWebExtension(aId, aUri, allowContentMessaging, aCallback) {
const params = {
@ -268,44 +403,106 @@ var GeckoViewWebExtension = {
},
async extensionById(aId) {
const scope = this.extensionScopes.get(aId);
let scope = this.extensionScopes.get(aId);
if (!scope) {
return null;
// Check if this is an installed extension we haven't seen yet
const addon = await AddonManager.getAddonByID(aId);
if (!addon) {
debug`Could not find extension with id=${aId}`;
return null;
}
scope = {
allowContentMessaging: false,
extension: addon,
};
}
return scope.extension;
},
async installWebExtension(aUri) {
const install = await AddonManager.getInstallForURL(aUri.spec);
const promise = new Promise(resolve => {
install.addListener(new ExtensionInstallListener(resolve));
});
const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
const mimeType = mimeService.getTypeFromURI(aUri);
AddonManager.installAddonFromWebpage(
mimeType,
null,
systemPrincipal,
install
);
return promise;
},
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 extension = await this.extensionById(aId);
if (!extension) {
return;
}
const browserAction = this.browserActions.get(extension);
if (!browserAction) {
return;
}
browserAction.click();
},
async pageActionClick(aId) {
const extension = await this.extensionById(aId);
if (!extension) {
return;
}
const pageAction = this.pageActions.get(extension);
if (!pageAction) {
return;
}
pageAction.click();
},
async actionDelegateAttached(aId) {
const extension = await this.extensionById(aId);
if (!extension) {
return;
}
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 onEvent(aEvent, aData, aCallback) {
debug`onEvent ${aEvent} ${aData}`;
switch (aEvent) {
case "GeckoView:BrowserAction:Click": {
const extension = await this.extensionById(aData.extensionId);
if (!extension) {
return;
}
const browserAction = this.browserActions.get(extension);
if (!browserAction) {
return;
}
browserAction.click();
this.browserActionClick(aData.extensionId);
break;
}
case "GeckoView:PageAction:Click": {
const extension = await this.extensionById(aData.extensionId);
if (!extension) {
return;
}
const pageAction = this.pageActions.get(extension);
if (!pageAction) {
return;
}
pageAction.click();
this.pageActionClick(aData.extensionId);
break;
}
case "GeckoView:RegisterWebExtension": {
@ -353,21 +550,7 @@ var GeckoViewWebExtension = {
}
case "GeckoView:ActionDelegate:Attached": {
const extension = await this.extensionById(aData.extensionId);
if (!extension) {
return;
}
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);
}
this.actionDelegateAttached(aData.extensionId);
break;
}
@ -383,9 +566,44 @@ var GeckoViewWebExtension = {
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: exportExtension(
extension,
extension.userPermissions,
/* aSourceURI */ null
),
});
break;
}
case "GeckoView:WebExtension:Install": {
// TODO
aCallback.onError(`Not implemented`);
const uri = Services.io.newURI(aData.locationUri);
if (uri == null) {
aCallback.onError(`Could not parse uri: ${uri}`);
return;
}
try {
const result = await this.installWebExtension(uri);
if (result.extension) {
aCallback.onSuccess(result);
} else {
aCallback.onError(result);
}
} catch (ex) {
debug`Install exception error ${ex}`;
aCallback.onError(`Unexpected error: ${ex}`);
}
break;
}
@ -396,8 +614,15 @@ var GeckoViewWebExtension = {
}
case "GeckoView:WebExtension:Uninstall": {
// TODO
aCallback.onError(`Not implemented`);
try {
await this.uninstallWebExtension(aData.webExtensionId);
aCallback.onSuccess();
} catch (ex) {
debug`Failed uninstall ${ex}`;
aCallback.onError(
`This extension cannot be uninstalled. Error: ${ex}.`
);
}
break;
}