diff options
Diffstat (limited to 'toolkit/components/places/PlacesPreviews.sys.mjs')
-rw-r--r-- | toolkit/components/places/PlacesPreviews.sys.mjs | 454 |
1 files changed, 454 insertions, 0 deletions
diff --git a/toolkit/components/places/PlacesPreviews.sys.mjs b/toolkit/components/places/PlacesPreviews.sys.mjs new file mode 100644 index 0000000000..d1ebd82e50 --- /dev/null +++ b/toolkit/components/places/PlacesPreviews.sys.mjs @@ -0,0 +1,454 @@ +/* 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/. */ + +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + BackgroundPageThumbs: "resource://gre/modules/BackgroundPageThumbs.jsm", + PageThumbsStorage: "resource://gre/modules/PageThumbs.jsm", +}); + +XPCOMUtils.defineLazyGetter(lazy, "logConsole", function() { + return console.createInstance({ + prefix: "PlacesPreviews", + maxLogLevel: Services.prefs.getBoolPref("places.previews.log", false) + ? "Debug" + : "Warn", + }); +}); + +// Toggling Places previews requires a restart, because a database trigger +// filling up tombstones is enabled on the database only when the pref is set +// on startup. +XPCOMUtils.defineLazyGetter(lazy, "previewsEnabled", function() { + return Services.prefs.getBoolPref("places.previews.enabled", false); +}); + +// Preview deletions are done in chunks of this size. +const DELETE_CHUNK_SIZE = 50; +// This is the time between deletion chunks. +const DELETE_TIMEOUT_MS = 60000; + +// The folder inside the profile folder where to store previews. +const PREVIEWS_DIRECTORY = "places-previews"; + +// How old a preview file should be before we replace it. +const DAYS_BEFORE_REPLACEMENT = 30; + +/** + * This extends Set to only keep the latest 100 entries. + */ +class LimitedSet extends Set { + #limit = 100; + add(key) { + super.add(key); + let oversize = this.size - this.#limit; + if (oversize > 0) { + for (let entry of this) { + if (oversize-- <= 0) { + break; + } + this.delete(entry); + } + } + } +} + +/** + * This class handles previews deletion from tombstones in the database. + * Deletion happens in chunks, each chunk runs after DELETE_TIMEOUT_MS, and + * the process is interrupted once there's nothing more to delete. + * Any page removal operations on the Places database will restart the timer. + */ +class DeletionHandler { + #timeoutId = null; + #shutdownProgress = {}; + + /** + * This can be set by tests to speed up the deletion process, otherwise the + * product should just use the default value. + */ + #timeout = DELETE_TIMEOUT_MS; + get timeout() { + return this.#timeout; + } + set timeout(val) { + if (this.#timeoutId) { + lazy.clearTimeout(this.#timeoutId); + this.#timeoutId = null; + } + this.#timeout = val; + this.ensureRunning(); + } + + constructor() { + // Clear any pending timeouts on shutdown. + lazy.PlacesUtils.history.shutdownClient.jsclient.addBlocker( + "PlacesPreviews.jsm::DeletionHandler", + async () => { + this.#shutdownProgress.shuttingDown = true; + lazy.clearTimeout(this.#timeoutId); + this.#timeoutId = null; + }, + { fetchState: () => this.#shutdownProgress } + ); + } + + /** + * This should be invoked everytime we expect there are tombstones to + * handle. If deletion is already pending, this is a no-op. + */ + ensureRunning() { + if (this.#timeoutId || this.#shutdownProgress.shuttingDown) { + return; + } + this.#timeoutId = lazy.setTimeout(() => { + this.#timeoutId = null; + ChromeUtils.idleDispatch(() => { + this.#deleteChunk().catch(ex => + lazy.logConsole.error("Error during previews deletion:" + ex) + ); + }); + }, this.timeout); + } + + /** + * Deletes a chunk of previews. + */ + async #deleteChunk() { + if (this.#shutdownProgress.shuttingDown) { + return; + } + // Select tombstones, delete images, then delete tombstones. This order + // ensures that in case of problems we'll try again in the future. + let db = await lazy.PlacesUtils.promiseDBConnection(); + let count; + let hashes = ( + await db.executeCached( + `SELECT hash, (SELECT count(*) FROM moz_previews_tombstones) AS count + FROM moz_previews_tombstones LIMIT ${DELETE_CHUNK_SIZE}` + ) + ).map(r => { + if (count === undefined) { + count = r.getResultByName("count"); + } + return r.getResultByName("hash"); + }); + if (!count || this.#shutdownProgress.shuttingDown) { + // There's nothing to delete, or it's too late. + return; + } + + let deleted = []; + for (let hash of hashes) { + let filePath = PlacesPreviews.getPathForHash(hash); + try { + await IOUtils.remove(filePath); + PlacesPreviews.onDelete(filePath); + deleted.push(hash); + } catch (ex) { + if (DOMException.isInstance(ex) && ex.name == "NotFoundError") { + deleted.push(hash); + } else { + lazy.logConsole.error("Unable to delete file: " + filePath); + } + } + if (this.#shutdownProgress.shuttingDown) { + return; + } + } + // Delete hashes from tombstones. + let params = deleted.reduce((p, c, i) => { + p["hash" + i] = c; + return p; + }, {}); + await lazy.PlacesUtils.withConnectionWrapper( + "PlacesPreviews.jsm::ExpirePreviews", + async db => { + await db.execute( + `DELETE FROM moz_previews_tombstones WHERE hash in + (${Object.keys(params) + .map(p => `:${p}`) + .join(",")})`, + params + ); + } + ); + + if (count > DELETE_CHUNK_SIZE) { + this.ensureRunning(); + } + } +} + +/** + * Handles previews for Places urls. + * Previews are stored in WebP format, using MD5 hash of the page url in hex + * format. All the previews are saved into a "places-previews" folder under + * the roaming profile folder. + */ +export const PlacesPreviews = new (class extends EventEmitter { + #placesObserver = null; + #deletionHandler = null; + // This is used as a cache to avoid fetching the same preview multiple + // times in a short timeframe. + #recentlyUpdatedPreviews = new LimitedSet(); + + fileExtension = ".webp"; + fileContentType = "image/webp"; + + constructor() { + super(); + // Observe page removals and delete previews when necessary. + this.#placesObserver = new PlacesWeakCallbackWrapper( + this.handlePlacesEvents.bind(this) + ); + PlacesObservers.addListener( + ["history-cleared", "page-removed"], + this.#placesObserver + ); + + // Start deletion in case it was interruped during the previous session, + // it will end once there's nothing more to delete. + this.#deletionHandler = new DeletionHandler(); + this.#deletionHandler.ensureRunning(); + } + + handlePlacesEvents(events) { + for (const event of events) { + if ( + event.type == "history-cleared" || + (event.type == "page-removed" && event.isRemovedFromStore) + ) { + this.#deletionHandler.ensureRunning(); + return; + } + } + } + + /** + * Whether the feature is enabled. Use this instead of directly checking + * the pref, since it requires a restart. + */ + get enabled() { + return lazy.previewsEnabled; + } + + /** + * Returns the path to the previews folder. + * @returns {string} The path to the previews folder. + */ + getPath() { + return PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + PREVIEWS_DIRECTORY + ); + } + + /** + * Returns the file path of the preview for the given url. + * This doesn't guarantee the file exists. + * @param {string} url Address of the page. + * @returns {string} File path of the preview for the given url. + */ + getPathForUrl(url) { + return PathUtils.join( + this.getPath(), + lazy.PlacesUtils.md5(url, { format: "hex" }) + this.fileExtension + ); + } + + /** + * Returns the file path of the preview having the given hash. + * @param {string} hash md5 hash in hex format. + * @returns {string } File path of the preview having the given hash. + */ + getPathForHash(hash) { + return PathUtils.join(this.getPath(), hash + this.fileExtension); + } + + /** + * Returns the moz-page-thumb: url to show the preview for the given url. + * @param {string} url Address of the page. + * @returns {string} Preview url for the given page url. + */ + getPageThumbURL(url) { + return ( + "moz-page-thumb://" + + "places-previews" + + "/?url=" + + encodeURIComponent(url) + + "&revision=" + + lazy.PageThumbsStorage.getRevision(url) + ); + } + + /** + * Updates the preview for the given page url. The update happens in + * background, using a windowless browser with very conservative privacy + * settings. Due to this, it may not look exactly like the page that the user + * is normally facing when logged in. See BackgroundPageThumbs.jsm for + * additional details. + * Unless `forceUpdate` is set, the preview is not updated if: + * - It was already fetched recently + * - The stored preview is younger than DAYS_BEFORE_REPLACEMENT + * The previem image is encoded using WebP. + * @param {string} url The address of the page. + * @param {boolean} [forceUpdate] Whether to update the preview regardless. + * @returns {boolean} Whether a preview is available and ready. + */ + async update(url, { forceUpdate = false } = {}) { + if (!this.enabled) { + return false; + } + let filePath = this.getPathForUrl(url); + if (!forceUpdate) { + if (this.#recentlyUpdatedPreviews.has(filePath)) { + lazy.logConsole.debug("Skipping update because recently updated"); + return true; + } + try { + let fileInfo = await IOUtils.stat(filePath); + if ( + fileInfo.lastModified > + Date.now() - DAYS_BEFORE_REPLACEMENT * 86400000 + ) { + // File is recent enough. + this.#recentlyUpdatedPreviews.add(filePath); + lazy.logConsole.debug("Skipping update because file is recent"); + return true; + } + } catch (ex) { + // If the file doesn't exist, we always update it. + if (!DOMException.isInstance(ex) || ex.name != "NotFoundError") { + lazy.logConsole.error("Error while trying to stat() preview" + ex); + return false; + } + } + } + + let buffer = await new Promise(resolve => { + let observer = (subject, topic, errorUrl) => { + if (errorUrl == url) { + resolve(null); + } + }; + Services.obs.addObserver(observer, "page-thumbnail:error"); + lazy.BackgroundPageThumbs.capture(url, { + dontStore: true, + contentType: this.fileContentType, + onDone: (url, reason, handle) => { + Services.obs.removeObserver(observer, "page-thumbnail:error"); + resolve(handle?.data); + }, + }); + }); + if (!buffer) { + lazy.logConsole.error("Unable to fetch preview: " + url); + return false; + } + try { + await IOUtils.makeDirectory(this.getPath(), { ignoreExisting: true }); + await IOUtils.write(filePath, new Uint8Array(buffer), { + tmpPath: filePath + ".tmp", + }); + } catch (ex) { + lazy.logConsole.error( + lazy.logConsole.error("Unable to create preview: " + ex) + ); + return false; + } + this.#recentlyUpdatedPreviews.add(filePath); + return true; + } + + /** + * Removes orphan previews that are not tracked by Places. + * Orphaning should normally not happen, but unexpected manipulation (e.g. the + * user touching the profile folder, or third party applications) could cause + * it. + * This method is slow, because it has to go through all the Places stored + * pages, thus it's suggested to only run it as periodic maintenance. + * @returns {boolean} Whether orphans deletion ran. + */ + async deleteOrphans() { + if (!this.enabled) { + return false; + } + + // From the previews directory, get all the files whose name matches our + // format. Avoid any other filenames, also for safety reasons, since we are + // injecting them into SQL. + let files = await IOUtils.getChildren(this.getPath()); + let hashes = files + .map(f => PathUtils.filename(f)) + .filter(n => /^[a-f0-9]{32}\.webp$/) + .map(n => n.substring(0, n.lastIndexOf("."))); + + await lazy.PlacesUtils.withConnectionWrapper( + "PlacesPreviews.jsm::deleteOrphans", + async db => { + await db.execute( + ` + WITH files(hash) AS ( + VALUES ${hashes.map(h => `('${h}')`).join(", ")} + ) + INSERT OR IGNORE INTO moz_previews_tombstones + SELECT hash FROM files + EXCEPT + SELECT md5hex(url) FROM moz_places + ` + ); + } + ); + this.#deletionHandler.ensureRunning(); + return true; + } + + /** + * This is invoked by #deletionHandler every time a preview file is removed. + * @param {string} filePath The path of the deleted file. + */ + onDelete(filePath) { + this.#recentlyUpdatedPreviews.delete(filePath); + this.emit("places-preview-deleted", filePath); + } + + /** + * Used by tests to change the deletion timeout between chunks. + * @param {integer} timeout New timeout in milliseconds. + */ + testSetDeletionTimeout(timeout) { + if (timeout === null) { + this.#deletionHandler.timeout = DELETE_TIMEOUT_MS; + } else { + this.#deletionHandler.timeout = timeout; + } + } +})(); + +/** + * Used to exposes nsIPlacesPreviewsHelperService to the moz-page-thumb protocol + * cpp implementation. + */ +export function PlacesPreviewsHelperService() {} + +PlacesPreviewsHelperService.prototype = { + classID: Components.ID("{bd0a4d3b-ff26-4d4d-9a62-a513e1c1bf92}"), + QueryInterface: ChromeUtils.generateQI(["nsIPlacesPreviewsHelperService"]), + + getFilePathForURL(url) { + return PlacesPreviews.getPathForUrl(url); + }, +}; |