VulpesOS/gaia/apps/settings/js/modules/addon_manager.js
OverSu b38dea0597 Publish Vulpes Preview 15 sources for desktop, Android and Tundra
Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
2026-09-30 23:13:03 +02:00

688 lines
21 KiB
JavaScript
Executable file

define(function(require) {
'use strict';
var mozApps = require('modules/navigator/mozApps');
var App = require('modules/app');
var AppsCache = require('modules/apps_cache');
var ObservableArray = require('modules/mvvm/observable_array');
var MatchPattern = require('shared/addons/match_pattern');
/**
* AddonManager is a singleton with methods for querying the currently
* installed addons, enabling, disabling, and uninstalling addons, and
* for obtaining a list of apps targeted by a specified add-on. The changes to
* installed addons are propagated via an observable array.
*
* AddonManager uses the AppsCache module for efficient access to the full
* list of installed apps.
*
* @class AddonManager
* @requires module:modules/navigator/mozApps
* @requires module:modules/apps_cache
* @requires module:modules/mvvm/observable_array
* @returns {AddonManager}
*/
function AddonManager() {
this._addons = ObservableArray([]);
this._bindEvents();
AppsCache.apps().then((apps) => {
apps.some((app) => {
if (this._isAddon(app)) {
this._addons.push(App(app));
}
});
});
}
AddonManager.prototype = {
/**
* Bind to install and uninstall events.
*
* @access private
* @memberOf AddonManager.prototype
*/
_bindEvents: function() {
var _boundUpdateAddons = this._updateAddons.bind(this);
// Bug 1215546 & 1218298
// The 'oninstall' event is fired when user started installing the
// app, but manifest is not available yet. In order to get the
// manifest, we need to listen to 'downloadsuccess' on the app
// (not mozApps.mgmt), and that's when manifest will be ready.
AppsCache.addEventListener('oninstall', (evt) => {
var app = evt && evt.application;
// Bug 1220195
// If installed via WebIDE, _isAddon will return true, and we should
// run _boundUpdateAddons immediately because 'downloadsuccess' would
// never fire. If installed from Marketplace we need to wait for the
// 'downloadsuccess' event and run _boundUpdateAddons later.
if (this._isAddon(app)) {
_boundUpdateAddons(evt);
} else {
app.addEventListener('downloadsuccess', _boundUpdateAddons);
}
});
AppsCache.addEventListener('onuninstall', _boundUpdateAddons);
mozApps.mgmt.addEventListener('enabledstatechange', _boundUpdateAddons);
},
/**
* We have to update the addon array based on incoming evt.
*
* @access private
* @memberOf AddonManager.prototype
* @param {Object} evt
*/
_updateAddons: function(evt) {
var app = evt && evt.application;
var type = evt.type;
var index;
if (this._isAddon(app)) {
if ((type === 'downloadsuccess' || type === 'install') &&
!this._alreadyExists(app)) {
this._addons.push(App(app));
} else if (type === 'uninstall') {
index = this._findAddonIndex(app);
if (index !== -1) {
this._addons.splice(index, 1);
}
} else if (type === 'enabledstatechange') {
index = this._findAddonIndex(app);
if (index !== -1) {
this._addons.get(index)._enabled = app.enabled;
}
}
}
},
/**
* Check if an addon already exists.
*
* @access private
* @memberOf AddonManager.prototype
* @param {DOMApplication} addon
* @returns {Boolean}
*/
_alreadyExists: function(addon) {
return this._findAddonIndex(addon) !== -1;
},
/**
* Get the index of an addon.
*
* @access private
* @memberOf AddonManager.prototype
* @param {DOMApplication} addon
* @returns {Number}
*/
_findAddonIndex: function(addon) {
return this._addons.array.findIndex((elem) => {
return addon.manifestURL === elem.instance.manifestURL;
});
},
/**
* Addons can only apply to apps with the same or lower privilege. Returns
* a bloolean indicating whether the addon can be applied on the app.
*
* @access private
* @memberOf AddonManager.prototype
* @param {DOMApplication} addon
* @param {DOMApplication} app
* @returns {Boolean}
*/
_privilegeCheck: function(addon, app) {
var addonManifest = this._getManifest(addon);
var appManifest = this._getManifest(app);
return addonManifest.type === appManifest.type ||
addonManifest.type === 'certified' ||
addonManifest.type === 'privileged';
},
/**
* Get the manifest.
*
* @access private
* @memberOf AddonManager.prototype
* @param {DOMApplication} app
* @returns {Object}
*/
_getManifest: function(app) {
return app.manifest || app.updateManifest;
},
/**
* Check whether reboot is required when disabling the addon.
*
* @access private
* @memberOf AddonManager.prototype
* @param {DOMApplication} app
* @param {Array} contentScripts
* @returns {Boolean}
*/
_needsReboot: function({app, contentScripts}) {
var manifest = this._getManifest(app);
var role = manifest.role;
// If the addon affects a system app
if (role === 'system' || role === 'homescreen') {
// and the app is customized with a script, then we need reboot
return contentScripts.some(c => c.js.length > 0);
} else {
return false;
}
},
/**
* Check whether restart is required when disabling the addon.
*
* @access private
* @memberOf AddonManager.prototype
* @param {DOMApplication} app
* @param {Array} contentScripts
* @returns {Boolean}
*/
_needsRestart: function({app, contentScripts}) {
return contentScripts.some(c => c.js.length > 0);
},
/**
* Check whether this app is an addon.
*
* @access private
* @memberOf AddonManager.prototype
* @param {DOMApplication} app
* @returns {Boolean}
*/
_isAddon: function(app) {
var manifest = this._getManifest(app);
return manifest && manifest.role === 'addon';
},
/**
* This internal utility function returns an array of objects describing
* the apps that are affected by the given addon.
*
* @access private
* @memberOf AddonManager.prototype
* @param {DOMApplication} addon
* @returns {Boolean}
*/
_getAffectedApps: function(addon) {
if (!this._isAddon(addon)) {
return Promise.reject('not an addon');
}
var manifest = this._getManifest(addon);
var customizations = manifest && manifest.customizations;
// content_scripts field is internally copied from manifest.json to
// manifest.webapp
var contentScripts = manifest && manifest.content_scripts;
// If the addon specifies customizations, then it is obsolete and does not
// work
if (customizations) {
return Promise.resolve([]);
}
// If there are no content scripts or content scripts list is empty, the
// addon does not affect any apps.
if (!contentScripts || contentScripts.length === 0) {
return Promise.resolve([]);
}
return AppsCache.apps()
.then(apps => apps.filter(app => {
var manifest = this._getManifest(app);
// Ignore apps that are themselves add-ons or if the addon doesn't
// have high enough privileges to affect this app then we can just
// return now.
return manifest.role !== 'addon' &&
this._privilegeCheck(addon, app);
}))
// Match apps based on add-on's content sctipts.
.then(apps => {
var matches = [];
apps.forEach(app => {
var matched;
if ((matched = this._matchContentScripts(contentScripts, app))) {
matches.push({ app: app, contentScripts: matched });
}
});
return matches;
})
.catch(() => []);
},
/**
* Returns a list of content scripts (from an addon manifest), if available,
* that affect the app by a given addon. We implement getAddonTargets()
* using this information , derive the 'reboot' and 'restart' return
* values of disableAddon() from it and also use this function to apply
* addon filters.
*
* @access private
* @memberOf AddonManager.prototype
* @param {DOMApplication} addon
* @param {DOMApplication} app
* @returns {Array?} appliedContentScripts if available
*/
_matchContentScripts: function(contentScripts, app) {
var manifest = this._getManifest(app);
// Get the URL of the app origin plus its launch path to
// compare against each of the filters.
// XXX: Note that we and do not check the paths used by
// activity handlers or any other paths in the manifest.
var launchPath = manifest.launch_path || '';
var launchURL = new URL(launchPath, app.origin);
// For each content script, compile the pattern string MatchPattern
var patterns = contentScripts.map(contentScript => {
return {
matches: new MatchPattern(contentScript.matches),
excludeMatches: new MatchPattern(contentScript.exclude_matches)
};
});
var appliedContentScripts = [];
// Now loop through the patterns to see what content scripts are applied
// to this app
for(var i = 0; i < patterns.length; i++) {
var pattern = patterns[i];
if (!pattern.excludeMatches.matches(launchURL) &&
pattern.matches.matches(launchURL)) {
appliedContentScripts.push(contentScripts[i]);
break;
}
}
// If any content scripts were applied to this app, return the list of
// appliedContentScripts
if (appliedContentScripts.length > 0) {
return appliedContentScripts;
}
},
/**
* Convert exported add-on into an Array Buffer.
*
* @access private
* @memberOf AddonManager.prototype
* @param {Blob} blob
* @returns {Promise}
*/
_blobToArrayBuffer: function(blob) {
return new Promise(function(resolve, reject) {
var fileReader = new FileReader();
fileReader.onload = function() {
resolve(fileReader.result);
};
fileReader.onerror = function(reason) {
reject(reason);
};
fileReader.readAsArrayBuffer(blob);
});
},
/**
* Upack an add-on script.
*
* @access private
* @memberOf AddonManager.prototype
* @param {ArrayBuffer} arrayBuffer
* @returns {Promise}
*/
_unpackScript: function(arrayBuffer) {
return this._JSZip.then(JSZip => {
var zip = new JSZip();
zip.load(arrayBuffer);
var applicationZipFile = zip.file('application.zip');
if (!applicationZipFile) { return; }
var applicationZip = new JSZip();
applicationZip.load(applicationZipFile.asArrayBuffer());
var scriptFile = applicationZip.file('main.js');
if (!scriptFile) { return; }
return(scriptFile.asText());
});
},
/**
* Generate a new add-on.
*
* @access private
* @memberOf AddonManager.prototype
* @param {JSON} manifest add-on manifest
* @param {String} script add-on script
* @returns {Promise}
*/
_generate: function(manifest, script) {
return this._JSZip.then(JSZip => {
var id = 'addon' + Math.round(Math.random() * 100000000);
var applicationZip = new JSZip();
// Ensure that we are creating an addon with a new id.
manifest.role = 'addon';
manifest.type = 'certified';
manifest.origin = 'app://' + id + '.gaiamobile.org';
manifest.activities = manifest.activities || {};
applicationZip.file('manifest.webapp', JSON.stringify(manifest));
applicationZip.file('main.js', script);
var packageZip = new JSZip();
packageZip.file('metadata.json', JSON.stringify({
installOrigin: 'http://gaiamobile.org',
manifestURL: 'app://' + id + '.gaiamobile.org/update.webapp',
version: 1
}));
packageZip.file('update.webapp', JSON.stringify({
name: manifest.name,
package_path: '/application.zip'
}));
packageZip.file('application.zip',
applicationZip.generate({ type: 'arraybuffer' }));
return new Blob([packageZip.generate({ type: 'arraybuffer' })],
{ type: 'application/zip' });
});
},
/**
* Lazily require jszip. It is currently used only for renaming Add-ons.
*
* @access private
* @memberOf AddonManager.prototype
* @returns {Promise}
*/
get _JSZip() {
return new Promise(resolve => { require(['vendor/jszip'], resolve); });
},
/**
* Install add-on, but importing it using a memory-backed blob.
*
* @access private
* @memberOf AddonManager.prototype
* @param {Blob} blob add-on blob
* @returns {Promise}
*/
_install: function(blob) {
return navigator.mozApps.mgmt.import(blob).then(addon => {
// Enable the addon by default.
var app = { instance: addon };
this.enableAddon(app);
return app;
});
},
/**
* Check whether an addon is enabled.
*
* @access public
* @memberOf AddonManager.prototype
* @param {App} addon
* @returns {Boolean}
*/
isEnabled: function(addon) {
return this._isAddon(addon.instance) && addon.instance.enabled === true;
},
/**
* Check whether an addon can be deleted.
*
* @access public
* @memberOf AddonManager.prototype
* @param {App} addon
* @returns {Boolean}
*/
canDelete: function(addon) {
return this._isAddon(addon.instance) && addon.instance.removable === true;
},
/**
* Enable an addon
*
* @access public
* @memberOf AddonManager.prototype
* @param {App} addon
*/
enableAddon: function(addon) {
if (this._isAddon(addon.instance) && !addon.instance.enabled) {
mozApps.mgmt.setEnabled(addon.instance, true);
}
},
/**
* Disable an addon. Returns a string indicating whether reboot or restart
* required for disabling the addon completely.
*
* @access public
* @memberOf AddonManager.prototype
* @param {App} addon
*/
disableAddon: function(addon) {
if (this._isAddon(addon.instance) && addon.instance.enabled) {
mozApps.mgmt.setEnabled(addon.instance, false);
}
},
/**
* Delete an addon.
*
* @access public
* @memberOf AddonManager.prototype
* @param {App} addon
* @returns {Promise}
*/
deleteAddon: function(addon) {
if (!this._isAddon(addon.instance)) {
return Promise.reject('not an addon');
}
if (!addon.instance.removable) {
return Promise.reject('addon is not deletable');
}
return new Promise(function(resolve, reject) {
var request = mozApps.mgmt.uninstall(addon.instance);
request.onsuccess = function() {
resolve();
};
request.onerror = function() {
reject();
};
});
},
/**
* Rename an addon.
*
* @access public
* @memberOf AddonManager.prototype
* @param {App} addon
* @param {String} name addon name
* @returns {Promise}
*/
renameAddon: function(addon, name) {
if (!name) {
return Promise.reject('no name given');
}
if (!this._isAddon(addon.instance)) {
return Promise.reject('not an addon');
}
var manifest = this._getManifest(addon.instance);
if (name === manifest.name) {
return Promise.reject('name is unchanged');
}
var newManifest, addonBlob;
// To rename an addon we are creating a new DOMApplication that will have
// an updated name. Other manifest attributes are preserved.
// Export an add-on as a blob.
return addon.instance.export().then(blob => {
addonBlob = blob;
// Copy over manifest object.
newManifest = Object.assign({}, manifest);
newManifest.name = name;
// Delete original add-on.
return this.deleteAddon(addon);
}).then(() => {
// Convert exported add-on into an Array Buffer.
return this._blobToArrayBuffer(addonBlob);
}).then(this._unpackScript.bind(this)).then(script => {
// Generate a new add-on using the original manifest and a new name.
return this._generate(newManifest, script);
}).then(this._install.bind(this));
},
/**
* Share an addon.
*
* @access public
* @memberOf AddonManager.prototype
* @param {App} addon
* @returns {Promise}
*/
shareAddon: function(addon) {
if (!this._isAddon(addon.instance)) {
return Promise.reject('not an addon');
}
return new Promise(function(resolve, reject) {
var activity = new window.MozActivity({
name: 'share',
data: {
type: 'app',
app: addon.instance.manifestURL
}
});
activity.onsuccess = function() {
resolve();
};
activity.onerror = function() {
reject((activity.error && activity.error.name) || 'activity error');
};
});
},
/**
* Return an array of the apps that are targeted by any of this
* addon's content scripts. Note, however, that addons can also target
* arbitrary web pages and hosted apps. This test only applies to
* installed packaged apps and so may be of limited utility.
*
* @access public
* @memberOf AddonManager.prototype
* @param {App} addon
* @returns {Promise}
*/
getAddonTargets: function(addon) {
return this._getAffectedApps(addon.instance).then((affectedApps) => {
return affectedApps.map((x) => x.app);
});
},
/**
* Return a string indicating whether reboot or restart is required for
* disabling the addon.
*
* @access public
* @memberOf AddonManager.prototype
* @param {App} addon
* @returns {Promise.<String>}
*/
getAddonDisableType: function(addon) {
return this._getAffectedApps(addon.instance).then((affectedApps) => {
var needsReboot = affectedApps.some(this._needsReboot.bind(this));
if (needsReboot) {
return 'reboot';
}
var needsRestart = affectedApps.some(this._needsRestart.bind(this));
if (needsRestart) {
return 'restart';
}
return '';
});
},
/**
* Return the App object that matches the given manifest url.
*
* @access public
* @memberOf AddonManager.prototype
* @param {String} manifestURL
* @returns {Promise.<App>}
*/
findAddonByManifestURL: function(manifestURL) {
return AppsCache.apps().then(() => {
return this._addons.array.find((addon) => {
return addon.instance.manifestURL === manifestURL;
});
});
},
/**
* Returns a promise for a boolean flag indicating whether an addon affects
* an application or not..
*
* @access public
* @memberOf AddonManager.prototype
* @param {DOMApplication} addon addon to check
* @param {String} manifestURL app manifest URL to check
* @returns {Promise.<Boolean>}
*/
addonAffectsApp: function(addon, manifestURL) {
if (!manifestURL) {
return Promise.reject('No manifestURL given for an app');
}
if (!this._isAddon(addon.instance)) {
return Promise.reject('not an addon');
}
var manifest = this._getManifest(addon.instance);
var customizations = manifest && manifest.customizations;
// content_scripts field is internally copied from manifest.json to
// manifest.webapp
var contentScripts = manifest && manifest.content_scripts;
// If the addon specifies customizations, then it is obsolete and does not
// work
if (customizations) {
return Promise.resolve(false);
}
// If there are no content scripts or content scripts list is empty, the
// addon does not affect any apps.
if (!contentScripts || contentScripts.length === 0) {
return Promise.resolve(false);
}
return AppsCache.apps()
.then(apps => apps.find(app => app.manifestURL === manifestURL))
.then(app => app ?
this._matchContentScripts(contentScripts, app) : undefined)
.then(appliedContentScripts => !!appliedContentScripts)
.catch(() => false);
},
get length() {
return this._addons.length;
},
get addons() {
return this._addons;
}
};
return new AddonManager();
});