fune/browser/components/ssb/SiteSpecificBrowserService.jsm

908 lines
24 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/. */
/**
* A Site Specific Browser intends to allow the user to navigate through the
* chosen site in the SSB UI. Any attempt to load something outside the site
* should be loaded in a normal browser. In order to achieve this we have to use
* various APIs to listen for attempts to load new content and take appropriate
* action. Often this requires returning synchronous responses to method calls
* in content processes and will require data about the SSB in order to respond
* correctly. Here we implement an architecture to support that:
*
* In the main process the SiteSpecificBrowser class implements all the
* functionality involved with managing an SSB. All content processes can
* synchronously retrieve a matching SiteSpecificBrowserBase that has enough
* data about the SSB in order to be able to respond to load requests
* synchronously. To support this we give every SSB a unique ID (UUID based)
* and the appropriate data is shared via sharedData. Once created the ID can be
* used to retrieve the SiteSpecificBrowser instance in the main process or
* SiteSpecificBrowserBase instance in any content process.
*/
var EXPORTED_SYMBOLS = [
"SiteSpecificBrowserService",
"SiteSpecificBrowserBase",
"SiteSpecificBrowser",
"SSBCommandLineHandler",
];
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { XPCOMUtils } = ChromeUtils.import(
"resource://gre/modules/XPCOMUtils.jsm"
);
XPCOMUtils.defineLazyModuleGetters(this, {
ManifestObtainer: "resource://gre/modules/ManifestObtainer.jsm",
ManifestProcessor: "resource://gre/modules/ManifestProcessor.jsm",
KeyValueService: "resource://gre/modules/kvstore.jsm",
OS: "resource://gre/modules/osfile.jsm",
ImageTools: "resource:///modules/ssb/ImageTools.jsm",
AppConstants: "resource://gre/modules/AppConstants.jsm",
});
if (AppConstants.platform == "win") {
ChromeUtils.defineModuleGetter(
this,
"WindowsSupport",
"resource:///modules/ssb/WindowsSupport.jsm"
);
}
/**
* A schema version for the SSB data stored in the kvstore.
*
* Version 1 has the `manifest` and `config` properties.
*/
const DATA_VERSION = 1;
/**
* The prefix used for SSB ids in the store.
*/
const SSB_STORE_PREFIX = "ssb:";
/**
* A prefix that will sort immediately after any SSB ids in the store.
*/
const SSB_STORE_LAST = "ssb;";
function uuid() {
return Cc["@mozilla.org/uuid-generator;1"]
.getService(Ci.nsIUUIDGenerator)
.generateUUID()
.toString();
}
const sharedDataKey = id => `SiteSpecificBrowserBase:${id}`;
const storeKey = id => SSB_STORE_PREFIX + id;
/**
* Builds a lookup table for all the icons in order of size.
*/
function buildIconList(icons) {
let iconList = [];
for (let icon of icons) {
for (let sizeSpec of icon.sizes) {
let size =
sizeSpec == "any" ? Number.MAX_SAFE_INTEGER : parseInt(sizeSpec);
iconList.push({
icon,
size,
});
}
}
iconList.sort((a, b) => {
// Given that we're using MAX_SAFE_INTEGER adding a value to that would
// overflow and give odd behaviour. And we're using numbers supplied by a
// website so just compare for safety.
if (a.size < b.size) {
return -1;
}
if (a.size > b.size) {
return 1;
}
return 0;
});
return iconList;
}
const IS_MAIN_PROCESS =
Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT;
/**
* Tests whether an app manifest's scope includes the given URI.
*
* @param {nsIURI} scope the manifest's scope.
* @param {nsIURI} uri the URI to test.
* @returns {boolean} true if the uri is included in the scope.
*/
function scopeIncludes(scope, uri) {
// https://w3c.github.io/manifest/#dfn-within-scope
if (scope.prePath != uri.prePath) {
return false;
}
return uri.filePath.startsWith(scope.filePath);
}
/**
* Generates a basic app manifest for a URI.
*
* @param {nsIURI} uri the start URI for the site.
* @return {Manifest} an app manifest.
*/
function manifestForURI(uri) {
try {
let manifestURI = Services.io.newURI("/manifest.json", null, uri);
return ManifestProcessor.process({
jsonText: "{}",
manifestURL: manifestURI.spec,
docURL: uri.spec,
});
} catch (e) {
console.error(`Failed to generate a SSB manifest for ${uri.spec}.`, e);
throw e;
}
}
/**
* Creates an IconResource from the LinkHandler data.
*
* @param {object} iconData the data from the LinkHandler actor.
* @return {Promise<IconResource>} an icon resource.
*/
async function getIconResource(iconData) {
// This should be a data url so no network traffic.
let imageData = await ImageTools.loadImage(
Services.io.newURI(iconData.iconURL)
);
if (imageData.container.type == Ci.imgIContainer.TYPE_VECTOR) {
return {
src: iconData.iconURL,
purpose: ["any"],
type: imageData.type,
sizes: ["any"],
};
}
// TODO: For ico files we should find all the available sizes: Bug 1604285.
return {
src: iconData.iconURL,
purpose: ["any"],
type: imageData.type,
sizes: [`${imageData.container.width}x${imageData.container.height}`],
};
}
/**
* Generates an app manifest for a site loaded in a browser element.
*
* @param {Element} browser the browser element the site is loaded in.
* @return {Promise<Manifest>} an app manifest.
*/
async function buildManifestForBrowser(browser) {
let manifest = null;
try {
manifest = await ManifestObtainer.browserObtainManifest(browser);
} catch (e) {
// We can function without a valid manifest.
console.error(e);
}
// Reject the manifest if its scope doesn't include the current document.
if (
!manifest ||
!scopeIncludes(Services.io.newURI(manifest.scope), browser.currentURI)
) {
manifest = manifestForURI(browser.currentURI);
}
// Cache all the icons as data URIs since we can need access to them when
// the website is not loaded.
manifest.icons = (
await Promise.all(
manifest.icons.map(async icon => {
if (icon.src.startsWith("data:")) {
return icon;
}
let actor = browser.browsingContext.currentWindowGlobal.getActor(
"SiteSpecificBrowser"
);
try {
icon.src = await actor.sendQuery("LoadIcon", icon.src);
} catch (e) {
// Bad icon, drop it from the list.
return null;
}
return icon;
})
)
).filter(icon => icon);
// If the site provided no icons then try to use the normal page icons.
if (!manifest.icons.length) {
let linkHandler = browser.browsingContext.currentWindowGlobal.getActor(
"LinkHandler"
);
for (let icon of [linkHandler.icon, linkHandler.richIcon]) {
if (!icon) {
continue;
}
try {
manifest.icons.push(await getIconResource(icon));
} catch (e) {
console.warn(`Failed to load icon resource ${icon.originalURL}`, e);
}
}
}
return manifest;
}
/**
* Maintains an ID -> SSB mapping in the main process. Content processes should
* use sharedData to get a SiteSpecificBrowserBase.
*
* We do not currently expire data from here so once created an SSB instance
* lives for the lifetime of the application. The expectation is that the
* numbers of different SSBs used will be low and the memory use will also
* be low.
*/
const SSBMap = new Map();
/**
* The base contains the data about an SSB instance needed in content processes.
*
* The only data needed currently is site's `scope` which is just a URI.
*/
class SiteSpecificBrowserBase {
/**
* Creates a new SiteSpecificBrowserBase. Generally should only be called by
* code within this module.
*
* @param {nsIURI} scope the scope for the SSB.
*/
constructor(scope) {
this._scope = scope;
}
/**
* Gets the SiteSpecifcBrowserBase for an ID. If this is the main process this
* will instead return the SiteSpecificBrowser instance itself but generally
* don't call this from the main process.
*
* The returned object is not "live" and will not be updated with any
* configuration changes from the main process so do not cache this, get it
* when needed and then discard.
*
* @param {string} id the SSB ID.
* @return {SiteSpecificBrowserBase|null} the instance if it exists.
*/
static get(id) {
if (IS_MAIN_PROCESS) {
return SiteSpecificBrowser.get(id);
}
let key = sharedDataKey(id);
if (!Services.cpmm.sharedData.has(key)) {
return null;
}
let scope = Services.io.newURI(Services.cpmm.sharedData.get(key));
return new SiteSpecificBrowserBase(scope);
}
/**
* Checks whether the given URI is considered to be a part of this SSB or not.
* Any URIs that return false should be loaded in a normal browser.
*
* @param {nsIURI} uri the URI to check.
* @return {boolean} whether this SSB can load the URI.
*/
canLoad(uri) {
// Always allow loading about:blank as it is the initial page for iframes.
if (uri.spec == "about:blank") {
return true;
}
return scopeIncludes(this._scope, uri);
}
}
/**
* The SSB instance used in the main process.
*
* We maintain three pieces of data for an SSB:
*
* First is the string UUID for identification purposes.
*
* Second is an app manifest (https://w3c.github.io/manifest/). If the site does
* not provide one a basic one will be automatically generated. The intent is to
* never modify this such that it can be updated from the site when needed
* without blowing away any configuration changes a user might want to make to
* the SSB itself.
*
* Thirdly there is the SSB configuration. This includes internal data, user
* overrides for the app manifest and custom SSB extensions to the app manifest.
*
* We pass data based on these down to the SiteSpecificBrowserBase in this and
* other processes (via `_updateSharedData`).
*/
class SiteSpecificBrowser extends SiteSpecificBrowserBase {
/**
* Creates a new SiteSpecificBrowser. Generally should only be called by
* code within this module.
*
* @param {string} id the SSB's unique ID.
* @param {Manifest} manifest the app manifest for the SSB.
* @param {object?} config the SSB configuration settings.
*/
constructor(id, manifest, config = {}) {
if (!IS_MAIN_PROCESS) {
throw new Error(
"SiteSpecificBrowser instances are only available in the main process."
);
}
super(Services.io.newURI(manifest.scope));
this._id = id;
this._manifest = manifest;
this._config = Object.assign(
{
needsUpdate: true,
persisted: false,
},
config
);
// Cache the SSB for retrieval.
SSBMap.set(id, this);
this._updateSharedData();
}
/**
* Loads the SiteSpecificBrowser for the given ID.
*
* @param {string} id the SSB's unique ID.
* @param {object?} data the data to deserialize from. Do not use externally.
* @return {Promise<SiteSpecificBrowser?>} the instance if it exists.
*/
static async load(id, data = null) {
if (!IS_MAIN_PROCESS) {
throw new Error(
"SiteSpecificBrowser instances are only available in the main process."
);
}
if (SSBMap.has(id)) {
return SSBMap.get(id);
}
if (!data) {
let kvstore = await SiteSpecificBrowserService.getKVStore();
data = await kvstore.get(storeKey(id), null);
}
if (!data) {
return null;
}
try {
let parsed = JSON.parse(data);
parsed.config.persisted = true;
return new SiteSpecificBrowser(id, parsed.manifest, parsed.config);
} catch (e) {
console.error(e);
}
return null;
}
/**
* Gets the SiteSpecifcBrowser for an ID. Can only be called from the main
* process.
*
* @param {string} id the SSB ID.
* @return {SiteSpecificBrowser|null} the instance if it exists.
*/
static get(id) {
if (!IS_MAIN_PROCESS) {
throw new Error(
"SiteSpecificBrowser instances are only available in the main process."
);
}
return SSBMap.get(id);
}
/**
* Creates an SSB from a parsed app manifest.
*
* @param {Manifest} manifest the app manifest for the site.
* @return {Promise<SiteSpecificBrowser>} the generated SSB.
*/
static async createFromManifest(manifest) {
if (!SiteSpecificBrowserService.isEnabled) {
throw new Error("Site specific browsing is disabled.");
}
if (!manifest.scope.startsWith("https:")) {
throw new Error(
"Site specific browsers can only be opened for secure sites."
);
}
return new SiteSpecificBrowser(uuid(), manifest, { needsUpdate: false });
}
/**
* Creates an SSB from a site loaded in a browser element.
*
* @param {Element} browser the browser element the site is loaded in.
* @return {Promise<SiteSpecificBrowser>} the generated SSB.
*/
static async createFromBrowser(browser) {
if (!SiteSpecificBrowserService.isEnabled) {
throw new Error("Site specific browsing is disabled.");
}
if (!browser.currentURI.schemeIs("https")) {
throw new Error(
"Site specific browsers can only be opened for secure sites."
);
}
let manifest = await buildManifestForBrowser(browser);
let ssb = await SiteSpecificBrowser.createFromManifest(manifest);
if (!manifest.name) {
ssb.name = browser.contentTitle;
}
return ssb;
}
/**
* Creates an SSB from a sURI.
*
* @param {nsIURI} uri the uri to generate from.
* @return {SiteSpecificBrowser} the generated SSB.
*/
static createFromURI(uri) {
if (!SiteSpecificBrowserService.isEnabled) {
throw new Error("Site specific browsing is disabled.");
}
if (!uri.schemeIs("https")) {
throw new Error(
"Site specific browsers can only be opened for secure sites."
);
}
return new SiteSpecificBrowser(uuid(), manifestForURI(uri));
}
/**
* Caches the data needed by content processes.
*/
_updateSharedData() {
Services.ppmm.sharedData.set(sharedDataKey(this.id), this._scope.spec);
Services.ppmm.sharedData.flush();
}
/**
* Persists the data to the store if needed. When a change in configuration
* has occured call this.
*/
async _maybeSave() {
// If this SSB is persisted then update it in the data store.
if (this._config.persisted) {
let data = {
manifest: this._manifest,
config: this._config,
};
let kvstore = await SiteSpecificBrowserService.getKVStore();
await kvstore.put(storeKey(this.id), JSON.stringify(data));
}
}
/**
* Installs this SiteSpecificBrowser such that it exists for future instances
* of the application and will appear in lists of installed SSBs.
*/
async install() {
if (this._config.persisted) {
return;
}
this._config.persisted = true;
await this._maybeSave();
if (AppConstants.platform == "win") {
await WindowsSupport.install(this);
}
Services.obs.notifyObservers(
null,
"site-specific-browser-install",
this.id
);
}
/**
* Uninstalls this SiteSpecificBrowser. Undoes eveerything above. The SSB is
* still usable afterwards.
*/
async uninstall() {
if (!this._config.persisted) {
return;
}
if (AppConstants.platform == "win") {
await WindowsSupport.uninstall(this);
}
this._config.persisted = false;
let kvstore = await SiteSpecificBrowserService.getKVStore();
await kvstore.delete(storeKey(this.id));
Services.obs.notifyObservers(
null,
"site-specific-browser-uninstall",
this.id
);
}
/**
* The SSB's ID.
*/
get id() {
return this._id;
}
get name() {
if (this._config.name) {
return this._config.name;
}
if (this._manifest.name) {
return this._manifest.name;
}
return this.startURI.host;
}
set name(val) {
this._config.name = val;
this._maybeSave();
}
/**
* The default URI to load.
*/
get startURI() {
return Services.io.newURI(this._manifest.start_url);
}
/**
* Whether this SSB needs to be checked for an updated manifest.
*/
get needsUpdate() {
return this._config.needsUpdate;
}
/**
* Gets the best icon for the requested size. It may not be the exact size
* requested.
*
* Finds the smallest icon that is larger than the requested size. If no such
* icon exists returns the largest icon available. Returns null only if there
* are no icons at all.
*
* @param {Number} size the size of the desired icon in pixels.
* @return {IconResource} the icon resource for the icon.
*/
getIcon(size) {
if (!this._iconSizes) {
this._iconSizes = buildIconList(this._manifest.icons);
}
if (!this._iconSizes.length) {
return null;
}
let i = 0;
while (i < this._iconSizes.length && this._iconSizes[i].size < size) {
i++;
}
return i < this._iconSizes.length
? this._iconSizes[i].icon
: this._iconSizes[this._iconSizes.length - 1].icon;
}
/**
* Gets the best icon for the requested size. If there isn't a perfect match
* the closest match will be scaled.
*
* @param {Number} size the size of the desired icon in pixels.
* @return {string|null} a data URI for the icon.
*/
async getScaledIcon(size) {
let icon = this.getIcon(size);
if (!icon) {
return null;
}
let { container } = await ImageTools.loadImage(
Services.io.newURI(icon.src)
);
return ImageTools.scaleImage(container, size, size);
}
/**
* Updates this SSB from a new app manifest.
*
* @param {Manifest} manifest the new app manifest.
*/
async updateFromManifest(manifest) {
this._manifest = manifest;
this._iconSizes = null;
this._scope = Services.io.newURI(this._manifest.scope);
this._config.needsUpdate = false;
this._updateSharedData();
await this._maybeSave();
}
/**
* Updates this SSB from the site loaded in the browser element.
*
* @param {Element} browser the browser element.
*/
async updateFromBrowser(browser) {
let manifest = await buildManifestForBrowser(browser);
await this.updateFromManifest(manifest);
}
/**
* Launches a SSB by opening the necessary UI.
*
* @param {nsIURI?} the initial URI to load. If not provided uses the default.
*/
launch(uri = null) {
let sa = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
let idstr = Cc["@mozilla.org/supports-string;1"].createInstance(
Ci.nsISupportsString
);
idstr.data = this.id;
sa.appendElement(idstr);
if (uri) {
let uristr = Cc["@mozilla.org/supports-string;1"].createInstance(
Ci.nsISupportsString
);
uristr.data = uri.spec;
sa.appendElement(uristr);
}
let win = Services.ww.openWindow(
null,
"chrome://browser/content/ssb/ssb.html",
"_blank",
"chrome,dialog=no,all",
sa
);
if (Services.appinfo.OS == "WINNT") {
WindowsSupport.applyOSIntegration(this, win);
}
}
}
/**
* Loads the KV store for SSBs. Should always resolve with a store even if that
* means wiping whatever is currently on disk because it was unreadable.
*/
async function loadKVStore() {
let dir = OS.Path.join(OS.Constants.Path.profileDir, "ssb");
/**
* Creates an empty store. Called when we know there is an empty directory.
*/
async function createStore() {
await OS.File.makeDir(dir);
let kvstore = await KeyValueService.getOrCreate(dir, "ssb");
await kvstore.put(
"_meta",
JSON.stringify({
version: DATA_VERSION,
})
);
return kvstore;
}
// First see if anything exists.
try {
let info = await OS.File.stat(dir);
if (!info.isDir) {
await OS.File.remove(dir, { ignoreAbsent: true });
return createStore();
}
} catch (e) {
if (e.becauseNoSuchFile) {
return createStore();
}
throw e;
}
// Something exists, try to load it.
try {
let kvstore = await KeyValueService.getOrCreate(dir, "ssb");
let meta = await kvstore.get("_meta", null);
if (meta) {
let data = JSON.parse(meta);
if (data.version == DATA_VERSION) {
return kvstore;
}
console.error(`SSB store is an unexpected version ${data.version}`);
} else {
console.error("SSB store was missing meta data.");
}
// We don't know how to handle this database, re-initialize it.
await kvstore.clear();
await kvstore.put(
"_meta",
JSON.stringify({
version: DATA_VERSION,
})
);
return kvstore;
} catch (e) {
console.error(e);
// Something is very wrong. Wipe all our data and start again.
await OS.File.removeDir(dir);
return createStore();
}
}
const SiteSpecificBrowserService = {
kvstore: null,
/**
* Returns a promise that resolves to the KV store for SSBs.
*/
getKVStore() {
if (!this.kvstore) {
this.kvstore = loadKVStore();
}
return this.kvstore;
},
/**
* Checks if OS integration is enabled. This will affect whether installs and
* uninstalls have effects on the OS itself amongst other things. Generally
* only disabled for testing.
*/
get useOSIntegration() {
if (Services.appinfo.OS != "WINNT") {
return false;
}
return Services.prefs.getBoolPref("browser.ssb.osintegration", true);
},
/**
* Returns a promise that resolves to an array of all of the installed SSBs.
*/
async list() {
let kvstore = await this.getKVStore();
let list = await kvstore.enumerate(SSB_STORE_PREFIX, SSB_STORE_LAST);
return Promise.all(
Array.from(list).map(({ key: id, value: data }) =>
SiteSpecificBrowser.load(id.substring(SSB_STORE_PREFIX.length), data)
)
);
},
};
XPCOMUtils.defineLazyPreferenceGetter(
SiteSpecificBrowserService,
"isEnabled",
"browser.ssb.enabled",
false
);
async function startSSB(id) {
// Loading the SSB is async. Until that completes and launches we will
// be without an open window and the platform will not continue startup
// in that case. Flag that a window is coming.
Services.startup.enterLastWindowClosingSurvivalArea();
// Whatever happens we must exitLastWindowClosingSurvivalArea when done.
try {
let ssb = await SiteSpecificBrowser.load(id);
if (ssb) {
ssb.launch();
} else {
dump(`No SSB installed as ID ${id}\n`);
}
} finally {
Services.startup.exitLastWindowClosingSurvivalArea();
}
}
class SSBCommandLineHandler {
/* nsICommandLineHandler */
handle(cmdLine) {
if (!SiteSpecificBrowserService.isEnabled) {
return;
}
let site = cmdLine.handleFlagWithParam("ssb", false);
if (site) {
cmdLine.preventDefault = true;
try {
let fixupInfo = Services.uriFixup.getFixupURIInfo(
site,
Services.uriFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS
);
let uri = fixupInfo.preferredURI;
if (!uri) {
dump(`Unable to parse '${site}' as a URI.\n`);
return;
}
if (fixupInfo.fixupChangedProtocol && uri.schemeIs("http")) {
uri = uri
.mutate()
.setScheme("https")
.finalize();
}
let ssb = SiteSpecificBrowser.createFromURI(uri);
ssb.launch();
} catch (e) {
dump(`Unable to parse '${site}' as a URI: ${e}\n`);
}
return;
}
let id = cmdLine.handleFlagWithParam("start-ssb", false);
if (id) {
cmdLine.preventDefault = true;
startSSB(id);
}
}
get helpInfo() {
return " --ssb <uri> Open a site specific browser for <uri>.\n";
}
}
SSBCommandLineHandler.prototype.QueryInterface = ChromeUtils.generateQI([
Ci.nsICommandLineHandler,
]);