1 // Copyright (c) 2012 The Chromium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 'use strict'; 6 7 /** 8 * Map of all currently open app window. The key is an app id. 9 */ 10 var appWindows = {}; 11 12 /** 13 * Synchronous queue for asynchronous calls. 14 * @type {AsyncUtil.Queue} 15 */ 16 var queue = new AsyncUtil.Queue(); 17 18 /** 19 * @return {Array.<DOMWindow>} Array of content windows for all currently open 20 * app windows. 21 */ 22 function getContentWindows() { 23 var views = []; 24 for (var key in appWindows) { 25 if (appWindows.hasOwnProperty(key)) 26 views.push(appWindows[key].contentWindow); 27 } 28 return views; 29 } 30 31 /** 32 * Type of a Files.app's instance launch. 33 * @enum {number} 34 */ 35 var LaunchType = { 36 ALWAYS_CREATE: 0, 37 FOCUS_ANY_OR_CREATE: 1, 38 FOCUS_SAME_OR_CREATE: 2 39 }; 40 41 /** 42 * Wrapper for an app window. 43 * 44 * Expects the following from the app scripts: 45 * 1. The page load handler should initialize the app using |window.appState| 46 * and call |util.platform.saveAppState|. 47 * 2. Every time the app state changes the app should update |window.appState| 48 * and call |util.platform.saveAppState| . 49 * 3. The app may have |unload| function to persist the app state that does not 50 * fit into |window.appState|. 51 * 52 * @param {AsyncUtil.Queue} queue Queue for asynchronous window launches. 53 * @param {string} url App window content url. 54 * @param {string} id App window id. 55 * @param {Object|function()} options Options object or a function to create it. 56 * @constructor 57 */ 58 function AppWindowWrapper(queue, url, id, options) { 59 this.queue_ = queue; 60 this.url_ = url; 61 this.id_ = id; 62 this.options_ = options; 63 this.window_ = null; 64 } 65 66 /** 67 * Shift distance to avoid overlapping windows. 68 * @type {number} 69 * @const 70 */ 71 AppWindowWrapper.SHIFT_DISTANCE = 40; 72 73 /** 74 * @return {boolean} True if the window is currently open. 75 */ 76 AppWindowWrapper.prototype.isOpen = function() { 77 return this.window_ && !this.window_.contentWindow.closed; 78 }; 79 80 /** 81 * Gets similar windows, it means with the same initial url. 82 * @return {Array.<AppWindow>} List of similar windows. 83 * @private 84 */ 85 AppWindowWrapper.prototype.getSimilarWindows_ = function() { 86 var result = []; 87 for (var appID in appWindows) { 88 if (appWindows[appID].contentWindow.appInitialURL == this.url_) 89 result.push(appWindows[appID]); 90 } 91 return result; 92 }; 93 94 /** 95 * Opens the window. 96 * 97 * @param {Object} appState App state. 98 * @param {function()} callback Completion callback. 99 */ 100 AppWindowWrapper.prototype.launch = function(appState, callback) { 101 if (this.isOpen()) { 102 console.error('The window is already open'); 103 callback(); 104 return; 105 } 106 this.appState_ = appState; 107 108 var options = this.options_; 109 if (typeof options == 'function') 110 options = options(); 111 options.id = this.url_; // This is to make Chrome reuse window geometries. 112 options.singleton = false; 113 options.hidden = true; 114 115 // Get similar windows, it means with the same initial url, eg. different 116 // main windows of Files.app. 117 var similarWindows = this.getSimilarWindows_(); 118 119 // Closure creating the window, once all preprocessing tasks are finished. 120 var createWindow = function() { 121 chrome.app.window.onRestored.removeListener(createWindow); 122 chrome.app.window.create(this.url_, options, function(appWindow) { 123 this.window_ = appWindow; 124 125 // If we have already another window of the same kind, then shift this 126 // window to avoid overlapping with the previous one. 127 if (similarWindows.length) { 128 var bounds = appWindow.getBounds(); 129 appWindow.moveTo(bounds.left + AppWindowWrapper.SHIFT_DISTANCE, 130 bounds.top + AppWindowWrapper.SHIFT_DISTANCE); 131 } 132 133 // Show after changing bounds is done. For the new UI, Files.app shows 134 // it's window as soon as the UI is pre-initialized. 135 if (!this.id_.match(FILES_ID_PATTERN)) 136 appWindow.show(); 137 138 appWindows[this.id_] = appWindow; 139 var contentWindow = appWindow.contentWindow; 140 contentWindow.appID = this.id_; 141 contentWindow.appState = this.appState_; 142 contentWindow.appInitialURL = this.url_; 143 if (window.IN_TEST) 144 contentWindow.IN_TEST = true; 145 appWindow.onClosed.addListener(function() { 146 if (contentWindow.unload) 147 contentWindow.unload(); 148 if (contentWindow.saveOnExit) { 149 contentWindow.saveOnExit.forEach(function(entry) { 150 util.AppCache.update(entry.key, entry.value); 151 }); 152 } 153 delete appWindows[this.id_]; 154 chrome.storage.local.remove(this.id_); // Forget the persisted state. 155 this.window_ = null; 156 maybeCloseBackgroundPage(); 157 }.bind(this)); 158 159 callback(); 160 }.bind(this)); 161 }.bind(this); 162 163 // Restore maximized windows, to avoid hiding them to tray, which can be 164 // confusing for users. 165 for (var index = 0; index < similarWindows.length; index++) { 166 if (similarWindows[index].isMaximized()) { 167 var createWindowAndRemoveListener = function() { 168 createWindow(); 169 similarWindows[index].onRestored.removeListener( 170 createWindowAndRemoveListener); 171 }; 172 similarWindows[index].onRestored.addListener( 173 createWindowAndRemoveListener); 174 similarWindows[index].restore(); 175 return; 176 } 177 } 178 179 // If no maximized windows, then create the window immediately. 180 createWindow(); 181 }; 182 183 /** 184 * Enqueues opening the window. 185 * @param {Object} appState App state. 186 */ 187 AppWindowWrapper.prototype.enqueueLaunch = function(appState) { 188 this.queue_.run(this.launch.bind(this, appState)); 189 }; 190 191 /** 192 * Wrapper for a singleton app window. 193 * 194 * In addition to the AppWindowWrapper requirements the app scripts should 195 * have |reload| method that re-initializes the app based on a changed 196 * |window.appState|. 197 * 198 * @param {AsyncUtil.Queue} queue Queue for asynchronous window launches. 199 * @param {string} url App window content url. 200 * @param {Object|function()} options Options object or a function to return it. 201 * @constructor 202 */ 203 function SingletonAppWindowWrapper(queue, url, options) { 204 AppWindowWrapper.call(this, queue, url, url, options); 205 } 206 207 /** 208 * Inherits from AppWindowWrapper. 209 */ 210 SingletonAppWindowWrapper.prototype = { __proto__: AppWindowWrapper.prototype }; 211 212 /** 213 * Open the window. 214 * 215 * Activates an existing window or creates a new one. 216 * 217 * @param {Object} appState App state. 218 * @param {function()} callback Completion callback. 219 */ 220 SingletonAppWindowWrapper.prototype.launch = function(appState, callback) { 221 if (this.isOpen()) { 222 this.window_.contentWindow.appState = appState; 223 this.window_.contentWindow.reload(); 224 this.window_.focus(); 225 callback(); 226 return; 227 } 228 229 AppWindowWrapper.prototype.launch.call(this, appState, callback); 230 }; 231 232 /** 233 * Reopen a window if its state is saved in the local storage. 234 */ 235 SingletonAppWindowWrapper.prototype.reopen = function() { 236 chrome.storage.local.get(this.id_, function(items) { 237 var value = items[this.id_]; 238 if (!value) 239 return; // No app state persisted. 240 241 try { 242 var appState = JSON.parse(value); 243 } catch (e) { 244 console.error('Corrupt launch data for ' + this.id_, value); 245 return; 246 } 247 this.enqueueLaunch(appState); 248 }.bind(this)); 249 }; 250 251 252 /** 253 * Prefix for the file manager window ID. 254 */ 255 var FILES_ID_PREFIX = 'files#'; 256 257 /** 258 * Regexp matching a file manager window ID. 259 */ 260 var FILES_ID_PATTERN = new RegExp('^' + FILES_ID_PREFIX + '(\\d*)$'); 261 262 /** 263 * Value of the next file manager window ID. 264 */ 265 var nextFileManagerWindowID = 0; 266 267 /** 268 * @return {Object} File manager window create options. 269 */ 270 function createFileManagerOptions() { 271 return { 272 defaultLeft: Math.round(window.screen.availWidth * 0.1), 273 defaultTop: Math.round(window.screen.availHeight * 0.1), 274 defaultWidth: Math.round(window.screen.availWidth * 0.8), 275 defaultHeight: Math.round(window.screen.availHeight * 0.8), 276 minWidth: 320, 277 minHeight: 240, 278 frame: 'none', 279 transparentBackground: true 280 }; 281 } 282 283 /** 284 * @param {Object=} opt_appState App state. 285 * @param {number=} opt_id Window id. 286 * @param {LaunchType=} opt_type Launch type. Default: ALWAYS_CREATE. 287 * @param {function(string)=} opt_callback Completion callback with the App ID. 288 */ 289 function launchFileManager(opt_appState, opt_id, opt_type, opt_callback) { 290 var type = opt_type || LaunchType.ALWAYS_CREATE; 291 292 // Wait until all windows are created. 293 queue.run(function(onTaskCompleted) { 294 // Check if there is already a window with the same path. If so, then 295 // reuse it instead of opening a new one. 296 if (type == LaunchType.FOCUS_SAME_OR_CREATE || 297 type == LaunchType.FOCUS_ANY_OR_CREATE) { 298 if (opt_appState && opt_appState.defaultPath) { 299 for (var key in appWindows) { 300 var contentWindow = appWindows[key].contentWindow; 301 if (contentWindow.appState && 302 opt_appState.defaultPath == contentWindow.appState.defaultPath) { 303 appWindows[key].focus(); 304 if (opt_callback) 305 opt_callback(key); 306 onTaskCompleted(); 307 return; 308 } 309 } 310 } 311 } 312 313 // Focus any window if none is focused. Try restored first. 314 if (type == LaunchType.FOCUS_ANY_OR_CREATE) { 315 // If there is already a focused window, then finish. 316 for (var key in appWindows) { 317 // The isFocused() method should always be available, but in case 318 // Files.app's failed on some error, wrap it with try catch. 319 try { 320 if (appWindows[key].contentWindow.isFocused()) { 321 if (opt_callback) 322 opt_callback(key); 323 onTaskCompleted(); 324 return; 325 } 326 } catch (e) { 327 console.error(e.message); 328 } 329 } 330 // Try to focus the first non-minimized window. 331 for (var key in appWindows) { 332 if (!appWindows[key].isMinimized()) { 333 appWindows[key].focus(); 334 if (opt_callback) 335 opt_callback(key); 336 onTaskCompleted(); 337 return; 338 } 339 } 340 // Restore and focus any window. 341 for (var key in appWindows) { 342 appWindows[key].focus(); 343 if (opt_callback) 344 opt_callback(key); 345 onTaskCompleted(); 346 return; 347 } 348 } 349 350 // Create a new instance in case of ALWAYS_CREATE type, or as a fallback 351 // for other types. 352 353 var id = opt_id || nextFileManagerWindowID; 354 nextFileManagerWindowID = Math.max(nextFileManagerWindowID, id + 1); 355 var appId = FILES_ID_PREFIX + id; 356 357 var appWindow = new AppWindowWrapper( 358 queue, 359 'main.html', 360 appId, 361 createFileManagerOptions); 362 appWindow.enqueueLaunch(opt_appState || {}); 363 if (opt_callback) 364 opt_callback(appId); 365 onTaskCompleted(); 366 }); 367 } 368 369 /** 370 * Relaunch file manager windows based on the persisted state. 371 */ 372 function reopenFileManagers() { 373 chrome.storage.local.get(function(items) { 374 for (var key in items) { 375 if (items.hasOwnProperty(key)) { 376 var match = key.match(FILES_ID_PATTERN); 377 if (match) { 378 var id = Number(match[1]); 379 try { 380 var appState = JSON.parse(items[key]); 381 launchFileManager(appState, id); 382 } catch (e) { 383 console.error('Corrupt launch data for ' + id); 384 } 385 } 386 } 387 } 388 }); 389 } 390 391 /** 392 * Executes a file browser task. 393 * 394 * @param {string} action Task id. 395 * @param {Object} details Details object. 396 */ 397 function onExecute(action, details) { 398 var urls = details.entries.map(function(e) { return e.toURL() }); 399 400 switch (action) { 401 case 'play': 402 launchAudioPlayer({items: urls, position: 0}); 403 break; 404 405 case 'watch': 406 launchVideoPlayer(urls[0]); 407 break; 408 409 default: 410 // Every other action opens a Files app window. 411 var appState = { 412 params: { 413 action: action 414 }, 415 defaultPath: details.entries[0].fullPath, 416 }; 417 // For mounted devices just focus any Files.app window. The mounted 418 // volume will appear on the navigation list. 419 var type = action == 'auto-open' ? LaunchType.FOCUS_ANY_OR_CREATE : 420 LaunchType.FOCUS_SAME_OR_CREATE; 421 launchFileManager(appState, 422 undefined, // App ID. 423 type); 424 break; 425 } 426 } 427 428 429 /** 430 * @return {Object} Audio player window create options. 431 */ 432 function createAudioPlayerOptions() { 433 var WIDTH = 280; 434 var MIN_HEIGHT = 35 + 58; 435 var MAX_HEIGHT = 35 + 58 * 3; 436 var BOTTOM = 80; 437 var RIGHT = 20; 438 439 return { 440 defaultLeft: (window.screen.availWidth - WIDTH - RIGHT), 441 defaultTop: (window.screen.availHeight - MIN_HEIGHT - BOTTOM), 442 minHeight: MIN_HEIGHT, 443 maxHeight: MAX_HEIGHT, 444 height: MIN_HEIGHT, 445 minWidth: WIDTH, 446 maxWidth: WIDTH, 447 width: WIDTH 448 }; 449 } 450 451 var audioPlayer = new SingletonAppWindowWrapper(queue, 452 'mediaplayer.html', 453 createAudioPlayerOptions); 454 455 /** 456 * Launch the audio player. 457 * @param {Object} playlist Playlist. 458 */ 459 function launchAudioPlayer(playlist) { 460 audioPlayer.enqueueLaunch(playlist); 461 } 462 463 var videoPlayer = new SingletonAppWindowWrapper(queue, 464 'video_player.html', 465 {hidden: true}); 466 467 /** 468 * Launch the video player. 469 * @param {string} url Video url. 470 */ 471 function launchVideoPlayer(url) { 472 videoPlayer.enqueueLaunch({url: url}); 473 } 474 475 /** 476 * Launches the app. 477 */ 478 function onLaunched() { 479 if (nextFileManagerWindowID == 0) { 480 // The app just launched. Remove window state records that are not needed 481 // any more. 482 chrome.storage.local.get(function(items) { 483 for (var key in items) { 484 if (items.hasOwnProperty(key)) { 485 if (key.match(FILES_ID_PATTERN)) 486 chrome.storage.local.remove(key); 487 } 488 } 489 }); 490 } 491 492 launchFileManager(); 493 } 494 495 /** 496 * Restarted the app, restore windows. 497 */ 498 function onRestarted() { 499 reopenFileManagers(); 500 audioPlayer.reopen(); 501 videoPlayer.reopen(); 502 } 503 504 /** 505 * Handles clicks on a custom item on the launcher context menu. 506 * @param {OnClickData} info Event details. 507 */ 508 function onContextMenuClicked(info) { 509 if (info.menuItemId == 'new-window') { 510 // Find the focused window (if any) and use it's current path for the 511 // new window. If not found, then launch with the default path. 512 for (var key in appWindows) { 513 try { 514 if (appWindows[key].contentWindow.isFocused()) { 515 var appState = { 516 defaultPath: appWindows[key].contentWindow.appState.defaultPath 517 }; 518 launchFileManager(appState); 519 return; 520 } 521 } catch (ignore) { 522 // The isFocused method may not be defined during initialization. 523 // Therefore, wrapped with a try-catch block. 524 } 525 } 526 527 // Launch with the default path. 528 launchFileManager(); 529 } 530 } 531 532 /** 533 * Closes the background page, if it is not needed. 534 */ 535 function maybeCloseBackgroundPage() { 536 if (Object.keys(appWindows).length === 0 && 537 !FileCopyManager.getInstance().hasQueuedTasks()) 538 close(); 539 } 540 541 /** 542 * Initializes the context menu. Recreates if already exists. 543 * @param {Object} strings Hash array of strings. 544 */ 545 function initContextMenu(strings) { 546 try { 547 chrome.contextMenus.remove('new-window'); 548 } catch (ignore) { 549 // There is no way to detect if the context menu is already added, therefore 550 // try to recreate it every time. 551 } 552 chrome.contextMenus.create({ 553 id: 'new-window', 554 contexts: ['launcher'], 555 title: strings['NEW_WINDOW_BUTTON_LABEL'] 556 }); 557 } 558 559 /** 560 * Initializes the background page of Files.app. 561 */ 562 function initApp() { 563 // Initialize handlers. 564 chrome.fileBrowserHandler.onExecute.addListener(onExecute); 565 chrome.app.runtime.onLaunched.addListener(onLaunched); 566 chrome.app.runtime.onRestarted.addListener(onRestarted); 567 chrome.contextMenus.onClicked.addListener(onContextMenuClicked); 568 569 // Fetch strings and initialize the context menu. 570 queue.run(function(callback) { 571 chrome.fileBrowserPrivate.getStrings(function(strings) { 572 initContextMenu(strings); 573 chrome.storage.local.set({strings: strings}, callback); 574 }); 575 }); 576 } 577 578 // Initialize Files.app. 579 initApp(); 580