/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; this.EXPORTED_SYMBOLS = ["NewTabUtils"]; const Ci = Components.interfaces; const Cc = Components.classes; const Cu = Components.utils; Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", "resource://gre/modules/PlacesUtils.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "PageThumbs", "resource:///modules/PageThumbs.jsm"); XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm"); XPCOMUtils.defineLazyGetter(this, "gPrincipal", function () { let uri = Services.io.newURI("about:newtab", null, null); return Services.scriptSecurityManager.getNoAppCodebasePrincipal(uri); }); XPCOMUtils.defineLazyGetter(this, "gCryptoHash", function () { return Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash); }); XPCOMUtils.defineLazyGetter(this, "gUnicodeConverter", function () { let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"] .createInstance(Ci.nsIScriptableUnicodeConverter); converter.charset = 'utf8'; return converter; }); // The preference that tells whether this feature is enabled. const PREF_NEWTAB_ENABLED = "browser.newtabpage.enabled"; // The preference that tells the number of rows of the newtab grid. const PREF_NEWTAB_ROWS = "browser.newtabpage.rows"; // The preference that tells the number of columns of the newtab grid. const PREF_NEWTAB_COLUMNS = "browser.newtabpage.columns"; // The maximum number of results we want to retrieve from history. const HISTORY_RESULTS_LIMIT = 100; // The gather telemetry topic. const TOPIC_GATHER_TELEMETRY = "gather-telemetry"; /** * Calculate the MD5 hash for a string. * @param aValue * The string to convert. * @return The base64 representation of the MD5 hash. */ function toHash(aValue) { let value = gUnicodeConverter.convertToByteArray(aValue); gCryptoHash.init(gCryptoHash.MD5); gCryptoHash.update(value, value.length); return gCryptoHash.finish(true); } /** * Singleton that provides storage functionality. */ XPCOMUtils.defineLazyGetter(this, "Storage", function() { return new LinksStorage(); }); function LinksStorage() { // Handle migration of data across versions. try { if (this._storedVersion < this._version) { // This is either an upgrade, or version information is missing. if (this._storedVersion < 1) { this._migrateToV1(); } // Add further migration steps here. } else { // This is a downgrade. Since we cannot predict future, upgrades should // be backwards compatible. We will set the version to the old value // regardless, so, on next upgrade, the migration steps will run again. // For this reason, they should also be able to run multiple times, even // on top of an already up-to-date storage. } } catch (ex) { // Something went wrong in the update process, we can't recover from here, // so just clear the storage and start from scratch (dataloss!). Components.utils.reportError( "Unable to migrate the newTab storage to the current version. "+ "Restarting from scratch.\n" + ex); this.clear(); } // Set the version to the current one. this._storedVersion = this._version; } LinksStorage.prototype = { get _version() 1, get _prefs() Object.freeze({ pinnedLinks: "browser.newtabpage.pinned", blockedLinks: "browser.newtabpage.blocked", }), get _storedVersion() { if (this.__storedVersion === undefined) { try { this.__storedVersion = Services.prefs.getIntPref("browser.newtabpage.storageVersion"); } catch (ex) { this.__storedVersion = 0; } } return this.__storedVersion; }, set _storedVersion(aValue) { Services.prefs.setIntPref("browser.newtabpage.storageVersion", aValue); this.__storedVersion = aValue; return aValue; }, /** * V1 changes storage from chromeappsstore.sqlite to prefs. */ _migrateToV1: function Storage__migrateToV1() { // Import data from the old chromeappsstore.sqlite file, if exists. let file = FileUtils.getFile("ProfD", ["chromeappsstore.sqlite"]); if (!file.exists()) return; let db = Services.storage.openUnsharedDatabase(file); let stmt = db.createStatement( "SELECT key, value FROM webappsstore2 WHERE scope = 'batwen.:about'"); try { while (stmt.executeStep()) { let key = stmt.row.key; let value = JSON.parse(stmt.row.value); switch (key) { case "pinnedLinks": this.set(key, value); break; case "blockedLinks": // Convert urls to hashes. let hashes = {}; for (let url in value) { hashes[toHash(url)] = 1; } this.set(key, hashes); break; default: // Ignore unknown keys. break; } } } finally { stmt.finalize(); db.close(); } }, /** * Gets the value for a given key from the storage. * @param aKey The storage key (a string). * @param aDefault A default value if the key doesn't exist. * @return The value for the given key. */ get: function Storage_get(aKey, aDefault) { let value; try { let prefValue = Services.prefs.getComplexValue(this._prefs[aKey], Ci.nsISupportsString).data; value = JSON.parse(prefValue); } catch (e) {} return value || aDefault; }, /** * Sets the storage value for a given key. * @param aKey The storage key (a string). * @param aValue The value to set. */ set: function Storage_set(aKey, aValue) { // Page titles may contain unicode, thus use complex values. let string = Cc["@mozilla.org/supports-string;1"] .createInstance(Ci.nsISupportsString); string.data = JSON.stringify(aValue); Services.prefs.setComplexValue(this._prefs[aKey], Ci.nsISupportsString, string); }, /** * Removes the storage value for a given key. * @param aKey The storage key (a string). */ remove: function Storage_remove(aKey) { Services.prefs.clearUserPref(this._prefs[aKey]); }, /** * Clears the storage and removes all values. */ clear: function Storage_clear() { for (let key in this._prefs) { this.remove(key); } } }; /** * Singleton that serves as a registry for all open 'New Tab Page's. */ let AllPages = { /** * The array containing all active pages. */ _pages: [], /** * Cached value that tells whether the New Tab Page feature is enabled. */ _enabled: null, /** * Adds a page to the internal list of pages. * @param aPage The page to register. */ register: function AllPages_register(aPage) { this._pages.push(aPage); this._addObserver(); }, /** * Removes a page from the internal list of pages. * @param aPage The page to unregister. */ unregister: function AllPages_unregister(aPage) { let index = this._pages.indexOf(aPage); if (index > -1) this._pages.splice(index, 1); }, /** * Returns whether the 'New Tab Page' is enabled. */ get enabled() { if (this._enabled === null) this._enabled = Services.prefs.getBoolPref(PREF_NEWTAB_ENABLED); return this._enabled; }, /** * Enables or disables the 'New Tab Page' feature. */ set enabled(aEnabled) { if (this.enabled != aEnabled) Services.prefs.setBoolPref(PREF_NEWTAB_ENABLED, !!aEnabled); }, /** * Returns the number of registered New Tab Pages (i.e. the number of open * about:newtab instances). */ get length() { return this._pages.length; }, /** * Updates all currently active pages but the given one. * @param aExceptPage The page to exclude from updating. */ update: function AllPages_update(aExceptPage) { this._pages.forEach(function (aPage) { if (aExceptPage != aPage) aPage.update(); }); }, /** * Implements the nsIObserver interface to get notified when the preference * value changes. */ observe: function AllPages_observe() { // Clear the cached value. this._enabled = null; let args = Array.slice(arguments); this._pages.forEach(function (aPage) { aPage.observe.apply(aPage, args); }, this); }, /** * Adds a preference observer and turns itself into a no-op after the first * invokation. */ _addObserver: function AllPages_addObserver() { Services.prefs.addObserver(PREF_NEWTAB_ENABLED, this, true); this._addObserver = function () {}; }, QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]) }; /** * Singleton that keeps Grid preferences */ let GridPrefs = { /** * Cached value that tells the number of rows of newtab grid. */ _gridRows: null, get gridRows() { if (!this._gridRows) { this._gridRows = Math.max(1, Services.prefs.getIntPref(PREF_NEWTAB_ROWS)); } return this._gridRows; }, /** * Cached value that tells the number of columns of newtab grid. */ _gridColumns: null, get gridColumns() { if (!this._gridColumns) { this._gridColumns = Math.max(1, Services.prefs.getIntPref(PREF_NEWTAB_COLUMNS)); } return this._gridColumns; }, /** * Initializes object. Adds a preference observer */ init: function GridPrefs_init() { Services.prefs.addObserver(PREF_NEWTAB_ROWS, this, false); Services.prefs.addObserver(PREF_NEWTAB_COLUMNS, this, false); }, /** * Implements the nsIObserver interface to get notified when the preference * value changes. */ observe: function GridPrefs_observe(aSubject, aTopic, aData) { if (aData == PREF_NEWTAB_ROWS) { this._gridRows = null; } else { this._gridColumns = null; } AllPages.update(); } }; GridPrefs.init(); /** * Singleton that keeps track of all pinned links and their positions in the * grid. */ let PinnedLinks = { /** * The cached list of pinned links. */ _links: null, /** * The array of pinned links. */ get links() { if (!this._links) this._links = Storage.get("pinnedLinks", []); return this._links; }, /** * Pins a link at the given position. * @param aLink The link to pin. * @param aIndex The grid index to pin the cell at. */ pin: function PinnedLinks_pin(aLink, aIndex) { // Clear the link's old position, if any. this.unpin(aLink); this.links[aIndex] = aLink; this.save(); }, /** * Unpins a given link. * @param aLink The link to unpin. */ unpin: function PinnedLinks_unpin(aLink) { let index = this._indexOfLink(aLink); if (index != -1) { this.links[index] = null; this.save(); } }, /** * Saves the current list of pinned links. */ save: function PinnedLinks_save() { Storage.set("pinnedLinks", this.links); }, /** * Checks whether a given link is pinned. * @params aLink The link to check. * @return whether The link is pinned. */ isPinned: function PinnedLinks_isPinned(aLink) { return this._indexOfLink(aLink) != -1; }, /** * Resets the links cache. */ resetCache: function PinnedLinks_resetCache() { this._links = null; }, /** * Finds the index of a given link in the list of pinned links. * @param aLink The link to find an index for. * @return The link's index. */ _indexOfLink: function PinnedLinks_indexOfLink(aLink) { for (let i = 0; i < this.links.length; i++) { let link = this.links[i]; if (link && link.url == aLink.url) return i; } // The given link is unpinned. return -1; } }; /** * Singleton that keeps track of all blocked links in the grid. */ let BlockedLinks = { /** * The cached list of blocked links. */ _links: null, /** * The list of blocked links. */ get links() { if (!this._links) this._links = Storage.get("blockedLinks", {}); return this._links; }, /** * Blocks a given link. * @param aLink The link to block. */ block: function BlockedLinks_block(aLink) { this.links[toHash(aLink.url)] = 1; this.save(); // Make sure we unpin blocked links. PinnedLinks.unpin(aLink); }, /** * Unblocks a given link. * @param aLink The link to unblock. */ unblock: function BlockedLinks_unblock(aLink) { if (this.isBlocked(aLink)) { delete this.links[toHash(aLink.url)]; this.save(); } }, /** * Saves the current list of blocked links. */ save: function BlockedLinks_save() { Storage.set("blockedLinks", this.links); }, /** * Returns whether a given link is blocked. * @param aLink The link to check. */ isBlocked: function BlockedLinks_isBlocked(aLink) { return (toHash(aLink.url) in this.links); }, /** * Checks whether the list of blocked links is empty. * @return Whether the list is empty. */ isEmpty: function BlockedLinks_isEmpty() { return Object.keys(this.links).length == 0; }, /** * Resets the links cache. */ resetCache: function BlockedLinks_resetCache() { this._links = null; } }; /** * Singleton that serves as the default link provider for the grid. It queries * the history to retrieve the most frequently visited sites. */ let PlacesProvider = { /** * Gets the current set of links delivered by this provider. * @param aCallback The function that the array of links is passed to. */ getLinks: function PlacesProvider_getLinks(aCallback) { let options = PlacesUtils.history.getNewQueryOptions(); options.maxResults = HISTORY_RESULTS_LIMIT; // Sort by frecency, descending. options.sortingMode = Ci.nsINavHistoryQueryOptions.SORT_BY_FRECENCY_DESCENDING let links = []; let callback = { handleResult: function (aResultSet) { let row; while ((row = aResultSet.getNextRow())) { let url = row.getResultByIndex(1); if (LinkChecker.checkLoadURI(url)) { let title = row.getResultByIndex(2); links.push({url: url, title: title}); } } }, handleError: function (aError) { // Should we somehow handle this error? aCallback([]); }, handleCompletion: function (aReason) { aCallback(links); } }; // Execute the query. let query = PlacesUtils.history.getNewQuery(); let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase); db.asyncExecuteLegacyQueries([query], 1, options, callback); } }; /** * Singleton that provides access to all links contained in the grid (including * the ones that don't fit on the grid). A link is a plain object with title * and url properties. * * Example: * * {url: "http://www.mozilla.org/", title: "Mozilla"} */ let Links = { /** * The links cache. */ _links: null, /** * The default provider for links. */ _provider: PlacesProvider, /** * List of callbacks waiting for the cache to be populated. */ _populateCallbacks: [], /** * Populates the cache with fresh links from the current provider. * @param aCallback The callback to call when finished (optional). * @param aForce When true, populates the cache even when it's already filled. */ populateCache: function Links_populateCache(aCallback, aForce) { let callbacks = this._populateCallbacks; // Enqueue the current callback. callbacks.push(aCallback); // There was a callback waiting already, thus the cache has not yet been // populated. if (callbacks.length > 1) return; function executeCallbacks() { while (callbacks.length) { let callback = callbacks.shift(); if (callback) { try { callback(); } catch (e) { // We want to proceed even if a callback fails. } } } } if (this._links && !aForce) { executeCallbacks(); } else { this._provider.getLinks(function (aLinks) { this._links = aLinks; executeCallbacks(); }.bind(this)); this._addObserver(); } }, /** * Gets the current set of links contained in the grid. * @return The links in the grid. */ getLinks: function Links_getLinks() { let pinnedLinks = Array.slice(PinnedLinks.links); // Filter blocked and pinned links. let links = this._links.filter(function (link) { return !BlockedLinks.isBlocked(link) && !PinnedLinks.isPinned(link); }); // Try to fill the gaps between pinned links. for (let i = 0; i < pinnedLinks.length && links.length; i++) if (!pinnedLinks[i]) pinnedLinks[i] = links.shift(); // Append the remaining links if any. if (links.length) pinnedLinks = pinnedLinks.concat(links); return pinnedLinks; }, /** * Resets the links cache. */ resetCache: function Links_resetCache() { this._links = null; }, /** * Implements the nsIObserver interface to get notified about browser history * sanitization. */ observe: function Links_observe(aSubject, aTopic, aData) { // Make sure to update open about:newtab instances. If there are no opened // pages we can just wait for the next new tab to populate the cache again. if (AllPages.length && AllPages.enabled) this.populateCache(function () { AllPages.update() }, true); else this._links = null; }, /** * Adds a sanitization observer and turns itself into a no-op after the first * invokation. */ _addObserver: function Links_addObserver() { Services.obs.addObserver(this, "browser:purge-session-history", true); this._addObserver = function () {}; }, QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]) }; /** * Singleton used to collect telemetry data. * */ let Telemetry = { /** * Initializes object. */ init: function Telemetry_init() { Services.obs.addObserver(this, TOPIC_GATHER_TELEMETRY, false); }, /** * Collects data. */ _collect: function Telemetry_collect() { let probes = [ { histogram: "NEWTAB_PAGE_ENABLED", value: AllPages.enabled }, { histogram: "NEWTAB_PAGE_PINNED_SITES_COUNT", value: PinnedLinks.links.length }, { histogram: "NEWTAB_PAGE_BLOCKED_SITES_COUNT", value: Object.keys(BlockedLinks.links).length } ]; probes.forEach(function Telemetry_collect_forEach(aProbe) { Services.telemetry.getHistogramById(aProbe.histogram) .add(aProbe.value); }); }, /** * Listens for gather telemetry topic. */ observe: function Telemetry_observe(aSubject, aTopic, aData) { this._collect(); } }; /** * Singleton that checks if a given link should be displayed on about:newtab * or if we should rather not do it for security reasons. URIs that inherit * their caller's principal will be filtered. */ let LinkChecker = { _cache: {}, get flags() { return Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL | Ci.nsIScriptSecurityManager.DONT_REPORT_ERRORS; }, checkLoadURI: function LinkChecker_checkLoadURI(aURI) { if (!(aURI in this._cache)) this._cache[aURI] = this._doCheckLoadURI(aURI); return this._cache[aURI]; }, _doCheckLoadURI: function Links_doCheckLoadURI(aURI) { try { Services.scriptSecurityManager. checkLoadURIStrWithPrincipal(gPrincipal, aURI, this.flags); return true; } catch (e) { // We got a weird URI or one that would inherit the caller's principal. return false; } } }; let ExpirationFilter = { init: function ExpirationFilter_init() { PageThumbs.addExpirationFilter(this); }, filterForThumbnailExpiration: function ExpirationFilter_filterForThumbnailExpiration(aCallback) { if (!AllPages.enabled) { aCallback([]); return; } Links.populateCache(function () { let urls = []; // Add all URLs to the list that we want to keep thumbnails for. for (let link of Links.getLinks().slice(0, 25)) { if (link && link.url) urls.push(link.url); } aCallback(urls); }); } }; /** * Singleton that provides the public API of this JSM. */ this.NewTabUtils = { _initialized: false, init: function NewTabUtils_init() { if (!this._initialized) { this._initialized = true; ExpirationFilter.init(); Telemetry.init(); } }, /** * Restores all sites that have been removed from the grid. */ restore: function NewTabUtils_restore() { Storage.clear(); Links.resetCache(); PinnedLinks.resetCache(); BlockedLinks.resetCache(); Links.populateCache(function () { AllPages.update(); }, true); }, /** * Undoes all sites that have been removed from the grid and keep the pinned * tabs. * @param aCallback the callback method. */ undoAll: function NewTabUtils_undoAll(aCallback) { Storage.remove("blockedLinks"); Links.resetCache(); BlockedLinks.resetCache(); Links.populateCache(aCallback, true); }, links: Links, allPages: AllPages, linkChecker: LinkChecker, pinnedLinks: PinnedLinks, blockedLinks: BlockedLinks, gridPrefs: GridPrefs };