// Copyright (c) 2012 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * Number of runtime errors catched in the background page. * @type {number} */ var JSErrorCount = 0; /** * Counts runtime JavaScript errors. */ window.onerror = function() { JSErrorCount++; }; /** * Type of a Files.app's instance launch. * @enum {number} */ var LaunchType = Object.freeze({ ALWAYS_CREATE: 0, FOCUS_ANY_OR_CREATE: 1, FOCUS_SAME_OR_CREATE: 2 }); /** * Root class of the background page. * @constructor */ function Background() { /** * Map of all currently open app windows. The key is an app id. * @type {Object.} */ this.appWindows = {}; /** * Synchronous queue for asynchronous calls. * @type {AsyncUtil.Queue} */ this.queue = new AsyncUtil.Queue(); /** * Progress center of the background page. * @type {ProgressCenter} */ this.progressCenter = new ProgressCenter(); /** * File operation manager. * @type {FileOperationManager} */ this.fileOperationManager = FileOperationManager.getInstance(); /** * Event handler for progress center. * @type {FileOperationHandler} * @private */ this.fileOperationHandler_ = new FileOperationHandler(this); /** * String assets. * @type {Object.} */ this.stringData = null; /** * Callback list to be invoked after initialization. * It turns to null after initialization. * * @type {Array.} * @private */ this.initializeCallbacks_ = []; /** * Last time when the background page can close. * * @type {number} * @private */ this.lastTimeCanClose_ = null; // Seal self. Object.seal(this); // Initialize handlers. chrome.fileBrowserHandler.onExecute.addListener(this.onExecute_.bind(this)); chrome.app.runtime.onLaunched.addListener(this.onLaunched_.bind(this)); chrome.app.runtime.onRestarted.addListener(this.onRestarted_.bind(this)); chrome.contextMenus.onClicked.addListener( this.onContextMenuClicked_.bind(this)); // Fetch strings and initialize the context menu. this.queue.run(function(callNextStep) { chrome.fileBrowserPrivate.getStrings(function(strings) { // Initialize string assets. this.stringData = strings; loadTimeData.data = strings; this.initContextMenu_(); // Invoke initialize callbacks. for (var i = 0; i < this.initializeCallbacks_.length; i++) { this.initializeCallbacks_[i](); } this.initializeCallbacks_ = null; callNextStep(); }.bind(this)); }.bind(this)); } /** * A number of delay milliseconds from the first call of tryClose to the actual * close action. * @type {number} * @const * @private */ Background.CLOSE_DELAY_MS_ = 5000; /** * Make a key of window geometry preferences for the given initial URL. * @param {string} url Initialize URL that the window has. * @return {string} Key of window geometry preferences. */ Background.makeGeometryKey = function(url) { return 'windowGeometry' + ':' + url; }; /** * Register callback to be invoked after initialization. * If the initialization is already done, the callback is invoked immediately. * * @param {function()} callback Initialize callback to be registered. */ Background.prototype.ready = function(callback) { if (this.initializeCallbacks_ !== null) this.initializeCallbacks_.push(callback); else callback(); }; /** * Checks the current condition of background page and closes it if possible. */ Background.prototype.tryClose = function() { // If the file operation is going, the background page cannot close. if (this.fileOperationManager.hasQueuedTasks()) { this.lastTimeCanClose_ = null; return; } var views = chrome.extension.getViews(); var closing = false; for (var i = 0; i < views.length; i++) { // If the window that is not the background page itself and it is not // closing, the background page cannot close. if (views[i] !== window && !views[i].closing) { this.lastTimeCanClose_ = null; return; } closing = closing || views[i].closing; } // If some windows are closing, or the background page can close but could not // 5 seconds ago, We need more time for sure. if (closing || this.lastTimeCanClose_ === null || Date.now() - this.lastTimeCanClose_ < Background.CLOSE_DELAY_MS_) { if (this.lastTimeCanClose_ === null) this.lastTimeCanClose_ = Date.now(); setTimeout(this.tryClose.bind(this), Background.CLOSE_DELAY_MS_); return; } // Otherwise we can close the background page. close(); }; /** * Gets similar windows, it means with the same initial url. * @param {string} url URL that the obtained windows have. * @return {Array.} List of similar windows. */ Background.prototype.getSimilarWindows = function(url) { var result = []; for (var appID in this.appWindows) { if (this.appWindows[appID].contentWindow.appInitialURL === url) result.push(this.appWindows[appID]); } return result; }; /** * Wrapper for an app window. * * Expects the following from the app scripts: * 1. The page load handler should initialize the app using |window.appState| * and call |util.platform.saveAppState|. * 2. Every time the app state changes the app should update |window.appState| * and call |util.platform.saveAppState| . * 3. The app may have |unload| function to persist the app state that does not * fit into |window.appState|. * * @param {string} url App window content url. * @param {string} id App window id. * @param {Object} options Options object to create it. * @constructor */ function AppWindowWrapper(url, id, options) { this.url_ = url; this.id_ = id; // Do deep copy for the template of options to assign customized params later. this.options_ = JSON.parse(JSON.stringify(options)); this.window_ = null; this.appState_ = null; this.openingOrOpened_ = false; this.queue = new AsyncUtil.Queue(); Object.seal(this); } /** * Shift distance to avoid overlapping windows. * @type {number} * @const */ AppWindowWrapper.SHIFT_DISTANCE = 40; /** * Opens the window. * * @param {Object} appState App state. * @param {function()=} opt_callback Completion callback. */ AppWindowWrapper.prototype.launch = function(appState, opt_callback) { // Check if the window is opened or not. if (this.openingOrOpened_) { console.error('The window is already opened.'); if (opt_callback) opt_callback(); return; } this.openingOrOpened_ = true; // Save application state. this.appState_ = appState; // Get similar windows, it means with the same initial url, eg. different // main windows of Files.app. var similarWindows = background.getSimilarWindows(this.url_); // Restore maximized windows, to avoid hiding them to tray, which can be // confusing for users. this.queue.run(function(callback) { for (var index = 0; index < similarWindows.length; index++) { if (similarWindows[index].isMaximized()) { var createWindowAndRemoveListener = function() { similarWindows[index].onRestored.removeListener( createWindowAndRemoveListener); callback(); }; similarWindows[index].onRestored.addListener( createWindowAndRemoveListener); similarWindows[index].restore(); return; } } // If no maximized windows, then create the window immediately. callback(); }); // Obtains the last geometry. var lastBounds; this.queue.run(function(callback) { var key = Background.makeGeometryKey(this.url_); chrome.storage.local.get(key, function(preferences) { if (!chrome.runtime.lastError) lastBounds = preferences[key]; callback(); }); }.bind(this)); // Closure creating the window, once all preprocessing tasks are finished. this.queue.run(function(callback) { // Apply the last bounds. if (lastBounds) this.options_.bounds = lastBounds; // Create a window. chrome.app.window.create(this.url_, this.options_, function(appWindow) { this.window_ = appWindow; callback(); }.bind(this)); }.bind(this)); // After creating. this.queue.run(function(callback) { // If there is another window in the same position, shift the window. var makeBoundsKey = function(bounds) { return bounds.left + '/' + bounds.top; }; var notAvailablePositions = {}; for (var i = 0; i < similarWindows.length; i++) { var key = makeBoundsKey(similarWindows[i].getBounds()); notAvailablePositions[key] = true; } var candidateBounds = this.window_.getBounds(); while (true) { var key = makeBoundsKey(candidateBounds); if (!notAvailablePositions[key]) break; // Make the position available to avoid an infinite loop. notAvailablePositions[key] = false; var nextLeft = candidateBounds.left + AppWindowWrapper.SHIFT_DISTANCE; var nextRight = nextLeft + candidateBounds.width; candidateBounds.left = nextRight >= screen.availWidth ? nextRight % screen.availWidth : nextLeft; var nextTop = candidateBounds.top + AppWindowWrapper.SHIFT_DISTANCE; var nextBottom = nextTop + candidateBounds.height; candidateBounds.top = nextBottom >= screen.availHeight ? nextBottom % screen.availHeight : nextTop; } this.window_.moveTo(candidateBounds.left, candidateBounds.top); // Save the properties. var appWindow = this.window_; background.appWindows[this.id_] = appWindow; var contentWindow = appWindow.contentWindow; contentWindow.appID = this.id_; contentWindow.appState = this.appState_; contentWindow.appInitialURL = this.url_; if (window.IN_TEST) contentWindow.IN_TEST = true; // Register event listners. appWindow.onBoundsChanged.addListener(this.onBoundsChanged_.bind(this)); appWindow.onClosed.addListener(this.onClosed_.bind(this)); // Callback. if (opt_callback) opt_callback(); callback(); }.bind(this)); }; /** * Handles the onClosed extension API event. * @private */ AppWindowWrapper.prototype.onClosed_ = function() { // Unload the window. var appWindow = this.window_; var contentWindow = this.window_.contentWindow; if (contentWindow.unload) contentWindow.unload(); this.window_ = null; this.openingOrOpened_ = false; // Updates preferences. if (contentWindow.saveOnExit) { contentWindow.saveOnExit.forEach(function(entry) { util.AppCache.update(entry.key, entry.value); }); } chrome.storage.local.remove(this.id_); // Forget the persisted state. // Remove the window from the set. delete background.appWindows[this.id_]; // If there is no application window, reset window ID. if (!Object.keys(background.appWindows).length) nextFileManagerWindowID = 0; background.tryClose(); }; /** * Handles onBoundsChanged extension API event. * @private */ AppWindowWrapper.prototype.onBoundsChanged_ = function() { var preferences = {}; preferences[Background.makeGeometryKey(this.url_)] = this.window_.getBounds(); chrome.storage.local.set(preferences); }; /** * Wrapper for a singleton app window. * * In addition to the AppWindowWrapper requirements the app scripts should * have |reload| method that re-initializes the app based on a changed * |window.appState|. * * @param {string} url App window content url. * @param {Object|function()} options Options object or a function to return it. * @constructor */ function SingletonAppWindowWrapper(url, options) { AppWindowWrapper.call(this, url, url, options); } /** * Inherits from AppWindowWrapper. */ SingletonAppWindowWrapper.prototype = {__proto__: AppWindowWrapper.prototype}; /** * Open the window. * * Activates an existing window or creates a new one. * * @param {Object} appState App state. * @param {function()=} opt_callback Completion callback. */ SingletonAppWindowWrapper.prototype.launch = function(appState, opt_callback) { // If the window is not opened yet, just call the parent method. if (!this.openingOrOpened_) { AppWindowWrapper.prototype.launch.call(this, appState, opt_callback); return; } // If the window is already opened, reload the window. // The queue is used to wait until the window is opened. this.queue.run(function(nextStep) { this.window_.contentWindow.appState = appState; this.window_.contentWindow.reload(); this.window_.focus(); if (opt_callback) opt_callback(); nextStep(); }.bind(this)); }; /** * Reopen a window if its state is saved in the local storage. */ SingletonAppWindowWrapper.prototype.reopen = function() { chrome.storage.local.get(this.id_, function(items) { var value = items[this.id_]; if (!value) return; // No app state persisted. try { var appState = JSON.parse(value); } catch (e) { console.error('Corrupt launch data for ' + this.id_, value); return; } this.launch(appState); }.bind(this)); }; /** * Prefix for the file manager window ID. */ var FILES_ID_PREFIX = 'files#'; /** * Regexp matching a file manager window ID. */ var FILES_ID_PATTERN = new RegExp('^' + FILES_ID_PREFIX + '(\\d*)$'); /** * Value of the next file manager window ID. */ var nextFileManagerWindowID = 0; /** * File manager window create options. * @type {Object} * @const */ var FILE_MANAGER_WINDOW_CREATE_OPTIONS = Object.freeze({ bounds: Object.freeze({ left: Math.round(window.screen.availWidth * 0.1), top: Math.round(window.screen.availHeight * 0.1), width: Math.round(window.screen.availWidth * 0.8), height: Math.round(window.screen.availHeight * 0.8) }), minWidth: 320, minHeight: 240, frame: 'none', hidden: true, transparentBackground: true }); /** * @param {Object=} opt_appState App state. * @param {number=} opt_id Window id. * @param {LaunchType=} opt_type Launch type. Default: ALWAYS_CREATE. * @param {function(string)=} opt_callback Completion callback with the App ID. */ function launchFileManager(opt_appState, opt_id, opt_type, opt_callback) { var type = opt_type || LaunchType.ALWAYS_CREATE; // Wait until all windows are created. background.queue.run(function(onTaskCompleted) { // Check if there is already a window with the same path. If so, then // reuse it instead of opening a new one. if (type == LaunchType.FOCUS_SAME_OR_CREATE || type == LaunchType.FOCUS_ANY_OR_CREATE) { if (opt_appState && opt_appState.defaultPath) { for (var key in background.appWindows) { if (!key.match(FILES_ID_PATTERN)) continue; var contentWindow = background.appWindows[key].contentWindow; if (contentWindow.appState && opt_appState.defaultPath == contentWindow.appState.defaultPath) { background.appWindows[key].focus(); if (opt_callback) opt_callback(key); onTaskCompleted(); return; } } } } // Focus any window if none is focused. Try restored first. if (type == LaunchType.FOCUS_ANY_OR_CREATE) { // If there is already a focused window, then finish. for (var key in background.appWindows) { if (!key.match(FILES_ID_PATTERN)) continue; // The isFocused() method should always be available, but in case // Files.app's failed on some error, wrap it with try catch. try { if (background.appWindows[key].contentWindow.isFocused()) { if (opt_callback) opt_callback(key); onTaskCompleted(); return; } } catch (e) { console.error(e.message); } } // Try to focus the first non-minimized window. for (var key in background.appWindows) { if (!key.match(FILES_ID_PATTERN)) continue; if (!background.appWindows[key].isMinimized()) { background.appWindows[key].focus(); if (opt_callback) opt_callback(key); onTaskCompleted(); return; } } // Restore and focus any window. for (var key in background.appWindows) { if (!key.match(FILES_ID_PATTERN)) continue; background.appWindows[key].focus(); if (opt_callback) opt_callback(key); onTaskCompleted(); return; } } // Create a new instance in case of ALWAYS_CREATE type, or as a fallback // for other types. var id = opt_id || nextFileManagerWindowID; nextFileManagerWindowID = Math.max(nextFileManagerWindowID, id + 1); var appId = FILES_ID_PREFIX + id; var appWindow = new AppWindowWrapper( 'main.html', appId, FILE_MANAGER_WINDOW_CREATE_OPTIONS); appWindow.launch(opt_appState || {}, function() { if (opt_callback) opt_callback(appId); onTaskCompleted(); }); }); } /** * Executes a file browser task. * * @param {string} action Task id. * @param {Object} details Details object. * @private */ Background.prototype.onExecute_ = function(action, details) { var urls = details.entries.map(function(e) { return e.toURL(); }); switch (action) { case 'play': launchAudioPlayer({items: urls, position: 0}); break; case 'watch': launchVideoPlayer(urls[0]); break; default: var launchEnable = null; var queue = new AsyncUtil.Queue(); queue.run(function(nextStep) { // If it is not auto-open (triggered by mounting external devices), we // always launch Files.app. if (action != 'auto-open') { launchEnable = true; nextStep(); return; } // If the disable-default-apps flag is on, Files.app is not opened // automatically on device mount because it obstculs the manual test. chrome.commandLinePrivate.hasSwitch('disable-default-apps', function(flag) { launchEnable = !flag; nextStep(); }); }); queue.run(function(nextStep) { if (!launchEnable) { nextStep(); return; } // Every other action opens a Files app window. var appState = { params: { action: action }, defaultPath: details.entries[0].fullPath }; // For mounted devices just focus any Files.app window. The mounted // volume will appear on the navigation list. var type = action == 'auto-open' ? LaunchType.FOCUS_ANY_OR_CREATE : LaunchType.FOCUS_SAME_OR_CREATE; launchFileManager(appState, /* App ID */ undefined, type, nextStep); }); break; } }; /** * Audio player window create options. * @type {Object} * @const */ var AUDIO_PLAYER_CREATE_OPTIONS = Object.freeze({ type: 'panel', hidden: true, minHeight: 35 + 58, minWidth: 280, height: 35 + 58, width: 280 }); var audioPlayer = new SingletonAppWindowWrapper('mediaplayer.html', AUDIO_PLAYER_CREATE_OPTIONS); /** * Launch the audio player. * @param {Object} playlist Playlist. */ function launchAudioPlayer(playlist) { audioPlayer.launch(playlist); } var videoPlayer = new SingletonAppWindowWrapper('video_player.html', {hidden: true}); /** * Launch the video player. * @param {string} url Video url. */ function launchVideoPlayer(url) { videoPlayer.launch({url: url}); } /** * Launches the app. * @private */ Background.prototype.onLaunched_ = function() { if (nextFileManagerWindowID == 0) { // The app just launched. Remove window state records that are not needed // any more. chrome.storage.local.get(function(items) { for (var key in items) { if (items.hasOwnProperty(key)) { if (key.match(FILES_ID_PATTERN)) chrome.storage.local.remove(key); } } }); } launchFileManager(null, null, LaunchType.FOCUS_ANY_OR_CREATE); }; /** * Restarted the app, restore windows. * @private */ Background.prototype.onRestarted_ = function() { // Reopen file manager windows. chrome.storage.local.get(function(items) { for (var key in items) { if (items.hasOwnProperty(key)) { var match = key.match(FILES_ID_PATTERN); if (match) { var id = Number(match[1]); try { var appState = JSON.parse(items[key]); launchFileManager(appState, id); } catch (e) { console.error('Corrupt launch data for ' + id); } } } } }); // Reopen sub-applications. audioPlayer.reopen(); videoPlayer.reopen(); }; /** * Handles clicks on a custom item on the launcher context menu. * @param {OnClickData} info Event details. * @private */ Background.prototype.onContextMenuClicked_ = function(info) { if (info.menuItemId == 'new-window') { // Find the focused window (if any) and use it's current path for the // new window. If not found, then launch with the default path. for (var key in background.appWindows) { try { if (background.appWindows[key].contentWindow.isFocused()) { var appState = { defaultPath: background.appWindows[key].contentWindow. appState.defaultPath }; launchFileManager(appState); return; } } catch (ignore) { // The isFocused method may not be defined during initialization. // Therefore, wrapped with a try-catch block. } } // Launch with the default path. launchFileManager(); } }; /** * Initializes the context menu. Recreates if already exists. * @private */ Background.prototype.initContextMenu_ = function() { try { chrome.contextMenus.remove('new-window'); } catch (ignore) { // There is no way to detect if the context menu is already added, therefore // try to recreate it every time. } chrome.contextMenus.create({ id: 'new-window', contexts: ['launcher'], title: str('NEW_WINDOW_BUTTON_LABEL') }); }; /** * Singleton instance of Background. * @type {Background} */ window.background = new Background();