/* 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/. */ /** * Provides access to downloads from previous sessions on platforms that store * them in a different location than session downloads. * * This module works with objects that are compatible with Download, while using * the Places interfaces internally. Some of the Places objects may also be * exposed to allow the consumers to integrate with history view commands. */ import { DownloadList } from "resource://gre/modules/DownloadList.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { Downloads: "resource://gre/modules/Downloads.sys.mjs", FileUtils: "resource://gre/modules/FileUtils.sys.mjs", PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", }); // Places query used to retrieve all history downloads for the related list. const HISTORY_PLACES_QUERY = "place:transition=" + Ci.nsINavHistoryService.TRANSITION_DOWNLOAD + "&sort=" + Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING; const DESTINATIONFILEURI_ANNO = "downloads/destinationFileURI"; const METADATA_ANNO = "downloads/metaData"; const METADATA_STATE_FINISHED = 1; const METADATA_STATE_FAILED = 2; const METADATA_STATE_CANCELED = 3; const METADATA_STATE_PAUSED = 4; const METADATA_STATE_BLOCKED_PARENTAL = 6; const METADATA_STATE_DIRTY = 8; /** * Provides methods to retrieve downloads from previous sessions and store * downloads for future sessions. */ export var DownloadHistory = { /** * Retrieves the main DownloadHistoryList object which provides a unified view * on downloads from both previous browsing sessions and this session. * * @param type * Determines which type of downloads from this session should be * included in the list. This is Downloads.PUBLIC by default, but can * also be Downloads.PRIVATE or Downloads.ALL. * @param maxHistoryResults * Optional number that limits the amount of results the history query * may return. * * @return {Promise} * @resolves The requested DownloadHistoryList object. * @rejects JavaScript exception. */ async getList({ type = lazy.Downloads.PUBLIC, maxHistoryResults } = {}) { await DownloadCache.ensureInitialized(); let key = `${type}|${maxHistoryResults ? maxHistoryResults : -1}`; if (!this._listPromises[key]) { this._listPromises[key] = lazy.Downloads.getList(type).then(list => { // When the amount of history downloads is capped, we request the list in // descending order, to make sure that the list can apply the limit. let query = HISTORY_PLACES_QUERY + (maxHistoryResults ? "&maxResults=" + maxHistoryResults : ""); return new DownloadHistoryList(list, query); }); } return this._listPromises[key]; }, /** * This object is populated with one key for each type of download list that * can be returned by the getList method. The values are promises that resolve * to DownloadHistoryList objects. */ _listPromises: {}, async addDownloadToHistory(download) { if ( download.source.isPrivate || !lazy.PlacesUtils.history.canAddURI( lazy.PlacesUtils.toURI(download.source.url) ) ) { return; } await DownloadCache.addDownload(download); await this._updateHistoryListData(download.source.url); }, /** * Stores new detailed metadata for the given download in history. This is * normally called after a download finishes, fails, or is canceled. * * Failed or canceled downloads with partial data are not stored as paused, * because the information from the session download is required for resuming. * * @param download * Download object whose metadata should be updated. If the object * represents a private download, the call has no effect. */ async updateMetaData(download) { if ( download.source.isPrivate || !download.stopped || !lazy.PlacesUtils.history.canAddURI( lazy.PlacesUtils.toURI(download.source.url) ) ) { return; } let state = METADATA_STATE_CANCELED; if (download.succeeded) { state = METADATA_STATE_FINISHED; } else if (download.error) { if (download.error.becauseBlockedByParentalControls) { state = METADATA_STATE_BLOCKED_PARENTAL; } else if (download.error.becauseBlockedByReputationCheck) { state = METADATA_STATE_DIRTY; } else { state = METADATA_STATE_FAILED; } } let metaData = { state, deleted: download.deleted, endTime: download.endTime, }; if (download.succeeded) { metaData.fileSize = download.target.size; } // The verdict may still be present even if the download succeeded. if (download.error && download.error.reputationCheckVerdict) { metaData.reputationCheckVerdict = download.error.reputationCheckVerdict; } // This should be executed before any async parts, to ensure the cache is // updated before any notifications are activated. await DownloadCache.setMetadata(download.source.url, metaData); await this._updateHistoryListData(download.source.url); }, async _updateHistoryListData(sourceUrl) { for (let key of Object.getOwnPropertyNames(this._listPromises)) { let downloadHistoryList = await this._listPromises[key]; downloadHistoryList.updateForMetaDataChange( sourceUrl, DownloadCache.get(sourceUrl) ); } }, }; /** * This cache exists: * - in order to optimize the load of DownloadsHistoryList, when Places * annotations for history downloads must be read. In fact, annotations are * stored in a single table, and reading all of them at once is much more * efficient than an individual query. * - to avoid needing to do asynchronous reading of the database during download * list updates, which are designed to be synchronous (to improve UI * responsiveness). * * The cache is initialized the first time DownloadHistory.getList is called, or * when data is added. */ var DownloadCache = { _data: new Map(), _initializePromise: null, /** * Initializes the cache, loading the data from the places database. * * @return {Promise} Returns a promise that is resolved once the * initialization is complete. */ ensureInitialized() { if (this._initializePromise) { return this._initializePromise; } this._initializePromise = (async () => { const placesObserver = new PlacesWeakCallbackWrapper( this.handlePlacesEvents.bind(this) ); PlacesObservers.addListener( ["history-cleared", "page-removed"], placesObserver ); let pageAnnos = await lazy.PlacesUtils.history.fetchAnnotatedPages([ METADATA_ANNO, DESTINATIONFILEURI_ANNO, ]); let metaDataPages = pageAnnos.get(METADATA_ANNO); if (metaDataPages) { for (let { uri, content } of metaDataPages) { try { this._data.set(uri.href, JSON.parse(content)); } catch (ex) { // Do nothing - JSON.parse could throw. } } } let destinationFilePages = pageAnnos.get(DESTINATIONFILEURI_ANNO); if (destinationFilePages) { for (let { uri, content } of destinationFilePages) { let newData = this.get(uri.href); newData.targetFileSpec = content; this._data.set(uri.href, newData); } } })(); return this._initializePromise; }, /** * This returns an object containing the meta data for the supplied URL. * * @param {String} url The url to get the meta data for. * @return {Object|null} Returns an empty object if there is no meta data found, or * an object containing the meta data. The meta data * will look like: * * { targetFileSpec, state, deleted, endTime, fileSize, ... } * * The targetFileSpec property is the value of "downloads/destinationFileURI", * while the other properties are taken from "downloads/metaData". Any of the * properties may be missing from the object. */ get(url) { return this._data.get(url) || {}; }, /** * Adds a download to the cache and the places database. * * @param {Download} download The download to add to the database and cache. */ async addDownload(download) { await this.ensureInitialized(); let targetFile = new lazy.FileUtils.File(download.target.path); let targetUri = Services.io.newFileURI(targetFile); // This should be executed before any async parts, to ensure the cache is // updated before any notifications are activated. // Note: this intentionally overwrites any metadata as this is // the start of a new download. this._data.set(download.source.url, { targetFileSpec: targetUri.spec }); let originalPageInfo = await lazy.PlacesUtils.history.fetch( download.source.url ); let pageInfo = await lazy.PlacesUtils.history.insert({ url: download.source.url, // In case we are downloading a file that does not correspond to a web // page for which the title is present, we populate the otherwise empty // history title with the name of the destination file, to allow it to be // visible and searchable in history results. title: (originalPageInfo && originalPageInfo.title) || targetFile.leafName, visits: [ { // The start time is always available when we reach this point. date: download.startTime, transition: lazy.PlacesUtils.history.TRANSITIONS.DOWNLOAD, referrer: download.source.referrerInfo ? download.source.referrerInfo.originalReferrer : null, }, ], }); await lazy.PlacesUtils.history.update({ annotations: new Map([["downloads/destinationFileURI", targetUri.spec]]), // XXX Bug 1479445: We shouldn't have to supply both guid and url here, // but currently we do. guid: pageInfo.guid, url: pageInfo.url, }); }, /** * Sets the metadata for a given url. If the cache already contains meta data * for the given url, it will be overwritten (note: the targetFileSpec will be * maintained). * * @param {String} url The url to set the meta data for. * @param {Object} metadata The new metaData to save in the cache. */ async setMetadata(url, metadata) { await this.ensureInitialized(); // This should be executed before any async parts, to ensure the cache is // updated before any notifications are activated. let existingData = this.get(url); let newData = { ...metadata }; if ("targetFileSpec" in existingData) { newData.targetFileSpec = existingData.targetFileSpec; } this._data.set(url, newData); try { await lazy.PlacesUtils.history.update({ annotations: new Map([[METADATA_ANNO, JSON.stringify(metadata)]]), url, }); } catch (ex) { console.error(ex); } }, QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]), handlePlacesEvents(events) { for (const event of events) { switch (event.type) { case "history-cleared": { this._data.clear(); break; } case "page-removed": { if (event.isRemovedFromStore) { this._data.delete(event.url); } break; } } } }, }; /** * Represents a download from the browser history. This object implements part * of the interface of the Download object. * * While Download objects are shared between the public DownloadList and all the * DownloadHistoryList instances, multiple HistoryDownload objects referring to * the same item can be created for different DownloadHistoryList instances. * * @param placesNode * The Places node from which the history download should be initialized. */ function HistoryDownload(placesNode) { this.placesNode = placesNode; // History downloads should get the referrer from Places (bug 829201). this.source = { url: placesNode.uri, isPrivate: false, }; this.target = { path: undefined, exists: false, size: undefined, }; // In case this download cannot obtain its end time from the Places metadata, // use the time from the Places node, that is the start time of the download. this.endTime = placesNode.time / 1000; } HistoryDownload.prototype = { /** * DownloadSlot containing this history download. */ slot: null, /** * Pushes information from Places metadata into this object. */ updateFromMetaData(metaData) { try { this.target.path = Cc["@mozilla.org/network/protocol;1?name=file"] .getService(Ci.nsIFileProtocolHandler) .getFileFromURLSpec(metaData.targetFileSpec).path; } catch (ex) { this.target.path = undefined; } if ("state" in metaData) { this.succeeded = metaData.state == METADATA_STATE_FINISHED; this.canceled = metaData.state == METADATA_STATE_CANCELED || metaData.state == METADATA_STATE_PAUSED; this.endTime = metaData.endTime; this.deleted = metaData.deleted; // Recreate partial error information from the state saved in history. if (metaData.state == METADATA_STATE_FAILED) { this.error = { message: "History download failed." }; } else if (metaData.state == METADATA_STATE_BLOCKED_PARENTAL) { this.error = { becauseBlockedByParentalControls: true }; } else if (metaData.state == METADATA_STATE_DIRTY) { this.error = { becauseBlockedByReputationCheck: true, reputationCheckVerdict: metaData.reputationCheckVerdict || "", }; } else { this.error = null; } // Normal history downloads are assumed to exist until the user interface // is refreshed, at which point these values may be updated. this.target.exists = true; this.target.size = metaData.fileSize; } else { // Metadata might be missing from a download that has started but hasn't // stopped already. Normally, this state is overridden with the one from // the corresponding in-progress session download. But if the browser is // terminated abruptly and additionally the file with information about // in-progress downloads is lost, we may end up using this state. We use // the failed state to allow the download to be restarted. // // On the other hand, if the download is missing the target file // annotation as well, it is just a very old one, and we can assume it // succeeded. this.succeeded = !this.target.path; this.error = this.target.path ? { message: "Unstarted download." } : null; this.canceled = false; this.deleted = false; // These properties may be updated if the user interface is refreshed. this.target.exists = false; this.target.size = undefined; } }, /** * History downloads are never in progress. */ stopped: true, /** * No percentage indication is shown for history downloads. */ hasProgress: false, /** * History downloads cannot be restarted using their partial data, even if * they are indicated as paused in their Places metadata. The only way is to * use the information from a persisted session download, that will be shown * instead of the history download. In case this session download is not * available, we show the history download as canceled, not paused. */ hasPartialData: false, /** * This method may be called when deleting a history download. */ async finalize() {}, /** * This method mimicks the "refresh" method of session downloads. */ async refresh() { try { this.target.size = (await IOUtils.stat(this.target.path)).size; this.target.exists = true; } catch (ex) { // We keep the known file size from the metadata, if any. this.target.exists = false; } this.slot.list._notifyAllViews("onDownloadChanged", this); }, /** * This method mimicks the "manuallyRemoveData" method of session downloads. */ async manuallyRemoveData() { let { path } = this.target; if (this.target.path && this.succeeded) { // Temp files are made "read-only" by DownloadIntegration.downloadDone, so // reset the permission bits to read/write. This won't be necessary after // bug 1733587 since Downloads won't ever be temporary. await IOUtils.setPermissions(path, 0o660); await IOUtils.remove(path, { ignoreAbsent: true }); } this.deleted = true; await this.refresh(); }, }; /** * Represents one item in the list of public session and history downloads. * * The object may contain a session download, a history download, or both. When * both a history and a session download are present, the session download gets * priority and its information is accessed. * * @param list * The DownloadHistoryList that owns this DownloadSlot object. */ function DownloadSlot(list) { this.list = list; } DownloadSlot.prototype = { list: null, /** * Download object representing the session download contained in this slot. */ sessionDownload: null, /** * HistoryDownload object contained in this slot. */ get historyDownload() { return this._historyDownload; }, set historyDownload(historyDownload) { this._historyDownload = historyDownload; if (historyDownload) { historyDownload.slot = this; } }, _historyDownload: null, /** * Returns the Download or HistoryDownload object for displaying information * and executing commands in the user interface. */ get download() { return this.sessionDownload || this.historyDownload; }, }; /** * Represents an ordered collection of DownloadSlot objects containing a merged * view on session downloads and history downloads. Views on this list will * receive notifications for changes to both types of downloads. * * Downloads in this list are sorted from oldest to newest, with all session * downloads after all the history downloads. When a new history download is * added and the list also contains session downloads, the insertBefore option * of the onDownloadAdded notification refers to the first session download. * * The list of downloads cannot be modified using the DownloadList methods. * * @param publicList * Underlying DownloadList containing public downloads. * @param place * Places query used to retrieve history downloads. */ var DownloadHistoryList = function(publicList, place) { DownloadList.call(this); // While "this._slots" contains all the data in order, the other properties // provide fast access for the most common operations. this._slots = []; this._slotsForUrl = new Map(); this._slotForDownload = new WeakMap(); // Start the asynchronous queries to retrieve history and session downloads. publicList.addView(this).catch(Cu.reportError); let query = {}, options = {}; lazy.PlacesUtils.history.queryStringToQuery(place, query, options); // NB: The addObserver call sets our nsINavHistoryResultObserver.result. let result = lazy.PlacesUtils.history.executeQuery( query.value, options.value ); result.addObserver(this); // Our history result observer is long lived for fast shared views, so free // the reference on shutdown to prevent leaks. Services.obs.addObserver(() => { this.result = null; }, "quit-application-granted"); }; DownloadHistoryList.prototype = { /** * This is set when executing the Places query. */ get result() { return this._result; }, set result(result) { if (this._result == result) { return; } if (this._result) { this._result.removeObserver(this); this._result.root.containerOpen = false; } this._result = result; if (this._result) { this._result.root.containerOpen = true; } }, _result: null, /** * Updates the download history item when the meta data or destination file * changes. * * @param {String} sourceUrl The sourceUrl which was updated. * @param {Object} metaData The new meta data for the sourceUrl. */ updateForMetaDataChange(sourceUrl, metaData) { let slotsForUrl = this._slotsForUrl.get(sourceUrl); if (!slotsForUrl) { return; } for (let slot of slotsForUrl) { if (slot.sessionDownload) { // The visible data doesn't change, so we don't have to notify views. return; } slot.historyDownload.updateFromMetaData(metaData); this._notifyAllViews("onDownloadChanged", slot.download); } }, /** * Index of the first slot that contains a session download. This is equal to * the length of the list when there are no session downloads. */ _firstSessionSlotIndex: 0, _insertSlot({ slot, index, slotsForUrl }) { // Add the slot to the ordered array. this._slots.splice(index, 0, slot); this._downloads.splice(index, 0, slot.download); if (!slot.sessionDownload) { this._firstSessionSlotIndex++; } // Add the slot to the fast access maps. slotsForUrl.add(slot); this._slotsForUrl.set(slot.download.source.url, slotsForUrl); // Add the associated view items. this._notifyAllViews("onDownloadAdded", slot.download, { insertBefore: this._downloads[index + 1], }); }, _removeSlot({ slot, slotsForUrl }) { // Remove the slot from the ordered array. let index = this._slots.indexOf(slot); this._slots.splice(index, 1); this._downloads.splice(index, 1); if (this._firstSessionSlotIndex > index) { this._firstSessionSlotIndex--; } // Remove the slot from the fast access maps. slotsForUrl.delete(slot); if (slotsForUrl.size == 0) { this._slotsForUrl.delete(slot.download.source.url); } // Remove the associated view items. this._notifyAllViews("onDownloadRemoved", slot.download); }, /** * Ensures that the information about a history download is stored in at least * one slot, adding a new one at the end of the list if necessary. * * A reference to the same Places node will be stored in the HistoryDownload * object for all the DownloadSlot objects associated with the source URL. * * @param placesNode * The Places node that represents the history download. */ _insertPlacesNode(placesNode) { let slotsForUrl = this._slotsForUrl.get(placesNode.uri) || new Set(); // If there are existing slots associated with this URL, we only have to // ensure that the Places node reference is kept updated in case the more // recent Places notification contained a different node object. if (slotsForUrl.size > 0) { for (let slot of slotsForUrl) { if (!slot.historyDownload) { slot.historyDownload = new HistoryDownload(placesNode); } else { slot.historyDownload.placesNode = placesNode; } } return; } // If there are no existing slots for this URL, we have to create a new one. // Since the history download is visible in the slot, we also have to update // the object using the Places metadata. let historyDownload = new HistoryDownload(placesNode); historyDownload.updateFromMetaData(DownloadCache.get(placesNode.uri)); let slot = new DownloadSlot(this); slot.historyDownload = historyDownload; this._insertSlot({ slot, slotsForUrl, index: this._firstSessionSlotIndex }); }, // nsINavHistoryResultObserver containerStateChanged(node, oldState, newState) { this.invalidateContainer(node); }, // nsINavHistoryResultObserver invalidateContainer(container) { this._notifyAllViews("onDownloadBatchStarting"); // Remove all the current slots containing only history downloads. for (let index = this._slots.length - 1; index >= 0; index--) { let slot = this._slots[index]; if (slot.sessionDownload) { // The visible data doesn't change, so we don't have to notify views. slot.historyDownload = null; } else { let slotsForUrl = this._slotsForUrl.get(slot.download.source.url); this._removeSlot({ slot, slotsForUrl }); } } // Add new slots or reuse existing ones for history downloads. for (let index = container.childCount - 1; index >= 0; --index) { try { this._insertPlacesNode(container.getChild(index)); } catch (ex) { console.error(ex); } } this._notifyAllViews("onDownloadBatchEnded"); }, // nsINavHistoryResultObserver nodeInserted(parent, placesNode) { this._insertPlacesNode(placesNode); }, // nsINavHistoryResultObserver nodeRemoved(parent, placesNode, aOldIndex) { let slotsForUrl = this._slotsForUrl.get(placesNode.uri); for (let slot of slotsForUrl) { if (slot.sessionDownload) { // The visible data doesn't change, so we don't have to notify views. slot.historyDownload = null; } else { this._removeSlot({ slot, slotsForUrl }); } } }, // nsINavHistoryResultObserver nodeIconChanged() {}, nodeTitleChanged() {}, nodeKeywordChanged() {}, nodeDateAddedChanged() {}, nodeLastModifiedChanged() {}, nodeHistoryDetailsChanged() {}, nodeTagsChanged() {}, sortingChanged() {}, nodeMoved() {}, nodeURIChanged() {}, batching() {}, // DownloadList callback onDownloadAdded(download) { let url = download.source.url; let slotsForUrl = this._slotsForUrl.get(url) || new Set(); // For every source URL, there can be at most one slot containing a history // download without an associated session download. If we find one, then we // can reuse it for the current session download, although we have to move // it together with the other session downloads. let slot = [...slotsForUrl][0]; if (slot && !slot.sessionDownload) { // Remove the slot because we have to change its position. this._removeSlot({ slot, slotsForUrl }); } else { slot = new DownloadSlot(this); } slot.sessionDownload = download; this._insertSlot({ slot, slotsForUrl, index: this._slots.length }); this._slotForDownload.set(download, slot); }, // DownloadList callback onDownloadChanged(download) { let slot = this._slotForDownload.get(download); this._notifyAllViews("onDownloadChanged", slot.download); }, // DownloadList callback onDownloadRemoved(download) { let url = download.source.url; let slotsForUrl = this._slotsForUrl.get(url); let slot = this._slotForDownload.get(download); this._removeSlot({ slot, slotsForUrl }); this._slotForDownload.delete(download); // If there was only one slot for this source URL and it also contained a // history download, we should resurrect it in the correct area of the list. if (slotsForUrl.size == 0 && slot.historyDownload) { // We have one download slot containing both a session download and a // history download, and we are now removing the session download. // Previously, we did not use the Places metadata because it was obscured // by the session download. Since this is no longer the case, we have to // read the latest metadata before resurrecting the history download. slot.historyDownload.updateFromMetaData(DownloadCache.get(url)); slot.sessionDownload = null; // Place the resurrected history slot after all the session slots. this._insertSlot({ slot, slotsForUrl, index: this._firstSessionSlotIndex, }); } }, // DownloadList add() { throw new Error("Not implemented."); }, // DownloadList remove() { throw new Error("Not implemented."); }, // DownloadList removeFinished() { throw new Error("Not implemented."); }, }; Object.setPrototypeOf(DownloadHistoryList.prototype, DownloadList.prototype);