diff options
Diffstat (limited to 'devtools/client/webide/modules')
-rw-r--r-- | devtools/client/webide/modules/addons.js | 197 | ||||
-rw-r--r-- | devtools/client/webide/modules/app-manager.js | 850 | ||||
-rw-r--r-- | devtools/client/webide/modules/app-projects.js | 235 | ||||
-rw-r--r-- | devtools/client/webide/modules/app-validator.js | 292 | ||||
-rw-r--r-- | devtools/client/webide/modules/build.js | 199 | ||||
-rw-r--r-- | devtools/client/webide/modules/config-view.js | 373 | ||||
-rw-r--r-- | devtools/client/webide/modules/moz.build | 21 | ||||
-rw-r--r-- | devtools/client/webide/modules/project-list.js | 375 | ||||
-rw-r--r-- | devtools/client/webide/modules/runtime-list.js | 207 | ||||
-rw-r--r-- | devtools/client/webide/modules/runtimes.js | 673 | ||||
-rw-r--r-- | devtools/client/webide/modules/simulator-process.js | 325 | ||||
-rw-r--r-- | devtools/client/webide/modules/simulators.js | 368 | ||||
-rw-r--r-- | devtools/client/webide/modules/tab-store.js | 178 | ||||
-rw-r--r-- | devtools/client/webide/modules/utils.js | 68 |
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; |