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.
8 * Type of a Files.app's instance launch.
11 var LaunchType = Object.freeze({
13 FOCUS_ANY_OR_CREATE: 1,
14 FOCUS_SAME_OR_CREATE: 2
18 * Root class of the background page.
21 function Background() {
23 * Map of all currently open app windows. The key is an app ID.
24 * @type {Object.<string, AppWindow>}
29 * Map of all currently open file dialogs. The key is an app ID.
30 * @type {Object.<string, DOMWindow>}
35 * Synchronous queue for asynchronous calls.
36 * @type {AsyncUtil.Queue}
38 this.queue = new AsyncUtil.Queue();
41 * Progress center of the background page.
42 * @type {ProgressCenter}
44 this.progressCenter = new ProgressCenter();
47 * File operation manager.
48 * @type {FileOperationManager}
50 this.fileOperationManager = new FileOperationManager();
53 * Event handler for progress center.
54 * @type {FileOperationHandler}
57 this.fileOperationHandler_ = new FileOperationHandler(this);
60 * Event handler for C++ sides notifications.
61 * @type {DeviceHandler}
64 this.deviceHandler_ = new DeviceHandler();
65 this.deviceHandler_.addEventListener(
66 DeviceHandler.VOLUME_NAVIGATION_REQUESTED,
68 this.navigateToVolume(event.volumeId);
73 * @type {DriveSyncHandler}
76 this.driveSyncHandler_ = new DriveSyncHandler(this.progressCenter);
77 this.driveSyncHandler_.addEventListener(
78 DriveSyncHandler.COMPLETED_EVENT,
79 function() { this.tryClose(); }.bind(this));
82 * Promise of string data.
85 this.stringDataPromise = new Promise(function(fulfill) {
86 chrome.fileManagerPrivate.getStrings(fulfill);
91 * @type {Object.<string, string>}
93 this.stringData = null;
96 * Callback list to be invoked after initialization.
97 * It turns to null after initialization.
99 * @type {Array.<function()>}
102 this.initializeCallbacks_ = [];
105 * Last time when the background page can close.
110 this.lastTimeCanClose_ = null;
115 // Initialize handlers.
116 chrome.fileBrowserHandler.onExecute.addListener(this.onExecute_.bind(this));
117 chrome.app.runtime.onLaunched.addListener(this.onLaunched_.bind(this));
118 chrome.app.runtime.onRestarted.addListener(this.onRestarted_.bind(this));
119 chrome.contextMenus.onClicked.addListener(
120 this.onContextMenuClicked_.bind(this));
122 this.queue.run(function(callback) {
123 this.stringDataPromise.then(function(strings) {
125 this.stringData = strings;
126 loadTimeData.data = strings;
128 // Init context menu.
129 this.initContextMenu_();
132 }.bind(this)).catch(function(error) {
133 console.error(error.stack || error);
140 * A number of delay milliseconds from the first call of tryClose to the actual
146 Background.CLOSE_DELAY_MS_ = 5000;
149 * Make a key of window geometry preferences for the given initial URL.
150 * @param {string} url Initialize URL that the window has.
151 * @return {string} Key of window geometry preferences.
153 Background.makeGeometryKey = function(url) {
154 return 'windowGeometry' + ':' + url;
158 * Key for getting and storing the last window state (maximized or not).
162 Background.MAXIMIZED_KEY_ = 'isMaximized';
165 * Register callback to be invoked after initialization.
166 * If the initialization is already done, the callback is invoked immediately.
168 * @param {function()} callback Initialize callback to be registered.
170 Background.prototype.ready = function(callback) {
171 this.stringDataPromise.then(callback);
175 * Checks the current condition of background page and closes it if possible.
177 Background.prototype.tryClose = function() {
178 // If the file operation is going, the background page cannot close.
179 if (this.fileOperationManager.hasQueuedTasks() ||
180 this.driveSyncHandler_.syncing) {
181 this.lastTimeCanClose_ = null;
185 var views = chrome.extension.getViews();
187 for (var i = 0; i < views.length; i++) {
188 // If the window that is not the background page itself and it is not
189 // closing, the background page cannot close.
190 if (views[i] !== window && !views[i].closing) {
191 this.lastTimeCanClose_ = null;
194 closing = closing || views[i].closing;
197 // If some windows are closing, or the background page can close but could not
198 // 5 seconds ago, We need more time for sure.
200 this.lastTimeCanClose_ === null ||
201 Date.now() - this.lastTimeCanClose_ < Background.CLOSE_DELAY_MS_) {
202 if (this.lastTimeCanClose_ === null)
203 this.lastTimeCanClose_ = Date.now();
204 setTimeout(this.tryClose.bind(this), Background.CLOSE_DELAY_MS_);
208 // Otherwise we can close the background page.
213 * Gets similar windows, it means with the same initial url.
214 * @param {string} url URL that the obtained windows have.
215 * @return {Array.<AppWindow>} List of similar windows.
217 Background.prototype.getSimilarWindows = function(url) {
219 for (var appID in this.appWindows) {
220 if (this.appWindows[appID].contentWindow.appInitialURL === url)
221 result.push(this.appWindows[appID]);
227 * Opens the root directory of the volume in Files.app.
228 * @param {string} volumeId ID of a volume to be opened.
230 Background.prototype.navigateToVolume = function(volumeId) {
231 VolumeManager.getInstance().then(function(volumeManager) {
232 var volumeInfoList = volumeManager.volumeInfoList;
233 var index = volumeInfoList.findIndex(volumeId);
234 var volumeInfo = volumeInfoList.item(index);
235 return volumeInfo.resolveDisplayRoot();
236 }).then(function(entry) {
238 {currentDirectoryURL: entry.toURL()},
240 LaunchType.FOCUS_SAME_OR_CREATE);
241 }).catch(function(error) {
242 console.error(error.stack || error);
247 * Wrapper for an app window.
249 * Expects the following from the app scripts:
250 * 1. The page load handler should initialize the app using |window.appState|
251 * and call |util.saveAppState|.
252 * 2. Every time the app state changes the app should update |window.appState|
253 * and call |util.saveAppState| .
254 * 3. The app may have |unload| function to persist the app state that does not
255 * fit into |window.appState|.
257 * @param {string} url App window content url.
258 * @param {string} id App window id.
259 * @param {Object} options Options object to create it.
262 function AppWindowWrapper(url, id, options) {
265 // Do deep copy for the template of options to assign customized params later.
266 this.options_ = JSON.parse(JSON.stringify(options));
268 this.appState_ = null;
269 this.openingOrOpened_ = false;
270 this.queue = new AsyncUtil.Queue();
274 AppWindowWrapper.prototype = {
276 * @return {AppWindow} Wrapped application window.
284 * Focuses the window on the specified desktop.
285 * @param {AppWindow} appWindow Application window.
286 * @param {string=} opt_profileId The profiled ID of the target window. If it is
287 * dropped, the window is focused on the current window.
289 AppWindowWrapper.focusOnDesktop = function(appWindow, opt_profileId) {
290 new Promise(function(onFulfilled, onRejected) {
292 onFulfilled(opt_profileId);
294 chrome.fileManagerPrivate.getProfiles(
295 function(profiles, currentId, displayedId) {
296 onFulfilled(currentId);
299 }).then(function(profileId) {
300 appWindow.contentWindow.chrome.fileManagerPrivate.visitDesktop(
309 * Shift distance to avoid overlapping windows.
313 AppWindowWrapper.SHIFT_DISTANCE = 40;
316 * Sets the icon of the window.
317 * @param {string} iconPath Path of the icon.
319 AppWindowWrapper.prototype.setIcon = function(iconPath) {
320 this.window_.setIcon(iconPath);
326 * @param {Object} appState App state.
327 * @param {boolean} reopen True if the launching is triggered automatically.
329 * @param {function()=} opt_callback Completion callback.
331 AppWindowWrapper.prototype.launch = function(appState, reopen, opt_callback) {
332 // Check if the window is opened or not.
333 if (this.openingOrOpened_) {
334 console.error('The window is already opened.');
339 this.openingOrOpened_ = true;
341 // Save application state.
342 this.appState_ = appState;
344 // Get similar windows, it means with the same initial url, eg. different
345 // main windows of Files.app.
346 var similarWindows = background.getSimilarWindows(this.url_);
348 // Restore maximized windows, to avoid hiding them to tray, which can be
349 // confusing for users.
350 this.queue.run(function(callback) {
351 for (var index = 0; index < similarWindows.length; index++) {
352 if (similarWindows[index].isMaximized()) {
353 var createWindowAndRemoveListener = function() {
354 similarWindows[index].onRestored.removeListener(
355 createWindowAndRemoveListener);
358 similarWindows[index].onRestored.addListener(
359 createWindowAndRemoveListener);
360 similarWindows[index].restore();
364 // If no maximized windows, then create the window immediately.
368 // Obtains the last geometry and window state (maximized or not).
370 var isMaximized = false;
371 this.queue.run(function(callback) {
372 var boundsKey = Background.makeGeometryKey(this.url_);
373 var maximizedKey = Background.MAXIMIZED_KEY_;
374 chrome.storage.local.get([boundsKey, maximizedKey], function(preferences) {
375 if (!chrome.runtime.lastError) {
376 lastBounds = preferences[boundsKey];
377 isMaximized = preferences[maximizedKey];
383 // Closure creating the window, once all preprocessing tasks are finished.
384 this.queue.run(function(callback) {
385 // Apply the last bounds.
387 this.options_.bounds = lastBounds;
389 this.options_.state = 'maximized';
392 chrome.app.window.create(this.url_, this.options_, function(appWindow) {
393 this.window_ = appWindow;
399 this.queue.run(function(callback) {
400 // If there is another window in the same position, shift the window.
401 var makeBoundsKey = function(bounds) {
402 return bounds.left + '/' + bounds.top;
404 var notAvailablePositions = {};
405 for (var i = 0; i < similarWindows.length; i++) {
406 var key = makeBoundsKey(similarWindows[i].getBounds());
407 notAvailablePositions[key] = true;
409 var candidateBounds = this.window_.getBounds();
411 var key = makeBoundsKey(candidateBounds);
412 if (!notAvailablePositions[key])
414 // Make the position available to avoid an infinite loop.
415 notAvailablePositions[key] = false;
416 var nextLeft = candidateBounds.left + AppWindowWrapper.SHIFT_DISTANCE;
417 var nextRight = nextLeft + candidateBounds.width;
418 candidateBounds.left = nextRight >= screen.availWidth ?
419 nextRight % screen.availWidth : nextLeft;
420 var nextTop = candidateBounds.top + AppWindowWrapper.SHIFT_DISTANCE;
421 var nextBottom = nextTop + candidateBounds.height;
422 candidateBounds.top = nextBottom >= screen.availHeight ?
423 nextBottom % screen.availHeight : nextTop;
425 this.window_.moveTo(candidateBounds.left, candidateBounds.top);
427 // Save the properties.
428 var appWindow = this.window_;
429 background.appWindows[this.id_] = appWindow;
430 var contentWindow = appWindow.contentWindow;
431 contentWindow.appID = this.id_;
432 contentWindow.appState = this.appState_;
433 contentWindow.appReopen = reopen;
434 contentWindow.appInitialURL = this.url_;
436 contentWindow.IN_TEST = true;
438 // Register event listeners.
439 appWindow.onBoundsChanged.addListener(this.onBoundsChanged_.bind(this));
440 appWindow.onClosed.addListener(this.onClosed_.bind(this));
450 * Handles the onClosed extension API event.
453 AppWindowWrapper.prototype.onClosed_ = function() {
454 // Remember the last window state (maximized or normal).
455 var preferences = {};
456 preferences[Background.MAXIMIZED_KEY_] = this.window_.isMaximized();
457 chrome.storage.local.set(preferences);
459 // Unload the window.
460 var appWindow = this.window_;
461 var contentWindow = this.window_.contentWindow;
462 if (contentWindow.unload)
463 contentWindow.unload();
465 this.openingOrOpened_ = false;
467 // Updates preferences.
468 if (contentWindow.saveOnExit) {
469 contentWindow.saveOnExit.forEach(function(entry) {
470 util.AppCache.update(entry.key, entry.value);
473 chrome.storage.local.remove(this.id_); // Forget the persisted state.
475 // Remove the window from the set.
476 delete background.appWindows[this.id_];
478 // If there is no application window, reset window ID.
479 if (!Object.keys(background.appWindows).length)
480 nextFileManagerWindowID = 0;
481 background.tryClose();
485 * Handles onBoundsChanged extension API event.
488 AppWindowWrapper.prototype.onBoundsChanged_ = function() {
489 if (!this.window_.isMaximized()) {
490 var preferences = {};
491 preferences[Background.makeGeometryKey(this.url_)] =
492 this.window_.getBounds();
493 chrome.storage.local.set(preferences);
498 * Wrapper for a singleton app window.
500 * In addition to the AppWindowWrapper requirements the app scripts should
501 * have |reload| method that re-initializes the app based on a changed
504 * @param {string} url App window content url.
505 * @param {Object|function()} options Options object or a function to return it.
508 function SingletonAppWindowWrapper(url, options) {
509 AppWindowWrapper.call(this, url, url, options);
513 * Inherits from AppWindowWrapper.
515 SingletonAppWindowWrapper.prototype = {__proto__: AppWindowWrapper.prototype};
520 * Activates an existing window or creates a new one.
522 * @param {Object} appState App state.
523 * @param {boolean} reopen True if the launching is triggered automatically.
525 * @param {function()=} opt_callback Completion callback.
527 SingletonAppWindowWrapper.prototype.launch =
528 function(appState, reopen, opt_callback) {
529 // If the window is not opened yet, just call the parent method.
530 if (!this.openingOrOpened_) {
531 AppWindowWrapper.prototype.launch.call(
532 this, appState, reopen, opt_callback);
536 // If the window is already opened, reload the window.
537 // The queue is used to wait until the window is opened.
538 this.queue.run(function(nextStep) {
539 this.window_.contentWindow.appState = appState;
540 this.window_.contentWindow.appReopen = reopen;
541 this.window_.contentWindow.reload();
549 * Reopen a window if its state is saved in the local storage.
550 * @param {function()=} opt_callback Completion callback.
552 SingletonAppWindowWrapper.prototype.reopen = function(opt_callback) {
553 chrome.storage.local.get(this.id_, function(items) {
554 var value = items[this.id_];
556 opt_callback && opt_callback();
557 return; // No app state persisted.
561 var appState = JSON.parse(value);
563 console.error('Corrupt launch data for ' + this.id_, value);
564 opt_callback && opt_callback();
567 this.launch(appState, true, opt_callback);
572 * Prefix for the file manager window ID.
576 var FILES_ID_PREFIX = 'files#';
579 * Regexp matching a file manager window ID.
583 var FILES_ID_PATTERN = new RegExp('^' + FILES_ID_PREFIX + '(\\d*)$');
586 * Prefix for the dialog ID.
590 var DIALOG_ID_PREFIX = 'dialog#';
593 * Value of the next file manager window ID.
596 var nextFileManagerWindowID = 0;
599 * Value of the next file manager dialog ID.
602 var nextFileManagerDialogID = 0;
605 * File manager window create options.
609 var FILE_MANAGER_WINDOW_CREATE_OPTIONS = Object.freeze({
610 bounds: Object.freeze({
611 left: Math.round(window.screen.availWidth * 0.1),
612 top: Math.round(window.screen.availHeight * 0.1),
613 width: Math.round(window.screen.availWidth * 0.8),
614 height: Math.round(window.screen.availHeight * 0.8)
622 * @param {Object=} opt_appState App state.
623 * @param {number=} opt_id Window id.
624 * @param {LaunchType=} opt_type Launch type. Default: ALWAYS_CREATE.
625 * @param {function(string)=} opt_callback Completion callback with the App ID.
627 function launchFileManager(opt_appState, opt_id, opt_type, opt_callback) {
628 var type = opt_type || LaunchType.ALWAYS_CREATE;
630 // Wait until all windows are created.
631 background.queue.run(function(onTaskCompleted) {
632 // Check if there is already a window with the same URL. If so, then
633 // reuse it instead of opening a new one.
634 if (type == LaunchType.FOCUS_SAME_OR_CREATE ||
635 type == LaunchType.FOCUS_ANY_OR_CREATE) {
637 for (var key in background.appWindows) {
638 if (!key.match(FILES_ID_PATTERN))
641 var contentWindow = background.appWindows[key].contentWindow;
642 if (!contentWindow.appState)
645 // Different current directories.
646 if (opt_appState.currentDirectoryURL !==
647 contentWindow.appState.currentDirectoryURL) {
651 // Selection URL specified, and it is different.
652 if (opt_appState.selectionURL &&
653 opt_appState.selectionURL !==
654 contentWindow.appState.selectionURL) {
658 AppWindowWrapper.focusOnDesktop(
659 background.appWindows[key], opt_appState.displayedId);
668 // Focus any window if none is focused. Try restored first.
669 if (type == LaunchType.FOCUS_ANY_OR_CREATE) {
670 // If there is already a focused window, then finish.
671 for (var key in background.appWindows) {
672 if (!key.match(FILES_ID_PATTERN))
675 // The isFocused() method should always be available, but in case
676 // Files.app's failed on some error, wrap it with try catch.
678 if (background.appWindows[key].contentWindow.isFocused()) {
685 console.error(e.message);
688 // Try to focus the first non-minimized window.
689 for (var key in background.appWindows) {
690 if (!key.match(FILES_ID_PATTERN))
693 if (!background.appWindows[key].isMinimized()) {
694 AppWindowWrapper.focusOnDesktop(
695 background.appWindows[key], (opt_appState || {}).displayedId);
702 // Restore and focus any window.
703 for (var key in background.appWindows) {
704 if (!key.match(FILES_ID_PATTERN))
707 AppWindowWrapper.focusOnDesktop(
708 background.appWindows[key], (opt_appState || {}).displayedId);
716 // Create a new instance in case of ALWAYS_CREATE type, or as a fallback
719 var id = opt_id || nextFileManagerWindowID;
720 nextFileManagerWindowID = Math.max(nextFileManagerWindowID, id + 1);
721 var appId = FILES_ID_PREFIX + id;
723 var appWindow = new AppWindowWrapper(
726 FILE_MANAGER_WINDOW_CREATE_OPTIONS);
727 appWindow.launch(opt_appState || {}, false, function() {
728 AppWindowWrapper.focusOnDesktop(
729 appWindow.window_, (opt_appState || {}).displayedId);
738 * Registers dialog window to the background page.
740 * @param {DOMWindow} dialogWindow Window of the dialog.
742 function registerDialog(dialogWindow) {
743 var id = DIALOG_ID_PREFIX + (nextFileManagerDialogID++);
744 background.dialogs[id] = dialogWindow;
745 dialogWindow.addEventListener('pagehide', function() {
746 delete background.dialogs[id];
751 * Executes a file browser task.
753 * @param {string} action Task id.
754 * @param {Object} details Details object.
757 Background.prototype.onExecute_ = function(action, details) {
760 var urls = util.entriesToURLs(details.entries);
761 launchAudioPlayer({items: urls, position: 0});
766 params: {action: action},
767 // It is not allowed to call getParent() here, since there may be
768 // no permissions to access it at this stage. Therefore we are passing
769 // the selectionURL only, and the currentDirectory will be resolved
771 selectionURL: details.entries[0].toURL()
774 // Every other action opens a Files app window.
775 // For mounted devices just focus any Files.app window. The mounted
776 // volume will appear on the navigation list.
780 LaunchType.FOCUS_SAME_OR_CREATE);
786 * Icon of the audio player.
787 * TODO(yoshiki): Consider providing an exact size icon, instead of relying
788 * on downsampling by ash.
793 var AUDIO_PLAYER_ICON = 'audio_player/icons/audio-player-64.png';
795 // The instance of audio player. Until it's ready, this is null.
796 var audioPlayer = null;
798 // Queue to serializes the initialization, launching and reloading of the audio
799 // player, so races won't happen.
800 var audioPlayerInitializationQueue = new AsyncUtil.Queue();
802 audioPlayerInitializationQueue.run(function(callback) {
804 * Audio player window create options.
807 var audioPlayerCreateOptions = Object.freeze({
810 minHeight: 44 + 73, // 44px: track, 73px: controller
812 height: 44 + 73, // collapsed
816 audioPlayer = new SingletonAppWindowWrapper('audio_player.html',
817 audioPlayerCreateOptions);
822 * Launches the audio player.
823 * @param {Object} playlist Playlist.
824 * @param {string=} opt_displayedId ProfileID of the desktop where the audio
825 * player should show.
827 function launchAudioPlayer(playlist, opt_displayedId) {
828 audioPlayerInitializationQueue.run(function(callback) {
829 audioPlayer.launch(playlist, false, function(appWindow) {
830 audioPlayer.setIcon(AUDIO_PLAYER_ICON);
831 AppWindowWrapper.focusOnDesktop(audioPlayer.rawAppWindow,
842 Background.prototype.onLaunched_ = function() {
843 if (nextFileManagerWindowID == 0) {
844 // The app just launched. Remove window state records that are not needed
846 chrome.storage.local.get(function(items) {
847 for (var key in items) {
848 if (items.hasOwnProperty(key)) {
849 if (key.match(FILES_ID_PATTERN))
850 chrome.storage.local.remove(key);
855 launchFileManager(null, null, LaunchType.FOCUS_ANY_OR_CREATE);
859 * Restarted the app, restore windows.
862 Background.prototype.onRestarted_ = function() {
863 // Reopen file manager windows.
864 chrome.storage.local.get(function(items) {
865 for (var key in items) {
866 if (items.hasOwnProperty(key)) {
867 var match = key.match(FILES_ID_PATTERN);
869 var id = Number(match[1]);
871 var appState = JSON.parse(items[key]);
872 launchFileManager(appState, id);
874 console.error('Corrupt launch data for ' + id);
881 // Reopen audio player.
882 audioPlayerInitializationQueue.run(function(callback) {
883 audioPlayer.reopen(function() {
884 // If the audioPlayer is reopened, change its window's icon. Otherwise
885 // there is no reopened window so just skip the call of setIcon.
886 if (audioPlayer.rawAppWindow)
887 audioPlayer.setIcon(AUDIO_PLAYER_ICON);
894 * Handles clicks on a custom item on the launcher context menu.
895 * @param {OnClickData} info Event details.
898 Background.prototype.onContextMenuClicked_ = function(info) {
899 if (info.menuItemId == 'new-window') {
900 // Find the focused window (if any) and use it's current url for the
901 // new window. If not found, then launch with the default url.
902 for (var key in background.appWindows) {
904 if (background.appWindows[key].contentWindow.isFocused()) {
906 // Do not clone the selection url, only the current directory.
907 currentDirectoryURL: background.appWindows[key].contentWindow.
908 appState.currentDirectoryURL
910 launchFileManager(appState);
914 // The isFocused method may not be defined during initialization.
915 // Therefore, wrapped with a try-catch block.
919 // Launch with the default URL.
925 * Initializes the context menu. Recreates if already exists.
928 Background.prototype.initContextMenu_ = function() {
930 // According to the spec [1], the callback is optional. But no callback
931 // causes an error for some reason, so we call it with null-callback to
932 // prevent the error. http://crbug.com/353877
933 // - [1] https://developer.chrome.com/extensions/contextMenus#method-remove
934 chrome.contextMenus.remove('new-window', function() {});
936 // There is no way to detect if the context menu is already added, therefore
937 // try to recreate it every time.
939 chrome.contextMenus.create({
941 contexts: ['launcher'],
942 title: str('NEW_WINDOW_BUTTON_LABEL')
947 * Singleton instance of Background.
950 window.background = new Background();