mirror of
https://github.com/mozilla/gecko-dev.git
synced 2025-11-09 04:39:03 +02:00
582 lines
15 KiB
JavaScript
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,
|
|
});
|
|
});
|
|
};
|