/* 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 { Cu, Cc, Ci, components } = require("chrome"); const { PROFILE_IDLE, PROFILE_RUNNING, PROFILE_COMPLETED, SHOW_PLATFORM_DATA, L10N_BUNDLE } = require("devtools/profiler/consts"); const { TextEncoder } = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {}); var EventEmitter = require("devtools/toolkit/event-emitter"); var Cleopatra = require("devtools/profiler/cleopatra"); var Sidebar = require("devtools/profiler/sidebar"); var ProfilerController = require("devtools/profiler/controller"); var { Promise: promise } = Cu.import("resource://gre/modules/Promise.jsm", {}); Cu.import("resource:///modules/devtools/gDevTools.jsm"); Cu.import("resource://gre/modules/devtools/Console.jsm"); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource:///modules/devtools/ViewHelpers.jsm"); Cu.import("resource://gre/modules/osfile.jsm"); Cu.import("resource://gre/modules/NetUtil.jsm"); loader.lazyGetter(this, "L10N", () => new ViewHelpers.L10N(L10N_BUNDLE)); /** * Profiler panel. It is responsible for creating and managing * different profile instances (see cleopatra.js). * * ProfilerPanel is an event emitter. It can emit the following * events: * * - ready: after the panel is done loading everything, * including the default profile instance. * - started: after the panel successfuly starts our SPS * profiler. * - stopped: after the panel successfuly stops our SPS * profiler and is ready to hand over profiling * data * - parsed: after Cleopatra finishes parsing profiling * data. * - destroyed: after the panel cleans up after itself and * is ready to be destroyed. * * The following events are used mainly by tests to prevent * accidential oranges: * * - profileCreated: after a new profile is created. * - profileSwitched: after user switches to a different * profile. */ function ProfilerPanel(frame, toolbox) { this.isReady = false; this.window = frame.window; this.document = frame.document; this.target = toolbox.target; this.profiles = new Map(); this._uid = 0; this._msgQueue = {}; EventEmitter.decorate(this); } ProfilerPanel.prototype = { isReady: null, window: null, document: null, target: null, controller: null, profiles: null, sidebar: null, _uid: null, _activeUid: null, _runningUid: null, _browserWin: null, _msgQueue: null, get controls() { let doc = this.document; return { get record() doc.querySelector("#profiler-start"), get import() doc.querySelector("#profiler-import"), }; }, get activeProfile() { return this.profiles.get(this._activeUid); }, set activeProfile(profile) { if (this._activeUid === profile.uid) return; if (this.activeProfile) this.activeProfile.hide(); this._activeUid = profile.uid; profile.show(); }, set recordingProfile(profile) { let btn = this.controls.record; this._runningUid = profile ? profile.uid : null; if (this._runningUid) btn.setAttribute("checked", true); else btn.removeAttribute("checked"); }, get recordingProfile() { return this.profiles.get(this._runningUid); }, get browserWindow() { if (this._browserWin) { return this._browserWin; } let win = this.window.top; let type = win.document.documentElement.getAttribute("windowtype"); if (type !== "navigator:browser") { win = Services.wm.getMostRecentWindow("navigator:browser"); } return this._browserWin = win; }, get showPlatformData() { return Services.prefs.getBoolPref(SHOW_PLATFORM_DATA); }, set showPlatformData(enabled) { Services.prefs.setBoolPref(SHOW_PLATFORM_DATA, enabled); }, /** * Open a debug connection and, on success, switch to the newly created * profile. * * @return Promise */ open: function PP_open() { // Local profiling needs to make the target remote. let target = this.target; let targetPromise = !target.isRemote ? target.makeRemote() : promise.resolve(target); return targetPromise .then((target) => { let deferred = promise.defer(); this.controller = new ProfilerController(this.target); this.sidebar = new Sidebar(this.document.querySelector("#profiles-list")); this.sidebar.on("save", (_, uid) => { let profile = this.profiles.get(uid); if (!profile.data) return void Cu.reportError("Can't save profile because there's no data."); this.openFileDialog({ mode: "save", name: profile.name }).then((file) => { if (file) this.saveProfile(file, profile.data); }); }); this.sidebar.on("select", (_, uid) => { let profile = this.profiles.get(uid); this.activeProfile = profile; if (profile.isReady) { return void this.emit("profileSwitched", profile.uid); } profile.once("ready", () => { this.emit("profileSwitched", profile.uid); }); }); this.controller.connect(() => { let btn = this.controls.record; btn.addEventListener("click", () => this.toggleRecording(), false); btn.removeAttribute("disabled"); let imp = this.controls.import; imp.addEventListener("click", () => { this.openFileDialog({ mode: "open" }).then((file) => { if (file) this.loadProfile(file); }); }, false); imp.removeAttribute("disabled"); // Import queued profiles. for (let [name, data] of this.controller.profiles) { this.importProfile(name, data.data); } this.isReady = true; this.emit("ready"); deferred.resolve(this); }); this.controller.on("profileEnd", (_, data) => { this.importProfile(data.name, data.data); if (this.recordingProfile && !data.fromConsole) this.recordingProfile = null; this.emit("stopped"); }); return deferred.promise; }) .then(null, (reason) => Cu.reportError("ProfilePanel open failed: " + reason.message)); }, /** * Creates a new profile instance (see cleopatra.js) and * adds an appropriate item to the sidebar. Note that * this method doesn't automatically switch user to * the newly created profile, they have do to switch * explicitly. * * @param string name * (optional) name of the new profile * * @return Profile */ createProfile: function (name, opts={}) { if (name && this.getProfileByName(name)) { return this.getProfileByName(name); } let uid = ++this._uid; let name = name || this.controller.getProfileName(); let profile = new Cleopatra(this, { uid: uid, name: name, showPlatformData: this.showPlatformData, external: opts.external }); this.profiles.set(uid, profile); this.sidebar.addProfile(profile); this.emit("profileCreated", uid); return profile; }, /** * Imports profile data * * @param string name, new profile name * @param object data, profile data to import * @param object opts, (optional) if property 'external' is found * Cleopatra will hide arrow buttons. * * @return Profile */ importProfile: function (name, data, opts={}) { let profile = this.createProfile(name, { external: opts.external }); profile.isStarted = false; profile.isFinished = true; profile.data = data; profile.parse(data, () => this.emit("parsed")); this.sidebar.setProfileState(profile, PROFILE_COMPLETED); if (!this.sidebar.selectedItem) this.sidebar.selectedItem = this.sidebar.getItemByProfile(profile); return profile; }, /** * Starts or stops profile recording. */ toggleRecording: function () { let profile = this.recordingProfile; if (!profile) { profile = this.createProfile(); this.startProfiling(profile.name, () => { profile.isStarted = true; this.sidebar.setProfileState(profile, PROFILE_RUNNING); this.recordingProfile = profile; this.emit("started"); }); return; } this.stopProfiling(profile.name, (data) => { profile.isStarted = false; profile.isFinished = true; profile.data = data; profile.parse(data, () => this.emit("parsed")); this.sidebar.setProfileState(profile, PROFILE_COMPLETED); this.activeProfile = profile; this.sidebar.selectedItem = this.sidebar.getItemByProfile(profile); this.recordingProfile = null; this.emit("stopped"); }); }, /** * Start collecting profile data. * * @param function onStart * A function to call once we get the message * that profiling had been successfuly started. */ startProfiling: function (name, onStart) { this.controller.start(name, (err) => { if (err) { return void Cu.reportError("ProfilerController.start: " + err.message); } onStart(); this.emit("started"); }); }, /** * Stop collecting profile data. * * @param function onStop * A function to call once we get the message * that profiling had been successfuly stopped. */ stopProfiling: function (name, onStop) { this.controller.isActive((err, isActive) => { if (err) { Cu.reportError("ProfilerController.isActive: " + err.message); return; } if (!isActive) { return; } this.controller.stop(name, (err, data) => { if (err) { Cu.reportError("ProfilerController.stop: " + err.message); return; } onStop(data); this.emit("stopped", data); }); }); }, /** * Lookup an individual profile by its name. * * @param string name name of the profile * @return profile object or null */ getProfileByName: function PP_getProfileByName(name) { if (!this.profiles) { return null; } for (let [ uid, profile ] of this.profiles) { if (profile.name === name) { return profile; } } return null; }, /** * Lookup an individual profile by its UID. * * @param number uid UID of the profile * @return profile object or null */ getProfileByUID: function PP_getProfileByUID(uid) { if (!this.profiles) { return null; } return this.profiles.get(uid) || null; }, /** * Iterates over each available profile and calls * a callback with it as a parameter. * * @param function cb a callback to call */ eachProfile: function PP_eachProfile(cb) { let uid = this._uid; if (!this.profiles) { return; } while (uid >= 0) { if (this.profiles.has(uid)) { cb(this.profiles.get(uid)); } uid -= 1; } }, /** * Broadcast messages to all Cleopatra instances. * * @param number target * UID of the recepient profile. All profiles will receive the message * but the profile specified by 'target' will have a special property, * isCurrent, set to true. * @param object data * An object with a property 'task' that will be sent over to Cleopatra. */ broadcast: function PP_broadcast(target, data) { if (!this.profiles) { return; } this.eachProfile((profile) => { profile.message({ uid: target, isCurrent: target === profile.uid, task: data.task }); }); }, /** * Open file specified in data in either a debugger or view-source. * * @param object data * An object describing the file. It must have three properties: * - uri * - line * - isChrome (chrome files are opened via view-source) */ displaySource: function PP_displaySource(data) { let { browserWindow: win, document: doc } = this; let { uri, line, isChrome } = data; let deferred = promise.defer(); if (isChrome) { return void win.gViewSourceUtils.viewSource(uri, null, doc, line); } let showSource = ({ DebuggerView }) => { if (DebuggerView.Sources.containsValue(uri)) { DebuggerView.setEditorLocation(uri, line).then(deferred.resolve); } // XXX: What to do if the source isn't present in the Debugger? // Switch back to the Profiler panel and viewSource()? } // If the Debugger was already open, switch to it and try to show the // source immediately. Otherwise, initialize it and wait for the sources // to be added first. let toolbox = gDevTools.getToolbox(this.target); let debuggerAlreadyOpen = toolbox.getPanel("jsdebugger"); toolbox.selectTool("jsdebugger").then(({ panelWin: dbg }) => { if (debuggerAlreadyOpen) { showSource(dbg); } else { dbg.once(dbg.EVENTS.SOURCES_ADDED, () => showSource(dbg)); } }); return deferred.promise; }, /** * Opens a normal file dialog. * * @params object opts, (optional) property 'mode' can be used to * specify which dialog to open. Can be either * 'save' or 'open' (default is 'open'). * @return promise */ openFileDialog: function (opts={}) { let deferred = promise.defer(); let picker = Ci.nsIFilePicker; let fp = Cc["@mozilla.org/filepicker;1"].createInstance(picker); let { name, mode } = opts; let save = mode === "save"; let title = L10N.getStr(save ? "profiler.saveFileAs" : "profiler.openFile"); fp.init(this.window, title, save ? picker.modeSave : picker.modeOpen); fp.appendFilter("JSON", "*.json"); fp.appendFilters(picker.filterText | picker.filterAll); if (save) fp.defaultString = (name || "profile") + ".json"; fp.open((result) => { deferred.resolve(result === picker.returnCancel ? null : fp.file); }); return deferred.promise; }, /** * Saves profile data to disk * * @param File file * @param object data * * @return promise */ saveProfile: function (file, data) { let encoder = new TextEncoder(); let buffer = encoder.encode(JSON.stringify({ profile: data }, null, " ")); let opts = { tmpPath: file.path + ".tmp" }; return OS.File.writeAtomic(file.path, buffer, opts); }, /** * Reads profile data from disk * * @param File file * @return promise */ loadProfile: function (file) { let deferred = promise.defer(); let ch = NetUtil.newChannel(file); ch.contentType = "application/json"; NetUtil.asyncFetch(ch, (input, status) => { if (!components.isSuccessCode(status)) throw new Error(status); let conv = Cc["@mozilla.org/intl/scriptableunicodeconverter"] .createInstance(Ci.nsIScriptableUnicodeConverter); conv.charset = "UTF-8"; let data = NetUtil.readInputStreamToString(input, input.available()); data = conv.ConvertToUnicode(data); this.importProfile(file.leafName, JSON.parse(data).profile, { external: true }); deferred.resolve(); }); return deferred.promise; }, /** * Cleanup. */ destroy: function PP_destroy() { if (this.profiles) { let uid = this._uid; while (uid >= 0) { if (this.profiles.has(uid)) { this.profiles.get(uid).destroy(); this.profiles.delete(uid); } uid -= 1; } } if (this.controller) { this.controller.destroy(); } this.isReady = null; this.window = null; this.document = null; this.target = null; this.controller = null; this.profiles = null; this._uid = null; this._activeUid = null; this.emit("destroyed"); } }; module.exports = ProfilerPanel;