summaryrefslogtreecommitdiffstats
path: root/devtools/client/webide/modules
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/webide/modules')
-rw-r--r--devtools/client/webide/modules/addons.js197
-rw-r--r--devtools/client/webide/modules/app-manager.js850
-rw-r--r--devtools/client/webide/modules/app-projects.js235
-rw-r--r--devtools/client/webide/modules/app-validator.js292
-rw-r--r--devtools/client/webide/modules/build.js199
-rw-r--r--devtools/client/webide/modules/config-view.js373
-rw-r--r--devtools/client/webide/modules/moz.build21
-rw-r--r--devtools/client/webide/modules/project-list.js375
-rw-r--r--devtools/client/webide/modules/runtime-list.js207
-rw-r--r--devtools/client/webide/modules/runtimes.js673
-rw-r--r--devtools/client/webide/modules/simulator-process.js325
-rw-r--r--devtools/client/webide/modules/simulators.js368
-rw-r--r--devtools/client/webide/modules/tab-store.js178
-rw-r--r--devtools/client/webide/modules/utils.js68
14 files changed, 4361 insertions, 0 deletions
diff --git a/devtools/client/webide/modules/addons.js b/devtools/client/webide/modules/addons.js
new file mode 100644
index 000000000..4dc09f1ca
--- /dev/null
+++ b/devtools/client/webide/modules/addons.js
@@ -0,0 +1,197 @@
+/* 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 promise = require("promise");
+const {AddonManager} = require("resource://gre/modules/AddonManager.jsm");
+const Services = require("Services");
+const {getJSON} = require("devtools/client/shared/getjson");
+const EventEmitter = require("devtools/shared/event-emitter");
+
+const ADDONS_URL = "devtools.webide.addonsURL";
+
+var SIMULATOR_LINK = Services.prefs.getCharPref("devtools.webide.simulatorAddonsURL");
+var ADB_LINK = Services.prefs.getCharPref("devtools.webide.adbAddonURL");
+var ADAPTERS_LINK = Services.prefs.getCharPref("devtools.webide.adaptersAddonURL");
+var SIMULATOR_ADDON_ID = Services.prefs.getCharPref("devtools.webide.simulatorAddonID");
+var ADB_ADDON_ID = Services.prefs.getCharPref("devtools.webide.adbAddonID");
+var ADAPTERS_ADDON_ID = Services.prefs.getCharPref("devtools.webide.adaptersAddonID");
+
+var platform = Services.appShell.hiddenDOMWindow.navigator.platform;
+var OS = "";
+if (platform.indexOf("Win") != -1) {
+ OS = "win32";
+} else if (platform.indexOf("Mac") != -1) {
+ OS = "mac64";
+} else if (platform.indexOf("Linux") != -1) {
+ if (platform.indexOf("x86_64") != -1) {
+ OS = "linux64";
+ } else {
+ OS = "linux32";
+ }
+}
+
+var addonsListener = {};
+addonsListener.onEnabled =
+addonsListener.onDisabled =
+addonsListener.onInstalled =
+addonsListener.onUninstalled = (updatedAddon) => {
+ GetAvailableAddons().then(addons => {
+ for (let a of [...addons.simulators, addons.adb, addons.adapters]) {
+ if (a.addonID == updatedAddon.id) {
+ a.updateInstallStatus();
+ }
+ }
+ });
+};
+AddonManager.addAddonListener(addonsListener);
+
+var GetAvailableAddons_promise = null;
+var GetAvailableAddons = exports.GetAvailableAddons = function () {
+ if (!GetAvailableAddons_promise) {
+ let deferred = promise.defer();
+ GetAvailableAddons_promise = deferred.promise;
+ let addons = {
+ simulators: [],
+ adb: null
+ };
+ getJSON(ADDONS_URL).then(json => {
+ for (let stability in json) {
+ for (let version of json[stability]) {
+ addons.simulators.push(new SimulatorAddon(stability, version));
+ }
+ }
+ addons.adb = new ADBAddon();
+ addons.adapters = new AdaptersAddon();
+ deferred.resolve(addons);
+ }, e => {
+ GetAvailableAddons_promise = null;
+ deferred.reject(e);
+ });
+ }
+ return GetAvailableAddons_promise;
+};
+
+exports.ForgetAddonsList = function () {
+ GetAvailableAddons_promise = null;
+};
+
+function Addon() {}
+Addon.prototype = {
+ _status: "unknown",
+ set status(value) {
+ if (this._status != value) {
+ this._status = value;
+ this.emit("update");
+ }
+ },
+ get status() {
+ return this._status;
+ },
+
+ updateInstallStatus: function () {
+ AddonManager.getAddonByID(this.addonID, (addon) => {
+ if (addon && !addon.userDisabled) {
+ this.status = "installed";
+ } else {
+ this.status = "uninstalled";
+ }
+ });
+ },
+
+ install: function () {
+ AddonManager.getAddonByID(this.addonID, (addon) => {
+ if (addon && !addon.userDisabled) {
+ this.status = "installed";
+ return;
+ }
+ this.status = "preparing";
+ if (addon && addon.userDisabled) {
+ addon.userDisabled = false;
+ } else {
+ AddonManager.getInstallForURL(this.xpiLink, (install) => {
+ install.addListener(this);
+ install.install();
+ }, "application/x-xpinstall");
+ }
+ });
+ },
+
+ uninstall: function () {
+ AddonManager.getAddonByID(this.addonID, (addon) => {
+ addon.uninstall();
+ });
+ },
+
+ installFailureHandler: function (install, message) {
+ this.status = "uninstalled";
+ this.emit("failure", message);
+ },
+
+ onDownloadStarted: function () {
+ this.status = "downloading";
+ },
+
+ onInstallStarted: function () {
+ this.status = "installing";
+ },
+
+ onDownloadProgress: function (install) {
+ if (install.maxProgress == -1) {
+ this.emit("progress", -1);
+ } else {
+ this.emit("progress", install.progress / install.maxProgress);
+ }
+ },
+
+ onInstallEnded: function ({addon}) {
+ addon.userDisabled = false;
+ },
+
+ onDownloadCancelled: function (install) {
+ this.installFailureHandler(install, "Download cancelled");
+ },
+ onDownloadFailed: function (install) {
+ this.installFailureHandler(install, "Download failed");
+ },
+ onInstallCancelled: function (install) {
+ this.installFailureHandler(install, "Install cancelled");
+ },
+ onInstallFailed: function (install) {
+ this.installFailureHandler(install, "Install failed");
+ },
+};
+
+function SimulatorAddon(stability, version) {
+ EventEmitter.decorate(this);
+ this.stability = stability;
+ this.version = version;
+ // This addon uses the string "linux" for "linux32"
+ let fixedOS = OS == "linux32" ? "linux" : OS;
+ this.xpiLink = SIMULATOR_LINK.replace(/#OS#/g, fixedOS)
+ .replace(/#VERSION#/g, version)
+ .replace(/#SLASHED_VERSION#/g, version.replace(/\./g, "_"));
+ this.addonID = SIMULATOR_ADDON_ID.replace(/#SLASHED_VERSION#/g, version.replace(/\./g, "_"));
+ this.updateInstallStatus();
+}
+SimulatorAddon.prototype = Object.create(Addon.prototype);
+
+function ADBAddon() {
+ EventEmitter.decorate(this);
+ // This addon uses the string "linux" for "linux32"
+ let fixedOS = OS == "linux32" ? "linux" : OS;
+ this.xpiLink = ADB_LINK.replace(/#OS#/g, fixedOS);
+ this.addonID = ADB_ADDON_ID;
+ this.updateInstallStatus();
+}
+ADBAddon.prototype = Object.create(Addon.prototype);
+
+function AdaptersAddon() {
+ EventEmitter.decorate(this);
+ this.xpiLink = ADAPTERS_LINK.replace(/#OS#/g, OS);
+ this.addonID = ADAPTERS_ADDON_ID;
+ this.updateInstallStatus();
+}
+AdaptersAddon.prototype = Object.create(Addon.prototype);
diff --git a/devtools/client/webide/modules/app-manager.js b/devtools/client/webide/modules/app-manager.js
new file mode 100644
index 000000000..88dfcdd44
--- /dev/null
+++ b/devtools/client/webide/modules/app-manager.js
@@ -0,0 +1,850 @@
+/* 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");
+
+const promise = require("promise");
+const {TargetFactory} = require("devtools/client/framework/target");
+const Services = require("Services");
+const {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm", {});
+const EventEmitter = require("devtools/shared/event-emitter");
+const {TextEncoder, OS} = Cu.import("resource://gre/modules/osfile.jsm", {});
+const {AppProjects} = require("devtools/client/webide/modules/app-projects");
+const TabStore = require("devtools/client/webide/modules/tab-store");
+const {AppValidator} = require("devtools/client/webide/modules/app-validator");
+const {ConnectionManager, Connection} = require("devtools/shared/client/connection-manager");
+const {AppActorFront} = require("devtools/shared/apps/app-actor-front");
+const {getDeviceFront} = require("devtools/shared/fronts/device");
+const {getPreferenceFront} = require("devtools/shared/fronts/preference");
+const {getSettingsFront} = require("devtools/shared/fronts/settings");
+const {Task} = require("devtools/shared/task");
+const {RuntimeScanners, RuntimeTypes} = require("devtools/client/webide/modules/runtimes");
+const {NetUtil} = Cu.import("resource://gre/modules/NetUtil.jsm", {});
+const Telemetry = require("devtools/client/shared/telemetry");
+const {ProjectBuilding} = require("./build");
+
+const Strings = Services.strings.createBundle("chrome://devtools/locale/webide.properties");
+
+var AppManager = exports.AppManager = {
+
+ DEFAULT_PROJECT_ICON: "chrome://webide/skin/default-app-icon.png",
+ DEFAULT_PROJECT_NAME: "--",
+
+ _initialized: false,
+
+ init: function () {
+ if (this._initialized) {
+ return;
+ }
+ this._initialized = true;
+
+ 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.onTabList = this.onTabList.bind(this);
+ this.onTabNavigate = this.onTabNavigate.bind(this);
+ this.onTabClosed = this.onTabClosed.bind(this);
+ this.tabStore.on("tab-list", this.onTabList);
+ this.tabStore.on("navigate", this.onTabNavigate);
+ this.tabStore.on("closed", this.onTabClosed);
+
+ this._clearRuntimeList();
+ this._rebuildRuntimeList = this._rebuildRuntimeList.bind(this);
+ RuntimeScanners.on("runtime-list-updated", this._rebuildRuntimeList);
+ RuntimeScanners.enable();
+ this._rebuildRuntimeList();
+
+ this.onInstallProgress = this.onInstallProgress.bind(this);
+
+ this._telemetry = new Telemetry();
+ },
+
+ destroy: function () {
+ if (!this._initialized) {
+ return;
+ }
+ this._initialized = false;
+
+ this.selectedProject = null;
+ this.selectedRuntime = null;
+ RuntimeScanners.off("runtime-list-updated", this._rebuildRuntimeList);
+ RuntimeScanners.disable();
+ this.runtimeList = null;
+ this.tabStore.off("tab-list", this.onTabList);
+ 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;
+ },
+
+ /**
+ * This module emits various events when state changes occur. The basic event
+ * naming scheme is that event "X" means "X has changed" or "X is available".
+ * Some names are more detailed to clarify their precise meaning.
+ *
+ * The events this module may emit include:
+ * before-project:
+ * The selected project is about to change. The event includes a special
+ * |cancel| callback that will abort the project change if desired.
+ * connection:
+ * The connection status has changed (connected, disconnected, etc.)
+ * install-progress:
+ * A project being installed to a runtime has made further progress. This
+ * event contains additional details about exactly how far the process is
+ * when such information is available.
+ * project:
+ * The selected project has changed.
+ * project-started:
+ * The selected project started running on the connected runtime.
+ * project-stopped:
+ * The selected project stopped running on the connected runtime.
+ * project-removed:
+ * The selected project was removed from the project list.
+ * project-validated:
+ * The selected project just completed validation. As part of validation,
+ * many pieces of metadata about the project are refreshed, including its
+ * name, manifest details, etc.
+ * runtime:
+ * The selected runtime has changed.
+ * runtime-apps-icons:
+ * The list of URLs for the runtime app icons are available.
+ * runtime-global-actors:
+ * The list of global actors for the entire runtime (but not actors for a
+ * specific tab or app) are now available, so we can test for features
+ * like preferences and settings.
+ * runtime-details:
+ * The selected runtime's details have changed, such as its user-visible
+ * name.
+ * runtime-list:
+ * The list of available runtimes has changed, or any of the user-visible
+ * details (like names) for the non-selected runtimes has changed.
+ * runtime-telemetry:
+ * Detailed runtime telemetry has been recorded. Used by tests.
+ * runtime-targets:
+ * The list of remote runtime targets available from the currently
+ * connected runtime (such as tabs or apps) has changed, or any of the
+ * user-visible details (like names) for the non-selected runtime targets
+ * has changed. This event includes |type| in the details, to distinguish
+ * "apps" and "tabs".
+ */
+ 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 () {
+ console.log("Connection status changed: " + this.connection.status);
+
+ if (this.connection.status == Connection.Status.DISCONNECTED) {
+ this.selectedRuntime = null;
+ }
+
+ if (!this.connected) {
+ 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) => {
+ if (response.webappsActor) {
+ let front = new AppActorFront(this.connection.client,
+ response);
+ front.on("install-progress", this.onInstallProgress);
+ front.watchApps(() => this.checkIfProjectIsRunning())
+ .then(() => {
+ // This can't be done earlier as many operations
+ // in the apps actor require watchApps to be called
+ // first.
+ this._appsFront = front;
+ this._listTabsResponse = response;
+ this._recordRuntimeInfo();
+ this.update("runtime-global-actors");
+ })
+ .then(() => {
+ this.checkIfProjectIsRunning();
+ this.update("runtime-targets", { type: "apps" });
+ front.fetchIcons().then(() => this.update("runtime-apps-icons"));
+ });
+ } else {
+ this._listTabsResponse = response;
+ this._recordRuntimeInfo();
+ this.update("runtime-global-actors");
+ }
+ });
+ }
+
+ this.update("connection");
+ },
+
+ get connected() {
+ return this.connection &&
+ this.connection.status == Connection.Status.CONNECTED;
+ },
+
+ 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-started");
+ } else {
+ this.update("project-stopped");
+ }
+ }
+ },
+
+ listTabs: function () {
+ return this.tabStore.listTabs();
+ },
+
+ onTabList: function () {
+ this.update("runtime-targets", { type: "tabs" });
+ },
+
+ // TODO: Merge this into TabProject as part of project-agnostic work
+ onTabNavigate: function () {
+ this.update("runtime-targets", { type: "tabs" });
+ 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") {
+ // Fx >=39 exposes a ChromeActor to debug the main process
+ if (this.connection.client.mainRoot.traits.allowChromeProcess) {
+ return this.connection.client.getProcess()
+ .then(aResponse => {
+ return TargetFactory.forRemoteTab({
+ form: aResponse.form,
+ client: this.connection.client,
+ chrome: true
+ });
+ });
+ } else {
+ // Fx <39 exposes tab actors on the root actor
+ return TargetFactory.forRemoteTab({
+ form: this._listTabsResponse,
+ client: this.connection.client,
+ chrome: true,
+ isTabActor: false
+ });
+ }
+ }
+
+ 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(project) {
+ // A regular comparison doesn't work as we recreate a new object every time
+ let prev = this._selectedProject;
+ if (!prev && !project) {
+ return;
+ } else if (prev && project && prev.type === project.type) {
+ let type = project.type;
+ if (type === "runtimeApp") {
+ if (prev.app.manifestURL === project.app.manifestURL) {
+ return;
+ }
+ } else if (type === "tab") {
+ if (prev.app.actor === project.app.actor) {
+ return;
+ }
+ } else if (type === "packaged" || type === "hosted") {
+ if (prev.location === project.location) {
+ return;
+ }
+ } else if (type === "mainProcess") {
+ return;
+ } else {
+ throw new Error("Unsupported project type: " + type);
+ }
+ }
+
+ let cancelled = false;
+ this.update("before-project", { cancel: () => { cancelled = true; } });
+ if (cancelled) {
+ return;
+ }
+
+ this._selectedProject = project;
+
+ // Clear out tab store's selected state, if any
+ this.tabStore.selectedTab = null;
+
+ if (project) {
+ if (project.type == "packaged" ||
+ project.type == "hosted") {
+ this.validateAndUpdateProject(project);
+ }
+ if (project.type == "tab") {
+ this.tabStore.selectedTab = project.app;
+ }
+ }
+
+ this.update("project");
+ this.checkIfProjectIsRunning();
+ },
+ get selectedProject() {
+ return this._selectedProject;
+ },
+
+ removeSelectedProject: Task.async(function* () {
+ let location = this.selectedProject.location;
+ AppManager.selectedProject = null;
+ // If the user cancels the removeProject operation, don't remove the project
+ if (AppManager.selectedProject != null) {
+ return;
+ }
+
+ yield AppProjects.remove(location);
+ AppManager.update("project-removed");
+ }),
+
+ packageProject: Task.async(function* (project) {
+ if (!project) {
+ return;
+ }
+ if (project.type == "packaged" ||
+ project.type == "hosted") {
+ yield ProjectBuilding.build({
+ project: project,
+ logger: this.update.bind(this, "pre-package")
+ });
+ }
+ }),
+
+ _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.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.connected) {
+ deferred.resolve();
+ } else {
+ deferred.reject();
+ }
+ };
+ this.connection.on(Connection.Events.CONNECTED, onConnectedOrDisconnected);
+ this.connection.on(Connection.Events.DISCONNECTED, onConnectedOrDisconnected);
+ try {
+ // Reset the connection's state to defaults
+ this.connection.resetOptions();
+ // Only watch for errors here. Final resolution occurs above, once
+ // we've reached the CONNECTED state.
+ this.selectedRuntime.connect(this.connection)
+ .then(null, e => deferred.reject(e));
+ } catch (e) {
+ deferred.reject(e);
+ }
+ }, 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);
+ });
+ }).catch(() => {
+ // Empty rejection handler to silence uncaught rejection warnings
+ // |connectToRuntime| caller should listen for rejections.
+ // Bug 1121100 may find a better way to silence these.
+ });
+
+ return deferred.promise;
+ },
+
+ _recordRuntimeInfo: Task.async(function* () {
+ if (!this.connected) {
+ return;
+ }
+ let runtime = this.selectedRuntime;
+ this._telemetry.logKeyed("DEVTOOLS_WEBIDE_CONNECTED_RUNTIME_TYPE",
+ runtime.type || "UNKNOWN", true);
+ this._telemetry.logKeyed("DEVTOOLS_WEBIDE_CONNECTED_RUNTIME_ID",
+ runtime.id || "unknown", true);
+ if (!this.deviceFront) {
+ this.update("runtime-telemetry");
+ return;
+ }
+ let d = yield this.deviceFront.getDescription();
+ this._telemetry.logKeyed("DEVTOOLS_WEBIDE_CONNECTED_RUNTIME_PROCESSOR",
+ d.processor, true);
+ this._telemetry.logKeyed("DEVTOOLS_WEBIDE_CONNECTED_RUNTIME_OS",
+ d.os, true);
+ this._telemetry.logKeyed("DEVTOOLS_WEBIDE_CONNECTED_RUNTIME_PLATFORM_VERSION",
+ d.platformversion, true);
+ this._telemetry.logKeyed("DEVTOOLS_WEBIDE_CONNECTED_RUNTIME_APP_TYPE",
+ d.apptype, true);
+ this._telemetry.logKeyed("DEVTOOLS_WEBIDE_CONNECTED_RUNTIME_VERSION",
+ d.version, true);
+ this.update("runtime-telemetry");
+ }),
+
+ isMainProcessDebuggable: function () {
+ // Fx <39 exposes chrome tab actors on RootActor
+ // Fx >=39 exposes a dedicated actor via getProcess request
+ return this.connection.client &&
+ this.connection.client.mainRoot &&
+ this.connection.client.mainRoot.traits.allowChromeProcess ||
+ (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);
+ },
+
+ get settingsFront() {
+ if (!this._listTabsResponse) {
+ return null;
+ }
+ return getSettingsFront(this.connection.client, this._listTabsResponse);
+ },
+
+ disconnectRuntime: function () {
+ if (!this.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();
+ }
+ },
+
+ runtimeCanHandleApps: function () {
+ return !!this._appsFront;
+ },
+
+ 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");
+ }
+
+ if (!this._appsFront) {
+ console.error("Runtime doesn't have a webappsActor");
+ return promise.reject("Can't install");
+ }
+
+ return Task.spawn(function* () {
+ let self = AppManager;
+
+ // Package and validate project
+ yield self.packageProject(project);
+ yield self.validateAndUpdateProject(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") {
+ let packageDir = yield ProjectBuilding.getPackageDir(project);
+ console.log("Installing app from " + packageDir);
+
+ response = yield self._appsFront.installPackaged(packageDir,
+ 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);
+ }
+
+ // Addons don't have any document to load (yet?)
+ // So that there is no need to run them, installing is enough
+ if (project.manifest.manifest_version || project.manifest.role === "addon") {
+ return;
+ }
+
+ let {app} = response;
+ if (!app.running) {
+ let deferred = promise.defer();
+ self.on("app-manager-update", function onUpdate(event, what) {
+ if (what == "project-started") {
+ 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 */
+
+ validateAndUpdateProject: function (project) {
+ if (!project) {
+ return promise.reject();
+ }
+
+ return Task.spawn(function* () {
+
+ let packageDir = yield ProjectBuilding.getPackageDir(project);
+ let validation = new AppValidator({
+ type: project.type,
+ // Build process may place the manifest in a non-root directory
+ location: packageDir
+ });
+
+ yield validation.validate();
+
+ if (validation.manifest) {
+ let manifest = validation.manifest;
+ let iconPath;
+ if (manifest.icons) {
+ let size = Object.keys(manifest.icons).sort((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(packageDir);
+ 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 */
+
+ _clearRuntimeList: function () {
+ this.runtimeList = {
+ usb: [],
+ wifi: [],
+ simulator: [],
+ other: []
+ };
+ },
+
+ _rebuildRuntimeList: function () {
+ let runtimes = RuntimeScanners.listRuntimes();
+ this._clearRuntimeList();
+
+ // Reorganize runtimes by type
+ for (let runtime of runtimes) {
+ switch (runtime.type) {
+ case RuntimeTypes.USB:
+ this.runtimeList.usb.push(runtime);
+ break;
+ case RuntimeTypes.WIFI:
+ this.runtimeList.wifi.push(runtime);
+ break;
+ case RuntimeTypes.SIMULATOR:
+ this.runtimeList.simulator.push(runtime);
+ break;
+ default:
+ this.runtimeList.other.push(runtime);
+ }
+ }
+
+ this.update("runtime-details");
+ this.update("runtime-list");
+ },
+
+ /* MANIFEST UTILS */
+
+ 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);
diff --git a/devtools/client/webide/modules/app-projects.js b/devtools/client/webide/modules/app-projects.js
new file mode 100644
index 000000000..691d09064
--- /dev/null
+++ b/devtools/client/webide/modules/app-projects.js
@@ -0,0 +1,235 @@
+/* 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 {Cc, Ci, Cu, Cr} = require("chrome");
+const promise = require("promise");
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
+const {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm", {});
+
+/**
+ * IndexedDB wrapper that just save project objects
+ *
+ * The only constraint is that project objects have to have
+ * a unique `location` object.
+ */
+
+const IDB = {
+ _db: null,
+ databaseName: "AppProjects",
+
+ open: function () {
+ let deferred = promise.defer();
+
+ let request = indexedDB.open(IDB.databaseName, 5);
+ request.onerror = function (event) {
+ deferred.reject("Unable to open AppProjects indexedDB: " +
+ this.error.name + " - " + this.error.message);
+ };
+ request.onupgradeneeded = function (event) {
+ let db = event.target.result;
+ db.createObjectStore("projects", { keyPath: "location" });
+ };
+
+ request.onsuccess = function () {
+ let db = IDB._db = request.result;
+ let objectStore = db.transaction("projects").objectStore("projects");
+ let projects = [];
+ let toRemove = [];
+ objectStore.openCursor().onsuccess = function (event) {
+ let cursor = event.target.result;
+ if (cursor) {
+ if (cursor.value.location) {
+
+ // We need to make sure this object has a `.location` property.
+ // The UI depends on this property.
+ // This should not be needed as we make sure to register valid
+ // projects, but in the past (before bug 924568), we might have
+ // registered invalid objects.
+
+
+ // We also want to make sure the location is valid.
+ // If the location doesn't exist, we remove the project.
+
+ try {
+ let file = FileUtils.File(cursor.value.location);
+ if (file.exists()) {
+ projects.push(cursor.value);
+ } else {
+ toRemove.push(cursor.value.location);
+ }
+ } catch (e) {
+ if (e.result == Cr.NS_ERROR_FILE_UNRECOGNIZED_PATH) {
+ // A URL
+ projects.push(cursor.value);
+ }
+ }
+ }
+ cursor.continue();
+ } else {
+ let removePromises = [];
+ for (let location of toRemove) {
+ removePromises.push(IDB.remove(location));
+ }
+ promise.all(removePromises).then(() => {
+ deferred.resolve(projects);
+ });
+ }
+ };
+ };
+
+ return deferred.promise;
+ },
+
+ add: function (project) {
+ let deferred = promise.defer();
+
+ if (!project.location) {
+ // We need to make sure this object has a `.location` property.
+ deferred.reject("Missing location property on project object.");
+ } else {
+ let transaction = IDB._db.transaction(["projects"], "readwrite");
+ let objectStore = transaction.objectStore("projects");
+ let request = objectStore.add(project);
+ request.onerror = function (event) {
+ deferred.reject("Unable to add project to the AppProjects indexedDB: " +
+ this.error.name + " - " + this.error.message);
+ };
+ request.onsuccess = function () {
+ deferred.resolve();
+ };
+ }
+
+ return deferred.promise;
+ },
+
+ update: function (project) {
+ let deferred = promise.defer();
+
+ var transaction = IDB._db.transaction(["projects"], "readwrite");
+ var objectStore = transaction.objectStore("projects");
+ var request = objectStore.put(project);
+ request.onerror = function (event) {
+ deferred.reject("Unable to update project to the AppProjects indexedDB: " +
+ this.error.name + " - " + this.error.message);
+ };
+ request.onsuccess = function () {
+ deferred.resolve();
+ };
+
+ return deferred.promise;
+ },
+
+ remove: function (location) {
+ let deferred = promise.defer();
+
+ let request = IDB._db.transaction(["projects"], "readwrite")
+ .objectStore("projects")
+ .delete(location);
+ request.onsuccess = function (event) {
+ deferred.resolve();
+ };
+ request.onerror = function () {
+ deferred.reject("Unable to delete project to the AppProjects indexedDB: " +
+ this.error.name + " - " + this.error.message);
+ };
+
+ return deferred.promise;
+ }
+};
+
+var loadDeferred = promise.defer();
+
+loadDeferred.resolve(IDB.open().then(function (projects) {
+ AppProjects.projects = projects;
+ AppProjects.emit("ready", projects);
+}));
+
+const AppProjects = {
+ load: function () {
+ return loadDeferred.promise;
+ },
+
+ addPackaged: function (folder) {
+ let file = FileUtils.File(folder.path);
+ if (!file.exists()) {
+ return promise.reject("path doesn't exist");
+ }
+ let existingProject = this.get(folder.path);
+ if (existingProject) {
+ return promise.reject("Already added");
+ }
+ let project = {
+ type: "packaged",
+ location: folder.path,
+ // We need a unique id, that is the app origin,
+ // in order to identify the app when being installed on the device.
+ // The packaged app local path is a valid id, but only on the client.
+ // This origin will be used to generate the true id of an app:
+ // its manifest URL.
+ // If the app ends up specifying an explicit origin in its manifest,
+ // we will override this random UUID on app install.
+ packagedAppOrigin: generateUUID().toString().slice(1, -1)
+ };
+ return IDB.add(project).then(() => {
+ this.projects.push(project);
+ return project;
+ });
+ },
+
+ addHosted: function (manifestURL) {
+ let existingProject = this.get(manifestURL);
+ if (existingProject) {
+ return promise.reject("Already added");
+ }
+ let project = {
+ type: "hosted",
+ location: manifestURL
+ };
+ return IDB.add(project).then(() => {
+ this.projects.push(project);
+ return project;
+ });
+ },
+
+ update: function (project) {
+ return IDB.update(project);
+ },
+
+ updateLocation: function (project, newLocation) {
+ return IDB.remove(project.location)
+ .then(() => {
+ project.location = newLocation;
+ return IDB.add(project);
+ });
+ },
+
+ remove: function (location) {
+ return IDB.remove(location).then(() => {
+ for (let i = 0; i < this.projects.length; i++) {
+ if (this.projects[i].location == location) {
+ this.projects.splice(i, 1);
+ return;
+ }
+ }
+ throw new Error("Unable to find project in AppProjects store");
+ });
+ },
+
+ get: function (location) {
+ for (let i = 0; i < this.projects.length; i++) {
+ if (this.projects[i].location == location) {
+ return this.projects[i];
+ }
+ }
+ return null;
+ },
+
+ projects: []
+};
+
+EventEmitter.decorate(AppProjects);
+
+exports.AppProjects = AppProjects;
diff --git a/devtools/client/webide/modules/app-validator.js b/devtools/client/webide/modules/app-validator.js
new file mode 100644
index 000000000..750720110
--- /dev/null
+++ b/devtools/client/webide/modules/app-validator.js
@@ -0,0 +1,292 @@
+/* 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";
+
+var {Ci, Cu, CC} = require("chrome");
+const promise = require("promise");
+
+const {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm", {});
+const Services = require("Services");
+const {Task} = require("devtools/shared/task");
+var XMLHttpRequest = CC("@mozilla.org/xmlextras/xmlhttprequest;1");
+var strings = Services.strings.createBundle("chrome://devtools/locale/app-manager.properties");
+
+function AppValidator({ type, location }) {
+ this.type = type;
+ this.location = location;
+ this.errors = [];
+ this.warnings = [];
+}
+
+AppValidator.prototype.error = function (message) {
+ this.errors.push(message);
+};
+
+AppValidator.prototype.warning = function (message) {
+ this.warnings.push(message);
+};
+
+AppValidator.prototype._getPackagedManifestFile = function () {
+ let manifestFile = FileUtils.File(this.location);
+ if (!manifestFile.exists()) {
+ this.error(strings.GetStringFromName("validator.nonExistingFolder"));
+ return null;
+ }
+ if (!manifestFile.isDirectory()) {
+ this.error(strings.GetStringFromName("validator.expectProjectFolder"));
+ return null;
+ }
+
+ let appManifestFile = manifestFile.clone();
+ appManifestFile.append("manifest.webapp");
+
+ let jsonManifestFile = manifestFile.clone();
+ jsonManifestFile.append("manifest.json");
+
+ let hasAppManifest = appManifestFile.exists() && appManifestFile.isFile();
+ let hasJsonManifest = jsonManifestFile.exists() && jsonManifestFile.isFile();
+
+ if (!hasAppManifest && !hasJsonManifest) {
+ this.error(strings.GetStringFromName("validator.noManifestFile"));
+ return null;
+ }
+
+ return hasAppManifest ? appManifestFile : jsonManifestFile;
+};
+
+AppValidator.prototype._getPackagedManifestURL = function () {
+ let manifestFile = this._getPackagedManifestFile();
+ if (!manifestFile) {
+ return null;
+ }
+ return Services.io.newFileURI(manifestFile).spec;
+};
+
+AppValidator.checkManifest = function (manifestURL) {
+ let deferred = promise.defer();
+ let error;
+
+ let req = new XMLHttpRequest();
+ req.overrideMimeType("text/plain");
+
+ try {
+ req.open("GET", manifestURL, true);
+ req.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE | Ci.nsIRequest.INHIBIT_CACHING;
+ } catch (e) {
+ error = strings.formatStringFromName("validator.invalidManifestURL", [manifestURL], 1);
+ deferred.reject(error);
+ return deferred.promise;
+ }
+
+ req.onload = function () {
+ let manifest = null;
+ try {
+ manifest = JSON.parse(req.responseText);
+ } catch (e) {
+ error = strings.formatStringFromName("validator.invalidManifestJSON", [e, manifestURL], 2);
+ deferred.reject(error);
+ }
+
+ deferred.resolve({manifest, manifestURL});
+ };
+
+ req.onerror = function () {
+ error = strings.formatStringFromName("validator.noAccessManifestURL", [req.statusText, manifestURL], 2);
+ deferred.reject(error);
+ };
+
+ try {
+ req.send(null);
+ } catch (e) {
+ error = strings.formatStringFromName("validator.noAccessManifestURL", [e, manifestURL], 2);
+ deferred.reject(error);
+ }
+
+ return deferred.promise;
+};
+
+AppValidator.findManifestAtOrigin = function (manifestURL) {
+ let fixedManifest = Services.io.newURI(manifestURL, null, null).prePath + "/manifest.webapp";
+ return AppValidator.checkManifest(fixedManifest);
+};
+
+AppValidator.findManifestPath = function (manifestURL) {
+ let deferred = promise.defer();
+
+ if (manifestURL.endsWith("manifest.webapp")) {
+ deferred.reject();
+ } else {
+ let fixedManifest = manifestURL + "/manifest.webapp";
+ deferred.resolve(AppValidator.checkManifest(fixedManifest));
+ }
+
+ return deferred.promise;
+};
+
+AppValidator.checkAlternateManifest = function (manifestURL) {
+ return Task.spawn(function* () {
+ let result;
+ try {
+ result = yield AppValidator.findManifestPath(manifestURL);
+ } catch (e) {
+ result = yield AppValidator.findManifestAtOrigin(manifestURL);
+ }
+
+ return result;
+ });
+};
+
+AppValidator.prototype._fetchManifest = function (manifestURL) {
+ let deferred = promise.defer();
+ this.manifestURL = manifestURL;
+
+ AppValidator.checkManifest(manifestURL)
+ .then(({manifest, manifestURL}) => {
+ deferred.resolve(manifest);
+ }, error => {
+ AppValidator.checkAlternateManifest(manifestURL)
+ .then(({manifest, manifestURL}) => {
+ this.manifestURL = manifestURL;
+ deferred.resolve(manifest);
+ }, () => {
+ this.error(error);
+ deferred.resolve(null);
+ });
+ });
+
+ return deferred.promise;
+};
+
+AppValidator.prototype._getManifest = function () {
+ let manifestURL;
+ if (this.type == "packaged") {
+ manifestURL = this._getPackagedManifestURL();
+ if (!manifestURL)
+ return promise.resolve(null);
+ } else if (this.type == "hosted") {
+ manifestURL = this.location;
+ try {
+ Services.io.newURI(manifestURL, null, null);
+ } catch (e) {
+ this.error(strings.formatStringFromName("validator.invalidHostedManifestURL", [manifestURL, e.message], 2));
+ return promise.resolve(null);
+ }
+ } else {
+ this.error(strings.formatStringFromName("validator.invalidProjectType", [this.type], 1));
+ return promise.resolve(null);
+ }
+ return this._fetchManifest(manifestURL);
+};
+
+AppValidator.prototype.validateManifest = function (manifest) {
+ if (!manifest.name) {
+ this.error(strings.GetStringFromName("validator.missNameManifestProperty"));
+ }
+
+ if (!manifest.icons || Object.keys(manifest.icons).length === 0) {
+ this.warning(strings.GetStringFromName("validator.missIconsManifestProperty"));
+ } else if (!manifest.icons["128"]) {
+ this.warning(strings.GetStringFromName("validator.missIconMarketplace2"));
+ }
+};
+
+AppValidator.prototype._getOriginURL = function () {
+ if (this.type == "packaged") {
+ let manifestURL = Services.io.newURI(this.manifestURL, null, null);
+ return Services.io.newURI(".", null, manifestURL).spec;
+ } else if (this.type == "hosted") {
+ return Services.io.newURI(this.location, null, null).prePath;
+ }
+};
+
+AppValidator.prototype.validateLaunchPath = function (manifest) {
+ let deferred = promise.defer();
+ // The launch_path field has to start with a `/`
+ if (manifest.launch_path && manifest.launch_path[0] !== "/") {
+ this.error(strings.formatStringFromName("validator.nonAbsoluteLaunchPath", [manifest.launch_path], 1));
+ deferred.resolve();
+ return deferred.promise;
+ }
+ let origin = this._getOriginURL();
+ let path;
+ if (this.type == "packaged") {
+ path = "." + (manifest.launch_path || "/index.html");
+ } else if (this.type == "hosted") {
+ path = manifest.launch_path || "/";
+ }
+ let indexURL;
+ try {
+ indexURL = Services.io.newURI(path, null, Services.io.newURI(origin, null, null)).spec;
+ } catch (e) {
+ this.error(strings.formatStringFromName("validator.accessFailedLaunchPath", [origin + path], 1));
+ deferred.resolve();
+ return deferred.promise;
+ }
+
+ let req = new XMLHttpRequest();
+ req.overrideMimeType("text/plain");
+ try {
+ req.open("HEAD", indexURL, true);
+ req.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE | Ci.nsIRequest.INHIBIT_CACHING;
+ } catch (e) {
+ this.error(strings.formatStringFromName("validator.accessFailedLaunchPath", [indexURL], 1));
+ deferred.resolve();
+ return deferred.promise;
+ }
+ req.onload = () => {
+ if (req.status >= 400)
+ this.error(strings.formatStringFromName("validator.accessFailedLaunchPathBadHttpCode", [indexURL, req.status], 2));
+ deferred.resolve();
+ };
+ req.onerror = () => {
+ this.error(strings.formatStringFromName("validator.accessFailedLaunchPath", [indexURL], 1));
+ deferred.resolve();
+ };
+
+ try {
+ req.send(null);
+ } catch (e) {
+ this.error(strings.formatStringFromName("validator.accessFailedLaunchPath", [indexURL], 1));
+ deferred.resolve();
+ }
+
+ return deferred.promise;
+};
+
+AppValidator.prototype.validateType = function (manifest) {
+ let appType = manifest.type || "web";
+ if (["web", "privileged", "certified"].indexOf(appType) === -1) {
+ this.error(strings.formatStringFromName("validator.invalidAppType", [appType], 1));
+ } else if (this.type == "hosted" &&
+ ["certified", "privileged"].indexOf(appType) !== -1) {
+ this.error(strings.formatStringFromName("validator.invalidHostedPriviledges", [appType], 1));
+ }
+
+ // certified app are not fully supported on the simulator
+ if (appType === "certified") {
+ this.warning(strings.GetStringFromName("validator.noCertifiedSupport"));
+ }
+};
+
+AppValidator.prototype.validate = function () {
+ this.errors = [];
+ this.warnings = [];
+ return this._getManifest().
+ then((manifest) => {
+ if (manifest) {
+ this.manifest = manifest;
+
+ // Skip validations for add-ons
+ if (manifest.role === "addon" || manifest.manifest_version) {
+ return promise.resolve();
+ }
+
+ this.validateManifest(manifest);
+ this.validateType(manifest);
+ return this.validateLaunchPath(manifest);
+ }
+ });
+};
+
+exports.AppValidator = AppValidator;
diff --git a/devtools/client/webide/modules/build.js b/devtools/client/webide/modules/build.js
new file mode 100644
index 000000000..34cbcc0b7
--- /dev/null
+++ b/devtools/client/webide/modules/build.js
@@ -0,0 +1,199 @@
+/* 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, Cc, Ci} = require("chrome");
+
+const promise = require("promise");
+const { Task } = require("devtools/shared/task");
+const { TextDecoder, OS } = Cu.import("resource://gre/modules/osfile.jsm", {});
+const Subprocess = require("sdk/system/child_process/subprocess");
+
+const ProjectBuilding = exports.ProjectBuilding = {
+ fetchPackageManifest: Task.async(function* (project) {
+ let manifestPath = OS.Path.join(project.location, "package.json");
+ let exists = yield OS.File.exists(manifestPath);
+ if (!exists) {
+ // No explicit manifest, try to generate one if possible
+ return this.generatePackageManifest(project);
+ }
+
+ let data = yield OS.File.read(manifestPath);
+ data = new TextDecoder().decode(data);
+ let manifest;
+ try {
+ manifest = JSON.parse(data);
+ } catch (e) {
+ throw new Error("Error while reading WebIDE manifest at: '" + manifestPath +
+ "', invalid JSON: " + e.message);
+ }
+ return manifest;
+ }),
+
+ /**
+ * For common frameworks in the community, attempt to detect the build
+ * settings if none are defined. This makes it much easier to get started
+ * with WebIDE. Later on, perhaps an add-on could define such things for
+ * different frameworks.
+ */
+ generatePackageManifest: Task.async(function* (project) {
+ // Cordova
+ let cordovaConfigPath = OS.Path.join(project.location, "config.xml");
+ let exists = yield OS.File.exists(cordovaConfigPath);
+ if (!exists) {
+ return;
+ }
+ let data = yield OS.File.read(cordovaConfigPath);
+ data = new TextDecoder().decode(data);
+ if (data.contains("cordova.apache.org")) {
+ return {
+ "webide": {
+ "prepackage": "cordova prepare",
+ "packageDir": "./platforms/firefoxos/www"
+ }
+ };
+ }
+ }),
+
+ hasPrepackage: Task.async(function* (project) {
+ let manifest = yield ProjectBuilding.fetchPackageManifest(project);
+ return manifest && manifest.webide && "prepackage" in manifest.webide;
+ }),
+
+ // If the app depends on some build step, run it before pushing the app
+ build: Task.async(function* ({ project, logger }) {
+ if (!(yield this.hasPrepackage(project))) {
+ return;
+ }
+
+ let manifest = yield ProjectBuilding.fetchPackageManifest(project);
+
+ logger("start");
+ try {
+ yield this._build(project, manifest, logger);
+ logger("succeed");
+ } catch (e) {
+ logger("failed", e);
+ }
+ }),
+
+ _build: Task.async(function* (project, manifest, logger) {
+ // Look for `webide` property
+ manifest = manifest.webide;
+
+ let command, cwd, args = [], env = [];
+
+ // Copy frequently used env vars
+ let envService = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment);
+ ["HOME", "PATH"].forEach(key => {
+ let value = envService.get(key);
+ if (value) {
+ env.push(key + "=" + value);
+ }
+ });
+
+ if (typeof (manifest.prepackage) === "string") {
+ command = manifest.prepackage.replace(/%project%/g, project.location);
+ } else if (manifest.prepackage.command) {
+ command = manifest.prepackage.command;
+
+ args = manifest.prepackage.args || [];
+ args = args.map(a => a.replace(/%project%/g, project.location));
+
+ env = env.concat(manifest.prepackage.env || []);
+ env = env.map(a => a.replace(/%project%/g, project.location));
+
+ if (manifest.prepackage.cwd) {
+ // Normalize path for Windows support (converts / to \)
+ let path = OS.Path.normalize(manifest.prepackage.cwd);
+ // Note that Path.join also support absolute path and argument.
+ // So that if cwd is absolute, it will return cwd.
+ let rel = OS.Path.join(project.location, path);
+ let exists = yield OS.File.exists(rel);
+ if (exists) {
+ cwd = rel;
+ }
+ }
+ } else {
+ throw new Error("pre-package manifest is invalid, missing or invalid " +
+ "`prepackage` attribute");
+ }
+
+ if (!cwd) {
+ cwd = project.location;
+ }
+
+ logger("Running pre-package hook '" + command + "' " +
+ args.join(" ") +
+ " with ENV=[" + env.join(", ") + "]" +
+ " at " + cwd);
+
+ // Run the command through a shell command in order to support non absolute
+ // paths.
+ // On Windows `ComSpec` env variable is going to refer to cmd.exe,
+ // Otherwise, on Linux and Mac, SHELL env variable should refer to
+ // the user chosen shell program.
+ // (We do not check for OS, as on windows, with cygwin, ComSpec isn't set)
+ let shell = envService.get("ComSpec") || envService.get("SHELL");
+ args.unshift(command);
+
+ // For cmd.exe, we have to pass the `/C` option,
+ // but for unix shells we need -c.
+ // That to interpret next argument as a shell command.
+ if (envService.exists("ComSpec")) {
+ args.unshift("/C");
+ } else {
+ args.unshift("-c");
+ }
+
+ // Subprocess changes CWD, we have to save and restore it.
+ let originalCwd = yield OS.File.getCurrentDirectory();
+ try {
+ let defer = promise.defer();
+ Subprocess.call({
+ command: shell,
+ arguments: args,
+ environment: env,
+ workdir: cwd,
+
+ stdout: data =>
+ logger(data),
+ stderr: data =>
+ logger(data),
+
+ done: result => {
+ logger("Terminated with error code: " + result.exitCode);
+ if (result.exitCode == 0) {
+ defer.resolve();
+ } else {
+ defer.reject("pre-package command failed with error code " + result.exitCode);
+ }
+ }
+ });
+ defer.promise.then(() => {
+ OS.File.setCurrentDirectory(originalCwd);
+ });
+ yield defer.promise;
+ } catch (e) {
+ throw new Error("Unable to run pre-package command '" + command + "' " +
+ args.join(" ") + ":\n" + (e.message || e));
+ }
+ }),
+
+ getPackageDir: Task.async(function* (project) {
+ let manifest = yield ProjectBuilding.fetchPackageManifest(project);
+ if (!manifest || !manifest.webide || !manifest.webide.packageDir) {
+ return project.location;
+ }
+ manifest = manifest.webide;
+
+ let packageDir = OS.Path.join(project.location, manifest.packageDir);
+ // On Windows, replace / by \\
+ packageDir = OS.Path.normalize(packageDir);
+ let exists = yield OS.File.exists(packageDir);
+ if (exists) {
+ return packageDir;
+ }
+ throw new Error("Unable to resolve application package directory: '" + manifest.packageDir + "'");
+ })
+};
diff --git a/devtools/client/webide/modules/config-view.js b/devtools/client/webide/modules/config-view.js
new file mode 100644
index 000000000..5fb07e235
--- /dev/null
+++ b/devtools/client/webide/modules/config-view.js
@@ -0,0 +1,373 @@
+/* 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");
+
+const EventEmitter = require("devtools/shared/event-emitter");
+const Services = require("Services");
+const Strings = Services.strings.createBundle("chrome://devtools/locale/webide.properties");
+
+var ConfigView;
+
+module.exports = ConfigView = function (window) {
+ EventEmitter.decorate(this);
+ this._doc = window.document;
+ this._keys = [];
+ return this;
+};
+
+ConfigView.prototype = {
+ _renderByType: function (input, name, value, customType) {
+ value = customType || typeof value;
+
+ switch (value) {
+ case "boolean":
+ input.setAttribute("data-type", "boolean");
+ input.setAttribute("type", "checkbox");
+ break;
+ case "number":
+ input.setAttribute("data-type", "number");
+ input.setAttribute("type", "number");
+ break;
+ case "object":
+ input.setAttribute("data-type", "object");
+ input.setAttribute("type", "text");
+ break;
+ default:
+ input.setAttribute("data-type", "string");
+ input.setAttribute("type", "text");
+ break;
+ }
+ return input;
+ },
+
+ set front(front) {
+ this._front = front;
+ },
+
+ set keys(keys) {
+ this._keys = keys;
+ },
+
+ get keys() {
+ return this._keys;
+ },
+
+ set kind(kind) {
+ this._kind = kind;
+ },
+
+ set includeTypeName(include) {
+ this._includeTypeName = include;
+ },
+
+ search: function (event) {
+ if (event.target.value.length) {
+ let stringMatch = new RegExp(event.target.value, "i");
+
+ for (let i = 0; i < this._keys.length; i++) {
+ let key = this._keys[i];
+ let row = this._doc.getElementById("row-" + key);
+ if (key.match(stringMatch)) {
+ row.classList.remove("hide");
+ } else if (row) {
+ row.classList.add("hide");
+ }
+ }
+ } else {
+ var trs = this._doc.getElementById("device-fields").querySelectorAll("tr");
+
+ for (let i = 0; i < trs.length; i++) {
+ trs[i].classList.remove("hide");
+ }
+ }
+ },
+
+ generateDisplay: function (json) {
+ let deviceItems = Object.keys(json);
+ deviceItems.sort();
+ this.keys = deviceItems;
+ for (let i = 0; i < this.keys.length; i++) {
+ let key = this.keys[i];
+ this.generateField(key, json[key].value, json[key].hasUserValue);
+ }
+ },
+
+ generateField: function (name, value, hasUserValue, customType, newRow) {
+ let table = this._doc.querySelector("table");
+ let sResetDefault = Strings.GetStringFromName("device_reset_default");
+
+ if (this._keys.indexOf(name) === -1) {
+ this._keys.push(name);
+ }
+
+ let input = this._doc.createElement("input");
+ let tr = this._doc.createElement("tr");
+ tr.setAttribute("id", "row-" + name);
+ tr.classList.add("edit-row");
+ let td = this._doc.createElement("td");
+ td.classList.add("field-name");
+ td.textContent = name;
+ tr.appendChild(td);
+ td = this._doc.createElement("td");
+ input.classList.add("editable");
+ input.setAttribute("id", name);
+ input = this._renderByType(input, name, value, customType);
+
+ if (customType === "boolean" || input.type === "checkbox") {
+ input.checked = value;
+ } else {
+ if (typeof value === "object") {
+ value = JSON.stringify(value);
+ }
+ input.value = value;
+ }
+
+ if (!(this._includeTypeName || isNaN(parseInt(value, 10)))) {
+ input.type = "number";
+ }
+
+ td.appendChild(input);
+ tr.appendChild(td);
+ td = this._doc.createElement("td");
+ td.setAttribute("id", "td-" + name);
+
+ let button = this._doc.createElement("button");
+ button.setAttribute("data-id", name);
+ button.setAttribute("id", "btn-" + name);
+ button.classList.add("reset");
+ button.textContent = sResetDefault;
+ td.appendChild(button);
+
+ if (!hasUserValue) {
+ button.classList.add("hide");
+ }
+
+ tr.appendChild(td);
+
+ // If this is a new field, add it to the top of the table.
+ if (newRow) {
+ let existing = table.querySelector("#" + name);
+
+ if (!existing) {
+ table.insertBefore(tr, newRow);
+ } else {
+ existing.value = value;
+ }
+ } else {
+ table.appendChild(tr);
+ }
+ },
+
+ resetTable: function () {
+ let table = this._doc.querySelector("table");
+ let trs = table.querySelectorAll("tr:not(#add-custom-field)");
+
+ for (var i = 0; i < trs.length; i++) {
+ table.removeChild(trs[i]);
+ }
+
+ return table;
+ },
+
+ _getCallType: function (type, name) {
+ let frontName = "get";
+
+ if (this._includeTypeName) {
+ frontName += type;
+ }
+
+ return this._front[frontName + this._kind](name);
+ },
+
+ _setCallType: function (type, name, value) {
+ let frontName = "set";
+
+ if (this._includeTypeName) {
+ frontName += type;
+ }
+
+ return this._front[frontName + this._kind](name, value);
+ },
+
+ _saveByType: function (options) {
+ let fieldName = options.id;
+ let inputType = options.type;
+ let value = options.value;
+ let input = this._doc.getElementById(fieldName);
+
+ switch (inputType) {
+ case "boolean":
+ this._setCallType("Bool", fieldName, input.checked);
+ break;
+ case "number":
+ this._setCallType("Int", fieldName, value);
+ break;
+ case "object":
+ try {
+ value = JSON.parse(value);
+ } catch (e) {}
+ this._setCallType("Object", fieldName, value);
+ break;
+ default:
+ this._setCallType("Char", fieldName, value);
+ break;
+ }
+ },
+
+ updateField: function (event) {
+ if (event.target) {
+ let inputType = event.target.getAttribute("data-type");
+ let inputValue = event.target.checked || event.target.value;
+
+ if (event.target.nodeName == "input" &&
+ event.target.validity.valid &&
+ event.target.classList.contains("editable")) {
+ let id = event.target.id;
+ if (inputType === "boolean") {
+ if (event.target.checked) {
+ inputValue = true;
+ } else {
+ inputValue = false;
+ }
+ }
+
+ this._saveByType({
+ id: id,
+ type: inputType,
+ value: inputValue
+ });
+ this._doc.getElementById("btn-" + id).classList.remove("hide");
+ }
+ }
+ },
+
+ _resetToDefault: function (name, input, button) {
+ this._front["clearUser" + this._kind](name);
+ let dataType = input.getAttribute("data-type");
+ let tr = this._doc.getElementById("row-" + name);
+
+ switch (dataType) {
+ case "boolean":
+ this._defaultField = this._getCallType("Bool", name);
+ this._defaultField.then(boolean => {
+ input.checked = boolean;
+ }, () => {
+ input.checked = false;
+ tr.parentNode.removeChild(tr);
+ });
+ break;
+ case "number":
+ this._defaultField = this._getCallType("Int", name);
+ this._defaultField.then(number => {
+ input.value = number;
+ }, () => {
+ tr.parentNode.removeChild(tr);
+ });
+ break;
+ case "object":
+ this._defaultField = this._getCallType("Object", name);
+ this._defaultField.then(object => {
+ input.value = JSON.stringify(object);
+ }, () => {
+ tr.parentNode.removeChild(tr);
+ });
+ break;
+ default:
+ this._defaultField = this._getCallType("Char", name);
+ this._defaultField.then(string => {
+ input.value = string;
+ }, () => {
+ tr.parentNode.removeChild(tr);
+ });
+ break;
+ }
+
+ button.classList.add("hide");
+ },
+
+ checkReset: function (event) {
+ if (event.target.classList.contains("reset")) {
+ let btnId = event.target.getAttribute("data-id");
+ let input = this._doc.getElementById(btnId);
+ this._resetToDefault(btnId, input, event.target);
+ }
+ },
+
+ updateFieldType: function () {
+ let table = this._doc.querySelector("table");
+ let customValueType = table.querySelector("#custom-value-type").value;
+ let customTextEl = table.querySelector("#custom-value-text");
+ let customText = customTextEl.value;
+
+ if (customValueType.length === 0) {
+ return false;
+ }
+
+ switch (customValueType) {
+ case "boolean":
+ customTextEl.type = "checkbox";
+ customText = customTextEl.checked;
+ break;
+ case "number":
+ customText = parseInt(customText, 10) || 0;
+ customTextEl.type = "number";
+ break;
+ default:
+ customTextEl.type = "text";
+ break;
+ }
+
+ return customValueType;
+ },
+
+ clearNewFields: function () {
+ let table = this._doc.querySelector("table");
+ let customTextEl = table.querySelector("#custom-value-text");
+ if (customTextEl.checked) {
+ customTextEl.checked = false;
+ } else {
+ customTextEl.value = "";
+ }
+
+ this.updateFieldType();
+ },
+
+ updateNewField: function () {
+ let table = this._doc.querySelector("table");
+ let customValueType = this.updateFieldType();
+
+ if (!customValueType) {
+ return;
+ }
+
+ let customRow = table.querySelector("tr:nth-of-type(2)");
+ let customTextEl = table.querySelector("#custom-value-text");
+ let customTextNameEl = table.querySelector("#custom-value-name");
+
+ if (customTextEl.validity.valid) {
+ let customText = customTextEl.value;
+
+ if (customValueType === "boolean") {
+ customText = customTextEl.checked;
+ }
+
+ let customTextName = customTextNameEl.value.replace(/[^A-Za-z0-9\.\-_]/gi, "");
+ this.generateField(customTextName, customText, true, customValueType, customRow);
+ this._saveByType({
+ id: customTextName,
+ type: customValueType,
+ value: customText
+ });
+ customTextNameEl.value = "";
+ this.clearNewFields();
+ }
+ },
+
+ checkNewFieldSubmit: function (event) {
+ if (event.keyCode === 13) {
+ this._doc.getElementById("custom-value").click();
+ }
+ }
+};
diff --git a/devtools/client/webide/modules/moz.build b/devtools/client/webide/modules/moz.build
new file mode 100644
index 000000000..c4072b703
--- /dev/null
+++ b/devtools/client/webide/modules/moz.build
@@ -0,0 +1,21 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+DevToolsModules(
+ 'addons.js',
+ 'app-manager.js',
+ 'app-projects.js',
+ 'app-validator.js',
+ 'build.js',
+ 'config-view.js',
+ 'project-list.js',
+ 'runtime-list.js',
+ 'runtimes.js',
+ 'simulator-process.js',
+ 'simulators.js',
+ 'tab-store.js',
+ 'utils.js'
+)
diff --git a/devtools/client/webide/modules/project-list.js b/devtools/client/webide/modules/project-list.js
new file mode 100644
index 000000000..10766dd4f
--- /dev/null
+++ b/devtools/client/webide/modules/project-list.js
@@ -0,0 +1,375 @@
+/* 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");
+
+const Services = require("Services");
+const {AppProjects} = require("devtools/client/webide/modules/app-projects");
+const {AppManager} = require("devtools/client/webide/modules/app-manager");
+const promise = require("promise");
+const EventEmitter = require("devtools/shared/event-emitter");
+const {Task} = require("devtools/shared/task");
+const utils = require("devtools/client/webide/modules/utils");
+const Telemetry = require("devtools/client/shared/telemetry");
+
+const Strings = Services.strings.createBundle("chrome://devtools/locale/webide.properties");
+
+var ProjectList;
+
+module.exports = ProjectList = function (win, parentWindow) {
+ EventEmitter.decorate(this);
+ this._doc = win.document;
+ this._UI = parentWindow.UI;
+ this._parentWindow = parentWindow;
+ this._telemetry = new Telemetry();
+ this._panelNodeEl = "div";
+
+ this.onWebIDEUpdate = this.onWebIDEUpdate.bind(this);
+ this._UI.on("webide-update", this.onWebIDEUpdate);
+
+ AppManager.init();
+ this.appManagerUpdate = this.appManagerUpdate.bind(this);
+ AppManager.on("app-manager-update", this.appManagerUpdate);
+};
+
+ProjectList.prototype = {
+ get doc() {
+ return this._doc;
+ },
+
+ appManagerUpdate: function (event, what, details) {
+ // Got a message from app-manager.js
+ // See AppManager.update() for descriptions of what these events mean.
+ switch (what) {
+ case "project-removed":
+ case "runtime-apps-icons":
+ case "runtime-targets":
+ case "connection":
+ this.update(details);
+ break;
+ case "project":
+ this.updateCommands();
+ this.update(details);
+ break;
+ }
+ },
+
+ onWebIDEUpdate: function (event, what, details) {
+ if (what == "busy" || what == "unbusy") {
+ this.updateCommands();
+ }
+ },
+
+ /**
+ * testOptions: { chrome mochitest support
+ * folder: nsIFile, where to store the app
+ * index: Number, index of the app in the template list
+ * name: String name of the app
+ * }
+ */
+ newApp: function (testOptions) {
+ let parentWindow = this._parentWindow;
+ let self = this;
+ return this._UI.busyUntil(Task.spawn(function* () {
+ // Open newapp.xul, which will feed ret.location
+ let ret = {location: null, testOptions: testOptions};
+ parentWindow.openDialog("chrome://webide/content/newapp.xul", "newapp", "chrome,modal", ret);
+ if (!ret.location)
+ return;
+
+ // Retrieve added project
+ let project = AppProjects.get(ret.location);
+
+ // Select project
+ AppManager.selectedProject = project;
+
+ self._telemetry.actionOccurred("webideNewProject");
+ }), "creating new app");
+ },
+
+ importPackagedApp: function (location) {
+ let parentWindow = this._parentWindow;
+ let UI = this._UI;
+ return UI.busyUntil(Task.spawn(function* () {
+ let directory = utils.getPackagedDirectory(parentWindow, location);
+
+ if (!directory) {
+ // User cancelled directory selection
+ return;
+ }
+
+ yield UI.importAndSelectApp(directory);
+ }), "importing packaged app");
+ },
+
+ importHostedApp: function (location) {
+ let parentWindow = this._parentWindow;
+ let UI = this._UI;
+ return UI.busyUntil(Task.spawn(function* () {
+ let url = utils.getHostedURL(parentWindow, location);
+
+ if (!url) {
+ return;
+ }
+
+ yield UI.importAndSelectApp(url);
+ }), "importing hosted app");
+ },
+
+ /**
+ * opts: {
+ * panel: Object, currenl project panel node
+ * name: String, name of the project
+ * icon: String path of the project icon
+ * }
+ */
+ _renderProjectItem: function (opts) {
+ let span = opts.panel.querySelector("span") || this._doc.createElement("span");
+ span.textContent = opts.name;
+ let icon = opts.panel.querySelector("img") || this._doc.createElement("img");
+ icon.className = "project-image";
+ icon.setAttribute("src", opts.icon);
+ opts.panel.appendChild(icon);
+ opts.panel.appendChild(span);
+ opts.panel.setAttribute("title", opts.name);
+ },
+
+ refreshTabs: function () {
+ if (AppManager.connected) {
+ return AppManager.listTabs().then(() => {
+ this.updateTabs();
+ }).catch(console.error);
+ }
+ },
+
+ updateTabs: function () {
+ let tabsHeaderNode = this._doc.querySelector("#panel-header-tabs");
+ let tabsNode = this._doc.querySelector("#project-panel-tabs");
+
+ while (tabsNode.hasChildNodes()) {
+ tabsNode.firstChild.remove();
+ }
+
+ if (!AppManager.connected) {
+ tabsHeaderNode.setAttribute("hidden", "true");
+ return;
+ }
+
+ let tabs = AppManager.tabStore.tabs;
+
+ tabsHeaderNode.removeAttribute("hidden");
+
+ for (let i = 0; i < tabs.length; i++) {
+ let tab = tabs[i];
+ let URL = this._parentWindow.URL;
+ let url;
+ try {
+ url = new URL(tab.url);
+ } catch (e) {
+ // Don't try to handle invalid URLs, especially from Valence.
+ continue;
+ }
+ // 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).
+ if (url.origin) {
+ tab.favicon = url.origin + "/favicon.ico";
+ }
+ tab.name = tab.title || Strings.GetStringFromName("project_tab_loading");
+ if (url.protocol.startsWith("http")) {
+ tab.name = url.hostname + ": " + tab.name;
+ }
+ let panelItemNode = this._doc.createElement(this._panelNodeEl);
+ panelItemNode.className = "panel-item";
+ tabsNode.appendChild(panelItemNode);
+ this._renderProjectItem({
+ panel: panelItemNode,
+ name: tab.name,
+ icon: tab.favicon || AppManager.DEFAULT_PROJECT_ICON
+ });
+ panelItemNode.addEventListener("click", () => {
+ AppManager.selectedProject = {
+ type: "tab",
+ app: tab,
+ icon: tab.favicon || AppManager.DEFAULT_PROJECT_ICON,
+ location: tab.url,
+ name: tab.name
+ };
+ }, true);
+ }
+
+ return promise.resolve();
+ },
+
+ updateApps: function () {
+ let doc = this._doc;
+ let runtimeappsHeaderNode = doc.querySelector("#panel-header-runtimeapps");
+ let sortedApps = [];
+ for (let [manifestURL, app] of AppManager.apps) {
+ sortedApps.push(app);
+ }
+ sortedApps = sortedApps.sort((a, b) => {
+ return a.manifest.name > b.manifest.name;
+ });
+ let mainProcess = AppManager.isMainProcessDebuggable();
+ if (AppManager.connected && (sortedApps.length > 0 || mainProcess)) {
+ runtimeappsHeaderNode.removeAttribute("hidden");
+ } else {
+ runtimeappsHeaderNode.setAttribute("hidden", "true");
+ }
+
+ let runtimeAppsNode = doc.querySelector("#project-panel-runtimeapps");
+ while (runtimeAppsNode.hasChildNodes()) {
+ runtimeAppsNode.firstChild.remove();
+ }
+
+ if (mainProcess) {
+ let panelItemNode = doc.createElement(this._panelNodeEl);
+ panelItemNode.className = "panel-item";
+ this._renderProjectItem({
+ panel: panelItemNode,
+ name: Strings.GetStringFromName("mainProcess_label"),
+ icon: AppManager.DEFAULT_PROJECT_ICON
+ });
+ runtimeAppsNode.appendChild(panelItemNode);
+ panelItemNode.addEventListener("click", () => {
+ AppManager.selectedProject = {
+ type: "mainProcess",
+ name: Strings.GetStringFromName("mainProcess_label"),
+ icon: AppManager.DEFAULT_PROJECT_ICON
+ };
+ }, true);
+ }
+
+ for (let i = 0; i < sortedApps.length; i++) {
+ let app = sortedApps[i];
+ let panelItemNode = doc.createElement(this._panelNodeEl);
+ panelItemNode.className = "panel-item";
+ this._renderProjectItem({
+ panel: panelItemNode,
+ name: app.manifest.name,
+ icon: app.iconURL || AppManager.DEFAULT_PROJECT_ICON
+ });
+ runtimeAppsNode.appendChild(panelItemNode);
+ panelItemNode.addEventListener("click", () => {
+ AppManager.selectedProject = {
+ type: "runtimeApp",
+ app: app.manifest,
+ icon: app.iconURL || AppManager.DEFAULT_PROJECT_ICON,
+ name: app.manifest.name
+ };
+ }, true);
+ }
+
+ return promise.resolve();
+ },
+
+ updateCommands: function () {
+ let doc = this._doc;
+ let newAppCmd;
+ let packagedAppCmd;
+ let hostedAppCmd;
+
+ newAppCmd = doc.querySelector("#new-app");
+ packagedAppCmd = doc.querySelector("#packaged-app");
+ hostedAppCmd = doc.querySelector("#hosted-app");
+
+ if (!newAppCmd || !packagedAppCmd || !hostedAppCmd) {
+ return;
+ }
+
+ if (this._parentWindow.document.querySelector("window").classList.contains("busy")) {
+ newAppCmd.setAttribute("disabled", "true");
+ packagedAppCmd.setAttribute("disabled", "true");
+ hostedAppCmd.setAttribute("disabled", "true");
+ return;
+ }
+
+ newAppCmd.removeAttribute("disabled");
+ packagedAppCmd.removeAttribute("disabled");
+ hostedAppCmd.removeAttribute("disabled");
+ },
+
+ /**
+ * Trigger an update of the project and remote runtime list.
+ * @param options object (optional)
+ * An |options| object containing a type of |apps| or |tabs| will limit
+ * what is updated to only those sections.
+ */
+ update: function (options) {
+ let deferred = promise.defer();
+
+ if (options && options.type === "apps") {
+ return this.updateApps();
+ } else if (options && options.type === "tabs") {
+ return this.updateTabs();
+ }
+
+ let doc = this._doc;
+ let projectsNode = doc.querySelector("#project-panel-projects");
+
+ while (projectsNode.hasChildNodes()) {
+ projectsNode.firstChild.remove();
+ }
+
+ AppProjects.load().then(() => {
+ let projects = AppProjects.projects;
+ for (let i = 0; i < projects.length; i++) {
+ let project = projects[i];
+ let panelItemNode = doc.createElement(this._panelNodeEl);
+ panelItemNode.className = "panel-item";
+ projectsNode.appendChild(panelItemNode);
+ if (!project.validationStatus) {
+ // The result of the validation process (storing names, icons, …) is not stored in
+ // the IndexedDB database when App Manager v1 is used.
+ // We need to run the validation again and update the name and icon of the app.
+ AppManager.validateAndUpdateProject(project).then(() => {
+ this._renderProjectItem({
+ panel: panelItemNode,
+ name: project.name,
+ icon: project.icon
+ });
+ });
+ } else {
+ this._renderProjectItem({
+ panel: panelItemNode,
+ name: project.name || AppManager.DEFAULT_PROJECT_NAME,
+ icon: project.icon || AppManager.DEFAULT_PROJECT_ICON
+ });
+ }
+ panelItemNode.addEventListener("click", () => {
+ AppManager.selectedProject = project;
+ }, true);
+ }
+
+ deferred.resolve();
+ }, deferred.reject);
+
+ // List remote apps and the main process, if they exist
+ this.updateApps();
+
+ // Build the tab list right now, so it's fast...
+ this.updateTabs();
+
+ // But re-list them and rebuild, in case any tabs navigated since the last
+ // time they were listed.
+ if (AppManager.connected) {
+ AppManager.listTabs().then(() => {
+ this.updateTabs();
+ }).catch(console.error);
+ }
+
+ return deferred.promise;
+ },
+
+ destroy: function () {
+ this._doc = null;
+ AppManager.off("app-manager-update", this.appManagerUpdate);
+ this._UI.off("webide-update", this.onWebIDEUpdate);
+ this._UI = null;
+ this._parentWindow = null;
+ this._panelNodeEl = null;
+ }
+};
diff --git a/devtools/client/webide/modules/runtime-list.js b/devtools/client/webide/modules/runtime-list.js
new file mode 100644
index 000000000..295dd1705
--- /dev/null
+++ b/devtools/client/webide/modules/runtime-list.js
@@ -0,0 +1,207 @@
+/* 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 Services = require("Services");
+const {AppManager} = require("devtools/client/webide/modules/app-manager");
+const EventEmitter = require("devtools/shared/event-emitter");
+const {RuntimeScanners, WiFiScanner} = require("devtools/client/webide/modules/runtimes");
+const {Devices} = require("resource://devtools/shared/apps/Devices.jsm");
+const {Task} = require("devtools/shared/task");
+const utils = require("devtools/client/webide/modules/utils");
+
+const Strings = Services.strings.createBundle("chrome://devtools/locale/webide.properties");
+
+var RuntimeList;
+
+module.exports = RuntimeList = function (window, parentWindow) {
+ EventEmitter.decorate(this);
+ this._doc = window.document;
+ this._UI = parentWindow.UI;
+ this._Cmds = parentWindow.Cmds;
+ this._parentWindow = parentWindow;
+ this._panelNodeEl = "button";
+ this._panelBoxEl = "div";
+
+ this.onWebIDEUpdate = this.onWebIDEUpdate.bind(this);
+ this._UI.on("webide-update", this.onWebIDEUpdate);
+
+ AppManager.init();
+ this.appManagerUpdate = this.appManagerUpdate.bind(this);
+ AppManager.on("app-manager-update", this.appManagerUpdate);
+};
+
+RuntimeList.prototype = {
+ get doc() {
+ return this._doc;
+ },
+
+ appManagerUpdate: function (event, what, details) {
+ // Got a message from app-manager.js
+ // See AppManager.update() for descriptions of what these events mean.
+ switch (what) {
+ case "runtime-list":
+ this.update();
+ break;
+ case "connection":
+ case "runtime-global-actors":
+ this.updateCommands();
+ break;
+ }
+ },
+
+ onWebIDEUpdate: function (event, what, details) {
+ if (what == "busy" || what == "unbusy") {
+ this.updateCommands();
+ }
+ },
+
+ takeScreenshot: function () {
+ this._Cmds.takeScreenshot();
+ },
+
+ showRuntimeDetails: function () {
+ this._Cmds.showRuntimeDetails();
+ },
+
+ showPermissionsTable: function () {
+ this._Cmds.showPermissionsTable();
+ },
+
+ showDevicePreferences: function () {
+ this._Cmds.showDevicePrefs();
+ },
+
+ showSettings: function () {
+ this._Cmds.showSettings();
+ },
+
+ showTroubleShooting: function () {
+ this._Cmds.showTroubleShooting();
+ },
+
+ showAddons: function () {
+ this._Cmds.showAddons();
+ },
+
+ refreshScanners: function () {
+ RuntimeScanners.scan();
+ },
+
+ updateCommands: function () {
+ let doc = this._doc;
+
+ // Runtime commands
+ let screenshotCmd = doc.querySelector("#runtime-screenshot");
+ let permissionsCmd = doc.querySelector("#runtime-permissions");
+ let detailsCmd = doc.querySelector("#runtime-details");
+ let disconnectCmd = doc.querySelector("#runtime-disconnect");
+ let devicePrefsCmd = doc.querySelector("#runtime-preferences");
+ let settingsCmd = doc.querySelector("#runtime-settings");
+
+ if (AppManager.connected) {
+ if (AppManager.deviceFront) {
+ detailsCmd.removeAttribute("disabled");
+ permissionsCmd.removeAttribute("disabled");
+ screenshotCmd.removeAttribute("disabled");
+ }
+ if (AppManager.preferenceFront) {
+ devicePrefsCmd.removeAttribute("disabled");
+ }
+ if (AppManager.settingsFront) {
+ settingsCmd.removeAttribute("disabled");
+ }
+ disconnectCmd.removeAttribute("disabled");
+ } else {
+ detailsCmd.setAttribute("disabled", "true");
+ permissionsCmd.setAttribute("disabled", "true");
+ screenshotCmd.setAttribute("disabled", "true");
+ disconnectCmd.setAttribute("disabled", "true");
+ devicePrefsCmd.setAttribute("disabled", "true");
+ settingsCmd.setAttribute("disabled", "true");
+ }
+ },
+
+ update: function () {
+ let doc = this._doc;
+ let wifiHeaderNode = doc.querySelector("#runtime-header-wifi");
+
+ if (WiFiScanner.allowed) {
+ wifiHeaderNode.removeAttribute("hidden");
+ } else {
+ wifiHeaderNode.setAttribute("hidden", "true");
+ }
+
+ let usbListNode = doc.querySelector("#runtime-panel-usb");
+ let wifiListNode = doc.querySelector("#runtime-panel-wifi");
+ let simulatorListNode = doc.querySelector("#runtime-panel-simulator");
+ let otherListNode = doc.querySelector("#runtime-panel-other");
+ let noHelperNode = doc.querySelector("#runtime-panel-noadbhelper");
+ let noUSBNode = doc.querySelector("#runtime-panel-nousbdevice");
+
+ if (Devices.helperAddonInstalled) {
+ noHelperNode.setAttribute("hidden", "true");
+ } else {
+ noHelperNode.removeAttribute("hidden");
+ }
+
+ let runtimeList = AppManager.runtimeList;
+
+ if (!runtimeList) {
+ return;
+ }
+
+ if (runtimeList.usb.length === 0 && Devices.helperAddonInstalled) {
+ noUSBNode.removeAttribute("hidden");
+ } else {
+ noUSBNode.setAttribute("hidden", "true");
+ }
+
+ for (let [type, parent] of [
+ ["usb", usbListNode],
+ ["wifi", wifiListNode],
+ ["simulator", simulatorListNode],
+ ["other", otherListNode],
+ ]) {
+ while (parent.hasChildNodes()) {
+ parent.firstChild.remove();
+ }
+ for (let runtime of runtimeList[type]) {
+ let r = runtime;
+ let panelItemNode = doc.createElement(this._panelBoxEl);
+ panelItemNode.className = "panel-item-complex";
+
+ let connectButton = doc.createElement(this._panelNodeEl);
+ connectButton.className = "panel-item runtime-panel-item-" + type;
+ connectButton.textContent = r.name;
+
+ connectButton.addEventListener("click", () => {
+ this._UI.dismissErrorNotification();
+ this._UI.connectToRuntime(r);
+ }, true);
+ panelItemNode.appendChild(connectButton);
+
+ if (r.configure) {
+ let configButton = doc.createElement(this._panelNodeEl);
+ configButton.className = "configure-button";
+ configButton.addEventListener("click", r.configure.bind(r), true);
+ panelItemNode.appendChild(configButton);
+ }
+
+ parent.appendChild(panelItemNode);
+ }
+ }
+ },
+
+ destroy: function () {
+ this._doc = null;
+ AppManager.off("app-manager-update", this.appManagerUpdate);
+ this._UI.off("webide-update", this.onWebIDEUpdate);
+ this._UI = null;
+ this._Cmds = null;
+ this._parentWindow = null;
+ this._panelNodeEl = null;
+ }
+};
diff --git a/devtools/client/webide/modules/runtimes.js b/devtools/client/webide/modules/runtimes.js
new file mode 100644
index 000000000..a23337359
--- /dev/null
+++ b/devtools/client/webide/modules/runtimes.js
@@ -0,0 +1,673 @@
+/* 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} = require("chrome");
+const Services = require("Services");
+const {Devices} = require("resource://devtools/shared/apps/Devices.jsm");
+const {Connection} = require("devtools/shared/client/connection-manager");
+const {DebuggerServer} = require("devtools/server/main");
+const {Simulators} = require("devtools/client/webide/modules/simulators");
+const discovery = require("devtools/shared/discovery/discovery");
+const EventEmitter = require("devtools/shared/event-emitter");
+const promise = require("promise");
+loader.lazyRequireGetter(this, "AuthenticationResult",
+ "devtools/shared/security/auth", true);
+loader.lazyRequireGetter(this, "DevToolsUtils",
+ "devtools/shared/DevToolsUtils");
+
+const Strings = Services.strings.createBundle("chrome://devtools/locale/webide.properties");
+
+/**
+ * Runtime and Scanner API
+ *
+ * |RuntimeScanners| maintains a set of |Scanner| objects that produce one or
+ * more |Runtime|s to connect to. Add-ons can extend the set of known runtimes
+ * by registering additional |Scanner|s that emit them.
+ *
+ * Each |Scanner| must support the following API:
+ *
+ * enable()
+ * Bind any event handlers and start any background work the scanner needs to
+ * maintain an updated set of |Runtime|s.
+ * Called when the first consumer (such as WebIDE) actively interested in
+ * maintaining the |Runtime| list enables the registry.
+ * disable()
+ * Unbind any event handlers and stop any background work the scanner needs to
+ * maintain an updated set of |Runtime|s.
+ * Called when the last consumer (such as WebIDE) actively interested in
+ * maintaining the |Runtime| list disables the registry.
+ * emits "runtime-list-updated"
+ * If the set of runtimes a |Scanner| manages has changed, it must emit this
+ * event to notify consumers of changes.
+ * scan()
+ * Actively refreshes the list of runtimes the scanner knows about. If your
+ * scanner uses an active scanning approach (as opposed to listening for
+ * events when changes occur), the bulk of the work would be done here.
+ * @return Promise
+ * Should be resolved when scanning is complete. If scanning has no
+ * well-defined end point, you can resolve immediately, as long as
+ * update event is emitted later when changes are noticed.
+ * listRuntimes()
+ * Return the current list of runtimes known to the |Scanner| instance.
+ * @return Iterable
+ *
+ * Each |Runtime| must support the following API:
+ *
+ * |type| field
+ * The |type| must be one of the values from the |RuntimeTypes| object. This
+ * is used for Telemetry and to support displaying sets of |Runtime|s
+ * categorized by type.
+ * |id| field
+ * An identifier that is unique in the set of all runtimes with the same
+ * |type|. WebIDE tries to save the last used runtime via type + id, and
+ * tries to locate it again in the next session, so this value should attempt
+ * to be stable across Firefox sessions.
+ * |name| field
+ * A user-visible label to identify the runtime that will be displayed in a
+ * runtime list.
+ * |prolongedConnection| field
+ * A boolean value which should be |true| if the connection process is
+ * expected to take a unknown or large amount of time. A UI may use this as a
+ * hint to skip timeouts or other time-based code paths.
+ * connect()
+ * Configure the passed |connection| object with any settings need to
+ * successfully connect to the runtime, and call the |connection|'s connect()
+ * method.
+ * @param Connection connection
+ * A |Connection| object from the DevTools |ConnectionManager|.
+ * @return Promise
+ * Resolved once you've called the |connection|'s connect() method.
+ * configure() OPTIONAL
+ * Show a configuration screen if the runtime is configurable.
+ */
+
+/* SCANNER REGISTRY */
+
+var RuntimeScanners = {
+
+ _enabledCount: 0,
+ _scanners: new Set(),
+
+ get enabled() {
+ return !!this._enabledCount;
+ },
+
+ add(scanner) {
+ if (this.enabled) {
+ // Enable any scanner added while globally enabled
+ this._enableScanner(scanner);
+ }
+ this._scanners.add(scanner);
+ this._emitUpdated();
+ },
+
+ remove(scanner) {
+ this._scanners.delete(scanner);
+ if (this.enabled) {
+ // Disable any scanner removed while globally enabled
+ this._disableScanner(scanner);
+ }
+ this._emitUpdated();
+ },
+
+ has(scanner) {
+ return this._scanners.has(scanner);
+ },
+
+ scan() {
+ if (!this.enabled) {
+ return promise.resolve();
+ }
+
+ if (this._scanPromise) {
+ return this._scanPromise;
+ }
+
+ let promises = [];
+
+ for (let scanner of this._scanners) {
+ promises.push(scanner.scan());
+ }
+
+ this._scanPromise = promise.all(promises);
+
+ // Reset pending promise
+ this._scanPromise.then(() => {
+ this._scanPromise = null;
+ }, () => {
+ this._scanPromise = null;
+ });
+
+ return this._scanPromise;
+ },
+
+ listRuntimes: function* () {
+ for (let scanner of this._scanners) {
+ for (let runtime of scanner.listRuntimes()) {
+ yield runtime;
+ }
+ }
+ },
+
+ _emitUpdated() {
+ this.emit("runtime-list-updated");
+ },
+
+ enable() {
+ if (this._enabledCount++ !== 0) {
+ // Already enabled scanners during a previous call
+ return;
+ }
+ this._emitUpdated = this._emitUpdated.bind(this);
+ for (let scanner of this._scanners) {
+ this._enableScanner(scanner);
+ }
+ },
+
+ _enableScanner(scanner) {
+ scanner.enable();
+ scanner.on("runtime-list-updated", this._emitUpdated);
+ },
+
+ disable() {
+ if (--this._enabledCount !== 0) {
+ // Already disabled scanners during a previous call
+ return;
+ }
+ for (let scanner of this._scanners) {
+ this._disableScanner(scanner);
+ }
+ },
+
+ _disableScanner(scanner) {
+ scanner.off("runtime-list-updated", this._emitUpdated);
+ scanner.disable();
+ },
+
+};
+
+EventEmitter.decorate(RuntimeScanners);
+
+exports.RuntimeScanners = RuntimeScanners;
+
+/* SCANNERS */
+
+var SimulatorScanner = {
+
+ _runtimes: [],
+
+ enable() {
+ this._updateRuntimes = this._updateRuntimes.bind(this);
+ Simulators.on("updated", this._updateRuntimes);
+ this._updateRuntimes();
+ },
+
+ disable() {
+ Simulators.off("updated", this._updateRuntimes);
+ },
+
+ _emitUpdated() {
+ this.emit("runtime-list-updated");
+ },
+
+ _updateRuntimes() {
+ Simulators.findSimulators().then(simulators => {
+ this._runtimes = [];
+ for (let simulator of simulators) {
+ this._runtimes.push(new SimulatorRuntime(simulator));
+ }
+ this._emitUpdated();
+ });
+ },
+
+ scan() {
+ return promise.resolve();
+ },
+
+ listRuntimes: function () {
+ return this._runtimes;
+ }
+
+};
+
+EventEmitter.decorate(SimulatorScanner);
+RuntimeScanners.add(SimulatorScanner);
+
+/**
+ * TODO: Remove this comaptibility layer in the future (bug 1085393)
+ * This runtime exists to support the ADB Helper add-on below version 0.7.0.
+ *
+ * This scanner will list all ADB devices as runtimes, even if they may or may
+ * not actually connect (since the |DeprecatedUSBRuntime| assumes a Firefox OS
+ * device).
+ */
+var DeprecatedAdbScanner = {
+
+ _runtimes: [],
+
+ enable() {
+ this._updateRuntimes = this._updateRuntimes.bind(this);
+ Devices.on("register", this._updateRuntimes);
+ Devices.on("unregister", this._updateRuntimes);
+ Devices.on("addon-status-updated", this._updateRuntimes);
+ this._updateRuntimes();
+ },
+
+ disable() {
+ Devices.off("register", this._updateRuntimes);
+ Devices.off("unregister", this._updateRuntimes);
+ Devices.off("addon-status-updated", this._updateRuntimes);
+ },
+
+ _emitUpdated() {
+ this.emit("runtime-list-updated");
+ },
+
+ _updateRuntimes() {
+ this._runtimes = [];
+ for (let id of Devices.available()) {
+ let runtime = new DeprecatedUSBRuntime(id);
+ this._runtimes.push(runtime);
+ runtime.updateNameFromADB().then(() => {
+ this._emitUpdated();
+ }, () => {});
+ }
+ this._emitUpdated();
+ },
+
+ scan() {
+ return promise.resolve();
+ },
+
+ listRuntimes: function () {
+ return this._runtimes;
+ }
+
+};
+
+EventEmitter.decorate(DeprecatedAdbScanner);
+RuntimeScanners.add(DeprecatedAdbScanner);
+
+// ADB Helper 0.7.0 and later will replace this scanner on startup
+exports.DeprecatedAdbScanner = DeprecatedAdbScanner;
+
+/**
+ * This is a lazy ADB scanner shim which only tells the ADB Helper to start and
+ * stop as needed. The real scanner that lists devices lives in ADB Helper.
+ * ADB Helper 0.8.0 and later wait until these signals are received before
+ * starting ADB polling. For earlier versions, they have no effect.
+ */
+var LazyAdbScanner = {
+
+ enable() {
+ Devices.emit("adb-start-polling");
+ },
+
+ disable() {
+ Devices.emit("adb-stop-polling");
+ },
+
+ scan() {
+ return promise.resolve();
+ },
+
+ listRuntimes: function () {
+ return [];
+ }
+
+};
+
+EventEmitter.decorate(LazyAdbScanner);
+RuntimeScanners.add(LazyAdbScanner);
+
+var WiFiScanner = {
+
+ _runtimes: [],
+
+ init() {
+ this.updateRegistration();
+ Services.prefs.addObserver(this.ALLOWED_PREF, this, false);
+ },
+
+ enable() {
+ this._updateRuntimes = this._updateRuntimes.bind(this);
+ discovery.on("devtools-device-added", this._updateRuntimes);
+ discovery.on("devtools-device-updated", this._updateRuntimes);
+ discovery.on("devtools-device-removed", this._updateRuntimes);
+ this._updateRuntimes();
+ },
+
+ disable() {
+ discovery.off("devtools-device-added", this._updateRuntimes);
+ discovery.off("devtools-device-updated", this._updateRuntimes);
+ discovery.off("devtools-device-removed", this._updateRuntimes);
+ },
+
+ _emitUpdated() {
+ this.emit("runtime-list-updated");
+ },
+
+ _updateRuntimes() {
+ this._runtimes = [];
+ for (let device of discovery.getRemoteDevicesWithService("devtools")) {
+ this._runtimes.push(new WiFiRuntime(device));
+ }
+ this._emitUpdated();
+ },
+
+ scan() {
+ discovery.scan();
+ return promise.resolve();
+ },
+
+ listRuntimes: function () {
+ return this._runtimes;
+ },
+
+ ALLOWED_PREF: "devtools.remote.wifi.scan",
+
+ get allowed() {
+ return Services.prefs.getBoolPref(this.ALLOWED_PREF);
+ },
+
+ updateRegistration() {
+ if (this.allowed) {
+ RuntimeScanners.add(WiFiScanner);
+ } else {
+ RuntimeScanners.remove(WiFiScanner);
+ }
+ this._emitUpdated();
+ },
+
+ observe(subject, topic, data) {
+ if (data !== WiFiScanner.ALLOWED_PREF) {
+ return;
+ }
+ WiFiScanner.updateRegistration();
+ }
+
+};
+
+EventEmitter.decorate(WiFiScanner);
+WiFiScanner.init();
+
+exports.WiFiScanner = WiFiScanner;
+
+var StaticScanner = {
+ enable() {},
+ disable() {},
+ scan() { return promise.resolve(); },
+ listRuntimes() {
+ let runtimes = [gRemoteRuntime];
+ if (Services.prefs.getBoolPref("devtools.webide.enableLocalRuntime")) {
+ runtimes.push(gLocalRuntime);
+ }
+ return runtimes;
+ }
+};
+
+EventEmitter.decorate(StaticScanner);
+RuntimeScanners.add(StaticScanner);
+
+/* RUNTIMES */
+
+// These type strings are used for logging events to Telemetry.
+// You must update Histograms.json if new types are added.
+var RuntimeTypes = exports.RuntimeTypes = {
+ USB: "USB",
+ WIFI: "WIFI",
+ SIMULATOR: "SIMULATOR",
+ REMOTE: "REMOTE",
+ LOCAL: "LOCAL",
+ OTHER: "OTHER"
+};
+
+/**
+ * TODO: Remove this comaptibility layer in the future (bug 1085393)
+ * This runtime exists to support the ADB Helper add-on below version 0.7.0.
+ *
+ * This runtime assumes it is connecting to a Firefox OS device.
+ */
+function DeprecatedUSBRuntime(id) {
+ this._id = id;
+}
+
+DeprecatedUSBRuntime.prototype = {
+ type: RuntimeTypes.USB,
+ get device() {
+ return Devices.getByName(this._id);
+ },
+ connect: function (connection) {
+ if (!this.device) {
+ return promise.reject(new Error("Can't find device: " + this.name));
+ }
+ return this.device.connect().then((port) => {
+ connection.host = "localhost";
+ connection.port = port;
+ connection.connect();
+ });
+ },
+ get id() {
+ return this._id;
+ },
+ get name() {
+ return this._productModel || this._id;
+ },
+ updateNameFromADB: function () {
+ if (this._productModel) {
+ return promise.reject();
+ }
+ let deferred = promise.defer();
+ if (this.device && this.device.shell) {
+ this.device.shell("getprop ro.product.model").then(stdout => {
+ this._productModel = stdout;
+ deferred.resolve();
+ }, () => {});
+ } else {
+ this._productModel = null;
+ deferred.reject();
+ }
+ return deferred.promise;
+ },
+};
+
+// For testing use only
+exports._DeprecatedUSBRuntime = DeprecatedUSBRuntime;
+
+function WiFiRuntime(deviceName) {
+ this.deviceName = deviceName;
+}
+
+WiFiRuntime.prototype = {
+ type: RuntimeTypes.WIFI,
+ // Mark runtime as taking a long time to connect
+ prolongedConnection: true,
+ connect: function (connection) {
+ let service = discovery.getRemoteService("devtools", this.deviceName);
+ if (!service) {
+ return promise.reject(new Error("Can't find device: " + this.name));
+ }
+ connection.advertisement = service;
+ connection.authenticator.sendOOB = this.sendOOB;
+ // Disable the default connection timeout, since QR scanning can take an
+ // unknown amount of time. This prevents spurious errors (even after
+ // eventual success) from being shown.
+ connection.timeoutDelay = 0;
+ connection.connect();
+ return promise.resolve();
+ },
+ get id() {
+ return this.deviceName;
+ },
+ get name() {
+ return this.deviceName;
+ },
+
+ /**
+ * During OOB_CERT authentication, a notification dialog like this is used to
+ * to display a token which the user must transfer through some mechanism to the
+ * server to authenticate the devices.
+ *
+ * This implementation presents the token as text for the user to transfer
+ * manually. For a mobile device, you should override this implementation with
+ * something more convenient, such as displaying a QR code.
+ *
+ * This method receives an object containing:
+ * @param host string
+ * The host name or IP address of the debugger server.
+ * @param port number
+ * The port number of the debugger server.
+ * @param cert object (optional)
+ * The server's cert details.
+ * @param authResult AuthenticationResult
+ * Authentication result sent from the server.
+ * @param oob object (optional)
+ * The token data to be transferred during OOB_CERT step 8:
+ * * sha256: hash(ClientCert)
+ * * k : K(random 128-bit number)
+ * @return object containing:
+ * * close: Function to hide the notification
+ */
+ sendOOB(session) {
+ const WINDOW_ID = "devtools:wifi-auth";
+ let { authResult } = session;
+ // Only show in the PENDING state
+ if (authResult != AuthenticationResult.PENDING) {
+ throw new Error("Expected PENDING result, got " + authResult);
+ }
+
+ // Listen for the window our prompt opens, so we can close it programatically
+ let promptWindow;
+ let windowListener = {
+ onOpenWindow(xulWindow) {
+ let win = xulWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindow);
+ win.addEventListener("load", function listener() {
+ win.removeEventListener("load", listener, false);
+ if (win.document.documentElement.getAttribute("id") != WINDOW_ID) {
+ return;
+ }
+ // Found the window
+ promptWindow = win;
+ Services.wm.removeListener(windowListener);
+ }, false);
+ },
+ onCloseWindow() {},
+ onWindowTitleChange() {}
+ };
+ Services.wm.addListener(windowListener);
+
+ // |openDialog| is typically a blocking API, so |executeSoon| to get around this
+ DevToolsUtils.executeSoon(() => {
+ // Height determines the size of the QR code. Force a minimum size to
+ // improve scanability.
+ const MIN_HEIGHT = 600;
+ let win = Services.wm.getMostRecentWindow("devtools:webide");
+ let width = win.outerWidth * 0.8;
+ let height = Math.max(win.outerHeight * 0.5, MIN_HEIGHT);
+ win.openDialog("chrome://webide/content/wifi-auth.xhtml",
+ WINDOW_ID,
+ "modal=yes,width=" + width + ",height=" + height, session);
+ });
+
+ return {
+ close() {
+ if (!promptWindow) {
+ return;
+ }
+ promptWindow.close();
+ promptWindow = null;
+ }
+ };
+ }
+};
+
+// For testing use only
+exports._WiFiRuntime = WiFiRuntime;
+
+function SimulatorRuntime(simulator) {
+ this.simulator = simulator;
+}
+
+SimulatorRuntime.prototype = {
+ type: RuntimeTypes.SIMULATOR,
+ connect: function (connection) {
+ return this.simulator.launch().then(port => {
+ connection.host = "localhost";
+ connection.port = port;
+ connection.keepConnecting = true;
+ connection.once(Connection.Events.DISCONNECTED, e => this.simulator.kill());
+ connection.connect();
+ });
+ },
+ configure() {
+ Simulators.emit("configure", this.simulator);
+ },
+ get id() {
+ return this.simulator.id;
+ },
+ get name() {
+ return this.simulator.name;
+ },
+};
+
+// For testing use only
+exports._SimulatorRuntime = SimulatorRuntime;
+
+var gLocalRuntime = {
+ type: RuntimeTypes.LOCAL,
+ connect: function (connection) {
+ if (!DebuggerServer.initialized) {
+ DebuggerServer.init();
+ DebuggerServer.addBrowserActors();
+ }
+ DebuggerServer.allowChromeProcess = true;
+ connection.host = null; // Force Pipe transport
+ connection.port = null;
+ connection.connect();
+ return promise.resolve();
+ },
+ get id() {
+ return "local";
+ },
+ get name() {
+ return Strings.GetStringFromName("local_runtime");
+ },
+};
+
+// For testing use only
+exports._gLocalRuntime = gLocalRuntime;
+
+var gRemoteRuntime = {
+ type: RuntimeTypes.REMOTE,
+ connect: function (connection) {
+ let win = Services.wm.getMostRecentWindow("devtools:webide");
+ if (!win) {
+ return promise.reject(new Error("No WebIDE window found"));
+ }
+ let ret = {value: connection.host + ":" + connection.port};
+ let title = Strings.GetStringFromName("remote_runtime_promptTitle");
+ let message = Strings.GetStringFromName("remote_runtime_promptMessage");
+ let ok = Services.prompt.prompt(win, title, message, ret, null, {});
+ let [host, port] = ret.value.split(":");
+ if (!ok) {
+ return promise.reject({canceled: true});
+ }
+ if (!host || !port) {
+ return promise.reject(new Error("Invalid host or port"));
+ }
+ connection.host = host;
+ connection.port = port;
+ connection.connect();
+ return promise.resolve();
+ },
+ get name() {
+ return Strings.GetStringFromName("remote_runtime");
+ },
+};
+
+// For testing use only
+exports._gRemoteRuntime = gRemoteRuntime;
diff --git a/devtools/client/webide/modules/simulator-process.js b/devtools/client/webide/modules/simulator-process.js
new file mode 100644
index 000000000..7d0b57cc6
--- /dev/null
+++ b/devtools/client/webide/modules/simulator-process.js
@@ -0,0 +1,325 @@
+/* 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 { Cc, Ci, Cu } = require("chrome");
+
+const Environment = require("sdk/system/environment").env;
+const EventEmitter = require("devtools/shared/event-emitter");
+const promise = require("promise");
+const Subprocess = require("sdk/system/child_process/subprocess");
+const Services = require("Services");
+
+loader.lazyGetter(this, "OS", () => {
+ const Runtime = require("sdk/system/runtime");
+ switch (Runtime.OS) {
+ case "Darwin":
+ return "mac64";
+ case "Linux":
+ if (Runtime.XPCOMABI.indexOf("x86_64") === 0) {
+ return "linux64";
+ } else {
+ return "linux32";
+ }
+ case "WINNT":
+ return "win32";
+ default:
+ return "";
+ }
+});
+
+function SimulatorProcess() {}
+SimulatorProcess.prototype = {
+
+ // Check if B2G is running.
+ get isRunning() {
+ return !!this.process;
+ },
+
+ // Start the process and connect the debugger client.
+ run() {
+
+ // Resolve B2G binary.
+ let b2g = this.b2gBinary;
+ if (!b2g || !b2g.exists()) {
+ throw Error("B2G executable not found.");
+ }
+
+ // Ensure Gaia profile exists.
+ let gaia = this.gaiaProfile;
+ if (!gaia || !gaia.exists()) {
+ throw Error("Gaia profile directory not found.");
+ }
+
+ this.once("stdout", function () {
+ if (OS == "mac64") {
+ console.debug("WORKAROUND run osascript to show b2g-desktop window on OS=='mac64'");
+ // Escape double quotes and escape characters for use in AppleScript.
+ let path = b2g.path.replace(/\\/g, "\\\\").replace(/\"/g, '\\"');
+
+ Subprocess.call({
+ command: "/usr/bin/osascript",
+ arguments: ["-e", 'tell application "' + path + '" to activate'],
+ });
+ }
+ });
+
+ let logHandler = (e, data) => this.log(e, data.trim());
+ this.on("stdout", logHandler);
+ this.on("stderr", logHandler);
+ this.once("exit", () => {
+ this.off("stdout", logHandler);
+ this.off("stderr", logHandler);
+ });
+
+ let environment;
+ if (OS.indexOf("linux") > -1) {
+ environment = ["TMPDIR=" + Services.dirsvc.get("TmpD", Ci.nsIFile).path];
+ ["DISPLAY", "XAUTHORITY"].forEach(key => {
+ if (key in Environment) {
+ environment.push(key + "=" + Environment[key]);
+ }
+ });
+ }
+
+ // Spawn a B2G instance.
+ this.process = Subprocess.call({
+ command: b2g,
+ arguments: this.args,
+ environment: environment,
+ stdout: data => this.emit("stdout", data),
+ stderr: data => this.emit("stderr", data),
+ // On B2G instance exit, reset tracked process, remote debugger port and
+ // shuttingDown flag, then finally emit an exit event.
+ done: result => {
+ console.log("B2G terminated with " + result.exitCode);
+ this.process = null;
+ this.emit("exit", result.exitCode);
+ }
+ });
+ },
+
+ // Request a B2G instance kill.
+ kill() {
+ let deferred = promise.defer();
+ if (this.process) {
+ this.once("exit", (e, exitCode) => {
+ this.shuttingDown = false;
+ deferred.resolve(exitCode);
+ });
+ if (!this.shuttingDown) {
+ this.shuttingDown = true;
+ this.emit("kill", null);
+ this.process.kill();
+ }
+ return deferred.promise;
+ } else {
+ return promise.resolve(undefined);
+ }
+ },
+
+ // Maybe log output messages.
+ log(level, message) {
+ if (!Services.prefs.getBoolPref("devtools.webide.logSimulatorOutput")) {
+ return;
+ }
+ if (level === "stderr" || level === "error") {
+ console.error(message);
+ return;
+ }
+ console.log(message);
+ },
+
+ // Compute B2G CLI arguments.
+ get args() {
+ let args = [];
+
+ // Gaia profile.
+ args.push("-profile", this.gaiaProfile.path);
+
+ // Debugger server port.
+ let port = parseInt(this.options.port);
+ args.push("-start-debugger-server", "" + port);
+
+ // Screen size.
+ let width = parseInt(this.options.width);
+ let height = parseInt(this.options.height);
+ if (width && height) {
+ args.push("-screen", width + "x" + height);
+ }
+
+ // Ignore eventual zombie instances of b2g that are left over.
+ args.push("-no-remote");
+
+ // If we are running a simulator based on Mulet,
+ // we have to override the default chrome URL
+ // in order to prevent the Browser UI to appear.
+ if (this.b2gBinary.leafName.includes("firefox")) {
+ args.push("-chrome", "chrome://b2g/content/shell.html");
+ }
+
+ return args;
+ },
+};
+
+EventEmitter.decorate(SimulatorProcess.prototype);
+
+
+function CustomSimulatorProcess(options) {
+ this.options = options;
+}
+
+var CSPp = CustomSimulatorProcess.prototype = Object.create(SimulatorProcess.prototype);
+
+// Compute B2G binary file handle.
+Object.defineProperty(CSPp, "b2gBinary", {
+ get: function () {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
+ file.initWithPath(this.options.b2gBinary);
+ return file;
+ }
+});
+
+// Compute Gaia profile file handle.
+Object.defineProperty(CSPp, "gaiaProfile", {
+ get: function () {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
+ file.initWithPath(this.options.gaiaProfile);
+ return file;
+ }
+});
+
+exports.CustomSimulatorProcess = CustomSimulatorProcess;
+
+
+function AddonSimulatorProcess(addon, options) {
+ this.addon = addon;
+ this.options = options;
+}
+
+var ASPp = AddonSimulatorProcess.prototype = Object.create(SimulatorProcess.prototype);
+
+// Compute B2G binary file handle.
+Object.defineProperty(ASPp, "b2gBinary", {
+ get: function () {
+ let file;
+ try {
+ let pref = "extensions." + this.addon.id + ".customRuntime";
+ file = Services.prefs.getComplexValue(pref, Ci.nsIFile);
+ } catch (e) {}
+
+ if (!file) {
+ file = this.addon.getResourceURI().QueryInterface(Ci.nsIFileURL).file;
+ file.append("b2g");
+ let binaries = {
+ win32: "b2g-bin.exe",
+ mac64: "B2G.app/Contents/MacOS/b2g-bin",
+ linux32: "b2g-bin",
+ linux64: "b2g-bin",
+ };
+ binaries[OS].split("/").forEach(node => file.append(node));
+ }
+ // If the binary doesn't exists, it may be because of a simulator
+ // based on mulet, which has a different binary name.
+ if (!file.exists()) {
+ file = this.addon.getResourceURI().QueryInterface(Ci.nsIFileURL).file;
+ file.append("firefox");
+ let binaries = {
+ win32: "firefox.exe",
+ mac64: "FirefoxNightly.app/Contents/MacOS/firefox-bin",
+ linux32: "firefox-bin",
+ linux64: "firefox-bin",
+ };
+ binaries[OS].split("/").forEach(node => file.append(node));
+ }
+ return file;
+ }
+});
+
+// Compute Gaia profile file handle.
+Object.defineProperty(ASPp, "gaiaProfile", {
+ get: function () {
+ let file;
+
+ // Custom profile from simulator configuration.
+ if (this.options.gaiaProfile) {
+ file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
+ file.initWithPath(this.options.gaiaProfile);
+ return file;
+ }
+
+ // Custom profile from addon prefs.
+ try {
+ let pref = "extensions." + this.addon.id + ".gaiaProfile";
+ file = Services.prefs.getComplexValue(pref, Ci.nsIFile);
+ return file;
+ } catch (e) {}
+
+ // Default profile from addon.
+ file = this.addon.getResourceURI().QueryInterface(Ci.nsIFileURL).file;
+ file.append("profile");
+ return file;
+ }
+});
+
+exports.AddonSimulatorProcess = AddonSimulatorProcess;
+
+
+function OldAddonSimulatorProcess(addon, options) {
+ this.addon = addon;
+ this.options = options;
+}
+
+var OASPp = OldAddonSimulatorProcess.prototype = Object.create(AddonSimulatorProcess.prototype);
+
+// Compute B2G binary file handle.
+Object.defineProperty(OASPp, "b2gBinary", {
+ get: function () {
+ let file;
+ try {
+ let pref = "extensions." + this.addon.id + ".customRuntime";
+ file = Services.prefs.getComplexValue(pref, Ci.nsIFile);
+ } catch (e) {}
+
+ if (!file) {
+ file = this.addon.getResourceURI().QueryInterface(Ci.nsIFileURL).file;
+ let version = this.addon.name.match(/\d+\.\d+/)[0].replace(/\./, "_");
+ file.append("resources");
+ file.append("fxos_" + version + "_simulator");
+ file.append("data");
+ file.append(OS == "linux32" ? "linux" : OS);
+ let binaries = {
+ win32: "b2g/b2g-bin.exe",
+ mac64: "B2G.app/Contents/MacOS/b2g-bin",
+ linux32: "b2g/b2g-bin",
+ linux64: "b2g/b2g-bin",
+ };
+ binaries[OS].split("/").forEach(node => file.append(node));
+ }
+ return file;
+ }
+});
+
+// Compute B2G CLI arguments.
+Object.defineProperty(OASPp, "args", {
+ get: function () {
+ let args = [];
+
+ // Gaia profile.
+ args.push("-profile", this.gaiaProfile.path);
+
+ // Debugger server port.
+ let port = parseInt(this.options.port);
+ args.push("-dbgport", "" + port);
+
+ // Ignore eventual zombie instances of b2g that are left over.
+ args.push("-no-remote");
+
+ return args;
+ }
+});
+
+exports.OldAddonSimulatorProcess = OldAddonSimulatorProcess;
diff --git a/devtools/client/webide/modules/simulators.js b/devtools/client/webide/modules/simulators.js
new file mode 100644
index 000000000..f09df9e05
--- /dev/null
+++ b/devtools/client/webide/modules/simulators.js
@@ -0,0 +1,368 @@
+/* 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 { AddonManager } = require("resource://gre/modules/AddonManager.jsm");
+const { Task } = require("devtools/shared/task");
+loader.lazyRequireGetter(this, "ConnectionManager", "devtools/shared/client/connection-manager", true);
+loader.lazyRequireGetter(this, "AddonSimulatorProcess", "devtools/client/webide/modules/simulator-process", true);
+loader.lazyRequireGetter(this, "OldAddonSimulatorProcess", "devtools/client/webide/modules/simulator-process", true);
+loader.lazyRequireGetter(this, "CustomSimulatorProcess", "devtools/client/webide/modules/simulator-process", true);
+const asyncStorage = require("devtools/shared/async-storage");
+const EventEmitter = require("devtools/shared/event-emitter");
+const promise = require("promise");
+const Services = require("Services");
+
+const SimulatorRegExp = new RegExp(Services.prefs.getCharPref("devtools.webide.simulatorAddonRegExp"));
+const LocaleCompare = (a, b) => {
+ return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
+};
+
+var Simulators = {
+
+ // The list of simulator configurations.
+ _simulators: [],
+
+ /**
+ * Load a previously saved list of configurations (only once).
+ *
+ * @return Promise.
+ */
+ _load() {
+ if (this._loadingPromise) {
+ return this._loadingPromise;
+ }
+
+ this._loadingPromise = Task.spawn(function* () {
+ let jobs = [];
+
+ let value = yield asyncStorage.getItem("simulators");
+ if (Array.isArray(value)) {
+ value.forEach(options => {
+ let simulator = new Simulator(options);
+ Simulators.add(simulator, true);
+
+ // If the simulator had a reference to an addon, fix it.
+ if (options.addonID) {
+ let deferred = promise.defer();
+ AddonManager.getAddonByID(options.addonID, addon => {
+ simulator.addon = addon;
+ delete simulator.options.addonID;
+ deferred.resolve();
+ });
+ jobs.push(deferred.promise);
+ }
+ });
+ }
+
+ yield promise.all(jobs);
+ yield Simulators._addUnusedAddons();
+ Simulators.emitUpdated();
+ return Simulators._simulators;
+ });
+
+ return this._loadingPromise;
+ },
+
+ /**
+ * Add default simulators to the list for each new (unused) addon.
+ *
+ * @return Promise.
+ */
+ _addUnusedAddons: Task.async(function* () {
+ let jobs = [];
+
+ let addons = yield Simulators.findSimulatorAddons();
+ addons.forEach(addon => {
+ jobs.push(Simulators.addIfUnusedAddon(addon, true));
+ });
+
+ yield promise.all(jobs);
+ }),
+
+ /**
+ * Save the current list of configurations.
+ *
+ * @return Promise.
+ */
+ _save: Task.async(function* () {
+ yield this._load();
+
+ let value = Simulators._simulators.map(simulator => {
+ let options = JSON.parse(JSON.stringify(simulator.options));
+ if (simulator.addon != null) {
+ options.addonID = simulator.addon.id;
+ }
+ return options;
+ });
+
+ yield asyncStorage.setItem("simulators", value);
+ }),
+
+ /**
+ * List all available simulators.
+ *
+ * @return Promised simulator list.
+ */
+ findSimulators: Task.async(function* () {
+ yield this._load();
+ return Simulators._simulators;
+ }),
+
+ /**
+ * List all installed simulator addons.
+ *
+ * @return Promised addon list.
+ */
+ findSimulatorAddons() {
+ let deferred = promise.defer();
+ AddonManager.getAllAddons(all => {
+ let addons = [];
+ for (let addon of all) {
+ if (Simulators.isSimulatorAddon(addon)) {
+ addons.push(addon);
+ }
+ }
+ // Sort simulator addons by name.
+ addons.sort(LocaleCompare);
+ deferred.resolve(addons);
+ });
+ return deferred.promise;
+ },
+
+ /**
+ * Add a new simulator for `addon` if no other simulator uses it.
+ */
+ addIfUnusedAddon(addon, silently = false) {
+ let simulators = this._simulators;
+ let matching = simulators.filter(s => s.addon && s.addon.id == addon.id);
+ if (matching.length > 0) {
+ return promise.resolve();
+ }
+ let options = {};
+ options.name = addon.name.replace(" Simulator", "");
+ // Some addons specify a simulator type at the end of their version string,
+ // e.g. "2_5_tv".
+ let type = this.simulatorAddonVersion(addon).split("_")[2];
+ if (type) {
+ // "tv" is shorthand for type "television".
+ options.type = (type === "tv" ? "television" : type);
+ }
+ return this.add(new Simulator(options, addon), silently);
+ },
+
+ // TODO (Bug 1146521) Maybe find a better way to deal with removed addons?
+ removeIfUsingAddon(addon) {
+ let simulators = this._simulators;
+ let remaining = simulators.filter(s => !s.addon || s.addon.id != addon.id);
+ this._simulators = remaining;
+ if (remaining.length !== simulators.length) {
+ this.emitUpdated();
+ }
+ },
+
+ /**
+ * Add a new simulator to the list. Caution: `simulator.name` may be modified.
+ *
+ * @return Promise to added simulator.
+ */
+ add(simulator, silently = false) {
+ let simulators = this._simulators;
+ let uniqueName = this.uniqueName(simulator.options.name);
+ simulator.options.name = uniqueName;
+ simulators.push(simulator);
+ if (!silently) {
+ this.emitUpdated();
+ }
+ return promise.resolve(simulator);
+ },
+
+ /**
+ * Remove a simulator from the list.
+ */
+ remove(simulator) {
+ let simulators = this._simulators;
+ let remaining = simulators.filter(s => s !== simulator);
+ this._simulators = remaining;
+ if (remaining.length !== simulators.length) {
+ this.emitUpdated();
+ }
+ },
+
+ /**
+ * Get a unique name for a simulator (may add a suffix, e.g. "MyName (1)").
+ */
+ uniqueName(name) {
+ let simulators = this._simulators;
+
+ let names = {};
+ simulators.forEach(simulator => names[simulator.name] = true);
+
+ // Strip any previous suffix, add a new suffix if necessary.
+ let stripped = name.replace(/ \(\d+\)$/, "");
+ let unique = stripped;
+ for (let i = 1; names[unique]; i++) {
+ unique = stripped + " (" + i + ")";
+ }
+ return unique;
+ },
+
+ /**
+ * Compare an addon's ID against the expected form of a simulator addon ID,
+ * and try to extract its version if there is a match.
+ *
+ * Note: If a simulator addon is recognized, but no version can be extracted
+ * (e.g. custom RegExp pref value), we return "Unknown" to keep the returned
+ * value 'truthy'.
+ */
+ simulatorAddonVersion(addon) {
+ let match = SimulatorRegExp.exec(addon.id);
+ if (!match) {
+ return null;
+ }
+ let version = match[1];
+ return version || "Unknown";
+ },
+
+ /**
+ * Detect simulator addons, including "unofficial" ones.
+ */
+ isSimulatorAddon(addon) {
+ return !!this.simulatorAddonVersion(addon);
+ },
+
+ emitUpdated() {
+ this.emit("updated", { length: this._simulators.length });
+ this._simulators.sort(LocaleCompare);
+ this._save();
+ },
+
+ onConfigure(e, simulator) {
+ this._lastConfiguredSimulator = simulator;
+ },
+
+ onInstalled(addon) {
+ if (this.isSimulatorAddon(addon)) {
+ this.addIfUnusedAddon(addon);
+ }
+ },
+
+ onEnabled(addon) {
+ if (this.isSimulatorAddon(addon)) {
+ this.addIfUnusedAddon(addon);
+ }
+ },
+
+ onDisabled(addon) {
+ if (this.isSimulatorAddon(addon)) {
+ this.removeIfUsingAddon(addon);
+ }
+ },
+
+ onUninstalled(addon) {
+ if (this.isSimulatorAddon(addon)) {
+ this.removeIfUsingAddon(addon);
+ }
+ },
+};
+exports.Simulators = Simulators;
+AddonManager.addAddonListener(Simulators);
+EventEmitter.decorate(Simulators);
+Simulators.on("configure", Simulators.onConfigure.bind(Simulators));
+
+function Simulator(options = {}, addon = null) {
+ this.addon = addon;
+ this.options = options;
+
+ // Fill `this.options` with default values where needed.
+ let defaults = this.defaults;
+ for (let option in defaults) {
+ if (this.options[option] == null) {
+ this.options[option] = defaults[option];
+ }
+ }
+}
+Simulator.prototype = {
+
+ // Default simulation options.
+ _defaults: {
+ // Based on the Firefox OS Flame.
+ phone: {
+ width: 320,
+ height: 570,
+ pixelRatio: 1.5
+ },
+ // Based on a 720p HD TV.
+ television: {
+ width: 1280,
+ height: 720,
+ pixelRatio: 1,
+ }
+ },
+ _defaultType: "phone",
+
+ restoreDefaults() {
+ let defaults = this.defaults;
+ let options = this.options;
+ for (let option in defaults) {
+ options[option] = defaults[option];
+ }
+ },
+
+ launch() {
+ // Close already opened simulation.
+ if (this.process) {
+ return this.kill().then(this.launch.bind(this));
+ }
+
+ this.options.port = ConnectionManager.getFreeTCPPort();
+
+ // Choose simulator process type.
+ if (this.options.b2gBinary) {
+ // Custom binary.
+ this.process = new CustomSimulatorProcess(this.options);
+ } else if (this.version > "1.3") {
+ // Recent simulator addon.
+ this.process = new AddonSimulatorProcess(this.addon, this.options);
+ } else {
+ // Old simulator addon.
+ this.process = new OldAddonSimulatorProcess(this.addon, this.options);
+ }
+ this.process.run();
+
+ return promise.resolve(this.options.port);
+ },
+
+ kill() {
+ let process = this.process;
+ if (!process) {
+ return promise.resolve();
+ }
+ this.process = null;
+ return process.kill();
+ },
+
+ get defaults() {
+ let defaults = this._defaults;
+ return defaults[this.type] || defaults[this._defaultType];
+ },
+
+ get id() {
+ return this.name;
+ },
+
+ get name() {
+ return this.options.name;
+ },
+
+ get type() {
+ return this.options.type || this._defaultType;
+ },
+
+ get version() {
+ return this.options.b2gBinary ? "Custom" : this.addon.name.match(/\d+\.\d+/)[0];
+ },
+};
+exports.Simulator = Simulator;
diff --git a/devtools/client/webide/modules/tab-store.js b/devtools/client/webide/modules/tab-store.js
new file mode 100644
index 000000000..0fed366cc
--- /dev/null
+++ b/devtools/client/webide/modules/tab-store.js
@@ -0,0 +1,178 @@
+/* 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");
+
+const { TargetFactory } = require("devtools/client/framework/target");
+const EventEmitter = require("devtools/shared/event-emitter");
+const { Connection } = require("devtools/shared/client/connection-manager");
+const promise = require("promise");
+const { Task } = require("devtools/shared/task");
+
+const _knownTabStores = new WeakMap();
+
+var TabStore;
+
+module.exports = TabStore = function (connection) {
+ // If we already know about this connection,
+ // let's re-use the existing store.
+ if (_knownTabStores.has(connection)) {
+ return _knownTabStores.get(connection);
+ }
+
+ _knownTabStores.set(connection, this);
+
+ EventEmitter.decorate(this);
+
+ this._resetStore();
+
+ this.destroy = this.destroy.bind(this);
+ this._onStatusChanged = this._onStatusChanged.bind(this);
+
+ this._connection = connection;
+ this._connection.once(Connection.Events.DESTROYED, this.destroy);
+ this._connection.on(Connection.Events.STATUS_CHANGED, this._onStatusChanged);
+ this._onTabListChanged = this._onTabListChanged.bind(this);
+ this._onTabNavigated = this._onTabNavigated.bind(this);
+ this._onStatusChanged();
+ return this;
+};
+
+TabStore.prototype = {
+
+ destroy: function () {
+ if (this._connection) {
+ // While this.destroy is bound using .once() above, that event may not
+ // have occurred when the TabStore client calls destroy, so we
+ // manually remove it here.
+ this._connection.off(Connection.Events.DESTROYED, this.destroy);
+ this._connection.off(Connection.Events.STATUS_CHANGED, this._onStatusChanged);
+ _knownTabStores.delete(this._connection);
+ this._connection = null;
+ }
+ },
+
+ _resetStore: function () {
+ this.response = null;
+ this.tabs = [];
+ this._selectedTab = null;
+ this._selectedTabTargetPromise = null;
+ },
+
+ _onStatusChanged: function () {
+ if (this._connection.status == Connection.Status.CONNECTED) {
+ // Watch for changes to remote browser tabs
+ this._connection.client.addListener("tabListChanged",
+ this._onTabListChanged);
+ this._connection.client.addListener("tabNavigated",
+ this._onTabNavigated);
+ this.listTabs();
+ } else {
+ if (this._connection.client) {
+ this._connection.client.removeListener("tabListChanged",
+ this._onTabListChanged);
+ this._connection.client.removeListener("tabNavigated",
+ this._onTabNavigated);
+ }
+ this._resetStore();
+ }
+ },
+
+ _onTabListChanged: function () {
+ this.listTabs().then(() => this.emit("tab-list"))
+ .catch(console.error);
+ },
+
+ _onTabNavigated: function (e, { from, title, url }) {
+ if (!this._selectedTab || from !== this._selectedTab.actor) {
+ return;
+ }
+ this._selectedTab.url = url;
+ this._selectedTab.title = title;
+ this.emit("navigate");
+ },
+
+ listTabs: function () {
+ if (!this._connection || !this._connection.client) {
+ return promise.reject(new Error("Can't listTabs, not connected."));
+ }
+ let deferred = promise.defer();
+ this._connection.client.listTabs(response => {
+ if (response.error) {
+ this._connection.disconnect();
+ deferred.reject(response.error);
+ return;
+ }
+ let tabsChanged = JSON.stringify(this.tabs) !== JSON.stringify(response.tabs);
+ this.response = response;
+ this.tabs = response.tabs;
+ this._checkSelectedTab();
+ if (tabsChanged) {
+ this.emit("tab-list");
+ }
+ deferred.resolve(response);
+ });
+ return deferred.promise;
+ },
+
+ // TODO: Tab "selection" should really take place by creating a TabProject
+ // which is the selected project. This should be done as part of the
+ // project-agnostic work.
+ _selectedTab: null,
+ _selectedTabTargetPromise: null,
+ get selectedTab() {
+ return this._selectedTab;
+ },
+ set selectedTab(tab) {
+ if (this._selectedTab === tab) {
+ return;
+ }
+ this._selectedTab = tab;
+ this._selectedTabTargetPromise = null;
+ // Attach to the tab to follow navigation events
+ if (this._selectedTab) {
+ this.getTargetForTab();
+ }
+ },
+
+ _checkSelectedTab: function () {
+ if (!this._selectedTab) {
+ return;
+ }
+ let alive = this.tabs.some(tab => {
+ return tab.actor === this._selectedTab.actor;
+ });
+ if (!alive) {
+ this._selectedTab = null;
+ this._selectedTabTargetPromise = null;
+ this.emit("closed");
+ }
+ },
+
+ getTargetForTab: function () {
+ if (this._selectedTabTargetPromise) {
+ return this._selectedTabTargetPromise;
+ }
+ let store = this;
+ this._selectedTabTargetPromise = Task.spawn(function* () {
+ // If you connect to a tab, then detach from it, the root actor may have
+ // de-listed the actors that belong to the tab. This breaks the toolbox
+ // if you try to connect to the same tab again. To work around this
+ // issue, we force a "listTabs" request before connecting to a tab.
+ yield store.listTabs();
+ return TargetFactory.forRemoteTab({
+ form: store._selectedTab,
+ client: store._connection.client,
+ chrome: false
+ });
+ });
+ this._selectedTabTargetPromise.then(target => {
+ target.once("close", () => {
+ this._selectedTabTargetPromise = null;
+ });
+ });
+ return this._selectedTabTargetPromise;
+ },
+
+};
diff --git a/devtools/client/webide/modules/utils.js b/devtools/client/webide/modules/utils.js
new file mode 100644
index 000000000..7a19c7044
--- /dev/null
+++ b/devtools/client/webide/modules/utils.js
@@ -0,0 +1,68 @@
+/* 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 { Cc, Cu, Ci } = require("chrome");
+const { FileUtils } = Cu.import("resource://gre/modules/FileUtils.jsm", {});
+const Services = require("Services");
+const Strings = Services.strings.createBundle("chrome://devtools/locale/webide.properties");
+
+function doesFileExist(location) {
+ let file = new FileUtils.File(location);
+ return file.exists();
+}
+exports.doesFileExist = doesFileExist;
+
+function _getFile(location, ...pickerParams) {
+ if (location) {
+ return new FileUtils.File(location);
+ }
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ fp.init(...pickerParams);
+ let res = fp.show();
+ if (res == Ci.nsIFilePicker.returnCancel) {
+ return null;
+ }
+ return fp.file;
+}
+
+function getCustomBinary(window, location) {
+ return _getFile(location, window, Strings.GetStringFromName("selectCustomBinary_title"), Ci.nsIFilePicker.modeOpen);
+}
+exports.getCustomBinary = getCustomBinary;
+
+function getCustomProfile(window, location) {
+ return _getFile(location, window, Strings.GetStringFromName("selectCustomProfile_title"), Ci.nsIFilePicker.modeGetFolder);
+}
+exports.getCustomProfile = getCustomProfile;
+
+function getPackagedDirectory(window, location) {
+ return _getFile(location, window, Strings.GetStringFromName("importPackagedApp_title"), Ci.nsIFilePicker.modeGetFolder);
+}
+exports.getPackagedDirectory = getPackagedDirectory;
+
+function getHostedURL(window, location) {
+ let ret = { value: null };
+
+ if (!location) {
+ Services.prompt.prompt(window,
+ Strings.GetStringFromName("importHostedApp_title"),
+ Strings.GetStringFromName("importHostedApp_header"),
+ ret, null, {});
+ location = ret.value;
+ }
+
+ if (!location) {
+ return null;
+ }
+
+ // Clean location string and add "http://" if missing
+ location = location.trim();
+ try { // Will fail if no scheme
+ Services.io.extractScheme(location);
+ } catch (e) {
+ location = "http://" + location;
+ }
+ return location;
+}
+exports.getHostedURL = getHostedURL;