michael@0: /* This Source Code Form is subject to the terms of the Mozilla Public michael@0: * License, v. 2.0. If a copy of the MPL was not distributed with this file, michael@0: * You can obtain one at http://mozilla.org/MPL/2.0/. */ michael@0: michael@0: "use strict"; michael@0: michael@0: this.EXPORTED_SYMBOLS = ["WebappManager"]; michael@0: michael@0: const { classes: Cc, interfaces: Ci, utils: Cu } = Components; michael@0: michael@0: const UPDATE_URL_PREF = "browser.webapps.updateCheckUrl"; michael@0: michael@0: Cu.import("resource://gre/modules/AppsUtils.jsm"); michael@0: Cu.import("resource://gre/modules/XPCOMUtils.jsm"); michael@0: Cu.import("resource://gre/modules/Services.jsm"); michael@0: Cu.import("resource://gre/modules/NetUtil.jsm"); michael@0: Cu.import("resource://gre/modules/FileUtils.jsm"); michael@0: Cu.import("resource://gre/modules/DOMRequestHelper.jsm"); michael@0: Cu.import("resource://gre/modules/Webapps.jsm"); michael@0: Cu.import("resource://gre/modules/osfile.jsm"); michael@0: Cu.import("resource://gre/modules/Promise.jsm"); michael@0: Cu.import("resource://gre/modules/Task.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyModuleGetter(this, "Notifications", "resource://gre/modules/Notifications.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "sendMessageToJava", "resource://gre/modules/Messaging.jsm"); michael@0: XPCOMUtils.defineLazyModuleGetter(this, "PluralForm", "resource://gre/modules/PluralForm.jsm"); michael@0: michael@0: XPCOMUtils.defineLazyGetter(this, "Strings", function() { michael@0: return Services.strings.createBundle("chrome://browser/locale/webapp.properties"); michael@0: }); michael@0: michael@0: function debug(aMessage) { michael@0: // We use *dump* instead of Services.console.logStringMessage so the messages michael@0: // have the INFO level of severity instead of the ERROR level. And we don't michael@0: // append a newline character to the end of the message because *dump* spills michael@0: // into the Android native logging system, which strips newlines from messages michael@0: // and breaks messages into lines automatically at display time (i.e. logcat). michael@0: #ifdef DEBUG michael@0: dump(aMessage); michael@0: #endif michael@0: } michael@0: michael@0: this.WebappManager = { michael@0: __proto__: DOMRequestIpcHelper.prototype, michael@0: michael@0: get _testing() { michael@0: try { michael@0: return Services.prefs.getBoolPref("browser.webapps.testing"); michael@0: } catch(ex) { michael@0: return false; michael@0: } michael@0: }, michael@0: michael@0: install: function(aMessage, aMessageManager) { michael@0: if (this._testing) { michael@0: // Go directly to DOM. Do not download/install APK, do not collect $200. michael@0: DOMApplicationRegistry.doInstall(aMessage, aMessageManager); michael@0: return; michael@0: } michael@0: michael@0: this._installApk(aMessage, aMessageManager); michael@0: }, michael@0: michael@0: installPackage: function(aMessage, aMessageManager) { michael@0: if (this._testing) { michael@0: // Go directly to DOM. Do not download/install APK, do not collect $200. michael@0: DOMApplicationRegistry.doInstallPackage(aMessage, aMessageManager); michael@0: return; michael@0: } michael@0: michael@0: this._installApk(aMessage, aMessageManager); michael@0: }, michael@0: michael@0: _installApk: function(aMessage, aMessageManager) { return Task.spawn((function*() { michael@0: let filePath; michael@0: michael@0: try { michael@0: filePath = yield this._downloadApk(aMessage.app.manifestURL); michael@0: } catch(ex) { michael@0: aMessage.error = ex; michael@0: aMessageManager.sendAsyncMessage("Webapps:Install:Return:KO", aMessage); michael@0: debug("error downloading APK: " + ex); michael@0: return; michael@0: } michael@0: michael@0: sendMessageToJava({ michael@0: type: "Webapps:InstallApk", michael@0: filePath: filePath, michael@0: data: JSON.stringify(aMessage), michael@0: }); michael@0: }).bind(this)); }, michael@0: michael@0: _downloadApk: function(aManifestUrl) { michael@0: debug("_downloadApk for " + aManifestUrl); michael@0: let deferred = Promise.defer(); michael@0: michael@0: // Get the endpoint URL and convert it to an nsIURI/nsIURL object. michael@0: const GENERATOR_URL_PREF = "browser.webapps.apkFactoryUrl"; michael@0: const GENERATOR_URL_BASE = Services.prefs.getCharPref(GENERATOR_URL_PREF); michael@0: let generatorUrl = NetUtil.newURI(GENERATOR_URL_BASE).QueryInterface(Ci.nsIURL); michael@0: michael@0: // Populate the query part of the URL with the manifest URL parameter. michael@0: let params = { michael@0: manifestUrl: aManifestUrl, michael@0: }; michael@0: generatorUrl.query = michael@0: [p + "=" + encodeURIComponent(params[p]) for (p in params)].join("&"); michael@0: debug("downloading APK from " + generatorUrl.spec); michael@0: michael@0: let file = Cc["@mozilla.org/download-manager;1"]. michael@0: getService(Ci.nsIDownloadManager). michael@0: defaultDownloadsDirectory. michael@0: clone(); michael@0: file.append(aManifestUrl.replace(/[^a-zA-Z0-9]/gi, "") + ".apk"); michael@0: file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); michael@0: debug("downloading APK to " + file.path); michael@0: michael@0: let worker = new ChromeWorker("resource://gre/modules/WebappManagerWorker.js"); michael@0: worker.onmessage = function(event) { michael@0: let { type, message } = event.data; michael@0: michael@0: worker.terminate(); michael@0: michael@0: if (type == "success") { michael@0: deferred.resolve(file.path); michael@0: } else { // type == "failure" michael@0: debug("error downloading APK: " + message); michael@0: deferred.reject(message); michael@0: } michael@0: } michael@0: michael@0: // Trigger the download. michael@0: worker.postMessage({ url: generatorUrl.spec, path: file.path }); michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: askInstall: function(aData) { michael@0: let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile); michael@0: file.initWithPath(aData.profilePath); michael@0: michael@0: // We don't yet support pre-installing an appcache because it isn't clear michael@0: // how to do it without degrading the user experience (since users expect michael@0: // apps to be available after the system tells them they've been installed, michael@0: // which has already happened) and because nsCacheService shuts down michael@0: // when we trigger the native install dialog and doesn't re-init itself michael@0: // afterward (TODO: file bug about this behavior). michael@0: if ("appcache_path" in aData.app.manifest) { michael@0: debug("deleting appcache_path from manifest: " + aData.app.manifest.appcache_path); michael@0: delete aData.app.manifest.appcache_path; michael@0: } michael@0: michael@0: DOMApplicationRegistry.registryReady.then(() => { michael@0: DOMApplicationRegistry.confirmInstall(aData, file, (function(aManifest) { michael@0: let localeManifest = new ManifestHelper(aManifest, aData.app.origin); michael@0: michael@0: // aData.app.origin may now point to the app: url that hosts this app. michael@0: sendMessageToJava({ michael@0: type: "Webapps:Postinstall", michael@0: apkPackageName: aData.app.apkPackageName, michael@0: origin: aData.app.origin, michael@0: }); michael@0: michael@0: this.writeDefaultPrefs(file, localeManifest); michael@0: }).bind(this)); michael@0: }); michael@0: }, michael@0: michael@0: launch: function({ manifestURL, origin }) { michael@0: debug("launchWebapp: " + manifestURL); michael@0: michael@0: sendMessageToJava({ michael@0: type: "Webapps:Open", michael@0: manifestURL: manifestURL, michael@0: origin: origin michael@0: }); michael@0: }, michael@0: michael@0: uninstall: function(aData) { michael@0: debug("uninstall: " + aData.manifestURL); michael@0: michael@0: if (this._testing) { michael@0: // We don't have to do anything, as the registry does all the work. michael@0: return; michael@0: } michael@0: michael@0: // TODO: uninstall the APK. michael@0: }, michael@0: michael@0: autoInstall: function(aData) { michael@0: let oldApp = DOMApplicationRegistry.getAppByManifestURL(aData.manifestURL); michael@0: if (oldApp) { michael@0: // If the app is already installed, update the existing installation. michael@0: this._autoUpdate(aData, oldApp); michael@0: return; michael@0: } michael@0: michael@0: let mm = { michael@0: sendAsyncMessage: function (aMessageName, aData) { michael@0: // TODO hook this back to Java to report errors. michael@0: debug("sendAsyncMessage " + aMessageName + ": " + JSON.stringify(aData)); michael@0: } michael@0: }; michael@0: michael@0: let origin = Services.io.newURI(aData.manifestURL, null, null).prePath; michael@0: michael@0: let message = aData.request || { michael@0: app: { michael@0: origin: origin, michael@0: receipts: [], michael@0: } michael@0: }; michael@0: michael@0: if (aData.updateManifest) { michael@0: if (aData.zipFilePath) { michael@0: aData.updateManifest.package_path = aData.zipFilePath; michael@0: } michael@0: message.app.updateManifest = aData.updateManifest; michael@0: } michael@0: michael@0: // The manifest url may be subtly different between the michael@0: // time the APK was built and the APK being installed. michael@0: // Thus, we should take the APK as the source of truth. michael@0: message.app.manifestURL = aData.manifestURL; michael@0: message.app.manifest = aData.manifest; michael@0: message.app.apkPackageName = aData.apkPackageName; michael@0: message.profilePath = aData.profilePath; michael@0: message.mm = mm; michael@0: message.apkInstall = true; michael@0: michael@0: DOMApplicationRegistry.registryReady.then(() => { michael@0: switch (aData.type) { // can be hosted or packaged. michael@0: case "hosted": michael@0: DOMApplicationRegistry.doInstall(message, mm); michael@0: break; michael@0: michael@0: case "packaged": michael@0: message.isPackage = true; michael@0: DOMApplicationRegistry.doInstallPackage(message, mm); michael@0: break; michael@0: } michael@0: }); michael@0: }, michael@0: michael@0: _autoUpdate: function(aData, aOldApp) { return Task.spawn((function*() { michael@0: debug("_autoUpdate app of type " + aData.type); michael@0: michael@0: if (aOldApp.apkPackageName != aData.apkPackageName) { michael@0: // This happens when the app was installed as a shortcut via the old michael@0: // runtime and is now being updated to an APK. michael@0: debug("update apkPackageName from " + aOldApp.apkPackageName + " to " + aData.apkPackageName); michael@0: aOldApp.apkPackageName = aData.apkPackageName; michael@0: } michael@0: michael@0: if (aData.type == "hosted") { michael@0: let oldManifest = yield DOMApplicationRegistry.getManifestFor(aData.manifestURL); michael@0: DOMApplicationRegistry.updateHostedApp(aData, aOldApp.id, aOldApp, oldManifest, aData.manifest); michael@0: } else { michael@0: DOMApplicationRegistry.updatePackagedApp(aData, aOldApp.id, aOldApp, aData.manifest); michael@0: } michael@0: }).bind(this)); }, michael@0: michael@0: _checkingForUpdates: false, michael@0: michael@0: checkForUpdates: function(userInitiated) { return Task.spawn((function*() { michael@0: debug("checkForUpdates"); michael@0: michael@0: // Don't start checking for updates if we're already doing so. michael@0: // TODO: Consider cancelling the old one and starting a new one anyway michael@0: // if the user requested this one. michael@0: if (this._checkingForUpdates) { michael@0: debug("already checking for updates"); michael@0: return; michael@0: } michael@0: this._checkingForUpdates = true; michael@0: michael@0: try { michael@0: let installedApps = yield this._getInstalledApps(); michael@0: if (installedApps.length === 0) { michael@0: return; michael@0: } michael@0: michael@0: // Map APK names to APK versions. michael@0: let apkNameToVersion = yield this._getAPKVersions(installedApps.map(app => michael@0: app.apkPackageName).filter(apkPackageName => !!apkPackageName) michael@0: ); michael@0: michael@0: // Map manifest URLs to APK versions, which is what the service needs michael@0: // in order to tell us which apps are outdated; and also map them to app michael@0: // objects, which the downloader/installer uses to download/install APKs. michael@0: // XXX Will this cause us to update apps without packages, and if so, michael@0: // does that satisfy the legacy migration story? michael@0: let manifestUrlToApkVersion = {}; michael@0: let manifestUrlToApp = {}; michael@0: for (let app of installedApps) { michael@0: manifestUrlToApkVersion[app.manifestURL] = apkNameToVersion[app.apkPackageName] || 0; michael@0: manifestUrlToApp[app.manifestURL] = app; michael@0: } michael@0: michael@0: let outdatedApps = yield this._getOutdatedApps(manifestUrlToApkVersion, userInitiated); michael@0: michael@0: if (outdatedApps.length === 0) { michael@0: // If the user asked us to check for updates, tell 'em we came up empty. michael@0: if (userInitiated) { michael@0: this._notify({ michael@0: title: Strings.GetStringFromName("noUpdatesTitle"), michael@0: message: Strings.GetStringFromName("noUpdatesMessage"), michael@0: icon: "drawable://alert_app", michael@0: }); michael@0: } michael@0: return; michael@0: } michael@0: michael@0: let names = [manifestUrlToApp[url].name for (url of outdatedApps)].join(", "); michael@0: let accepted = yield this._notify({ michael@0: title: PluralForm.get(outdatedApps.length, Strings.GetStringFromName("downloadUpdateTitle")). michael@0: replace("#1", outdatedApps.length), michael@0: message: Strings.formatStringFromName("downloadUpdateMessage", [names], 1), michael@0: icon: "drawable://alert_app", michael@0: }).dismissed; michael@0: michael@0: if (accepted) { michael@0: yield this._updateApks([manifestUrlToApp[url] for (url of outdatedApps)]); michael@0: } michael@0: } michael@0: // There isn't a catch block because we want the error to propagate through michael@0: // the promise chain, so callers can receive it and choose to respond to it. michael@0: finally { michael@0: // Ensure we update the _checkingForUpdates flag even if there's an error; michael@0: // otherwise the process will get stuck and never check for updates again. michael@0: this._checkingForUpdates = false; michael@0: } michael@0: }).bind(this)); }, michael@0: michael@0: _getAPKVersions: function(packageNames) { michael@0: let deferred = Promise.defer(); michael@0: michael@0: sendMessageToJava({ michael@0: type: "Webapps:GetApkVersions", michael@0: packageNames: packageNames michael@0: }, data => deferred.resolve(data.versions)); michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: _getInstalledApps: function() { michael@0: let deferred = Promise.defer(); michael@0: DOMApplicationRegistry.getAll(apps => deferred.resolve(apps)); michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: _getOutdatedApps: function(installedApps, userInitiated) { michael@0: let deferred = Promise.defer(); michael@0: michael@0: let data = JSON.stringify({ installed: installedApps }); michael@0: michael@0: let notification; michael@0: if (userInitiated) { michael@0: notification = this._notify({ michael@0: title: Strings.GetStringFromName("checkingForUpdatesTitle"), michael@0: message: Strings.GetStringFromName("checkingForUpdatesMessage"), michael@0: // TODO: replace this with an animated icon. michael@0: icon: "drawable://alert_app", michael@0: progress: NaN, michael@0: }); michael@0: } michael@0: michael@0: let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. michael@0: createInstance(Ci.nsIXMLHttpRequest). michael@0: QueryInterface(Ci.nsIXMLHttpRequestEventTarget); michael@0: request.mozBackgroundRequest = true; michael@0: request.open("POST", Services.prefs.getCharPref(UPDATE_URL_PREF), true); michael@0: request.channel.loadFlags = Ci.nsIChannel.LOAD_ANONYMOUS | michael@0: Ci.nsIChannel.LOAD_BYPASS_CACHE | michael@0: Ci.nsIChannel.INHIBIT_CACHING; michael@0: request.onload = function() { michael@0: if (userInitiated) { michael@0: notification.cancel(); michael@0: } michael@0: deferred.resolve(JSON.parse(this.response).outdated); michael@0: }; michael@0: request.onerror = function() { michael@0: if (userInitiated) { michael@0: notification.cancel(); michael@0: } michael@0: deferred.reject(this.status || this.statusText); michael@0: }; michael@0: request.setRequestHeader("Content-Type", "application/json"); michael@0: request.setRequestHeader("Content-Length", data.length); michael@0: michael@0: request.send(data); michael@0: michael@0: return deferred.promise; michael@0: }, michael@0: michael@0: _updateApks: function(aApps) { return Task.spawn((function*() { michael@0: // Notify the user that we're in the progress of downloading updates. michael@0: let downloadingNames = [app.name for (app of aApps)].join(", "); michael@0: let notification = this._notify({ michael@0: title: PluralForm.get(aApps.length, Strings.GetStringFromName("downloadingUpdateTitle")). michael@0: replace("#1", aApps.length), michael@0: message: Strings.formatStringFromName("downloadingUpdateMessage", [downloadingNames], 1), michael@0: // TODO: replace this with an animated icon. UpdateService uses michael@0: // android.R.drawable.stat_sys_download, but I don't think we can reference michael@0: // a system icon with a drawable: URL here, so we'll have to craft our own. michael@0: icon: "drawable://alert_download", michael@0: // TODO: make this a determinate progress indicator once we can determine michael@0: // the sizes of the APKs and observe their progress. michael@0: progress: NaN, michael@0: }); michael@0: michael@0: // Download the APKs for the given apps. We do this serially to avoid michael@0: // saturating the user's network connection. michael@0: // TODO: download APKs in parallel (or at least more than one at a time) michael@0: // if it seems reasonable. michael@0: let downloadedApks = []; michael@0: let downloadFailedApps = []; michael@0: for (let app of aApps) { michael@0: try { michael@0: let filePath = yield this._downloadApk(app.manifestURL); michael@0: downloadedApks.push({ app: app, filePath: filePath }); michael@0: } catch(ex) { michael@0: downloadFailedApps.push(app); michael@0: } michael@0: } michael@0: michael@0: notification.cancel(); michael@0: michael@0: // Notify the user if any downloads failed, but don't do anything michael@0: // when the user accepts/cancels the notification. michael@0: // In the future, we might prompt the user to retry the download. michael@0: if (downloadFailedApps.length > 0) { michael@0: let downloadFailedNames = [app.name for (app of downloadFailedApps)].join(", "); michael@0: this._notify({ michael@0: title: PluralForm.get(downloadFailedApps.length, Strings.GetStringFromName("downloadFailedTitle")). michael@0: replace("#1", downloadFailedApps.length), michael@0: message: Strings.formatStringFromName("downloadFailedMessage", [downloadFailedNames], 1), michael@0: icon: "drawable://alert_app", michael@0: }); michael@0: } michael@0: michael@0: // If we weren't able to download any APKs, then there's nothing more to do. michael@0: if (downloadedApks.length === 0) { michael@0: return; michael@0: } michael@0: michael@0: // Prompt the user to update the apps for which we downloaded APKs, and wait michael@0: // until they accept/cancel the notification. michael@0: let downloadedNames = [apk.app.name for (apk of downloadedApks)].join(", "); michael@0: let accepted = yield this._notify({ michael@0: title: PluralForm.get(downloadedApks.length, Strings.GetStringFromName("installUpdateTitle")). michael@0: replace("#1", downloadedApks.length), michael@0: message: Strings.formatStringFromName("installUpdateMessage", [downloadedNames], 1), michael@0: icon: "drawable://alert_app", michael@0: }).dismissed; michael@0: michael@0: if (accepted) { michael@0: // The user accepted the notification, so install the downloaded APKs. michael@0: for (let apk of downloadedApks) { michael@0: let msg = { michael@0: app: apk.app, michael@0: // TODO: figure out why Webapps:InstallApk needs the "from" property. michael@0: from: apk.app.installOrigin, michael@0: }; michael@0: sendMessageToJava({ michael@0: type: "Webapps:InstallApk", michael@0: filePath: apk.filePath, michael@0: data: JSON.stringify(msg), michael@0: }); michael@0: } michael@0: } else { michael@0: // The user cancelled the notification, so remove the downloaded APKs. michael@0: for (let apk of downloadedApks) { michael@0: try { michael@0: yield OS.file.remove(apk.filePath); michael@0: } catch(ex) { michael@0: debug("error removing " + apk.filePath + " for cancelled update: " + ex); michael@0: } michael@0: } michael@0: } michael@0: michael@0: }).bind(this)); }, michael@0: michael@0: _notify: function(aOptions) { michael@0: dump("_notify: " + aOptions.title); michael@0: michael@0: // Resolves to true if the notification is "clicked" (i.e. touched) michael@0: // and false if the notification is "cancelled" by swiping it away. michael@0: let dismissed = Promise.defer(); michael@0: michael@0: // TODO: make notifications expandable so users can expand them to read text michael@0: // that gets cut off in standard notifications. michael@0: let id = Notifications.create({ michael@0: title: aOptions.title, michael@0: message: aOptions.message, michael@0: icon: aOptions.icon, michael@0: progress: aOptions.progress, michael@0: onClick: function(aId, aCookie) { michael@0: dismissed.resolve(true); michael@0: }, michael@0: onCancel: function(aId, aCookie) { michael@0: dismissed.resolve(false); michael@0: }, michael@0: }); michael@0: michael@0: // Return an object with a promise that resolves when the notification michael@0: // is dismissed by the user along with a method for cancelling it, michael@0: // so callers who want to wait for user action can do so, while those michael@0: // who want to control the notification's lifecycle can do that instead. michael@0: return { michael@0: dismissed: dismissed.promise, michael@0: cancel: function() { michael@0: Notifications.cancel(id); michael@0: }, michael@0: }; michael@0: }, michael@0: michael@0: autoUninstall: function(aData) { michael@0: DOMApplicationRegistry.registryReady.then(() => { michael@0: for (let id in DOMApplicationRegistry.webapps) { michael@0: let app = DOMApplicationRegistry.webapps[id]; michael@0: if (aData.apkPackageNames.indexOf(app.apkPackageName) > -1) { michael@0: debug("attempting to uninstall " + app.name); michael@0: DOMApplicationRegistry.uninstall( michael@0: app.manifestURL, michael@0: function() { michael@0: debug("success uninstalling " + app.name); michael@0: }, michael@0: function(error) { michael@0: debug("error uninstalling " + app.name + ": " + error); michael@0: } michael@0: ); michael@0: } michael@0: } michael@0: }); michael@0: }, michael@0: michael@0: writeDefaultPrefs: function(aProfile, aManifest) { michael@0: // build any app specific default prefs michael@0: let prefs = []; michael@0: if (aManifest.orientation) { michael@0: let orientation = aManifest.orientation; michael@0: if (Array.isArray(orientation)) { michael@0: orientation = orientation.join(","); michael@0: } michael@0: prefs.push({ name: "app.orientation.default", value: orientation }); michael@0: } michael@0: michael@0: // write them into the app profile michael@0: let defaultPrefsFile = aProfile.clone(); michael@0: defaultPrefsFile.append(this.DEFAULT_PREFS_FILENAME); michael@0: this._writeData(defaultPrefsFile, prefs); michael@0: }, michael@0: michael@0: _writeData: function(aFile, aPrefs) { michael@0: if (aPrefs.length > 0) { michael@0: let array = new TextEncoder().encode(JSON.stringify(aPrefs)); michael@0: OS.File.writeAtomic(aFile.path, array, { tmpPath: aFile.path + ".tmp" }).then(null, function onError(reason) { michael@0: debug("Error writing default prefs: " + reason); michael@0: }); michael@0: } michael@0: }, michael@0: michael@0: DEFAULT_PREFS_FILENAME: "default-prefs.js", michael@0: michael@0: };