1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 * Provides access to downloads from previous sessions on platforms that store
7 * them in a different location than session downloads.
9 * This module works with objects that are compatible with Download, while using
10 * the Places interfaces internally. Some of the Places objects may also be
11 * exposed to allow the consumers to integrate with history view commands.
14 import { DownloadList } from "resource://gre/modules/DownloadList.sys.mjs";
18 ChromeUtils.defineESModuleGetters(lazy, {
19 Downloads: "resource://gre/modules/Downloads.sys.mjs",
20 FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
21 PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
24 // Places query used to retrieve all history downloads for the related list.
25 const HISTORY_PLACES_QUERY = `place:transition=${Ci.nsINavHistoryService.TRANSITION_DOWNLOAD}&sort=${Ci.nsINavHistoryQueryOptions.SORT_BY_DATE_DESCENDING}`;
26 const DESTINATIONFILEURI_ANNO = "downloads/destinationFileURI";
27 const METADATA_ANNO = "downloads/metaData";
29 const METADATA_STATE_FINISHED = 1;
30 const METADATA_STATE_FAILED = 2;
31 const METADATA_STATE_CANCELED = 3;
32 const METADATA_STATE_PAUSED = 4;
33 const METADATA_STATE_BLOCKED_PARENTAL = 6;
34 const METADATA_STATE_DIRTY = 8;
37 * Provides methods to retrieve downloads from previous sessions and store
38 * downloads for future sessions.
40 export let DownloadHistory = {
42 * Retrieves the main DownloadHistoryList object which provides a unified view
43 * on downloads from both previous browsing sessions and this session.
46 * Determines which type of downloads from this session should be
47 * included in the list. This is Downloads.PUBLIC by default, but can
48 * also be Downloads.PRIVATE or Downloads.ALL.
49 * @param maxHistoryResults
50 * Optional number that limits the amount of results the history query
54 * @resolves The requested DownloadHistoryList object.
55 * @rejects JavaScript exception.
57 async getList({ type = lazy.Downloads.PUBLIC, maxHistoryResults } = {}) {
58 await DownloadCache.ensureInitialized();
60 let key = `${type}|${maxHistoryResults ? maxHistoryResults : -1}`;
61 if (!this._listPromises[key]) {
62 this._listPromises[key] = lazy.Downloads.getList(type).then(list => {
63 // When the amount of history downloads is capped, we request the list in
64 // descending order, to make sure that the list can apply the limit.
66 HISTORY_PLACES_QUERY +
67 (maxHistoryResults ? `&maxResults=${maxHistoryResults}` : "");
69 return new DownloadHistoryList(list, query);
73 return this._listPromises[key];
77 * This object is populated with one key for each type of download list that
78 * can be returned by the getList method. The values are promises that resolve
79 * to DownloadHistoryList objects.
83 async addDownloadToHistory(download) {
85 download.source.isPrivate ||
86 !lazy.PlacesUtils.history.canAddURI(
87 lazy.PlacesUtils.toURI(download.source.url)
93 await DownloadCache.addDownload(download);
95 await this._updateHistoryListData(download.source.url);
99 * Stores new detailed metadata for the given download in history. This is
100 * normally called after a download finishes, fails, or is canceled.
102 * Failed or canceled downloads with partial data are not stored as paused,
103 * because the information from the session download is required for resuming.
106 * Download object whose metadata should be updated. If the object
107 * represents a private download, the call has no effect.
109 async updateMetaData(download) {
111 download.source.isPrivate ||
113 !lazy.PlacesUtils.history.canAddURI(
114 lazy.PlacesUtils.toURI(download.source.url)
120 let state = METADATA_STATE_CANCELED;
121 if (download.succeeded) {
122 state = METADATA_STATE_FINISHED;
123 } else if (download.error) {
124 if (download.error.becauseBlockedByParentalControls) {
125 state = METADATA_STATE_BLOCKED_PARENTAL;
126 } else if (download.error.becauseBlockedByReputationCheck) {
127 state = METADATA_STATE_DIRTY;
129 state = METADATA_STATE_FAILED;
135 deleted: download.deleted,
136 endTime: download.endTime,
138 if (download.succeeded) {
139 metaData.fileSize = download.target.size;
142 // The verdict may still be present even if the download succeeded.
143 if (download.error && download.error.reputationCheckVerdict) {
144 metaData.reputationCheckVerdict = download.error.reputationCheckVerdict;
147 // This should be executed before any async parts, to ensure the cache is
148 // updated before any notifications are activated.
149 await DownloadCache.setMetadata(download.source.url, metaData);
151 await this._updateHistoryListData(download.source.url);
154 async _updateHistoryListData(sourceUrl) {
155 for (let key of Object.getOwnPropertyNames(this._listPromises)) {
156 let downloadHistoryList = await this._listPromises[key];
157 downloadHistoryList.updateForMetaDataChange(
159 DownloadCache.get(sourceUrl)
167 * - in order to optimize the load of DownloadsHistoryList, when Places
168 * annotations for history downloads must be read. In fact, annotations are
169 * stored in a single table, and reading all of them at once is much more
170 * efficient than an individual query.
171 * - to avoid needing to do asynchronous reading of the database during download
172 * list updates, which are designed to be synchronous (to improve UI
175 * The cache is initialized the first time DownloadHistory.getList is called, or
176 * when data is added.
178 let DownloadCache = {
180 _initializePromise: null,
183 * Initializes the cache, loading the data from the places database.
185 * @return {Promise} Returns a promise that is resolved once the
186 * initialization is complete.
188 ensureInitialized() {
189 if (this._initializePromise) {
190 return this._initializePromise;
192 this._initializePromise = (async () => {
193 const placesObserver = new PlacesWeakCallbackWrapper(
194 this.handlePlacesEvents.bind(this)
196 PlacesObservers.addListener(
197 ["history-cleared", "page-removed"],
201 let pageAnnos = await lazy.PlacesUtils.history.fetchAnnotatedPages([
203 DESTINATIONFILEURI_ANNO,
206 let metaDataPages = pageAnnos.get(METADATA_ANNO);
208 for (let { uri, content } of metaDataPages) {
210 this._data.set(uri.href, JSON.parse(content));
212 // Do nothing - JSON.parse could throw.
217 let destinationFilePages = pageAnnos.get(DESTINATIONFILEURI_ANNO);
218 if (destinationFilePages) {
219 for (let { uri, content } of destinationFilePages) {
220 let newData = this.get(uri.href);
221 newData.targetFileSpec = content;
222 this._data.set(uri.href, newData);
227 return this._initializePromise;
231 * This returns an object containing the meta data for the supplied URL.
233 * @param {String} url The url to get the meta data for.
234 * @return {Object|null} Returns an empty object if there is no meta data found, or
235 * an object containing the meta data. The meta data
238 * { targetFileSpec, state, deleted, endTime, fileSize, ... }
240 * The targetFileSpec property is the value of "downloads/destinationFileURI",
241 * while the other properties are taken from "downloads/metaData". Any of the
242 * properties may be missing from the object.
245 return this._data.get(url) || {};
249 * Adds a download to the cache and the places database.
251 * @param {Download} download The download to add to the database and cache.
253 async addDownload(download) {
254 await this.ensureInitialized();
256 let targetFile = new lazy.FileUtils.File(download.target.path);
257 let targetUri = Services.io.newFileURI(targetFile);
259 // This should be executed before any async parts, to ensure the cache is
260 // updated before any notifications are activated.
261 // Note: this intentionally overwrites any metadata as this is
262 // the start of a new download.
263 this._data.set(download.source.url, { targetFileSpec: targetUri.spec });
265 let originalPageInfo = await lazy.PlacesUtils.history.fetch(
269 let pageInfo = await lazy.PlacesUtils.history.insert({
270 url: download.source.url,
271 // In case we are downloading a file that does not correspond to a web
272 // page for which the title is present, we populate the otherwise empty
273 // history title with the name of the destination file, to allow it to be
274 // visible and searchable in history results.
276 (originalPageInfo && originalPageInfo.title) || targetFile.leafName,
279 // The start time is always available when we reach this point.
280 date: download.startTime,
281 transition: lazy.PlacesUtils.history.TRANSITIONS.DOWNLOAD,
282 referrer: download.source.referrerInfo
283 ? download.source.referrerInfo.originalReferrer
289 await lazy.PlacesUtils.history.update({
290 annotations: new Map([["downloads/destinationFileURI", targetUri.spec]]),
291 // XXX Bug 1479445: We shouldn't have to supply both guid and url here,
292 // but currently we do.
299 * Sets the metadata for a given url. If the cache already contains meta data
300 * for the given url, it will be overwritten (note: the targetFileSpec will be
303 * @param {String} url The url to set the meta data for.
304 * @param {Object} metadata The new metaData to save in the cache.
306 async setMetadata(url, metadata) {
307 await this.ensureInitialized();
309 // This should be executed before any async parts, to ensure the cache is
310 // updated before any notifications are activated.
311 let existingData = this.get(url);
312 let newData = { ...metadata };
313 if ("targetFileSpec" in existingData) {
314 newData.targetFileSpec = existingData.targetFileSpec;
316 this._data.set(url, newData);
319 await lazy.PlacesUtils.history.update({
320 annotations: new Map([[METADATA_ANNO, JSON.stringify(metadata)]]),
328 QueryInterface: ChromeUtils.generateQI(["nsISupportsWeakReference"]),
330 handlePlacesEvents(events) {
331 for (const event of events) {
332 switch (event.type) {
333 case "history-cleared": {
337 case "page-removed": {
338 if (event.isRemovedFromStore) {
339 this._data.delete(event.url);
349 * Represents a download from the browser history. This object implements part
350 * of the interface of the Download object.
352 * While Download objects are shared between the public DownloadList and all the
353 * DownloadHistoryList instances, multiple HistoryDownload objects referring to
354 * the same item can be created for different DownloadHistoryList instances.
357 * The Places node from which the history download should be initialized.
359 class HistoryDownload {
360 constructor(placesNode) {
361 this.placesNode = placesNode;
363 // History downloads should get the referrer from Places (bug 829201).
374 // In case this download cannot obtain its end time from the Places metadata,
375 // use the time from the Places node, that is the start time of the download.
376 this.endTime = placesNode.time / 1000;
380 * DownloadSlot containing this history download.
382 * @type {DownloadSlot}
387 * History downloads are never in progress.
394 * No percentage indication is shown for history downloads.
401 * History downloads cannot be restarted using their partial data, even if
402 * they are indicated as paused in their Places metadata. The only way is to
403 * use the information from a persisted session download, that will be shown
404 * instead of the history download. In case this session download is not
405 * available, we show the history download as canceled, not paused.
409 hasPartialData = false;
412 * Pushes information from Places metadata into this object.
414 updateFromMetaData(metaData) {
416 this.target.path = Cc["@mozilla.org/network/protocol;1?name=file"]
417 .getService(Ci.nsIFileProtocolHandler)
418 .getFileFromURLSpec(metaData.targetFileSpec).path;
420 this.target.path = undefined;
423 if ("state" in metaData) {
424 this.succeeded = metaData.state == METADATA_STATE_FINISHED;
426 metaData.state == METADATA_STATE_CANCELED ||
427 metaData.state == METADATA_STATE_PAUSED;
428 this.endTime = metaData.endTime;
429 this.deleted = metaData.deleted;
431 // Recreate partial error information from the state saved in history.
432 if (metaData.state == METADATA_STATE_FAILED) {
433 this.error = { message: "History download failed." };
434 } else if (metaData.state == METADATA_STATE_BLOCKED_PARENTAL) {
435 this.error = { becauseBlockedByParentalControls: true };
436 } else if (metaData.state == METADATA_STATE_DIRTY) {
438 becauseBlockedByReputationCheck: true,
439 reputationCheckVerdict: metaData.reputationCheckVerdict || "",
445 // Normal history downloads are assumed to exist until the user interface
446 // is refreshed, at which point these values may be updated.
447 this.target.exists = true;
448 this.target.size = metaData.fileSize;
450 // Metadata might be missing from a download that has started but hasn't
451 // stopped already. Normally, this state is overridden with the one from
452 // the corresponding in-progress session download. But if the browser is
453 // terminated abruptly and additionally the file with information about
454 // in-progress downloads is lost, we may end up using this state. We use
455 // the failed state to allow the download to be restarted.
457 // On the other hand, if the download is missing the target file
458 // annotation as well, it is just a very old one, and we can assume it
460 this.succeeded = !this.target.path;
461 this.error = this.target.path ? { message: "Unstarted download." } : null;
462 this.canceled = false;
463 this.deleted = false;
465 // These properties may be updated if the user interface is refreshed.
466 this.target.exists = false;
467 this.target.size = undefined;
472 * This method may be called when deleting a history download.
477 * This method mimicks the "refresh" method of session downloads.
481 this.target.size = (await IOUtils.stat(this.target.path)).size;
482 this.target.exists = true;
484 // We keep the known file size from the metadata, if any.
485 this.target.exists = false;
488 this.slot.list._notifyAllViews("onDownloadChanged", this);
492 * This method mimicks the "manuallyRemoveData" method of session downloads.
494 async manuallyRemoveData() {
495 let { path } = this.target;
496 if (this.target.path && this.succeeded) {
497 // Temp files are made "read-only" by DownloadIntegration.downloadDone, so
498 // reset the permission bits to read/write. This won't be necessary after
499 // bug 1733587 since Downloads won't ever be temporary.
500 await IOUtils.setPermissions(path, 0o660);
501 await IOUtils.remove(path, { ignoreAbsent: true });
504 await this.refresh();
509 * Represents one item in the list of public session and history downloads.
511 * The object may contain a session download, a history download, or both. When
512 * both a history and a session download are present, the session download gets
513 * priority and its information is accessed.
516 * The DownloadHistoryList that owns this DownloadSlot object.
524 * Download object representing the session download contained in this slot.
526 sessionDownload = null;
527 _historyDownload = null;
530 * HistoryDownload object contained in this slot.
532 get historyDownload() {
533 return this._historyDownload;
536 set historyDownload(historyDownload) {
537 this._historyDownload = historyDownload;
538 if (historyDownload) {
539 historyDownload.slot = this;
544 * Returns the Download or HistoryDownload object for displaying information
545 * and executing commands in the user interface.
548 return this.sessionDownload || this.historyDownload;
553 * Represents an ordered collection of DownloadSlot objects containing a merged
554 * view on session downloads and history downloads. Views on this list will
555 * receive notifications for changes to both types of downloads.
557 * Downloads in this list are sorted from oldest to newest, with all session
558 * downloads after all the history downloads. When a new history download is
559 * added and the list also contains session downloads, the insertBefore option
560 * of the onDownloadAdded notification refers to the first session download.
562 * The list of downloads cannot be modified using the DownloadList methods.
565 * Underlying DownloadList containing public downloads.
567 * Places query used to retrieve history downloads.
569 class DownloadHistoryList extends DownloadList {
570 constructor(publicList, place) {
573 // While "this._slots" contains all the data in order, the other properties
574 // provide fast access for the most common operations.
576 this._slotsForUrl = new Map();
577 this._slotForDownload = new WeakMap();
579 // Start the asynchronous queries to retrieve history and session downloads.
580 publicList.addView(this).catch(console.error);
583 lazy.PlacesUtils.history.queryStringToQuery(place, query, options);
585 // NB: The addObserver call sets our nsINavHistoryResultObserver.result.
586 let result = lazy.PlacesUtils.history.executeQuery(
590 result.addObserver(this);
592 // Our history result observer is long lived for fast shared views, so free
593 // the reference on shutdown to prevent leaks.
594 Services.obs.addObserver(() => {
596 }, "quit-application-granted");
600 * This is set when executing the Places query.
605 * Index of the first slot that contains a session download. This is equal to
606 * the length of the list when there are no session downloads.
610 _firstSessionSlotIndex = 0;
617 if (this._result == result) {
622 this._result.removeObserver(this);
623 this._result.root.containerOpen = false;
626 this._result = result;
629 this._result.root.containerOpen = true;
634 * Updates the download history item when the meta data or destination file
637 * @param {String} sourceUrl The sourceUrl which was updated.
638 * @param {Object} metaData The new meta data for the sourceUrl.
640 updateForMetaDataChange(sourceUrl, metaData) {
641 let slotsForUrl = this._slotsForUrl.get(sourceUrl);
646 for (let slot of slotsForUrl) {
647 if (slot.sessionDownload) {
648 // The visible data doesn't change, so we don't have to notify views.
651 slot.historyDownload.updateFromMetaData(metaData);
652 this._notifyAllViews("onDownloadChanged", slot.download);
656 _insertSlot({ slot, index, slotsForUrl }) {
657 // Add the slot to the ordered array.
658 this._slots.splice(index, 0, slot);
659 this._downloads.splice(index, 0, slot.download);
660 if (!slot.sessionDownload) {
661 this._firstSessionSlotIndex++;
664 // Add the slot to the fast access maps.
665 slotsForUrl.add(slot);
666 this._slotsForUrl.set(slot.download.source.url, slotsForUrl);
668 // Add the associated view items.
669 this._notifyAllViews("onDownloadAdded", slot.download, {
670 insertBefore: this._downloads[index + 1],
674 _removeSlot({ slot, slotsForUrl }) {
675 // Remove the slot from the ordered array.
676 let index = this._slots.indexOf(slot);
677 this._slots.splice(index, 1);
678 this._downloads.splice(index, 1);
679 if (this._firstSessionSlotIndex > index) {
680 this._firstSessionSlotIndex--;
683 // Remove the slot from the fast access maps.
684 slotsForUrl.delete(slot);
685 if (slotsForUrl.size == 0) {
686 this._slotsForUrl.delete(slot.download.source.url);
689 // Remove the associated view items.
690 this._notifyAllViews("onDownloadRemoved", slot.download);
694 * Ensures that the information about a history download is stored in at least
695 * one slot, adding a new one at the end of the list if necessary.
697 * A reference to the same Places node will be stored in the HistoryDownload
698 * object for all the DownloadSlot objects associated with the source URL.
701 * The Places node that represents the history download.
703 _insertPlacesNode(placesNode) {
704 let slotsForUrl = this._slotsForUrl.get(placesNode.uri) || new Set();
706 // If there are existing slots associated with this URL, we only have to
707 // ensure that the Places node reference is kept updated in case the more
708 // recent Places notification contained a different node object.
709 if (slotsForUrl.size > 0) {
710 for (let slot of slotsForUrl) {
711 if (!slot.historyDownload) {
712 slot.historyDownload = new HistoryDownload(placesNode);
714 slot.historyDownload.placesNode = placesNode;
720 // If there are no existing slots for this URL, we have to create a new one.
721 // Since the history download is visible in the slot, we also have to update
722 // the object using the Places metadata.
723 let historyDownload = new HistoryDownload(placesNode);
724 historyDownload.updateFromMetaData(DownloadCache.get(placesNode.uri));
725 let slot = new DownloadSlot(this);
726 slot.historyDownload = historyDownload;
727 this._insertSlot({ slot, slotsForUrl, index: this._firstSessionSlotIndex });
730 // nsINavHistoryResultObserver
731 containerStateChanged(node, oldState, newState) {
732 this.invalidateContainer(node);
735 // nsINavHistoryResultObserver
736 invalidateContainer(container) {
737 this._notifyAllViews("onDownloadBatchStarting");
739 // Remove all the current slots containing only history downloads.
740 for (let index = this._slots.length - 1; index >= 0; index--) {
741 let slot = this._slots[index];
742 if (slot.sessionDownload) {
743 // The visible data doesn't change, so we don't have to notify views.
744 slot.historyDownload = null;
746 let slotsForUrl = this._slotsForUrl.get(slot.download.source.url);
747 this._removeSlot({ slot, slotsForUrl });
751 // Add new slots or reuse existing ones for history downloads.
752 for (let index = container.childCount - 1; index >= 0; --index) {
754 this._insertPlacesNode(container.getChild(index));
760 this._notifyAllViews("onDownloadBatchEnded");
763 // nsINavHistoryResultObserver
764 nodeInserted(parent, placesNode) {
765 this._insertPlacesNode(placesNode);
768 // nsINavHistoryResultObserver
769 nodeRemoved(parent, placesNode, aOldIndex) {
770 let slotsForUrl = this._slotsForUrl.get(placesNode.uri);
771 for (let slot of slotsForUrl) {
772 if (slot.sessionDownload) {
773 // The visible data doesn't change, so we don't have to notify views.
774 slot.historyDownload = null;
776 this._removeSlot({ slot, slotsForUrl });
781 // nsINavHistoryResultObserver
783 nodeTitleChanged() {}
784 nodeKeywordChanged() {}
785 nodeDateAddedChanged() {}
786 nodeLastModifiedChanged() {}
787 nodeHistoryDetailsChanged() {}
794 // DownloadList callback
795 onDownloadAdded(download) {
796 let url = download.source.url;
797 let slotsForUrl = this._slotsForUrl.get(url) || new Set();
799 // For every source URL, there can be at most one slot containing a history
800 // download without an associated session download. If we find one, then we
801 // can reuse it for the current session download, although we have to move
802 // it together with the other session downloads.
803 let slot = [...slotsForUrl][0];
804 if (slot && !slot.sessionDownload) {
805 // Remove the slot because we have to change its position.
806 this._removeSlot({ slot, slotsForUrl });
808 slot = new DownloadSlot(this);
810 slot.sessionDownload = download;
811 this._insertSlot({ slot, slotsForUrl, index: this._slots.length });
812 this._slotForDownload.set(download, slot);
815 // DownloadList callback
816 onDownloadChanged(download) {
817 let slot = this._slotForDownload.get(download);
818 this._notifyAllViews("onDownloadChanged", slot.download);
821 // DownloadList callback
822 onDownloadRemoved(download) {
823 let url = download.source.url;
824 let slotsForUrl = this._slotsForUrl.get(url);
825 let slot = this._slotForDownload.get(download);
826 this._removeSlot({ slot, slotsForUrl });
828 this._slotForDownload.delete(download);
830 // If there was only one slot for this source URL and it also contained a
831 // history download, we should resurrect it in the correct area of the list.
832 if (slotsForUrl.size == 0 && slot.historyDownload) {
833 // We have one download slot containing both a session download and a
834 // history download, and we are now removing the session download.
835 // Previously, we did not use the Places metadata because it was obscured
836 // by the session download. Since this is no longer the case, we have to
837 // read the latest metadata before resurrecting the history download.
838 slot.historyDownload.updateFromMetaData(DownloadCache.get(url));
839 slot.sessionDownload = null;
840 // Place the resurrected history slot after all the session slots.
844 index: this._firstSessionSlotIndex,
851 throw new Error("Not implemented.");
856 throw new Error("Not implemented.");
861 throw new Error("Not implemented.");