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();
68 * @type {DriveSyncHandler}
71 this.driveSyncHandler_ = new DriveSyncHandler(this.progressCenter);
72 this.driveSyncHandler_.addEventListener(
73 DriveSyncHandler.COMPLETED_EVENT,
74 function() { this.tryClose(); }.bind(this));
78 * @type {Object.<string, string>}
80 this.stringData = null;
83 * Callback list to be invoked after initialization.
84 * It turns to null after initialization.
86 * @type {Array.<function()>}
89 this.initializeCallbacks_ = [];
92 * Last time when the background page can close.
97 this.lastTimeCanClose_ = null;
102 // Initialize handlers.
103 chrome.fileBrowserHandler.onExecute.addListener(this.onExecute_.bind(this));
104 chrome.app.runtime.onLaunched.addListener(this.onLaunched_.bind(this));
105 chrome.app.runtime.onRestarted.addListener(this.onRestarted_.bind(this));
106 chrome.contextMenus.onClicked.addListener(
107 this.onContextMenuClicked_.bind(this));
109 // Fetch strings and initialize the context menu.
110 this.queue.run(function(callNextStep) {
111 chrome.fileBrowserPrivate.getStrings(function(strings) {
112 // Initialize string assets.
113 this.stringData = strings;
114 loadTimeData.data = strings;
115 this.initContextMenu_();
117 // Invoke initialize callbacks.
118 for (var i = 0; i < this.initializeCallbacks_.length; i++) {
119 this.initializeCallbacks_[i]();
121 this.initializeCallbacks_ = null;
129 * A number of delay milliseconds from the first call of tryClose to the actual
135 Background.CLOSE_DELAY_MS_ = 5000;
138 * Make a key of window geometry preferences for the given initial URL.
139 * @param {string} url Initialize URL that the window has.
140 * @return {string} Key of window geometry preferences.
142 Background.makeGeometryKey = function(url) {
143 return 'windowGeometry' + ':' + url;
147 * Key for getting and storing the last window state (maximized or not).
151 Background.MAXIMIZED_KEY_ = 'isMaximized';
154 * Register callback to be invoked after initialization.
155 * If the initialization is already done, the callback is invoked immediately.
157 * @param {function()} callback Initialize callback to be registered.
159 Background.prototype.ready = function(callback) {
160 if (this.initializeCallbacks_ !== null)
161 this.initializeCallbacks_.push(callback);
167 * Checks the current condition of background page and closes it if possible.
169 Background.prototype.tryClose = function() {
170 // If the file operation is going, the background page cannot close.
171 if (this.fileOperationManager.hasQueuedTasks() ||
172 this.driveSyncHandler_.syncing) {
173 this.lastTimeCanClose_ = null;
177 var views = chrome.extension.getViews();
179 for (var i = 0; i < views.length; i++) {
180 // If the window that is not the background page itself and it is not
181 // closing, the background page cannot close.
182 if (views[i] !== window && !views[i].closing) {
183 this.lastTimeCanClose_ = null;
186 closing = closing || views[i].closing;
189 // If some windows are closing, or the background page can close but could not
190 // 5 seconds ago, We need more time for sure.
192 this.lastTimeCanClose_ === null ||
193 Date.now() - this.lastTimeCanClose_ < Background.CLOSE_DELAY_MS_) {
194 if (this.lastTimeCanClose_ === null)
195 this.lastTimeCanClose_ = Date.now();
196 setTimeout(this.tryClose.bind(this), Background.CLOSE_DELAY_MS_);
200 // Otherwise we can close the background page.
205 * Gets similar windows, it means with the same initial url.
206 * @param {string} url URL that the obtained windows have.
207 * @return {Array.<AppWindow>} List of similar windows.
209 Background.prototype.getSimilarWindows = function(url) {
211 for (var appID in this.appWindows) {
212 if (this.appWindows[appID].contentWindow.appInitialURL === url)
213 result.push(this.appWindows[appID]);
219 * Wrapper for an app window.
221 * Expects the following from the app scripts:
222 * 1. The page load handler should initialize the app using |window.appState|
223 * and call |util.platform.saveAppState|.
224 * 2. Every time the app state changes the app should update |window.appState|
225 * and call |util.platform.saveAppState| .
226 * 3. The app may have |unload| function to persist the app state that does not
227 * fit into |window.appState|.
229 * @param {string} url App window content url.
230 * @param {string} id App window id.
231 * @param {Object} options Options object to create it.
234 function AppWindowWrapper(url, id, options) {
237 // Do deep copy for the template of options to assign customized params later.
238 this.options_ = JSON.parse(JSON.stringify(options));
240 this.appState_ = null;
241 this.openingOrOpened_ = false;
242 this.queue = new AsyncUtil.Queue();
246 AppWindowWrapper.prototype = {
248 * @return {AppWindow} Wrapped application window.
256 * Focuses the window on the specified desktop.
257 * @param {AppWindow} appWindow Application window.
258 * @param {string=} opt_profileId The profiled ID of the target window. If it is
259 * dropped, the window is focused on the current window.
261 AppWindowWrapper.focusOnDesktop = function(appWindow, opt_profileId) {
262 new Promise(function(onFulfilled, onRejected) {
264 onFulfilled(opt_profileId);
266 chrome.fileBrowserPrivate.getProfiles(function(profiles,
269 onFulfilled(currentId);
272 }).then(function(profileId) {
273 appWindow.contentWindow.chrome.fileBrowserPrivate.visitDesktop(
274 profileId, function() {
281 * Shift distance to avoid overlapping windows.
285 AppWindowWrapper.SHIFT_DISTANCE = 40;
288 * Sets the icon of the window.
289 * @param {string} iconPath Path of the icon.
291 AppWindowWrapper.prototype.setIcon = function(iconPath) {
292 this.window_.setIcon(iconPath);
298 * @param {Object} appState App state.
299 * @param {boolean} reopen True if the launching is triggered automatically.
301 * @param {function()=} opt_callback Completion callback.
303 AppWindowWrapper.prototype.launch = function(appState, reopen, opt_callback) {
304 // Check if the window is opened or not.
305 if (this.openingOrOpened_) {
306 console.error('The window is already opened.');
311 this.openingOrOpened_ = true;
313 // Save application state.
314 this.appState_ = appState;
316 // Get similar windows, it means with the same initial url, eg. different
317 // main windows of Files.app.
318 var similarWindows = background.getSimilarWindows(this.url_);
320 // Restore maximized windows, to avoid hiding them to tray, which can be
321 // confusing for users.
322 this.queue.run(function(callback) {
323 for (var index = 0; index < similarWindows.length; index++) {
324 if (similarWindows[index].isMaximized()) {
325 var createWindowAndRemoveListener = function() {
326 similarWindows[index].onRestored.removeListener(
327 createWindowAndRemoveListener);
330 similarWindows[index].onRestored.addListener(
331 createWindowAndRemoveListener);
332 similarWindows[index].restore();
336 // If no maximized windows, then create the window immediately.
340 // Obtains the last geometry and window state (maximized or not).
342 var isMaximized = false;
343 this.queue.run(function(callback) {
344 var boundsKey = Background.makeGeometryKey(this.url_);
345 var maximizedKey = Background.MAXIMIZED_KEY_;
346 chrome.storage.local.get([boundsKey, maximizedKey], function(preferences) {
347 if (!chrome.runtime.lastError) {
348 lastBounds = preferences[boundsKey];
349 isMaximized = preferences[maximizedKey];
355 // Closure creating the window, once all preprocessing tasks are finished.
356 this.queue.run(function(callback) {
357 // Apply the last bounds.
359 this.options_.bounds = lastBounds;
361 this.options_.state = 'maximized';
364 chrome.app.window.create(this.url_, this.options_, function(appWindow) {
365 this.window_ = appWindow;
371 this.queue.run(function(callback) {
372 // If there is another window in the same position, shift the window.
373 var makeBoundsKey = function(bounds) {
374 return bounds.left + '/' + bounds.top;
376 var notAvailablePositions = {};
377 for (var i = 0; i < similarWindows.length; i++) {
378 var key = makeBoundsKey(similarWindows[i].getBounds());
379 notAvailablePositions[key] = true;
381 var candidateBounds = this.window_.getBounds();
383 var key = makeBoundsKey(candidateBounds);
384 if (!notAvailablePositions[key])
386 // Make the position available to avoid an infinite loop.
387 notAvailablePositions[key] = false;
388 var nextLeft = candidateBounds.left + AppWindowWrapper.SHIFT_DISTANCE;
389 var nextRight = nextLeft + candidateBounds.width;
390 candidateBounds.left = nextRight >= screen.availWidth ?
391 nextRight % screen.availWidth : nextLeft;
392 var nextTop = candidateBounds.top + AppWindowWrapper.SHIFT_DISTANCE;
393 var nextBottom = nextTop + candidateBounds.height;
394 candidateBounds.top = nextBottom >= screen.availHeight ?
395 nextBottom % screen.availHeight : nextTop;
397 this.window_.moveTo(candidateBounds.left, candidateBounds.top);
399 // Save the properties.
400 var appWindow = this.window_;
401 background.appWindows[this.id_] = appWindow;
402 var contentWindow = appWindow.contentWindow;
403 contentWindow.appID = this.id_;
404 contentWindow.appState = this.appState_;
405 contentWindow.appReopen = reopen;
406 contentWindow.appInitialURL = this.url_;
408 contentWindow.IN_TEST = true;
410 // Register event listeners.
411 appWindow.onBoundsChanged.addListener(this.onBoundsChanged_.bind(this));
412 appWindow.onClosed.addListener(this.onClosed_.bind(this));
422 * Handles the onClosed extension API event.
425 AppWindowWrapper.prototype.onClosed_ = function() {
426 // Remember the last window state (maximized or normal).
427 var preferences = {};
428 preferences[Background.MAXIMIZED_KEY_] = this.window_.isMaximized();
429 chrome.storage.local.set(preferences);
431 // Unload the window.
432 var appWindow = this.window_;
433 var contentWindow = this.window_.contentWindow;
434 if (contentWindow.unload)
435 contentWindow.unload();
437 this.openingOrOpened_ = false;
439 // Updates preferences.
440 if (contentWindow.saveOnExit) {
441 contentWindow.saveOnExit.forEach(function(entry) {
442 util.AppCache.update(entry.key, entry.value);
445 chrome.storage.local.remove(this.id_); // Forget the persisted state.
447 // Remove the window from the set.
448 delete background.appWindows[this.id_];
450 // If there is no application window, reset window ID.
451 if (!Object.keys(background.appWindows).length)
452 nextFileManagerWindowID = 0;
453 background.tryClose();
457 * Handles onBoundsChanged extension API event.
460 AppWindowWrapper.prototype.onBoundsChanged_ = function() {
461 if (!this.window_.isMaximized()) {
462 var preferences = {};
463 preferences[Background.makeGeometryKey(this.url_)] =
464 this.window_.getBounds();
465 chrome.storage.local.set(preferences);
470 * Wrapper for a singleton app window.
472 * In addition to the AppWindowWrapper requirements the app scripts should
473 * have |reload| method that re-initializes the app based on a changed
476 * @param {string} url App window content url.
477 * @param {Object|function()} options Options object or a function to return it.
480 function SingletonAppWindowWrapper(url, options) {
481 AppWindowWrapper.call(this, url, url, options);
485 * Inherits from AppWindowWrapper.
487 SingletonAppWindowWrapper.prototype = {__proto__: AppWindowWrapper.prototype};
492 * Activates an existing window or creates a new one.
494 * @param {Object} appState App state.
495 * @param {boolean} reopen True if the launching is triggered automatically.
497 * @param {function()=} opt_callback Completion callback.
499 SingletonAppWindowWrapper.prototype.launch =
500 function(appState, reopen, opt_callback) {
501 // If the window is not opened yet, just call the parent method.
502 if (!this.openingOrOpened_) {
503 AppWindowWrapper.prototype.launch.call(
504 this, appState, reopen, opt_callback);
508 // If the window is already opened, reload the window.
509 // The queue is used to wait until the window is opened.
510 this.queue.run(function(nextStep) {
511 this.window_.contentWindow.appState = appState;
512 this.window_.contentWindow.appReopen = reopen;
513 this.window_.contentWindow.reload();
521 * Reopen a window if its state is saved in the local storage.
522 * @param {function()=} opt_callback Completion callback.
524 SingletonAppWindowWrapper.prototype.reopen = function(opt_callback) {
525 chrome.storage.local.get(this.id_, function(items) {
526 var value = items[this.id_];
528 opt_callback && opt_callback();
529 return; // No app state persisted.
533 var appState = JSON.parse(value);
535 console.error('Corrupt launch data for ' + this.id_, value);
536 opt_callback && opt_callback();
539 this.launch(appState, true, opt_callback);
544 * Prefix for the file manager window ID.
548 var FILES_ID_PREFIX = 'files#';
551 * Regexp matching a file manager window ID.
555 var FILES_ID_PATTERN = new RegExp('^' + FILES_ID_PREFIX + '(\\d*)$');
558 * Prefix for the dialog ID.
562 var DIALOG_ID_PREFIX = 'dialog#';
565 * Value of the next file manager window ID.
568 var nextFileManagerWindowID = 0;
571 * Value of the next file manager dialog ID.
574 var nextFileManagerDialogID = 0;
577 * File manager window create options.
581 var FILE_MANAGER_WINDOW_CREATE_OPTIONS = Object.freeze({
582 bounds: Object.freeze({
583 left: Math.round(window.screen.availWidth * 0.1),
584 top: Math.round(window.screen.availHeight * 0.1),
585 width: Math.round(window.screen.availWidth * 0.8),
586 height: Math.round(window.screen.availHeight * 0.8)
592 transparentBackground: true
596 * @param {Object=} opt_appState App state.
597 * @param {number=} opt_id Window id.
598 * @param {LaunchType=} opt_type Launch type. Default: ALWAYS_CREATE.
599 * @param {function(string)=} opt_callback Completion callback with the App ID.
601 function launchFileManager(opt_appState, opt_id, opt_type, opt_callback) {
602 var type = opt_type || LaunchType.ALWAYS_CREATE;
604 // Wait until all windows are created.
605 background.queue.run(function(onTaskCompleted) {
606 // Check if there is already a window with the same URL. If so, then
607 // reuse it instead of opening a new one.
608 if (type == LaunchType.FOCUS_SAME_OR_CREATE ||
609 type == LaunchType.FOCUS_ANY_OR_CREATE) {
611 for (var key in background.appWindows) {
612 if (!key.match(FILES_ID_PATTERN))
615 var contentWindow = background.appWindows[key].contentWindow;
616 if (!contentWindow.appState)
619 // Different current directories.
620 if (opt_appState.currentDirectoryURL !==
621 contentWindow.appState.currentDirectoryURL) {
625 // Selection URL specified, and it is different.
626 if (opt_appState.selectionURL &&
627 opt_appState.selectionURL !==
628 contentWindow.appState.selectionURL) {
632 AppWindowWrapper.focusOnDesktop(
633 background.appWindows[key], opt_appState.displayedId);
642 // Focus any window if none is focused. Try restored first.
643 if (type == LaunchType.FOCUS_ANY_OR_CREATE) {
644 // If there is already a focused window, then finish.
645 for (var key in background.appWindows) {
646 if (!key.match(FILES_ID_PATTERN))
649 // The isFocused() method should always be available, but in case
650 // Files.app's failed on some error, wrap it with try catch.
652 if (background.appWindows[key].contentWindow.isFocused()) {
659 console.error(e.message);
662 // Try to focus the first non-minimized window.
663 for (var key in background.appWindows) {
664 if (!key.match(FILES_ID_PATTERN))
667 if (!background.appWindows[key].isMinimized()) {
668 AppWindowWrapper.focusOnDesktop(
669 background.appWindows[key], (opt_appState || {}).displayedId);
676 // Restore and focus any window.
677 for (var key in background.appWindows) {
678 if (!key.match(FILES_ID_PATTERN))
681 AppWindowWrapper.focusOnDesktop(
682 background.appWindows[key], (opt_appState || {}).displayedId);
690 // Create a new instance in case of ALWAYS_CREATE type, or as a fallback
693 var id = opt_id || nextFileManagerWindowID;
694 nextFileManagerWindowID = Math.max(nextFileManagerWindowID, id + 1);
695 var appId = FILES_ID_PREFIX + id;
697 var appWindow = new AppWindowWrapper(
700 FILE_MANAGER_WINDOW_CREATE_OPTIONS);
701 appWindow.launch(opt_appState || {}, false, function() {
702 AppWindowWrapper.focusOnDesktop(
703 appWindow.window_, (opt_appState || {}).displayedId);
712 * Registers dialog window to the background page.
714 * @param {DOMWindow} dialogWindow Window of the dialog.
716 function registerDialog(dialogWindow) {
717 var id = DIALOG_ID_PREFIX + (nextFileManagerDialogID++);
718 background.dialogs[id] = dialogWindow;
719 dialogWindow.addEventListener('pagehide', function() {
720 delete background.dialogs[id];
725 * Executes a file browser task.
727 * @param {string} action Task id.
728 * @param {Object} details Details object.
731 Background.prototype.onExecute_ = function(action, details) {
732 var urls = details.entries.map(function(e) { return e.toURL(); });
736 launchAudioPlayer({items: urls, position: 0});
740 var launchEnable = null;
741 var queue = new AsyncUtil.Queue();
742 queue.run(function(nextStep) {
743 // If it is not auto-open (triggered by mounting external devices), we
744 // always launch Files.app.
745 if (action != 'auto-open') {
750 // If the disable-default-apps flag is on, Files.app is not opened
751 // automatically on device mount not to obstruct the manual test.
752 chrome.commandLinePrivate.hasSwitch('disable-default-apps',
754 launchEnable = !flag;
758 queue.run(function(nextStep) {
764 // Every other action opens a Files app window.
769 // It is not allowed to call getParent() here, since there may be
770 // no permissions to access it at this stage. Therefore we are passing
771 // the selectionURL only, and the currentDirectory will be resolved
773 selectionURL: details.entries[0].toURL()
775 // For mounted devices just focus any Files.app window. The mounted
776 // volume will appear on the navigation list.
777 var type = action == 'auto-open' ? LaunchType.FOCUS_ANY_OR_CREATE :
778 LaunchType.FOCUS_SAME_OR_CREATE;
779 launchFileManager(appState, /* App ID */ undefined, type, nextStep);
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) {
803 // TODO(yoshiki): Remove '--file-manager-enable-new-audio-player' flag after
804 // the feature is launched.
805 var newAudioPlayerEnabled = true;
807 var audioPlayerHTML =
808 newAudioPlayerEnabled ? 'audio_player.html' : 'mediaplayer.html';
811 * Audio player window create options.
814 var audioPlayerCreateOptions = Object.freeze({
818 newAudioPlayerEnabled ?
819 (44 + 73) : // 44px: track, 73px: controller
820 (35 + 58), // 35px: track, 58px: controller
821 minWidth: newAudioPlayerEnabled ? 292 : 280,
822 height: newAudioPlayerEnabled ? (44 + 73) : (35 + 58), // collapsed
823 width: newAudioPlayerEnabled ? 292 : 280,
826 audioPlayer = new SingletonAppWindowWrapper(audioPlayerHTML,
827 audioPlayerCreateOptions);
832 * Launches the audio player.
833 * @param {Object} playlist Playlist.
834 * @param {string=} opt_displayedId ProfileID of the desktop where the audio
835 * player should show.
837 function launchAudioPlayer(playlist, opt_displayedId) {
838 audioPlayerInitializationQueue.run(function(callback) {
839 audioPlayer.launch(playlist, false, function(appWindow) {
840 audioPlayer.setIcon(AUDIO_PLAYER_ICON);
841 AppWindowWrapper.focusOnDesktop(audioPlayer.rawAppWindow,
852 Background.prototype.onLaunched_ = function() {
853 if (nextFileManagerWindowID == 0) {
854 // The app just launched. Remove window state records that are not needed
856 chrome.storage.local.get(function(items) {
857 for (var key in items) {
858 if (items.hasOwnProperty(key)) {
859 if (key.match(FILES_ID_PATTERN))
860 chrome.storage.local.remove(key);
865 launchFileManager(null, null, LaunchType.FOCUS_ANY_OR_CREATE);
869 * Restarted the app, restore windows.
872 Background.prototype.onRestarted_ = function() {
873 // Reopen file manager windows.
874 chrome.storage.local.get(function(items) {
875 for (var key in items) {
876 if (items.hasOwnProperty(key)) {
877 var match = key.match(FILES_ID_PATTERN);
879 var id = Number(match[1]);
881 var appState = JSON.parse(items[key]);
882 launchFileManager(appState, id);
884 console.error('Corrupt launch data for ' + id);
891 // Reopen audio player.
892 audioPlayerInitializationQueue.run(function(callback) {
893 audioPlayer.reopen(function() {
894 // If the audioPlayer is reopened, change its window's icon. Otherwise
895 // there is no reopened window so just skip the call of setIcon.
896 if (audioPlayer.rawAppWindow)
897 audioPlayer.setIcon(AUDIO_PLAYER_ICON);
904 * Handles clicks on a custom item on the launcher context menu.
905 * @param {OnClickData} info Event details.
908 Background.prototype.onContextMenuClicked_ = function(info) {
909 if (info.menuItemId == 'new-window') {
910 // Find the focused window (if any) and use it's current url for the
911 // new window. If not found, then launch with the default url.
912 for (var key in background.appWindows) {
914 if (background.appWindows[key].contentWindow.isFocused()) {
916 // Do not clone the selection url, only the current directory.
917 currentDirectoryURL: background.appWindows[key].contentWindow.
918 appState.currentDirectoryURL
920 launchFileManager(appState);
924 // The isFocused method may not be defined during initialization.
925 // Therefore, wrapped with a try-catch block.
929 // Launch with the default URL.
935 * Initializes the context menu. Recreates if already exists.
938 Background.prototype.initContextMenu_ = function() {
940 // According to the spec [1], the callback is optional. But no callback
941 // causes an error for some reason, so we call it with null-callback to
942 // prevent the error. http://crbug.com/353877
943 // - [1] https://developer.chrome.com/extensions/contextMenus#method-remove
944 chrome.contextMenus.remove('new-window', function() {});
946 // There is no way to detect if the context menu is already added, therefore
947 // try to recreate it every time.
949 chrome.contextMenus.create({
951 contexts: ['launcher'],
952 title: str('NEW_WINDOW_BUTTON_LABEL')
957 * Singleton instance of Background.
960 window.background = new Background();