diff options
Diffstat (limited to 'devtools/shared/apps/app-actor-front.js')
-rw-r--r-- | devtools/shared/apps/app-actor-front.js | 840 |
1 files changed, 840 insertions, 0 deletions
diff --git a/devtools/shared/apps/app-actor-front.js b/devtools/shared/apps/app-actor-front.js new file mode 100644 index 000000000..6cd793679 --- /dev/null +++ b/devtools/shared/apps/app-actor-front.js @@ -0,0 +1,840 @@ +/* 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"; + +const {Ci, Cc, Cr} = require("chrome"); +const {OS} = require("resource://gre/modules/osfile.jsm"); +const {FileUtils} = require("resource://gre/modules/FileUtils.jsm"); +const {NetUtil} = require("resource://gre/modules/NetUtil.jsm"); +const promise = require("promise"); +const defer = require("devtools/shared/defer"); +const DevToolsUtils = require("devtools/shared/DevToolsUtils"); +const EventEmitter = require("devtools/shared/event-emitter"); + +// Bug 1188401: When loaded from xpcshell tests, we do not have browser/ files +// and can't load target.js. Should be fixed by bug 912121. +loader.lazyRequireGetter(this, "TargetFactory", "devtools/client/framework/target", true); + +// XXX: bug 912476 make this module a real protocol.js front +// by converting webapps actor to protocol.js + +const PR_USEC_PER_MSEC = 1000; +const PR_RDWR = 0x04; +const PR_CREATE_FILE = 0x08; +const PR_TRUNCATE = 0x20; + +const CHUNK_SIZE = 10000; + +const appTargets = new Map(); + +function addDirToZip(writer, dir, basePath) { + let files = dir.directoryEntries; + + while (files.hasMoreElements()) { + let file = files.getNext().QueryInterface(Ci.nsIFile); + + if (file.isHidden() || + file.isSpecial() || + file.equals(writer.file)) + { + continue; + } + + if (file.isDirectory()) { + writer.addEntryDirectory(basePath + file.leafName + "/", + file.lastModifiedTime * PR_USEC_PER_MSEC, + true); + addDirToZip(writer, file, basePath + file.leafName + "/"); + } else { + writer.addEntryFile(basePath + file.leafName, + Ci.nsIZipWriter.COMPRESSION_DEFAULT, + file, + true); + } + } +} + +/** + * Convert an XPConnect result code to its name and message. + * We have to extract them from an exception per bug 637307 comment 5. + */ +function getResultText(code) { + let regexp = + /^\[Exception... "(.*)" nsresult: "0x[0-9a-fA-F]* \((.*)\)" location: ".*" data: .*\]$/; + let ex = Cc["@mozilla.org/js/xpc/Exception;1"]. + createInstance(Ci.nsIXPCException); + ex.initialize(null, code, null, null, null, null); + let [, message, name] = regexp.exec(ex.toString()); + return { name: name, message: message }; +} + +function zipDirectory(zipFile, dirToArchive) { + let deferred = defer(); + let writer = Cc["@mozilla.org/zipwriter;1"].createInstance(Ci.nsIZipWriter); + writer.open(zipFile, PR_RDWR | PR_CREATE_FILE | PR_TRUNCATE); + + this.addDirToZip(writer, dirToArchive, ""); + + writer.processQueue({ + onStartRequest: function onStartRequest(request, context) {}, + onStopRequest: (request, context, status) => { + if (status == Cr.NS_OK) { + writer.close(); + deferred.resolve(zipFile); + } + else { + let { name, message } = getResultText(status); + deferred.reject(name + ": " + message); + } + } + }, null); + + return deferred.promise; +} + +function uploadPackage(client, webappsActor, packageFile, progressCallback) { + if (client.traits.bulk) { + return uploadPackageBulk(client, webappsActor, packageFile, progressCallback); + } else { + return uploadPackageJSON(client, webappsActor, packageFile, progressCallback); + } +} + +function uploadPackageJSON(client, webappsActor, packageFile, progressCallback) { + let deferred = defer(); + + let request = { + to: webappsActor, + type: "uploadPackage" + }; + client.request(request, (res) => { + openFile(res.actor); + }); + + let fileSize; + let bytesRead = 0; + + function emitProgress() { + progressCallback({ + bytesSent: bytesRead, + totalBytes: fileSize + }); + } + + function openFile(actor) { + let openedFile; + OS.File.open(packageFile.path) + .then(file => { + openedFile = file; + return openedFile.stat(); + }) + .then(fileInfo => { + fileSize = fileInfo.size; + emitProgress(); + uploadChunk(actor, openedFile); + }); + } + function uploadChunk(actor, file) { + file.read(CHUNK_SIZE) + .then(function (bytes) { + bytesRead += bytes.length; + emitProgress(); + // To work around the fact that JSON.stringify translates the typed + // array to object, we are encoding the typed array here into a string + let chunk = String.fromCharCode.apply(null, bytes); + + let request = { + to: actor, + type: "chunk", + chunk: chunk + }; + client.request(request, (res) => { + if (bytes.length == CHUNK_SIZE) { + uploadChunk(actor, file); + } else { + file.close().then(function () { + endsUpload(actor); + }); + } + }); + }); + } + function endsUpload(actor) { + let request = { + to: actor, + type: "done" + }; + client.request(request, (res) => { + deferred.resolve(actor); + }); + } + return deferred.promise; +} + +function uploadPackageBulk(client, webappsActor, packageFile, progressCallback) { + let deferred = defer(); + + let request = { + to: webappsActor, + type: "uploadPackage", + bulk: true + }; + client.request(request, (res) => { + startBulkUpload(res.actor); + }); + + function startBulkUpload(actor) { + console.log("Starting bulk upload"); + let fileSize = packageFile.fileSize; + console.log("File size: " + fileSize); + + let request = client.startBulkRequest({ + actor: actor, + type: "stream", + length: fileSize + }); + + request.on("bulk-send-ready", ({copyFrom}) => { + NetUtil.asyncFetch({ + uri: NetUtil.newURI(packageFile), + loadUsingSystemPrincipal: true + }, function (inputStream) { + let copying = copyFrom(inputStream); + copying.on("progress", (e, progress) => { + progressCallback(progress); + }); + copying.then(() => { + console.log("Bulk upload done"); + inputStream.close(); + deferred.resolve(actor); + }); + }); + }); + } + + return deferred.promise; +} + +function removeServerTemporaryFile(client, fileActor) { + let request = { + to: fileActor, + type: "remove" + }; + client.request(request); +} + +/** + * progressCallback argument: + * Function called as packaged app installation proceeds. + * The progress object passed to this function contains: + * * bytesSent: The number of bytes sent so far + * * totalBytes: The total number of bytes to send + */ +function installPackaged(client, webappsActor, packagePath, appId, progressCallback) { + let deferred = defer(); + let file = FileUtils.File(packagePath); + let packagePromise; + if (file.isDirectory()) { + let tmpZipFile = FileUtils.getDir("TmpD", [], true); + tmpZipFile.append("application.zip"); + tmpZipFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, parseInt("666", 8)); + packagePromise = zipDirectory(tmpZipFile, file); + } else { + packagePromise = promise.resolve(file); + } + packagePromise.then((zipFile) => { + uploadPackage(client, webappsActor, zipFile, progressCallback) + .then((fileActor) => { + let request = { + to: webappsActor, + type: "install", + appId: appId, + upload: fileActor + }; + client.request(request, (res) => { + // If the install method immediatly fails, + // reject immediatly the installPackaged promise. + // Otherwise, wait for webappsEvent for completion + if (res.error) { + deferred.reject(res); + } + if ("error" in res) + deferred.reject({error: res.error, message: res.message}); + else + deferred.resolve({appId: res.appId}); + }); + // Ensure deleting the temporary package file, but only if that a temporary + // package created when we pass a directory as `packagePath` + if (zipFile != file) + zipFile.remove(false); + // In case of success or error, ensure deleting the temporary package file + // also created on the device, but only once install request is done + deferred.promise.then( + () => removeServerTemporaryFile(client, fileActor), + () => removeServerTemporaryFile(client, fileActor)); + }); + }); + return deferred.promise; +} +exports.installPackaged = installPackaged; + +function installHosted(client, webappsActor, appId, metadata, manifest) { + let deferred = defer(); + let request = { + to: webappsActor, + type: "install", + appId: appId, + metadata: metadata, + manifest: manifest + }; + client.request(request, (res) => { + if (res.error) { + deferred.reject(res); + } + if ("error" in res) + deferred.reject({error: res.error, message: res.message}); + else + deferred.resolve({appId: res.appId}); + }); + return deferred.promise; +} +exports.installHosted = installHosted; + +function getTargetForApp(client, webappsActor, manifestURL) { + // Ensure always returning the exact same JS object for a target + // of the same app in order to show only one toolbox per app and + // avoid re-creating lot of objects twice. + let existingTarget = appTargets.get(manifestURL); + if (existingTarget) + return promise.resolve(existingTarget); + + let deferred = defer(); + let request = { + to: webappsActor, + type: "getAppActor", + manifestURL: manifestURL, + }; + client.request(request, (res) => { + if (res.error) { + deferred.reject(res.error); + } else { + let options = { + form: res.actor, + client: client, + chrome: false + }; + + TargetFactory.forRemoteTab(options).then((target) => { + target.isApp = true; + appTargets.set(manifestURL, target); + target.on("close", () => { + appTargets.delete(manifestURL); + }); + deferred.resolve(target); + }, (error) => { + deferred.reject(error); + }); + } + }); + return deferred.promise; +} +exports.getTargetForApp = getTargetForApp; + +function reloadApp(client, webappsActor, manifestURL) { + return getTargetForApp(client, + webappsActor, + manifestURL). + then((target) => { + // Request the ContentActor to reload the app + let request = { + to: target.form.actor, + type: "reload", + options: { + force: true + }, + manifestURL: manifestURL + }; + return client.request(request); + }, () => { + throw new Error("Not running"); + }); +} +exports.reloadApp = reloadApp; + +function launchApp(client, webappsActor, manifestURL) { + return client.request({ + to: webappsActor, + type: "launch", + manifestURL: manifestURL + }); +} +exports.launchApp = launchApp; + +function closeApp(client, webappsActor, manifestURL) { + return client.request({ + to: webappsActor, + type: "close", + manifestURL: manifestURL + }); +} +exports.closeApp = closeApp; + +function getTarget(client, form) { + let deferred = defer(); + let options = { + form: form, + client: client, + chrome: false + }; + + TargetFactory.forRemoteTab(options).then((target) => { + target.isApp = true; + deferred.resolve(target); + }, (error) => { + deferred.reject(error); + }); + return deferred.promise; +} + +/** + * `App` instances are client helpers to manage a given app + * and its the tab actors + */ +function App(client, webappsActor, manifest) { + this.client = client; + this.webappsActor = webappsActor; + this.manifest = manifest; + + // This attribute is managed by the AppActorFront + this.running = false; + + this.iconURL = null; +} + +App.prototype = { + getForm: function () { + if (this._form) { + return promise.resolve(this._form); + } + let request = { + to: this.webappsActor, + type: "getAppActor", + manifestURL: this.manifest.manifestURL + }; + return this.client.request(request) + .then(res => { + return this._form = res.actor; + }); + }, + + getTarget: function () { + if (this._target) { + return promise.resolve(this._target); + } + return this.getForm(). + then((form) => getTarget(this.client, form)). + then((target) => { + target.on("close", () => { + delete this._form; + delete this._target; + }); + return this._target = target; + }); + }, + + launch: function () { + return launchApp(this.client, this.webappsActor, + this.manifest.manifestURL); + }, + + reload: function () { + return reloadApp(this.client, this.webappsActor, + this.manifest.manifestURL); + }, + + close: function () { + return closeApp(this.client, this.webappsActor, + this.manifest.manifestURL); + }, + + getIcon: function () { + if (this.iconURL) { + return promise.resolve(this.iconURL); + } + + let deferred = defer(); + + let request = { + to: this.webappsActor, + type: "getIconAsDataURL", + manifestURL: this.manifest.manifestURL + }; + + this.client.request(request, res => { + if (res.error) { + deferred.reject(res.message || res.error); + } else if (res.url) { + this.iconURL = res.url; + deferred.resolve(res.url); + } else { + deferred.reject("Unable to fetch app icon"); + } + }); + + return deferred.promise; + } +}; + + +/** + * `AppActorFront` is a client for the webapps actor. + */ +function AppActorFront(client, form) { + this.client = client; + this.actor = form.webappsActor; + + this._clientListener = this._clientListener.bind(this); + this._onInstallProgress = this._onInstallProgress.bind(this); + + this._listeners = []; + EventEmitter.decorate(this); +} + +AppActorFront.prototype = { + /** + * List `App` instances for all currently running apps. + */ + get runningApps() { + if (!this._apps) { + throw new Error("Can't get running apps before calling watchApps."); + } + let r = new Map(); + for (let [manifestURL, app] of this._apps) { + if (app.running) { + r.set(manifestURL, app); + } + } + return r; + }, + + /** + * List `App` instances for all installed apps. + */ + get apps() { + if (!this._apps) { + throw new Error("Can't get apps before calling watchApps."); + } + return this._apps; + }, + + /** + * Returns a `App` object instance for the given manifest URL + * (and cache it per AppActorFront object) + */ + _getApp: function (manifestURL) { + let app = this._apps ? this._apps.get(manifestURL) : null; + if (app) { + return promise.resolve(app); + } else { + let request = { + to: this.actor, + type: "getApp", + manifestURL: manifestURL + }; + return this.client.request(request) + .then(res => { + let app = new App(this.client, this.actor, res.app); + if (this._apps) { + this._apps.set(manifestURL, app); + } + return app; + }, e => { + console.error("Unable to retrieve app", manifestURL, e); + }); + } + }, + + /** + * Starts watching for app opening/closing installing/uninstalling. + * Needs to be called before using `apps` or `runningApps` attributes. + */ + watchApps: function (listener) { + // Fixes race between two references to the same front + // calling watchApps at the same time + if (this._loadingPromise) { + return this._loadingPromise; + } + + // Only call watchApps for the first listener being register, + // for all next ones, just send fake appOpen events for already + // opened apps + if (this._apps) { + this.runningApps.forEach((app, manifestURL) => { + listener("appOpen", app); + }); + return promise.resolve(); + } + + // First retrieve all installed apps and create + // related `App` object for each + let request = { + to: this.actor, + type: "getAll" + }; + return this._loadingPromise = this.client.request(request) + .then(res => { + delete this._loadingPromise; + this._apps = new Map(); + for (let a of res.apps) { + let app = new App(this.client, this.actor, a); + this._apps.set(a.manifestURL, app); + } + }) + .then(() => { + // Then retrieve all running apps in order to flag them as running + let request = { + to: this.actor, + type: "listRunningApps" + }; + return this.client.request(request) + .then(res => res.apps); + }) + .then(apps => { + let promises = apps.map(manifestURL => { + // _getApp creates `App` instance and register it to AppActorFront + return this._getApp(manifestURL) + .then(app => { + app.running = true; + // Fake appOpen event for all already opened + this._notifyListeners("appOpen", app); + }); + }); + return promise.all(promises); + }) + .then(() => { + // Finally ask to receive all app events + return this._listenAppEvents(listener); + }); + }, + + fetchIcons: function () { + // On demand, retrieve apps icons in order to be able + // to synchronously retrieve it on `App` objects + let promises = []; + for (let [manifestURL, app] of this._apps) { + promises.push(app.getIcon()); + } + + return DevToolsUtils.settleAll(promises) + .then(null, () => {}); + }, + + _listenAppEvents: function (listener) { + this._listeners.push(listener); + + if (this._listeners.length > 1) { + return promise.resolve(); + } + + let client = this.client; + let f = this._clientListener; + client.addListener("appOpen", f); + client.addListener("appClose", f); + client.addListener("appInstall", f); + client.addListener("appUninstall", f); + + let request = { + to: this.actor, + type: "watchApps" + }; + return this.client.request(request); + }, + + _unlistenAppEvents: function (listener) { + let idx = this._listeners.indexOf(listener); + if (idx != -1) { + this._listeners.splice(idx, 1); + } + + // Until we released all listener, we don't ask to stop sending events + if (this._listeners.length != 0) { + return promise.resolve(); + } + + let client = this.client; + let f = this._clientListener; + client.removeListener("appOpen", f); + client.removeListener("appClose", f); + client.removeListener("appInstall", f); + client.removeListener("appUninstall", f); + + // Remove `_apps` in order to allow calling watchApps again + // and repopulate the apps Map. + delete this._apps; + + let request = { + to: this.actor, + type: "unwatchApps" + }; + return this.client.request(request); + }, + + _clientListener: function (type, message) { + let { manifestURL } = message; + + // Reset the app object to get a fresh copy when we (re)install the app. + if (type == "appInstall" && this._apps && this._apps.has(manifestURL)) { + this._apps.delete(manifestURL); + } + + this._getApp(manifestURL).then((app) => { + switch (type) { + case "appOpen": + app.running = true; + this._notifyListeners("appOpen", app); + break; + case "appClose": + app.running = false; + this._notifyListeners("appClose", app); + break; + case "appInstall": + // The call to _getApp is going to create App object + + // This app may have been running while being installed, so check the list + // of running apps again to get the right answer. + let request = { + to: this.actor, + type: "listRunningApps" + }; + this.client.request(request) + .then(res => { + if (res.apps.indexOf(manifestURL) !== -1) { + app.running = true; + this._notifyListeners("appInstall", app); + this._notifyListeners("appOpen", app); + } else { + this._notifyListeners("appInstall", app); + } + }); + break; + case "appUninstall": + // Fake a appClose event if we didn't got one before uninstall + if (app.running) { + app.running = false; + this._notifyListeners("appClose", app); + } + this._apps.delete(manifestURL); + this._notifyListeners("appUninstall", app); + break; + default: + return; + } + }); + }, + + _notifyListeners: function (type, app) { + this._listeners.forEach(f => { + f(type, app); + }); + }, + + unwatchApps: function (listener) { + return this._unlistenAppEvents(listener); + }, + + /* + * Install a packaged app. + * + * Events are going to be emitted on the front + * as install progresses. Events will have the following fields: + * * bytesSent: The number of bytes sent so far + * * totalBytes: The total number of bytes to send + */ + installPackaged: function (packagePath, appId) { + let request = () => { + return installPackaged(this.client, this.actor, packagePath, appId, + this._onInstallProgress) + .then(response => ({ + appId: response.appId, + manifestURL: "app://" + response.appId + "/manifest.webapp" + })); + }; + return this._install(request); + }, + + _onInstallProgress: function (progress) { + this.emit("install-progress", progress); + }, + + _install: function (request) { + let deferred = defer(); + let finalAppId = null, manifestURL = null; + let installs = {}; + + // We need to resolve only once the request is done *AND* + // once we receive the related appInstall message for + // the same manifestURL + let resolve = app => { + this._unlistenAppEvents(listener); + installs = null; + deferred.resolve({ app: app, appId: finalAppId }); + }; + + // Listen for appInstall event, in order to resolve with + // the matching app object. + let listener = (type, app) => { + if (type == "appInstall") { + // Resolves immediately if the request has already resolved + // or just flag the installed app to eventually resolve + // when the request gets its response. + if (app.manifest.manifestURL === manifestURL) { + resolve(app); + } else { + installs[app.manifest.manifestURL] = app; + } + } + }; + this._listenAppEvents(listener) + // Execute the request + .then(request) + .then(response => { + finalAppId = response.appId; + manifestURL = response.manifestURL; + + // Resolves immediately if the appInstall event + // was dispatched during the request. + if (manifestURL in installs) { + resolve(installs[manifestURL]); + } + }, deferred.reject); + + return deferred.promise; + + }, + + /* + * Install a hosted app. + * + * Events are going to be emitted on the front + * as install progresses. Events will have the following fields: + * * bytesSent: The number of bytes sent so far + * * totalBytes: The total number of bytes to send + */ + installHosted: function (appId, metadata, manifest) { + let manifestURL = metadata.manifestURL || + metadata.origin + "/manifest.webapp"; + let request = () => { + return installHosted(this.client, this.actor, appId, metadata, + manifest) + .then(response => ({ + appId: response.appId, + manifestURL: manifestURL + })); + }; + return this._install(request); + } +}; + +exports.AppActorFront = AppActorFront; |