|
1 /* This Source Code Form is subject to the terms of the Mozilla Public |
|
2 * License, v. 2.0. If a copy of the MPL was not distributed with this |
|
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ |
|
4 |
|
5 const Cc = Components.classes; |
|
6 const Ci = Components.interfaces; |
|
7 const Cu = Components.utils; |
|
8 const Cr = Components.results; |
|
9 Cu.import("resource:///modules/devtools/gDevTools.jsm"); |
|
10 const {devtools} = Cu.import("resource://gre/modules/devtools/Loader.jsm", {}); |
|
11 const {require} = devtools; |
|
12 const {ConnectionManager, Connection} = require("devtools/client/connection-manager"); |
|
13 const {AppProjects} = require("devtools/app-manager/app-projects"); |
|
14 const {AppValidator} = require("devtools/app-manager/app-validator"); |
|
15 const {Services} = Cu.import("resource://gre/modules/Services.jsm"); |
|
16 const {FileUtils} = Cu.import("resource://gre/modules/FileUtils.jsm"); |
|
17 const {installHosted, installPackaged, getTargetForApp, |
|
18 reloadApp, launchApp, closeApp} = require("devtools/app-actor-front"); |
|
19 const {EventEmitter} = Cu.import("resource://gre/modules/devtools/event-emitter.js"); |
|
20 |
|
21 const promise = require("devtools/toolkit/deprecated-sync-thenables"); |
|
22 |
|
23 const MANIFEST_EDITOR_ENABLED = "devtools.appmanager.manifestEditor.enabled"; |
|
24 |
|
25 window.addEventListener("message", function(event) { |
|
26 try { |
|
27 let json = JSON.parse(event.data); |
|
28 if (json.name == "connection") { |
|
29 let cid = parseInt(json.cid); |
|
30 for (let c of ConnectionManager.connections) { |
|
31 if (c.uid == cid) { |
|
32 UI.connection = c; |
|
33 UI.onNewConnection(); |
|
34 break; |
|
35 } |
|
36 } |
|
37 } |
|
38 } catch(e) {} |
|
39 }); |
|
40 |
|
41 window.addEventListener("unload", function onUnload() { |
|
42 window.removeEventListener("unload", onUnload); |
|
43 UI.destroy(); |
|
44 }); |
|
45 |
|
46 let UI = { |
|
47 isReady: false, |
|
48 |
|
49 onload: function() { |
|
50 if (Services.prefs.getBoolPref(MANIFEST_EDITOR_ENABLED)) { |
|
51 document.querySelector("#lense").setAttribute("manifest-editable", ""); |
|
52 } |
|
53 |
|
54 this.template = new Template(document.body, AppProjects.store, Utils.l10n); |
|
55 this.template.start(); |
|
56 |
|
57 AppProjects.load().then(() => { |
|
58 AppProjects.store.object.projects.forEach(UI.validate); |
|
59 this.isReady = true; |
|
60 this.emit("ready"); |
|
61 }); |
|
62 }, |
|
63 |
|
64 destroy: function() { |
|
65 if (this.connection) { |
|
66 this.connection.off(Connection.Events.STATUS_CHANGED, this._onConnectionStatusChange); |
|
67 } |
|
68 this.template.destroy(); |
|
69 }, |
|
70 |
|
71 onNewConnection: function() { |
|
72 this.connection.on(Connection.Events.STATUS_CHANGED, this._onConnectionStatusChange); |
|
73 this._onConnectionStatusChange(); |
|
74 }, |
|
75 |
|
76 _onConnectionStatusChange: function() { |
|
77 if (this.connection.status != Connection.Status.CONNECTED) { |
|
78 document.body.classList.remove("connected"); |
|
79 this.listTabsResponse = null; |
|
80 } else { |
|
81 document.body.classList.add("connected"); |
|
82 this.connection.client.listTabs( |
|
83 response => {this.listTabsResponse = response} |
|
84 ); |
|
85 } |
|
86 }, |
|
87 |
|
88 get connected() { return !!this.listTabsResponse; }, |
|
89 |
|
90 _selectFolder: function() { |
|
91 let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); |
|
92 fp.init(window, Utils.l10n("project.filePickerTitle"), Ci.nsIFilePicker.modeGetFolder); |
|
93 let res = fp.show(); |
|
94 if (res != Ci.nsIFilePicker.returnCancel) |
|
95 return fp.file; |
|
96 return null; |
|
97 }, |
|
98 |
|
99 addPackaged: function(folder) { |
|
100 if (!folder) { |
|
101 folder = this._selectFolder(); |
|
102 } |
|
103 if (!folder) |
|
104 return; |
|
105 return AppProjects.addPackaged(folder) |
|
106 .then(function (project) { |
|
107 UI.validate(project); |
|
108 UI.selectProject(project.location); |
|
109 }); |
|
110 }, |
|
111 |
|
112 addHosted: function() { |
|
113 let form = document.querySelector("#new-hosted-project-wrapper"); |
|
114 if (!form.checkValidity()) |
|
115 return; |
|
116 |
|
117 let urlInput = document.querySelector("#url-input"); |
|
118 let manifestURL = urlInput.value; |
|
119 return AppProjects.addHosted(manifestURL) |
|
120 .then(function (project) { |
|
121 UI.validate(project); |
|
122 UI.selectProject(project.location); |
|
123 }); |
|
124 }, |
|
125 |
|
126 _getLocalIconURL: function(project, manifest) { |
|
127 let icon; |
|
128 if (manifest.icons) { |
|
129 let size = Object.keys(manifest.icons).sort(function(a, b) b - a)[0]; |
|
130 if (size) { |
|
131 icon = manifest.icons[size]; |
|
132 } |
|
133 } |
|
134 if (!icon) |
|
135 return "chrome://browser/skin/devtools/app-manager/default-app-icon.png"; |
|
136 if (project.type == "hosted") { |
|
137 let manifestURL = Services.io.newURI(project.location, null, null); |
|
138 let origin = Services.io.newURI(manifestURL.prePath, null, null); |
|
139 return Services.io.newURI(icon, null, origin).spec; |
|
140 } else if (project.type == "packaged") { |
|
141 let projectFolder = FileUtils.File(project.location); |
|
142 let folderURI = Services.io.newFileURI(projectFolder).spec; |
|
143 return folderURI + icon.replace(/^\/|\\/, ""); |
|
144 } |
|
145 }, |
|
146 |
|
147 validate: function(project) { |
|
148 let validation = new AppValidator(project); |
|
149 return validation.validate() |
|
150 .then(function () { |
|
151 if (validation.manifest) { |
|
152 project.icon = UI._getLocalIconURL(project, validation.manifest); |
|
153 project.manifest = validation.manifest; |
|
154 } |
|
155 |
|
156 project.validationStatus = "valid"; |
|
157 |
|
158 if (validation.warnings.length > 0) { |
|
159 project.warningsCount = validation.warnings.length; |
|
160 project.warnings = validation.warnings.join(",\n "); |
|
161 project.validationStatus = "warning"; |
|
162 } else { |
|
163 project.warnings = ""; |
|
164 project.warningsCount = 0; |
|
165 } |
|
166 |
|
167 if (validation.errors.length > 0) { |
|
168 project.errorsCount = validation.errors.length; |
|
169 project.errors = validation.errors.join(",\n "); |
|
170 project.validationStatus = "error"; |
|
171 } else { |
|
172 project.errors = ""; |
|
173 project.errorsCount = 0; |
|
174 } |
|
175 |
|
176 if (project.warningsCount && project.errorsCount) { |
|
177 project.validationStatus = "error warning"; |
|
178 } |
|
179 |
|
180 }); |
|
181 |
|
182 }, |
|
183 |
|
184 update: function(button, location) { |
|
185 button.disabled = true; |
|
186 let project = AppProjects.get(location); |
|
187 |
|
188 // Update the manifest editor view, in case the manifest was modified |
|
189 // outside of the app manager. This can happen in parallel with the other |
|
190 // steps. |
|
191 this._showManifestEditor(project); |
|
192 |
|
193 this.validate(project) |
|
194 .then(() => { |
|
195 // Install the app to the device if we are connected, |
|
196 // and there is no error |
|
197 if (project.errorsCount == 0 && this.connected) { |
|
198 return this.install(project); |
|
199 } |
|
200 }) |
|
201 .then(() => { |
|
202 button.disabled = false; |
|
203 // Finally try to reload the app if it is already opened |
|
204 if (this.connected) { |
|
205 this.reload(project); |
|
206 } |
|
207 }, |
|
208 (res) => { |
|
209 button.disabled = false; |
|
210 let message = res.error + ": " + res.message; |
|
211 alert(message); |
|
212 this.connection.log(message); |
|
213 }); |
|
214 }, |
|
215 |
|
216 saveManifest: function(button) { |
|
217 button.disabled = true; |
|
218 this.manifestEditor.save().then(() => button.disabled = false); |
|
219 }, |
|
220 |
|
221 reload: function (project) { |
|
222 if (!this.connected) { |
|
223 return promise.reject(); |
|
224 } |
|
225 return reloadApp(this.connection.client, |
|
226 this.listTabsResponse.webappsActor, |
|
227 this._getProjectManifestURL(project)). |
|
228 then(() => { |
|
229 this.connection.log("App reloaded"); |
|
230 }); |
|
231 }, |
|
232 |
|
233 remove: function(location, event) { |
|
234 if (event) { |
|
235 // We don't want the "click" event to be propagated to the project item. |
|
236 // That would trigger `selectProject()`. |
|
237 event.stopPropagation(); |
|
238 } |
|
239 |
|
240 let item = document.getElementById(location); |
|
241 |
|
242 let toSelect = document.querySelector(".project-item.selected"); |
|
243 toSelect = toSelect ? toSelect.id : ""; |
|
244 |
|
245 if (toSelect == location) { |
|
246 toSelect = null; |
|
247 let sibling; |
|
248 if (item.previousElementSibling) { |
|
249 sibling = item.previousElementSibling; |
|
250 } else { |
|
251 sibling = item.nextElementSibling; |
|
252 } |
|
253 if (sibling && !!AppProjects.get(sibling.id)) { |
|
254 toSelect = sibling.id; |
|
255 } |
|
256 } |
|
257 |
|
258 AppProjects.remove(location).then(() => { |
|
259 this.selectProject(toSelect); |
|
260 }); |
|
261 }, |
|
262 |
|
263 _getProjectManifestURL: function (project) { |
|
264 if (project.type == "packaged") { |
|
265 return "app://" + project.packagedAppOrigin + "/manifest.webapp"; |
|
266 } else if (project.type == "hosted") { |
|
267 return project.location; |
|
268 } |
|
269 }, |
|
270 |
|
271 install: function(project) { |
|
272 if (!this.connected) { |
|
273 return promise.reject(); |
|
274 } |
|
275 this.connection.log("Installing the " + project.manifest.name + " app..."); |
|
276 let installPromise; |
|
277 if (project.type == "packaged") { |
|
278 installPromise = installPackaged(this.connection.client, this.listTabsResponse.webappsActor, project.location, project.packagedAppOrigin) |
|
279 .then(({ appId }) => { |
|
280 // If the packaged app specified a custom origin override, |
|
281 // we need to update the local project origin |
|
282 project.packagedAppOrigin = appId; |
|
283 // And ensure the indexed db on disk is also updated |
|
284 AppProjects.update(project); |
|
285 }); |
|
286 } else { |
|
287 let manifestURLObject = Services.io.newURI(project.location, null, null); |
|
288 let origin = Services.io.newURI(manifestURLObject.prePath, null, null); |
|
289 let appId = origin.host; |
|
290 let metadata = { |
|
291 origin: origin.spec, |
|
292 manifestURL: project.location |
|
293 }; |
|
294 installPromise = installHosted(this.connection.client, this.listTabsResponse.webappsActor, appId, metadata, project.manifest); |
|
295 } |
|
296 |
|
297 installPromise.then(() => { |
|
298 this.connection.log("Install completed."); |
|
299 }, () => { |
|
300 this.connection.log("Install failed."); |
|
301 }); |
|
302 |
|
303 return installPromise; |
|
304 }, |
|
305 |
|
306 start: function(project) { |
|
307 if (!this.connected) { |
|
308 return promise.reject(); |
|
309 } |
|
310 let manifestURL = this._getProjectManifestURL(project); |
|
311 return launchApp(this.connection.client, |
|
312 this.listTabsResponse.webappsActor, |
|
313 manifestURL); |
|
314 }, |
|
315 |
|
316 stop: function(location) { |
|
317 if (!this.connected) { |
|
318 return promise.reject(); |
|
319 } |
|
320 let project = AppProjects.get(location); |
|
321 let manifestURL = this._getProjectManifestURL(project); |
|
322 return closeApp(this.connection.client, |
|
323 this.listTabsResponse.webappsActor, |
|
324 manifestURL); |
|
325 }, |
|
326 |
|
327 debug: function(button, location) { |
|
328 if (!this.connected) { |
|
329 return promise.reject(); |
|
330 } |
|
331 button.disabled = true; |
|
332 let project = AppProjects.get(location); |
|
333 |
|
334 let onFailedToStart = (error) => { |
|
335 // If not installed, install and open it |
|
336 if (error == "NO_SUCH_APP") { |
|
337 return this.install(project); |
|
338 } else { |
|
339 throw error; |
|
340 } |
|
341 }; |
|
342 let onStarted = () => { |
|
343 // Once we asked the app to launch, the app isn't necessary completely loaded. |
|
344 // launch request only ask the app to launch and immediatly returns. |
|
345 // We have to keep trying to get app tab actors required to create its target. |
|
346 let deferred = promise.defer(); |
|
347 let loop = (count) => { |
|
348 // Ensure not looping for ever |
|
349 if (count >= 100) { |
|
350 deferred.reject("Unable to connect to the app"); |
|
351 return; |
|
352 } |
|
353 // Also, in case the app wasn't installed yet, we also have to keep asking the |
|
354 // app to launch, as launch request made right after install may race. |
|
355 this.start(project); |
|
356 getTargetForApp( |
|
357 this.connection.client, |
|
358 this.listTabsResponse.webappsActor, |
|
359 this._getProjectManifestURL(project)). |
|
360 then(deferred.resolve, |
|
361 (err) => { |
|
362 if (err == "appNotFound") |
|
363 setTimeout(loop, 500, count + 1); |
|
364 else |
|
365 deferred.reject(err); |
|
366 }); |
|
367 }; |
|
368 loop(0); |
|
369 return deferred.promise; |
|
370 }; |
|
371 |
|
372 // First try to open the app |
|
373 this.start(project) |
|
374 .then(null, onFailedToStart) |
|
375 .then(onStarted) |
|
376 .then((target) => |
|
377 top.UI.openAndShowToolboxForTarget(target, |
|
378 project.manifest.name, |
|
379 project.icon)) |
|
380 .then(() => { |
|
381 // And only when the toolbox is opened, release the button |
|
382 button.disabled = false; |
|
383 }, |
|
384 (err) => { |
|
385 button.disabled = false; |
|
386 let message = err.error ? err.error + ": " + err.message : String(err); |
|
387 alert(message); |
|
388 this.connection.log(message); |
|
389 }); |
|
390 }, |
|
391 |
|
392 reveal: function(location) { |
|
393 let project = AppProjects.get(location); |
|
394 if (project.type == "packaged") { |
|
395 let projectFolder = FileUtils.File(project.location); |
|
396 projectFolder.reveal(); |
|
397 } else { |
|
398 // TODO: eventually open hosted apps in firefox |
|
399 // when permissions are correctly supported by firefox |
|
400 } |
|
401 }, |
|
402 |
|
403 selectProject: function(location) { |
|
404 let projects = AppProjects.store.object.projects; |
|
405 let idx = 0; |
|
406 for (; idx < projects.length; idx++) { |
|
407 if (projects[idx].location == location) { |
|
408 break; |
|
409 } |
|
410 } |
|
411 |
|
412 let oldButton = document.querySelector(".project-item.selected"); |
|
413 if (oldButton) { |
|
414 oldButton.classList.remove("selected"); |
|
415 } |
|
416 |
|
417 if (idx == projects.length) { |
|
418 // Not found. Empty lense. |
|
419 let lense = document.querySelector("#lense"); |
|
420 lense.setAttribute("template-for", '{"path":"","childSelector":""}'); |
|
421 this.template._processFor(lense); |
|
422 return; |
|
423 } |
|
424 |
|
425 let button = document.getElementById(location); |
|
426 button.classList.add("selected"); |
|
427 |
|
428 let template = '{"path":"projects.' + idx + '","childSelector":"#lense-template"}'; |
|
429 |
|
430 let lense = document.querySelector("#lense"); |
|
431 lense.setAttribute("template-for", template); |
|
432 this.template._processFor(lense); |
|
433 |
|
434 let project = projects[idx]; |
|
435 this._showManifestEditor(project).then(() => this.emit("project-selected")); |
|
436 }, |
|
437 |
|
438 _showManifestEditor: function(project) { |
|
439 if (this.manifestEditor) { |
|
440 this.manifestEditor.destroy(); |
|
441 } |
|
442 let editorContainer = document.querySelector("#lense .manifest-editor"); |
|
443 this.manifestEditor = new ManifestEditor(project); |
|
444 return this.manifestEditor.show(editorContainer); |
|
445 } |
|
446 }; |
|
447 |
|
448 // This must be bound immediately, as it might be used via the message listener |
|
449 // before UI.onload() has been called. |
|
450 UI._onConnectionStatusChange = UI._onConnectionStatusChange.bind(UI); |
|
451 |
|
452 EventEmitter.decorate(UI); |