gecko-dev/toolkit/mozapps/extensions/content/extensions.js

582 lines
15 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/* import-globals-from ../../../content/customElements.js */
/* import-globals-from aboutaddonsCommon.js */
/* globals ProcessingInstruction */
/* exported loadView */
const { AddonManager } = ChromeUtils.import(
"resource://gre/modules/AddonManager.jsm"
);
ChromeUtils.defineModuleGetter(
this,
"AMTelemetry",
"resource://gre/modules/AddonManager.jsm"
);
var gViewDefault = "addons://discover/";
document.addEventListener("load", initialize, true);
window.addEventListener("unload", shutdown);
var gPendingInitializations = 1;
Object.defineProperty(this, "gIsInitializing", {
get: () => gPendingInitializations > 0,
});
function initialize(event) {
// XXXbz this listener gets _all_ load events for all nodes in the
// document... but relies on not being called "too early".
if (event.target instanceof ProcessingInstruction) {
return;
}
document.removeEventListener("load", initialize, true);
if (!isDiscoverEnabled()) {
gViewDefault = "addons://list/extension";
}
// Support focusing the search bar from the XUL document.
document.addEventListener("keypress", e => {
getHtmlBrowser()
.contentDocument.querySelector("search-addons")
.handleEvent(e);
});
gViewController.initialize();
Services.obs.addObserver(sendEMPong, "EM-ping");
Services.obs.notifyObservers(window, "EM-loaded");
// If the initial view has already been selected (by a call to loadView from
// the above notifications) then bail out now
if (gViewController.initialViewSelected) {
return;
}
// If there is a history state to restore then use that
if (window.history.state) {
gViewController.updateState(window.history.state);
}
}
function notifyInitialized() {
if (!gIsInitializing) {
return;
}
gPendingInitializations--;
if (!gIsInitializing) {
var event = document.createEvent("Events");
event.initEvent("Initialized", true, true);
document.dispatchEvent(event);
}
}
function shutdown() {
gViewController.shutdown();
Services.obs.removeObserver(sendEMPong, "EM-ping");
}
function sendEMPong(aSubject, aTopic, aData) {
Services.obs.notifyObservers(window, "EM-pong");
}
async function recordViewTelemetry(param) {
let type;
let addon;
if (
param in AddonManager.addonTypes ||
["recent", "available"].includes(param)
) {
type = param;
} else if (param) {
let id = param.replace("/preferences", "");
addon = await AddonManager.getAddonByID(id);
}
AMTelemetry.recordViewEvent({ view: getCurrentViewName(), addon, type });
}
function getCurrentViewName() {
let view = gViewController.currentViewObj;
let entries = Object.entries(gViewController.viewObjects);
let viewIndex = entries.findIndex(([name, viewObj]) => {
return viewObj == view;
});
if (viewIndex != -1) {
return entries[viewIndex][0];
}
return "other";
}
// Used by external callers to load a specific view into the manager
function loadView(aViewId) {
if (!gViewController.initialViewSelected) {
// The caller opened the window and immediately loaded the view so it
// should be the initial history entry
gViewController.loadInitialView(aViewId);
} else {
gViewController.loadView(aViewId);
}
}
/**
* A wrapper around the HTML5 session history service that allows the browser
* back/forward controls to work within the manager
*/
var HTML5History = {
get index() {
return window.docShell.QueryInterface(Ci.nsIWebNavigation).sessionHistory
.index;
},
get canGoBack() {
return window.docShell.QueryInterface(Ci.nsIWebNavigation).canGoBack;
},
get canGoForward() {
return window.docShell.QueryInterface(Ci.nsIWebNavigation).canGoForward;
},
back() {
window.history.back();
},
forward() {
window.history.forward();
},
pushState(aState) {
window.history.pushState(aState, document.title);
},
replaceState(aState) {
window.history.replaceState(aState, document.title);
},
popState() {
function onStatePopped(aEvent) {
window.removeEventListener("popstate", onStatePopped, true);
// TODO To ensure we can't go forward again we put an additional entry
// for the current state into the history. Ideally we would just strip
// the history but there doesn't seem to be a way to do that. Bug 590661
window.history.pushState(aEvent.state, document.title);
}
window.addEventListener("popstate", onStatePopped, true);
window.history.back();
},
};
/**
* A wrapper around a fake history service
*/
var FakeHistory = {
pos: 0,
states: [null],
get index() {
return this.pos;
},
get canGoBack() {
return this.pos > 0;
},
get canGoForward() {
return this.pos + 1 < this.states.length;
},
back() {
if (this.pos == 0) {
throw Components.Exception("Cannot go back from this point");
}
this.pos--;
gViewController.updateState(this.states[this.pos]);
},
forward() {
if (this.pos + 1 >= this.states.length) {
throw Components.Exception("Cannot go forward from this point");
}
this.pos++;
gViewController.updateState(this.states[this.pos]);
},
pushState(aState) {
this.pos++;
this.states.splice(this.pos, this.states.length);
this.states.push(aState);
},
replaceState(aState) {
this.states[this.pos] = aState;
},
popState() {
if (this.pos == 0) {
throw Components.Exception("Cannot popState from this view");
}
this.states.splice(this.pos, this.states.length);
this.pos--;
gViewController.updateState(this.states[this.pos]);
},
};
// If the window has a session history then use the HTML5 History wrapper
// otherwise use our fake history implementation
if (window.docShell.QueryInterface(Ci.nsIWebNavigation).sessionHistory) {
var gHistory = HTML5History;
} else {
gHistory = FakeHistory;
}
var gViewController = {
viewPort: null,
currentViewId: "",
currentViewObj: null,
currentViewRequest: 0,
// All historyEntryId values must be unique within one session, because the
// IDs are used to map history entries to page state. It is not possible to
// see whether a historyEntryId was used in history entries before this page
// was loaded, so start counting from a random value to avoid collisions.
nextHistoryEntryId: Math.floor(Math.random() * 2 ** 32),
viewObjects: {},
viewChangeCallback: null,
initialViewSelected: false,
lastHistoryIndex: -1,
initialize() {
this.viewPort = document.getElementById("view-port");
this.headeredViews = document.getElementById("headered-views");
this.headeredViewsDeck = document.getElementById("headered-views-content");
this.viewObjects.shortcuts = htmlView("shortcuts");
this.viewObjects.list = htmlView("list");
this.viewObjects.detail = htmlView("detail");
this.viewObjects.updates = htmlView("updates");
this.viewObjects.discover = htmlView("discover");
for (let type in this.viewObjects) {
let view = this.viewObjects[type];
view.initialize();
}
gCategories.initialize();
window.controllers.appendController(this);
window.addEventListener("popstate", function(e) {
gViewController.updateState(e.state);
});
},
shutdown() {
if (this.currentViewObj) {
this.currentViewObj.hide();
}
this.currentViewRequest = 0;
for (let type in this.viewObjects) {
let view = this.viewObjects[type];
if ("shutdown" in view) {
try {
view.shutdown();
} catch (e) {
// this shouldn't be fatal
Cu.reportError(e);
}
}
}
window.controllers.removeController(this);
},
updateState(state) {
try {
this.loadViewInternal(state.view, state.previousView, state);
this.lastHistoryIndex = gHistory.index;
} catch (e) {
// The attempt to load the view failed, try moving further along history
if (this.lastHistoryIndex > gHistory.index) {
if (gHistory.canGoBack) {
gHistory.back();
} else {
gViewController.replaceView(gViewDefault);
}
} else if (gHistory.canGoForward) {
gHistory.forward();
} else {
gViewController.replaceView(gViewDefault);
}
}
},
parseViewId(aViewId) {
var matchRegex = /^addons:\/\/([^\/]+)\/(.*)$/;
var [, viewType, viewParam] = aViewId.match(matchRegex) || [];
return { type: viewType, param: decodeURIComponent(viewParam) };
},
get isLoading() {
return (
!this.currentViewObj || this.currentViewObj.node.hasAttribute("loading")
);
},
loadView(aViewId) {
var isRefresh = false;
if (aViewId == this.currentViewId) {
if (this.isLoading) {
return;
}
if (!("refresh" in this.currentViewObj)) {
return;
}
if (!this.currentViewObj.canRefresh()) {
return;
}
isRefresh = true;
}
var state = {
view: aViewId,
previousView: this.currentViewId,
historyEntryId: ++this.nextHistoryEntryId,
};
if (!isRefresh) {
gHistory.pushState(state);
this.lastHistoryIndex = gHistory.index;
}
this.loadViewInternal(aViewId, this.currentViewId, state);
},
// Replaces the existing view with a new one, rewriting the current history
// entry to match.
replaceView(aViewId) {
if (aViewId == this.currentViewId) {
return;
}
var state = {
view: aViewId,
previousView: null,
historyEntryId: ++this.nextHistoryEntryId,
};
gHistory.replaceState(state);
this.loadViewInternal(aViewId, null, state);
},
loadInitialView(aViewId) {
var state = {
view: aViewId,
previousView: null,
historyEntryId: ++this.nextHistoryEntryId,
};
gHistory.replaceState(state);
this.loadViewInternal(aViewId, null, state);
notifyInitialized();
},
get displayedView() {
if (this.viewPort.selectedPanel == this.headeredViews) {
return this.headeredViewsDeck.selectedPanel;
}
return this.viewPort.selectedPanel;
},
set displayedView(view) {
let node = view.node;
if (node.parentNode == this.headeredViewsDeck) {
this.headeredViewsDeck.selectedPanel = node;
this.viewPort.selectedPanel = this.headeredViews;
} else {
this.viewPort.selectedPanel = node;
}
},
loadViewInternal(aViewId, aPreviousView, aState, aEvent) {
var view = this.parseViewId(aViewId);
if (!view.type || !(view.type in this.viewObjects)) {
throw Components.Exception("Invalid view: " + view.type);
}
var viewObj = this.viewObjects[view.type];
if (!viewObj.node) {
throw Components.Exception(
"Root node doesn't exist for '" + view.type + "' view"
);
}
if (this.currentViewObj && aViewId != aPreviousView) {
try {
let canHide = this.currentViewObj.hide();
if (canHide === false) {
return;
}
this.displayedView.removeAttribute("loading");
} catch (e) {
// this shouldn't be fatal
Cu.reportError(e);
}
}
this.currentViewId = aViewId;
this.currentViewObj = viewObj;
this.displayedView = this.currentViewObj;
this.currentViewObj.node.setAttribute("loading", "true");
recordViewTelemetry(view.param);
if (aViewId == aPreviousView) {
this.currentViewObj.refresh(
view.param,
++this.currentViewRequest,
aState
);
} else {
this.currentViewObj.show(view.param, ++this.currentViewRequest, aState);
}
this.initialViewSelected = true;
},
// Moves back in the document history and removes the current history entry
popState(aCallback) {
this.viewChangeCallback = aCallback;
gHistory.popState();
},
notifyViewChanged() {
this.displayedView.removeAttribute("loading");
if (this.viewChangeCallback) {
this.viewChangeCallback();
this.viewChangeCallback = null;
}
var event = document.createEvent("Events");
event.initEvent("ViewChanged", true, true);
this.currentViewObj.node.dispatchEvent(event);
},
onEvent() {},
};
var gCategories = {
initialize() {
gPendingInitializations++;
promiseHtmlBrowserLoaded().then(async browser => {
await browser.contentWindow.customElements.whenDefined("categories-box");
let categoriesBox = browser.contentDocument.getElementById("categories");
await categoriesBox.promiseInitialized;
notifyInitialized();
});
},
};
const htmlViewOpts = {
loadViewFn(view) {
let viewId = view.startsWith("addons://") ? view : `addons://${view}`;
gViewController.loadView(viewId);
},
loadInitialViewFn(viewId) {
gViewController.loadInitialView(viewId);
},
replaceWithDefaultViewFn() {
gViewController.replaceView(gViewDefault);
},
get shouldLoadInitialView() {
// Let the HTML document load the view if `loadView` hasn't been called
// externally and we don't have history to refresh from.
return !gViewController.currentViewId && !window.history.state;
},
};
// View wrappers for the HTML version of about:addons. These delegate to an
// HTML browser that renders the actual views.
let htmlBrowser;
let _htmlBrowserLoaded;
function getHtmlBrowser() {
if (!htmlBrowser) {
gPendingInitializations++;
htmlBrowser = document.getElementById("html-view-browser");
htmlBrowser.loadURI(
"chrome://mozapps/content/extensions/aboutaddons.html",
{
triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
}
);
_htmlBrowserLoaded = new Promise(resolve =>
htmlBrowser.addEventListener("load", function loadListener() {
if (htmlBrowser.contentWindow.location.href != "about:blank") {
htmlBrowser.removeEventListener("load", loadListener);
resolve();
}
})
).then(() => {
htmlBrowser.contentWindow.initialize(htmlViewOpts);
notifyInitialized();
});
}
return htmlBrowser;
}
async function promiseHtmlBrowserLoaded() {
// Call getHtmlBrowser() first to ensure _htmlBrowserLoaded has been defined.
let browser = getHtmlBrowser();
await _htmlBrowserLoaded;
return browser;
}
function htmlView(type) {
return {
_browser: null,
node: null,
initialize() {
this._browser = getHtmlBrowser();
this.node = this._browser.closest("#html-view");
},
async show(param, request, state) {
await promiseHtmlBrowserLoaded();
await this._browser.contentWindow.show(type, param, state);
gViewController.notifyViewChanged();
},
async hide() {
await promiseHtmlBrowserLoaded();
return this._browser.contentWindow.hide();
},
getSelectedAddon() {
return null;
},
};
}
// Helper method exported into the about:addons global, used to open the
// abuse report panel from outside of the about:addons page
// (e.g. triggered from the browserAction context menu).
window.openAbuseReport = ({ addonId, reportEntryPoint }) => {
promiseHtmlBrowserLoaded().then(browser => {
browser.contentWindow.openAbuseReport({
addonId,
reportEntryPoint,
});
});
};