Home | History | Annotate | Download | only in js
      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