gecko-dev/browser/devtools/webide/modules/app-manager.js
2014-10-01 16:04:00 +02:00

712 lines
23 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/. */
const {Cu} = require("chrome");
let { Promise: promise } = Cu.import("resource://gre/modules/Promise.jsm", {});
const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {});
const {Devices} = Cu.import("resource://gre/modules/devtools/Devices.jsm");
const {Services} = Cu.import("resource://gre/modules/Services.jsm");
const {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm");
const {Simulator} = Cu.import("resource://gre/modules/devtools/Simulator.jsm");
const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js");
const {TextEncoder, OS} = Cu.import("resource://gre/modules/osfile.jsm", {});
const {AppProjects} = require("devtools/app-manager/app-projects");
const TabStore = require("devtools/webide/tab-store");
const {AppValidator} = require("devtools/app-manager/app-validator");
const {ConnectionManager, Connection} = require("devtools/client/connection-manager");
const {AppActorFront} = require("devtools/app-actor-front");
const {getDeviceFront} = require("devtools/server/actors/device");
const {getPreferenceFront} = require("devtools/server/actors/preference");
const {setTimeout} = require("sdk/timers");
const {Task} = Cu.import("resource://gre/modules/Task.jsm", {});
const {USBRuntime, WiFiRuntime, SimulatorRuntime,
gLocalRuntime, gRemoteRuntime} = require("devtools/webide/runtimes");
const discovery = require("devtools/toolkit/discovery/discovery");
const {NetUtil} = Cu.import("resource://gre/modules/NetUtil.jsm", {});
const Telemetry = require("devtools/shared/telemetry");
const Strings = Services.strings.createBundle("chrome://browser/locale/devtools/webide.properties");
const WIFI_SCANNING_PREF = "devtools.remote.wifi.scan";
exports.AppManager = AppManager = {
// FIXME: will break when devtools/app-manager will be removed:
DEFAULT_PROJECT_ICON: "chrome://browser/skin/devtools/app-manager/default-app-icon.png",
DEFAULT_PROJECT_NAME: "--",
init: function() {
let host = Services.prefs.getCharPref("devtools.debugger.remote-host");
let port = Services.prefs.getIntPref("devtools.debugger.remote-port");
this.connection = ConnectionManager.createConnection("localhost", port);
this.onConnectionChanged = this.onConnectionChanged.bind(this);
this.connection.on(Connection.Events.STATUS_CHANGED, this.onConnectionChanged);
this.tabStore = new TabStore(this.connection);
this.onTabNavigate = this.onTabNavigate.bind(this);
this.onTabClosed = this.onTabClosed.bind(this);
this.tabStore.on("navigate", this.onTabNavigate);
this.tabStore.on("closed", this.onTabClosed);
this.runtimeList = {
usb: [],
wifi: [],
simulator: [],
custom: [gRemoteRuntime]
};
if (Services.prefs.getBoolPref("devtools.webide.enableLocalRuntime")) {
this.runtimeList.custom.push(gLocalRuntime);
}
this.trackUSBRuntimes();
this.trackWiFiRuntimes();
this.trackSimulatorRuntimes();
this.onInstallProgress = this.onInstallProgress.bind(this);
this.observe = this.observe.bind(this);
Services.prefs.addObserver(WIFI_SCANNING_PREF, this, false);
this._telemetry = new Telemetry();
},
uninit: function() {
this.selectedProject = null;
this.selectedRuntime = null;
this.untrackUSBRuntimes();
this.untrackWiFiRuntimes();
this.untrackSimulatorRuntimes();
this.runtimeList = null;
this.tabStore.off("navigate", this.onTabNavigate);
this.tabStore.off("closed", this.onTabClosed);
this.tabStore.destroy();
this.tabStore = null;
this.connection.off(Connection.Events.STATUS_CHANGED, this.onConnectionChanged);
this._listTabsResponse = null;
this.connection.disconnect();
this.connection = null;
Services.prefs.removeObserver(WIFI_SCANNING_PREF, this);
},
observe: function(subject, topic, data) {
if (data !== WIFI_SCANNING_PREF) {
return;
}
// Cycle WiFi tracking to reflect the new value
this.untrackWiFiRuntimes();
this.trackWiFiRuntimes();
this._updateWiFiRuntimes();
},
update: function(what, details) {
// Anything we want to forward to the UI
this.emit("app-manager-update", what, details);
},
reportError: function(l10nProperty, ...l10nArgs) {
let win = Services.wm.getMostRecentWindow("devtools:webide");
if (win) {
win.UI.reportError(l10nProperty, ...l10nArgs);
} else {
let text;
if (l10nArgs.length > 0) {
text = Strings.formatStringFromName(l10nProperty, l10nArgs, l10nArgs.length);
} else {
text = Strings.GetStringFromName(l10nProperty);
}
console.error(text);
}
},
onConnectionChanged: function() {
if (this.connection.status == Connection.Status.DISCONNECTED) {
this.selectedRuntime = null;
}
if (this.connection.status != Connection.Status.CONNECTED) {
console.log("Connection status changed: " + this.connection.status);
if (this._appsFront) {
this._appsFront.off("install-progress", this.onInstallProgress);
this._appsFront.unwatchApps();
this._appsFront = null;
}
this._listTabsResponse = null;
} else {
this.connection.client.listTabs((response) => {
let front = new AppActorFront(this.connection.client,
response);
front.on("install-progress", this.onInstallProgress);
front.watchApps(() => this.checkIfProjectIsRunning())
.then(() => front.fetchIcons())
.then(() => {
this._appsFront = front;
this.checkIfProjectIsRunning();
this.update("runtime-apps-found");
});
this._listTabsResponse = response;
this.update("list-tabs-response");
});
}
this.update("connection");
},
get apps() {
if (this._appsFront) {
return this._appsFront.apps;
} else {
return new Map();
}
},
onInstallProgress: function(event, details) {
this.update("install-progress", details);
},
isProjectRunning: function() {
if (this.selectedProject.type == "mainProcess" ||
this.selectedProject.type == "tab") {
return true;
}
let app = this._getProjectFront(this.selectedProject);
return app && app.running;
},
checkIfProjectIsRunning: function() {
if (this.selectedProject) {
if (this.isProjectRunning()) {
this.update("project-is-running");
} else {
this.update("project-is-not-running");
}
}
},
listTabs: function() {
return this.tabStore.listTabs();
},
// TODO: Merge this into TabProject as part of project-agnostic work
onTabNavigate: function() {
if (this.selectedProject.type !== "tab") {
return;
}
let tab = this.selectedProject.app = this.tabStore.selectedTab;
let uri = NetUtil.newURI(tab.url);
// Wanted to use nsIFaviconService here, but it only works for visited
// tabs, so that's no help for any remote tabs. Maybe some favicon wizard
// knows how to get high-res favicons easily, or we could offer actor
// support for this (bug 1061654).
tab.favicon = uri.prePath + "/favicon.ico";
tab.name = tab.title || Strings.GetStringFromName("project_tab_loading");
if (uri.scheme.startsWith("http")) {
tab.name = uri.host + ": " + tab.name;
}
this.selectedProject.location = tab.url;
this.selectedProject.name = tab.name;
this.selectedProject.icon = tab.favicon;
this.update("project-validated");
},
onTabClosed: function() {
if (this.selectedProject.type !== "tab") {
return;
}
this.selectedProject = null;
},
reloadTab: function() {
if (this.selectedProject && this.selectedProject.type != "tab") {
return promise.reject("tried to reload non-tab project");
}
return this.getTarget().then(target => {
target.activeTab.reload();
}, console.error.bind(console));
},
getTarget: function() {
if (this.selectedProject.type == "mainProcess") {
return devtools.TargetFactory.forRemoteTab({
form: this._listTabsResponse,
client: this.connection.client,
chrome: true
});
}
if (this.selectedProject.type == "tab") {
return this.tabStore.getTargetForTab();
}
let app = this._getProjectFront(this.selectedProject);
if (!app) {
return promise.reject("Can't find app front for selected project");
}
return Task.spawn(function* () {
// Once we asked the app to launch, the app isn't necessary completely loaded.
// launch request only ask the app to launch and immediatly returns.
// We have to keep trying to get app tab actors required to create its target.
for (let i = 0; i < 10; i++) {
try {
return yield app.getTarget();
} catch(e) {}
let deferred = promise.defer();
setTimeout(deferred.resolve, 500);
yield deferred.promise;
}
AppManager.reportError("error_cantConnectToApp", app.manifest.manifestURL);
throw new Error("can't connect to app");
});
},
getProjectManifestURL: function(project) {
let manifest = null;
if (project.type == "runtimeApp") {
manifest = project.app.manifestURL;
}
if (project.type == "hosted") {
manifest = project.location;
}
if (project.type == "packaged" && project.packagedAppOrigin) {
manifest = "app://" + project.packagedAppOrigin + "/manifest.webapp";
}
return manifest;
},
_getProjectFront: function(project) {
let manifest = this.getProjectManifestURL(project);
if (manifest && this._appsFront) {
return this._appsFront.apps.get(manifest);
}
return null;
},
_selectedProject: null,
set selectedProject(value) {
// A regular comparison still sees a difference when equal in some cases
if (JSON.stringify(this._selectedProject) !==
JSON.stringify(value)) {
this._selectedProject = value;
// Clear out tab store's selected state, if any
this.tabStore.selectedTab = null;
if (this.selectedProject) {
if (this.selectedProject.type == "packaged" ||
this.selectedProject.type == "hosted") {
this.validateProject(this.selectedProject);
}
if (this.selectedProject.type == "tab") {
this.tabStore.selectedTab = this.selectedProject.app;
}
}
this.update("project");
this.checkIfProjectIsRunning();
}
},
get selectedProject() {
return this._selectedProject;
},
removeSelectedProject: function() {
let location = this.selectedProject.location;
AppManager.selectedProject = null;
return AppProjects.remove(location);
},
_selectedRuntime: null,
set selectedRuntime(value) {
this._selectedRuntime = value;
if (!value && this.selectedProject &&
(this.selectedProject.type == "mainProcess" ||
this.selectedProject.type == "runtimeApp" ||
this.selectedProject.type == "tab")) {
this.selectedProject = null;
}
this.update("runtime");
},
get selectedRuntime() {
return this._selectedRuntime;
},
connectToRuntime: function(runtime) {
if (this.connection.status == Connection.Status.CONNECTED &&
this.selectedRuntime === runtime) {
// Already connected
return promise.resolve();
}
let deferred = promise.defer();
this.disconnectRuntime().then(() => {
this.selectedRuntime = runtime;
let onConnectedOrDisconnected = () => {
this.connection.off(Connection.Events.CONNECTED, onConnectedOrDisconnected);
this.connection.off(Connection.Events.DISCONNECTED, onConnectedOrDisconnected);
if (this.connection.status == Connection.Status.CONNECTED) {
deferred.resolve();
} else {
deferred.reject();
}
}
this.connection.on(Connection.Events.CONNECTED, onConnectedOrDisconnected);
this.connection.on(Connection.Events.DISCONNECTED, onConnectedOrDisconnected);
try {
this.selectedRuntime.connect(this.connection).then(
() => {},
deferred.reject.bind(deferred));
} catch(e) {
console.error(e);
deferred.reject();
}
}, deferred.reject);
// Record connection result in telemetry
let logResult = result => {
this._telemetry.log("DEVTOOLS_WEBIDE_CONNECTION_RESULT", result);
if (runtime.type) {
this._telemetry.log("DEVTOOLS_WEBIDE_" + runtime.type +
"_CONNECTION_RESULT", result);
}
};
deferred.promise.then(() => logResult(true), () => logResult(false));
// If successful, record connection time in telemetry
deferred.promise.then(() => {
const timerId = "DEVTOOLS_WEBIDE_CONNECTION_TIME_SECONDS";
this._telemetry.startTimer(timerId);
this.connection.once(Connection.Events.STATUS_CHANGED, () => {
this._telemetry.stopTimer(timerId);
});
});
return deferred.promise;
},
isMainProcessDebuggable: function() {
return this._listTabsResponse &&
this._listTabsResponse.consoleActor;
},
get deviceFront() {
if (!this._listTabsResponse) {
return null;
}
return getDeviceFront(this.connection.client, this._listTabsResponse);
},
get preferenceFront() {
if (!this._listTabsResponse) {
return null;
}
return getPreferenceFront(this.connection.client, this._listTabsResponse);
},
disconnectRuntime: function() {
if (this.connection.status != Connection.Status.CONNECTED) {
return promise.resolve();
}
let deferred = promise.defer();
this.connection.once(Connection.Events.DISCONNECTED, () => deferred.resolve());
this.connection.disconnect();
return deferred.promise;
},
launchRuntimeApp: function() {
if (this.selectedProject && this.selectedProject.type != "runtimeApp") {
return promise.reject("attempting to launch a non-runtime app");
}
let app = this._getProjectFront(this.selectedProject);
return app.launch();
},
launchOrReloadRuntimeApp: function() {
if (this.selectedProject && this.selectedProject.type != "runtimeApp") {
return promise.reject("attempting to launch / reload a non-runtime app");
}
let app = this._getProjectFront(this.selectedProject);
if (!app.running) {
return app.launch();
} else {
return app.reload();
}
},
installAndRunProject: function() {
let project = this.selectedProject;
if (!project || (project.type != "packaged" && project.type != "hosted")) {
console.error("Can't install project. Unknown type of project.");
return promise.reject("Can't install");
}
if (!this._listTabsResponse) {
this.reportError("error_cantInstallNotFullyConnected");
return promise.reject("Can't install");
}
return Task.spawn(function* () {
let self = AppManager;
yield self.validateProject(project);
if (project.errorsCount > 0) {
self.reportError("error_cantInstallValidationErrors");
return;
}
let installPromise;
if (project.type != "packaged" && project.type != "hosted") {
return promise.reject("Don't know how to install project");
}
let response;
if (project.type == "packaged") {
response = yield self._appsFront.installPackaged(project.location,
project.packagedAppOrigin);
// If the packaged app specified a custom origin override,
// we need to update the local project origin
project.packagedAppOrigin = response.appId;
// And ensure the indexed db on disk is also updated
AppProjects.update(project);
}
if (project.type == "hosted") {
let manifestURLObject = Services.io.newURI(project.location, null, null);
let origin = Services.io.newURI(manifestURLObject.prePath, null, null);
let appId = origin.host;
let metadata = {
origin: origin.spec,
manifestURL: project.location
};
response = yield self._appsFront.installHosted(appId,
metadata,
project.manifest);
}
let {app} = response;
if (!app.running) {
let deferred = promise.defer();
self.on("app-manager-update", function onUpdate(event, what) {
if (what == "project-is-running") {
self.off("app-manager-update", onUpdate);
deferred.resolve();
}
});
yield app.launch();
yield deferred.promise;
} else {
yield app.reload();
}
});
},
stopRunningApp: function() {
let app = this._getProjectFront(this.selectedProject);
return app.close();
},
/* PROJECT VALIDATION */
validateProject: function(project) {
if (!project) {
return promise.reject();
}
return Task.spawn(function* () {
let validation = new AppValidator(project);
yield validation.validate();
if (validation.manifest) {
let manifest = validation.manifest;
let iconPath;
if (manifest.icons) {
let size = Object.keys(manifest.icons).sort(function(a, b) b - a)[0];
if (size) {
iconPath = manifest.icons[size];
}
}
if (!iconPath) {
project.icon = AppManager.DEFAULT_PROJECT_ICON;
} else {
if (project.type == "hosted") {
let manifestURL = Services.io.newURI(project.location, null, null);
let origin = Services.io.newURI(manifestURL.prePath, null, null);
project.icon = Services.io.newURI(iconPath, null, origin).spec;
} else if (project.type == "packaged") {
let projectFolder = FileUtils.File(project.location);
let folderURI = Services.io.newFileURI(projectFolder).spec;
project.icon = folderURI + iconPath.replace(/^\/|\\/, "");
}
}
project.manifest = validation.manifest;
if ("name" in project.manifest) {
project.name = project.manifest.name;
} else {
project.name = AppManager.DEFAULT_PROJECT_NAME;
}
} else {
project.manifest = null;
project.icon = AppManager.DEFAULT_PROJECT_ICON;
project.name = AppManager.DEFAULT_PROJECT_NAME;
}
project.validationStatus = "valid";
if (validation.warnings.length > 0) {
project.warningsCount = validation.warnings.length;
project.warnings = validation.warnings;
project.validationStatus = "warning";
} else {
project.warnings = "";
project.warningsCount = 0;
}
if (validation.errors.length > 0) {
project.errorsCount = validation.errors.length;
project.errors = validation.errors;
project.validationStatus = "error";
} else {
project.errors = "";
project.errorsCount = 0;
}
if (project.warningsCount && project.errorsCount) {
project.validationStatus = "error warning";
}
if (project.type === "hosted" && project.location !== validation.manifestURL) {
yield AppProjects.updateLocation(project, validation.manifestURL);
} else if (AppProjects.get(project.location)) {
yield AppProjects.update(project);
}
if (AppManager.selectedProject === project) {
AppManager.update("project-validated");
}
});
},
/* RUNTIME LIST */
trackUSBRuntimes: function() {
this._updateUSBRuntimes = this._updateUSBRuntimes.bind(this);
Devices.on("register", this._updateUSBRuntimes);
Devices.on("unregister", this._updateUSBRuntimes);
Devices.on("addon-status-updated", this._updateUSBRuntimes);
this._updateUSBRuntimes();
},
untrackUSBRuntimes: function() {
Devices.off("register", this._updateUSBRuntimes);
Devices.off("unregister", this._updateUSBRuntimes);
Devices.off("addon-status-updated", this._updateUSBRuntimes);
},
_updateUSBRuntimes: function() {
this.runtimeList.usb = [];
for (let id of Devices.available()) {
let r = new USBRuntime(id);
this.runtimeList.usb.push(r);
r.updateNameFromADB().then(
() => {
this.update("runtimelist");
// Also update the runtime button label, if the currently selected
// runtime name changes
if (r == this.selectedRuntime) {
this.update("runtime");
}
},
() => {});
}
this.update("runtimelist");
},
get isWiFiScanningEnabled() {
return Services.prefs.getBoolPref(WIFI_SCANNING_PREF);
},
scanForWiFiRuntimes: function() {
if (!this.isWiFiScanningEnabled) {
return;
}
discovery.scan();
},
trackWiFiRuntimes: function() {
if (!this.isWiFiScanningEnabled) {
return;
}
this._updateWiFiRuntimes = this._updateWiFiRuntimes.bind(this);
discovery.on("devtools-device-added", this._updateWiFiRuntimes);
discovery.on("devtools-device-updated", this._updateWiFiRuntimes);
discovery.on("devtools-device-removed", this._updateWiFiRuntimes);
this._updateWiFiRuntimes();
},
untrackWiFiRuntimes: function() {
if (!this.isWiFiScanningEnabled) {
return;
}
discovery.off("devtools-device-added", this._updateWiFiRuntimes);
discovery.off("devtools-device-updated", this._updateWiFiRuntimes);
discovery.off("devtools-device-removed", this._updateWiFiRuntimes);
},
_updateWiFiRuntimes: function() {
this.runtimeList.wifi = [];
for (let device of discovery.getRemoteDevicesWithService("devtools")) {
this.runtimeList.wifi.push(new WiFiRuntime(device));
}
this.update("runtimelist");
},
trackSimulatorRuntimes: function() {
this._updateSimulatorRuntimes = this._updateSimulatorRuntimes.bind(this);
Simulator.on("register", this._updateSimulatorRuntimes);
Simulator.on("unregister", this._updateSimulatorRuntimes);
this._updateSimulatorRuntimes();
},
untrackSimulatorRuntimes: function() {
Simulator.off("register", this._updateSimulatorRuntimes);
Simulator.off("unregister", this._updateSimulatorRuntimes);
},
_updateSimulatorRuntimes: function() {
this.runtimeList.simulator = [];
for (let version of Simulator.availableVersions()) {
this.runtimeList.simulator.push(new SimulatorRuntime(version));
}
this.update("runtimelist");
},
writeManifest: function(project) {
if (project.type != "packaged") {
return promise.reject("Not a packaged app");
}
if (!project.manifest) {
project.manifest = {};
}
let folder = project.location;
let manifestPath = OS.Path.join(folder, "manifest.webapp");
let text = JSON.stringify(project.manifest, null, 2);
let encoder = new TextEncoder();
let array = encoder.encode(text);
return OS.File.writeAtomic(manifestPath, array, {tmpPath: manifestPath + ".tmp"});
},
}
EventEmitter.decorate(AppManager);