Source code

Revision control

Copy as Markdown

Other Tools

/* 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 let 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.
*/
let 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.
*/
class HistoryDownload {
constructor(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;
}
/**
* DownloadSlot containing this history download.
*
* @type {DownloadSlot}
*/
slot = null;
/**
* History downloads are never in progress.
*
* @type {Boolean}
*/
stopped = true;
/**
* No percentage indication is shown for history downloads.
*
* @type {Boolean}
*/
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.
*
* @type {Boolean}
*/
hasPartialData = false;
/**
* 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;
}
}
/**
* 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.
*/
class DownloadSlot {
constructor(list) {
this.list = list;
}
/**
* Download object representing the session download contained in this slot.
*/
sessionDownload = null;
_historyDownload = null;
/**
* HistoryDownload object contained in this slot.
*/
get historyDownload() {
return this._historyDownload;
}
set historyDownload(historyDownload) {
this._historyDownload = historyDownload;
if (historyDownload) {
historyDownload.slot = this;
}
}
/**
* 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.
*/
class DownloadHistoryList extends DownloadList {
constructor(publicList, place) {
super();
// 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(console.error);
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");
}
/**
* This is set when executing the Places query.
*/
_result = null;
/**
* 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.
*
* @type {Number}
*/
_firstSessionSlotIndex = 0;
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;
}
}
/**
* 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);
}
}
_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) {
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) {
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.");
}
}