forked from mirrors/gecko-dev
backup-settings changes: - adds a new button in the Backup section of about:preferences / about:settings - shows the turn on dialog after pressing the button Turn on dialog behaviour (implemented): - pressing the cancel will close the dialog - pressing the confirm button will set the pref browser.backup.scheduled.enabled=true and close the dialog - pressing the passwords checkbox will show more options Turn on dialog behaviour (not implemented): - requiring a password for the backup (see Bug 1895981) - modifying the save location and showing a file picker (see Bug 1895943) Other changes: - tests for backup-settings and the turn on dialog - Storybook template for the turn on dialog Lo-fi Figma designs: https://www.figma.com/design/vNbX4c0ws0L1qr0mxpKvsW/Fx-Backup?node-id=147-4558&t=PYLY0QMN1n8GR9vW-0 Differential Revision: https://phabricator.services.mozilla.com/D209769
934 lines
30 KiB
JavaScript
934 lines
30 KiB
JavaScript
/* 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 * as DefaultBackupResources from "resource:///modules/backup/BackupResources.sys.mjs";
|
|
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
|
|
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
|
|
|
|
const SCHEDULED_BACKUPS_ENABLED_PREF_NAME = "browser.backup.scheduled.enabled";
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
|
|
return console.createInstance({
|
|
prefix: "BackupService",
|
|
maxLogLevel: Services.prefs.getBoolPref("browser.backup.log", false)
|
|
? "Debug"
|
|
: "Warn",
|
|
});
|
|
});
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
|
|
return ChromeUtils.importESModule(
|
|
"resource://gre/modules/FxAccounts.sys.mjs"
|
|
).getFxAccountsSingleton();
|
|
});
|
|
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
ClientID: "resource://gre/modules/ClientID.sys.mjs",
|
|
JsonSchemaValidator:
|
|
"resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs",
|
|
UIState: "resource://services-sync/UIState.sys.mjs",
|
|
});
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "ZipWriter", () =>
|
|
Components.Constructor("@mozilla.org/zipwriter;1", "nsIZipWriter", "open")
|
|
);
|
|
|
|
XPCOMUtils.defineLazyPreferenceGetter(
|
|
lazy,
|
|
"scheduledBackupsPref",
|
|
SCHEDULED_BACKUPS_ENABLED_PREF_NAME,
|
|
false,
|
|
function onUpdateScheduledBackups(_pref, _prevVal, newVal) {
|
|
let bs = BackupService.get();
|
|
if (bs) {
|
|
bs.onUpdateScheduledBackups(newVal);
|
|
}
|
|
}
|
|
);
|
|
|
|
/**
|
|
* The BackupService class orchestrates the scheduling and creation of profile
|
|
* backups. It also does most of the heavy lifting for the restoration of a
|
|
* profile backup.
|
|
*/
|
|
export class BackupService extends EventTarget {
|
|
/**
|
|
* The BackupService singleton instance.
|
|
*
|
|
* @static
|
|
* @type {BackupService|null}
|
|
*/
|
|
static #instance = null;
|
|
|
|
/**
|
|
* Map of instantiated BackupResource classes.
|
|
*
|
|
* @type {Map<string, BackupResource>}
|
|
*/
|
|
#resources = new Map();
|
|
|
|
/**
|
|
* Set to true if a backup is currently in progress. Causes stateUpdate()
|
|
* to be called.
|
|
*
|
|
* @see BackupService.stateUpdate()
|
|
* @param {boolean} val
|
|
* True if a backup is in progress.
|
|
*/
|
|
set #backupInProgress(val) {
|
|
if (this.#_state.backupInProgress != val) {
|
|
this.#_state.backupInProgress = val;
|
|
this.stateUpdate();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* True if a backup is currently in progress.
|
|
*
|
|
* @type {boolean}
|
|
*/
|
|
get #backupInProgress() {
|
|
return this.#_state.backupInProgress;
|
|
}
|
|
|
|
/**
|
|
* Dispatches an event to let listeners know that the BackupService state
|
|
* object has been updated.
|
|
*/
|
|
stateUpdate() {
|
|
this.dispatchEvent(new CustomEvent("BackupService:StateUpdate"));
|
|
}
|
|
|
|
/**
|
|
* An object holding the current state of the BackupService instance, for
|
|
* the purposes of representing it in the user interface. Ideally, this would
|
|
* be named #state instead of #_state, but sphinx-js seems to be fairly
|
|
* unhappy with that coupled with the ``state`` getter.
|
|
*
|
|
* @type {object}
|
|
*/
|
|
#_state = {
|
|
backupFilePath: "Documents", // TODO: make save location configurable (bug 1895943)
|
|
backupInProgress: false,
|
|
scheduledBackupsEnabled: lazy.scheduledBackupsPref,
|
|
};
|
|
|
|
/**
|
|
* A Promise that will resolve once the postRecovery steps are done. It will
|
|
* also resolve if postRecovery steps didn't need to run.
|
|
*
|
|
* @see BackupService.checkForPostRecovery()
|
|
* @type {Promise<undefined>}
|
|
*/
|
|
#postRecoveryPromise;
|
|
|
|
/**
|
|
* The resolving function for #postRecoveryPromise, which should be called
|
|
* by checkForPostRecovery() before exiting.
|
|
*
|
|
* @type {Function}
|
|
*/
|
|
#postRecoveryResolver;
|
|
|
|
/**
|
|
* The name of the backup manifest file.
|
|
*
|
|
* @type {string}
|
|
*/
|
|
static get MANIFEST_FILE_NAME() {
|
|
return "backup-manifest.json";
|
|
}
|
|
|
|
/**
|
|
* The current schema version of the backup manifest that this BackupService
|
|
* uses when creating a backup.
|
|
*
|
|
* @type {number}
|
|
*/
|
|
static get MANIFEST_SCHEMA_VERSION() {
|
|
return 1;
|
|
}
|
|
|
|
/**
|
|
* A promise that resolves to the schema for the backup manifest that this
|
|
* BackupService uses when creating a backup. This should be accessed via
|
|
* the `MANIFEST_SCHEMA` static getter.
|
|
*
|
|
* @type {Promise<object>}
|
|
*/
|
|
static #manifestSchemaPromise = null;
|
|
|
|
/**
|
|
* The current schema version of the backup manifest that this BackupService
|
|
* uses when creating a backup.
|
|
*
|
|
* @type {Promise<object>}
|
|
*/
|
|
static get MANIFEST_SCHEMA() {
|
|
if (!BackupService.#manifestSchemaPromise) {
|
|
BackupService.#manifestSchemaPromise = BackupService._getSchemaForVersion(
|
|
BackupService.MANIFEST_SCHEMA_VERSION
|
|
);
|
|
}
|
|
|
|
return BackupService.#manifestSchemaPromise;
|
|
}
|
|
|
|
/**
|
|
* The name of the post recovery file written into the newly created profile
|
|
* directory just after a profile is recovered from a backup.
|
|
*
|
|
* @type {string}
|
|
*/
|
|
static get POST_RECOVERY_FILE_NAME() {
|
|
return "post-recovery.json";
|
|
}
|
|
|
|
/**
|
|
* Returns the schema for the backup manifest for a given version.
|
|
*
|
|
* This should really be #getSchemaForVersion, but for some reason,
|
|
* sphinx-js seems to choke on static async private methods (bug 1893362).
|
|
* We workaround this breakage by using the `_` prefix to indicate that this
|
|
* method should be _considered_ private, and ask that you not use this method
|
|
* outside of this class. The sphinx-js issue is tracked at
|
|
* https://github.com/mozilla/sphinx-js/issues/240.
|
|
*
|
|
* @private
|
|
* @param {number} version
|
|
* The version of the schema to return.
|
|
* @returns {Promise<object>}
|
|
*/
|
|
static async _getSchemaForVersion(version) {
|
|
let schemaURL = `chrome://browser/content/backup/BackupManifest.${version}.schema.json`;
|
|
let response = await fetch(schemaURL);
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* The level of Zip compression to use on the zipped staging folder.
|
|
*
|
|
* @type {number}
|
|
*/
|
|
static get COMPRESSION_LEVEL() {
|
|
return Ci.nsIZipWriter.COMPRESSION_BEST;
|
|
}
|
|
|
|
/**
|
|
* Returns a reference to a BackupService singleton. If this is the first time
|
|
* that this getter is accessed, this causes the BackupService singleton to be
|
|
* be instantiated.
|
|
*
|
|
* @static
|
|
* @type {BackupService}
|
|
*/
|
|
static init() {
|
|
if (this.#instance) {
|
|
return this.#instance;
|
|
}
|
|
this.#instance = new BackupService(DefaultBackupResources);
|
|
|
|
this.#instance.checkForPostRecovery().then(() => {
|
|
this.#instance.takeMeasurements();
|
|
});
|
|
|
|
return this.#instance;
|
|
}
|
|
|
|
/**
|
|
* Returns a reference to the BackupService singleton. If the singleton has
|
|
* not been initialized, an error is thrown.
|
|
*
|
|
* @static
|
|
* @returns {BackupService}
|
|
*/
|
|
static get() {
|
|
if (!this.#instance) {
|
|
throw new Error("BackupService not initialized");
|
|
}
|
|
return this.#instance;
|
|
}
|
|
|
|
/**
|
|
* Create a BackupService instance.
|
|
*
|
|
* @param {object} [backupResources=DefaultBackupResources] - Object containing BackupResource classes to associate with this service.
|
|
*/
|
|
constructor(backupResources = DefaultBackupResources) {
|
|
super();
|
|
lazy.logConsole.debug("Instantiated");
|
|
|
|
for (const resourceName in backupResources) {
|
|
let resource = backupResources[resourceName];
|
|
this.#resources.set(resource.key, resource);
|
|
}
|
|
|
|
let { promise, resolve } = Promise.withResolvers();
|
|
this.#postRecoveryPromise = promise;
|
|
this.#postRecoveryResolver = resolve;
|
|
}
|
|
|
|
/**
|
|
* Returns a reference to a Promise that will resolve with undefined once
|
|
* postRecovery steps have had a chance to run. This will also be resolved
|
|
* with undefined if no postRecovery steps needed to be run.
|
|
*
|
|
* @see BackupService.checkForPostRecovery()
|
|
* @returns {Promise<undefined>}
|
|
*/
|
|
get postRecoveryComplete() {
|
|
return this.#postRecoveryPromise;
|
|
}
|
|
|
|
/**
|
|
* Returns a state object describing the state of the BackupService for the
|
|
* purposes of representing it in the user interface. The returned state
|
|
* object is immutable.
|
|
*
|
|
* @type {object}
|
|
*/
|
|
get state() {
|
|
return Object.freeze(structuredClone(this.#_state));
|
|
}
|
|
|
|
/**
|
|
* @typedef {object} CreateBackupResult
|
|
* @property {string} stagingPath
|
|
* The staging path for where the backup was created.
|
|
*/
|
|
|
|
/**
|
|
* Create a backup of the user's profile.
|
|
*
|
|
* @param {object} [options]
|
|
* Options for the backup.
|
|
* @param {string} [options.profilePath=PathUtils.profileDir]
|
|
* The path to the profile to backup. By default, this is the current
|
|
* profile.
|
|
* @returns {Promise<CreateBackupResult|null>}
|
|
* A promise that resolves to an object containing the path to the staging
|
|
* folder where the backup was created, or null if the backup failed.
|
|
*/
|
|
async createBackup({ profilePath = PathUtils.profileDir } = {}) {
|
|
// createBackup does not allow re-entry or concurrent backups.
|
|
if (this.#backupInProgress) {
|
|
lazy.logConsole.warn("Backup attempt already in progress");
|
|
return null;
|
|
}
|
|
|
|
this.#backupInProgress = true;
|
|
|
|
try {
|
|
lazy.logConsole.debug(`Creating backup for profile at ${profilePath}`);
|
|
let manifest = await this.#createBackupManifest();
|
|
|
|
// First, check to see if a `backups` directory already exists in the
|
|
// profile.
|
|
let backupDirPath = PathUtils.join(profilePath, "backups");
|
|
lazy.logConsole.debug("Creating backups folder");
|
|
|
|
// ignoreExisting: true is the default, but we're being explicit that it's
|
|
// okay if this folder already exists.
|
|
await IOUtils.makeDirectory(backupDirPath, { ignoreExisting: true });
|
|
|
|
let stagingPath = await this.#prepareStagingFolder(backupDirPath);
|
|
|
|
// Sort resources be priority.
|
|
let sortedResources = Array.from(this.#resources.values()).sort(
|
|
(a, b) => {
|
|
return b.priority - a.priority;
|
|
}
|
|
);
|
|
|
|
// Perform the backup for each resource.
|
|
for (let resourceClass of sortedResources) {
|
|
try {
|
|
lazy.logConsole.debug(
|
|
`Backing up resource with key ${resourceClass.key}. ` +
|
|
`Requires encryption: ${resourceClass.requiresEncryption}`
|
|
);
|
|
let resourcePath = PathUtils.join(stagingPath, resourceClass.key);
|
|
await IOUtils.makeDirectory(resourcePath);
|
|
|
|
// `backup` on each BackupResource should return us a ManifestEntry
|
|
// that we eventually write to a JSON manifest file, but for now,
|
|
// we're just going to log it.
|
|
let manifestEntry = await new resourceClass().backup(
|
|
resourcePath,
|
|
profilePath
|
|
);
|
|
|
|
if (manifestEntry === undefined) {
|
|
lazy.logConsole.error(
|
|
`Backup of resource with key ${resourceClass.key} returned undefined
|
|
as its ManifestEntry instead of null or an object`
|
|
);
|
|
} else {
|
|
lazy.logConsole.debug(
|
|
`Backup of resource with key ${resourceClass.key} completed`,
|
|
manifestEntry
|
|
);
|
|
manifest.resources[resourceClass.key] = manifestEntry;
|
|
}
|
|
} catch (e) {
|
|
lazy.logConsole.error(
|
|
`Failed to backup resource: ${resourceClass.key}`,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
// Ensure that the manifest abides by the current schema, and log
|
|
// an error if somehow it doesn't. We'll want to collect telemetry for
|
|
// this case to make sure it's not happening in the wild. We debated
|
|
// throwing an exception here too, but that's not meaningfully better
|
|
// than creating a backup that's not schema-compliant. At least in this
|
|
// case, a user so-inclined could theoretically repair the manifest
|
|
// to make it valid.
|
|
let manifestSchema = await BackupService.MANIFEST_SCHEMA;
|
|
let schemaValidationResult = lazy.JsonSchemaValidator.validate(
|
|
manifest,
|
|
manifestSchema
|
|
);
|
|
if (!schemaValidationResult.valid) {
|
|
lazy.logConsole.error(
|
|
"Backup manifest does not conform to schema:",
|
|
manifest,
|
|
manifestSchema,
|
|
schemaValidationResult
|
|
);
|
|
// TODO: Collect telemetry for this case. (bug 1891817)
|
|
}
|
|
|
|
// Write the manifest to the staging folder.
|
|
let manifestPath = PathUtils.join(
|
|
stagingPath,
|
|
BackupService.MANIFEST_FILE_NAME
|
|
);
|
|
await IOUtils.writeJSON(manifestPath, manifest);
|
|
|
|
let renamedStagingPath = await this.#finalizeStagingFolder(stagingPath);
|
|
lazy.logConsole.log(
|
|
"Wrote backup to staging directory at ",
|
|
renamedStagingPath
|
|
);
|
|
|
|
let compressedStagingPath = await this.#compressStagingFolder(
|
|
renamedStagingPath,
|
|
backupDirPath
|
|
);
|
|
|
|
return { stagingPath: renamedStagingPath, compressedStagingPath };
|
|
} finally {
|
|
this.#backupInProgress = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Constructs the staging folder for the backup in the passed in backup
|
|
* folder. If a pre-existing staging folder exists, it will be cleared out.
|
|
*
|
|
* @param {string} backupDirPath
|
|
* The path to the backup folder.
|
|
* @returns {Promise<string>}
|
|
* The path to the empty staging folder.
|
|
*/
|
|
async #prepareStagingFolder(backupDirPath) {
|
|
let stagingPath = PathUtils.join(backupDirPath, "staging");
|
|
lazy.logConsole.debug("Checking for pre-existing staging folder");
|
|
if (await IOUtils.exists(stagingPath)) {
|
|
// A pre-existing staging folder exists. A previous backup attempt must
|
|
// have failed or been interrupted. We'll clear it out.
|
|
lazy.logConsole.warn("A pre-existing staging folder exists. Clearing.");
|
|
await IOUtils.remove(stagingPath, { recursive: true });
|
|
}
|
|
await IOUtils.makeDirectory(stagingPath);
|
|
|
|
return stagingPath;
|
|
}
|
|
|
|
/**
|
|
* Compresses a staging folder into a Zip file. If a pre-existing Zip file
|
|
* for a staging folder resides in destFolderPath, it is overwritten. The
|
|
* Zip file will have the same name as the stagingPath folder, with `.zip`
|
|
* as the extension.
|
|
*
|
|
* @param {string} stagingPath
|
|
* The path to the staging folder to be compressed.
|
|
* @param {string} destFolderPath
|
|
* The parent folder to write the Zip file to.
|
|
* @returns {Promise<string>}
|
|
* Resolves with the path to the created Zip file.
|
|
*/
|
|
async #compressStagingFolder(stagingPath, destFolderPath) {
|
|
const PR_RDWR = 0x04;
|
|
const PR_CREATE_FILE = 0x08;
|
|
const PR_TRUNCATE = 0x20;
|
|
|
|
let archivePath = PathUtils.join(
|
|
destFolderPath,
|
|
`${PathUtils.filename(stagingPath)}.zip`
|
|
);
|
|
let archiveFile = await IOUtils.getFile(archivePath);
|
|
|
|
let writer = new lazy.ZipWriter(
|
|
archiveFile,
|
|
PR_RDWR | PR_CREATE_FILE | PR_TRUNCATE
|
|
);
|
|
|
|
lazy.logConsole.log("Compressing staging folder to ", archivePath);
|
|
let rootPathNSIFile = await IOUtils.getDirectory(stagingPath);
|
|
await this.#compressChildren(rootPathNSIFile, stagingPath, writer);
|
|
await new Promise(resolve => {
|
|
let observer = {
|
|
onStartRequest(_request) {
|
|
lazy.logConsole.debug("Starting to write out archive file");
|
|
},
|
|
onStopRequest(_request, status) {
|
|
lazy.logConsole.log("Done writing archive file");
|
|
resolve(status);
|
|
},
|
|
};
|
|
writer.processQueue(observer, null);
|
|
});
|
|
writer.close();
|
|
|
|
return archivePath;
|
|
}
|
|
|
|
/**
|
|
* A helper function for #compressStagingFolder that iterates through a
|
|
* directory, and adds each file to a nsIZipWriter. For each directory it
|
|
* finds, it recurses.
|
|
*
|
|
* @param {nsIFile} rootPathNSIFile
|
|
* An nsIFile pointing at the root of the folder being compressed.
|
|
* @param {string} parentPath
|
|
* The path to the folder whose children should be iterated.
|
|
* @param {nsIZipWriter} writer
|
|
* The writer to add all of the children to.
|
|
* @returns {Promise<undefined>}
|
|
*/
|
|
async #compressChildren(rootPathNSIFile, parentPath, writer) {
|
|
let children = await IOUtils.getChildren(parentPath);
|
|
for (let childPath of children) {
|
|
let childState = await IOUtils.stat(childPath);
|
|
if (childState.type == "directory") {
|
|
await this.#compressChildren(rootPathNSIFile, childPath, writer);
|
|
} else {
|
|
let childFile = await IOUtils.getFile(childPath);
|
|
// nsIFile.getRelativePath returns paths using the "/" separator,
|
|
// regardless of which platform we're on. That's handy, because this
|
|
// is the same separator that nsIZipWriter expects for entries.
|
|
let pathRelativeToRoot = childFile.getRelativePath(rootPathNSIFile);
|
|
writer.addEntryFile(
|
|
pathRelativeToRoot,
|
|
BackupService.COMPRESSION_LEVEL,
|
|
childFile,
|
|
true
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Renames the staging folder to an ISO 8601 date string with dashes replacing colons and fractional seconds stripped off.
|
|
* The ISO date string should be formatted from YYYY-MM-DDTHH:mm:ss.sssZ to YYYY-MM-DDTHH-mm-ssZ
|
|
*
|
|
* @param {string} stagingPath
|
|
* The path to the populated staging folder.
|
|
* @returns {Promise<string|null>}
|
|
* The path to the renamed staging folder, or null if the stagingPath was
|
|
* not pointing to a valid folder.
|
|
*/
|
|
async #finalizeStagingFolder(stagingPath) {
|
|
if (!(await IOUtils.exists(stagingPath))) {
|
|
// If we somehow can't find the specified staging folder, cancel this step.
|
|
lazy.logConsole.error(
|
|
`Failed to finalize staging folder. Cannot find ${stagingPath}.`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
lazy.logConsole.debug("Finalizing and renaming staging folder");
|
|
let currentDateISO = new Date().toISOString();
|
|
// First strip the fractional seconds
|
|
let dateISOStripped = currentDateISO.replace(/\.\d+\Z$/, "Z");
|
|
// Now replace all colons with dashes
|
|
let dateISOFormatted = dateISOStripped.replaceAll(":", "-");
|
|
|
|
let stagingPathParent = PathUtils.parent(stagingPath);
|
|
let renamedBackupPath = PathUtils.join(
|
|
stagingPathParent,
|
|
dateISOFormatted
|
|
);
|
|
await IOUtils.move(stagingPath, renamedBackupPath);
|
|
|
|
let existingBackups = await IOUtils.getChildren(stagingPathParent);
|
|
|
|
/**
|
|
* Bug 1892532: for now, we only support a single backup file.
|
|
* If there are other pre-existing backup folders, delete them.
|
|
*/
|
|
for (let existingBackupPath of existingBackups) {
|
|
if (existingBackupPath !== renamedBackupPath) {
|
|
await IOUtils.remove(existingBackupPath, {
|
|
recursive: true,
|
|
});
|
|
}
|
|
}
|
|
return renamedBackupPath;
|
|
} catch (e) {
|
|
lazy.logConsole.error(
|
|
`Something went wrong while finalizing the staging folder. ${e}`
|
|
);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates and resolves with a backup manifest object with an empty resources
|
|
* property.
|
|
*
|
|
* @returns {Promise<object>}
|
|
*/
|
|
async #createBackupManifest() {
|
|
let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService(
|
|
Ci.nsIToolkitProfileService
|
|
);
|
|
let profileName;
|
|
if (!profileSvc.currentProfile) {
|
|
// We're probably running on a local build or in some special configuration.
|
|
// Let's pull in a profile name from the profile directory.
|
|
let profileFolder = PathUtils.split(PathUtils.profileDir).at(-1);
|
|
profileName = profileFolder.substring(profileFolder.indexOf(".") + 1);
|
|
} else {
|
|
profileName = profileSvc.currentProfile.name;
|
|
}
|
|
|
|
let meta = {
|
|
date: new Date().toISOString(),
|
|
appName: AppConstants.MOZ_APP_NAME,
|
|
appVersion: AppConstants.MOZ_APP_VERSION,
|
|
buildID: AppConstants.MOZ_BUILDID,
|
|
profileName,
|
|
machineName: lazy.fxAccounts.device.getLocalName(),
|
|
osName: Services.sysinfo.getProperty("name"),
|
|
osVersion: Services.sysinfo.getProperty("version"),
|
|
legacyClientID: await lazy.ClientID.getClientID(),
|
|
};
|
|
|
|
let fxaState = lazy.UIState.get();
|
|
if (fxaState.status == lazy.UIState.STATUS_SIGNED_IN) {
|
|
meta.accountID = fxaState.uid;
|
|
meta.accountEmail = fxaState.email;
|
|
}
|
|
|
|
return {
|
|
version: BackupService.MANIFEST_SCHEMA_VERSION,
|
|
meta,
|
|
resources: {},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Given a decompressed backup archive at recoveryPath, this method does the
|
|
* following:
|
|
*
|
|
* 1. Reads in the backup manifest from the archive and ensures that it is
|
|
* valid.
|
|
* 2. Creates a new named profile directory using the same name as the one
|
|
* found in the backup manifest, but with a different prefix.
|
|
* 3. Iterates over each resource in the manifest and calls the recover()
|
|
* method on each found BackupResource, passing in the associated
|
|
* ManifestEntry from the backup manifest, and collects any post-recovery
|
|
* data from those resources.
|
|
* 4. Writes a `post-recovery.json` file into the newly created profile
|
|
* directory.
|
|
* 5. Returns the name of the newly created profile directory.
|
|
*
|
|
* @param {string} recoveryPath
|
|
* The path to the decompressed backup archive on the file system.
|
|
* @param {boolean} [shouldLaunch=false]
|
|
* An optional argument that specifies whether an instance of the app
|
|
* should be launched with the newly recovered profile after recovery is
|
|
* complete.
|
|
* @param {string} [profileRootPath=null]
|
|
* An optional argument that specifies the root directory where the new
|
|
* profile directory should be created. If not provided, the default
|
|
* profile root directory will be used. This is primarily meant for
|
|
* testing.
|
|
* @returns {Promise<nsIToolkitProfile>}
|
|
* The nsIToolkitProfile that was created for the recovered profile.
|
|
* @throws {Exception}
|
|
* In the event that recovery somehow failed.
|
|
*/
|
|
async recoverFromBackup(
|
|
recoveryPath,
|
|
shouldLaunch = false,
|
|
profileRootPath = null
|
|
) {
|
|
lazy.logConsole.debug("Recovering from backup at ", recoveryPath);
|
|
|
|
try {
|
|
// Read in the backup manifest.
|
|
let manifestPath = PathUtils.join(
|
|
recoveryPath,
|
|
BackupService.MANIFEST_FILE_NAME
|
|
);
|
|
let manifest = await IOUtils.readJSON(manifestPath);
|
|
if (!manifest.version) {
|
|
throw new Error("Backup manifest version not found");
|
|
}
|
|
|
|
if (manifest.version > BackupService.MANIFEST_SCHEMA_VERSION) {
|
|
throw new Error(
|
|
"Cannot recover from a manifest newer than the current schema version"
|
|
);
|
|
}
|
|
|
|
// Make sure that it conforms to the schema.
|
|
let manifestSchema = await BackupService._getSchemaForVersion(
|
|
manifest.version
|
|
);
|
|
let schemaValidationResult = lazy.JsonSchemaValidator.validate(
|
|
manifest,
|
|
manifestSchema
|
|
);
|
|
if (!schemaValidationResult.valid) {
|
|
lazy.logConsole.error(
|
|
"Backup manifest does not conform to schema:",
|
|
manifest,
|
|
manifestSchema,
|
|
schemaValidationResult
|
|
);
|
|
// TODO: Collect telemetry for this case. (bug 1891817)
|
|
throw new Error("Cannot recover from an invalid backup manifest");
|
|
}
|
|
|
|
// In the future, if we ever bump the MANIFEST_SCHEMA_VERSION and need to
|
|
// do any special behaviours to interpret older schemas, this is where we
|
|
// can do that, and we can remove this comment.
|
|
|
|
let meta = manifest.meta;
|
|
|
|
// Okay, we have a valid backup-manifest.json. Let's create a new profile
|
|
// and start invoking the recover() method on each BackupResource.
|
|
let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService(
|
|
Ci.nsIToolkitProfileService
|
|
);
|
|
let profile = profileSvc.createUniqueProfile(
|
|
profileRootPath ? await IOUtils.getDirectory(profileRootPath) : null,
|
|
meta.profileName
|
|
);
|
|
|
|
let postRecovery = {};
|
|
|
|
// Iterate over each resource in the manifest and call recover() on each
|
|
// associated BackupResource.
|
|
for (let resourceKey in manifest.resources) {
|
|
let manifestEntry = manifest.resources[resourceKey];
|
|
let resourceClass = this.#resources.get(resourceKey);
|
|
if (!resourceClass) {
|
|
lazy.logConsole.error(
|
|
`No BackupResource found for key ${resourceKey}`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
lazy.logConsole.debug(
|
|
`Restoring resource with key ${resourceKey}. ` +
|
|
`Requires encryption: ${resourceClass.requiresEncryption}`
|
|
);
|
|
let resourcePath = PathUtils.join(recoveryPath, resourceKey);
|
|
let postRecoveryEntry = await new resourceClass().recover(
|
|
manifestEntry,
|
|
resourcePath,
|
|
profile.rootDir.path
|
|
);
|
|
postRecovery[resourceKey] = postRecoveryEntry;
|
|
} catch (e) {
|
|
lazy.logConsole.error(
|
|
`Failed to recover resource: ${resourceKey}`,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
// Make sure that a legacy telemetry client ID exists and is written to
|
|
// disk.
|
|
let clientID = await lazy.ClientID.getClientID();
|
|
lazy.logConsole.debug("Current client ID: ", clientID);
|
|
// Next, copy over the legacy telemetry client ID state from the currently
|
|
// running profile. The newly created profile that we're recovering into
|
|
// should inherit this client ID.
|
|
const TELEMETRY_STATE_FILENAME = "state.json";
|
|
const TELEMETRY_STATE_FOLDER = "datareporting";
|
|
await IOUtils.makeDirectory(
|
|
PathUtils.join(profile.rootDir.path, TELEMETRY_STATE_FOLDER)
|
|
);
|
|
await IOUtils.copy(
|
|
/* source */
|
|
PathUtils.join(
|
|
PathUtils.profileDir,
|
|
TELEMETRY_STATE_FOLDER,
|
|
TELEMETRY_STATE_FILENAME
|
|
),
|
|
/* destination */
|
|
PathUtils.join(
|
|
profile.rootDir.path,
|
|
TELEMETRY_STATE_FOLDER,
|
|
TELEMETRY_STATE_FILENAME
|
|
)
|
|
);
|
|
|
|
let postRecoveryPath = PathUtils.join(
|
|
profile.rootDir.path,
|
|
BackupService.POST_RECOVERY_FILE_NAME
|
|
);
|
|
await IOUtils.writeJSON(postRecoveryPath, postRecovery);
|
|
|
|
profileSvc.flush();
|
|
|
|
if (shouldLaunch) {
|
|
Services.startup.createInstanceWithProfile(profile);
|
|
}
|
|
|
|
return profile;
|
|
} catch (e) {
|
|
lazy.logConsole.error(
|
|
"Failed to recover from backup at ",
|
|
recoveryPath,
|
|
e
|
|
);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks for the POST_RECOVERY_FILE_NAME in the current profile directory.
|
|
* If one exists, instantiates any relevant BackupResource's, and calls
|
|
* postRecovery() on them with the appropriate entry from the file. Once
|
|
* this is done, deletes the file.
|
|
*
|
|
* The file is deleted even if one of the postRecovery() steps rejects or
|
|
* fails.
|
|
*
|
|
* This function resolves silently if the POST_RECOVERY_FILE_NAME file does
|
|
* not exist, which should be the majority of cases.
|
|
*
|
|
* @param {string} [profilePath=PathUtils.profileDir]
|
|
* The profile path to look for the POST_RECOVERY_FILE_NAME file. Defaults
|
|
* to the current profile.
|
|
* @returns {Promise<undefined>}
|
|
*/
|
|
async checkForPostRecovery(profilePath = PathUtils.profileDir) {
|
|
lazy.logConsole.debug(`Checking for post-recovery file in ${profilePath}`);
|
|
let postRecoveryFile = PathUtils.join(
|
|
profilePath,
|
|
BackupService.POST_RECOVERY_FILE_NAME
|
|
);
|
|
|
|
if (!(await IOUtils.exists(postRecoveryFile))) {
|
|
lazy.logConsole.debug("Did not find post-recovery file.");
|
|
this.#postRecoveryResolver();
|
|
return;
|
|
}
|
|
|
|
lazy.logConsole.debug("Found post-recovery file. Loading...");
|
|
|
|
try {
|
|
let postRecovery = await IOUtils.readJSON(postRecoveryFile);
|
|
for (let resourceKey in postRecovery) {
|
|
let postRecoveryEntry = postRecovery[resourceKey];
|
|
let resourceClass = this.#resources.get(resourceKey);
|
|
if (!resourceClass) {
|
|
lazy.logConsole.error(
|
|
`Invalid resource for post-recovery step: ${resourceKey}`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
lazy.logConsole.debug(`Running post-recovery step for ${resourceKey}`);
|
|
await new resourceClass().postRecovery(postRecoveryEntry);
|
|
lazy.logConsole.debug(`Done post-recovery step for ${resourceKey}`);
|
|
}
|
|
} finally {
|
|
await IOUtils.remove(postRecoveryFile, { ignoreAbsent: true });
|
|
this.#postRecoveryResolver();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets browser.backup.scheduled.enabled to true or false.
|
|
*
|
|
* @param { boolean } shouldEnableScheduledBackups true if scheduled backups should be enabled. Else, false.
|
|
*/
|
|
setScheduledBackups(shouldEnableScheduledBackups) {
|
|
Services.prefs.setBoolPref(
|
|
SCHEDULED_BACKUPS_ENABLED_PREF_NAME,
|
|
shouldEnableScheduledBackups
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Updates scheduledBackupsEnabled in the backup service state. Should be called every time
|
|
* the value for browser.backup.scheduled.enabled changes.
|
|
*
|
|
* @param {boolean} isScheduledBackupsEnabled True if scheduled backups are enabled. Else false.
|
|
*/
|
|
onUpdateScheduledBackups(isScheduledBackupsEnabled) {
|
|
if (this.#_state.scheduledBackupsEnabled != isScheduledBackupsEnabled) {
|
|
lazy.logConsole.debug(
|
|
"Updating scheduled backups",
|
|
isScheduledBackupsEnabled
|
|
);
|
|
this.#_state.scheduledBackupsEnabled = isScheduledBackupsEnabled;
|
|
this.stateUpdate();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Take measurements of the current profile state for Telemetry.
|
|
*
|
|
* @returns {Promise<undefined>}
|
|
*/
|
|
async takeMeasurements() {
|
|
lazy.logConsole.debug("Taking Telemetry measurements");
|
|
|
|
// Note: We're talking about kilobytes here, not kibibytes. That means
|
|
// 1000 bytes, and not 1024 bytes.
|
|
const BYTES_IN_KB = 1000;
|
|
const BYTES_IN_MB = 1000000;
|
|
|
|
// We'll start by measuring the available disk space on the storage
|
|
// device that the profile directory is on.
|
|
let profileDir = await IOUtils.getFile(PathUtils.profileDir);
|
|
|
|
let profDDiskSpaceBytes = profileDir.diskSpaceAvailable;
|
|
|
|
// Make the measurement fuzzier by rounding to the nearest 10MB.
|
|
let profDDiskSpaceMB =
|
|
Math.round(profDDiskSpaceBytes / BYTES_IN_MB / 100) * 100;
|
|
|
|
// And then record the value in kilobytes, since that's what everything
|
|
// else is going to be measured in.
|
|
Glean.browserBackup.profDDiskSpace.set(profDDiskSpaceMB * BYTES_IN_KB);
|
|
|
|
// Measure the size of each file we are going to backup.
|
|
for (let resourceClass of this.#resources.values()) {
|
|
try {
|
|
await new resourceClass().measure(PathUtils.profileDir);
|
|
} catch (e) {
|
|
lazy.logConsole.error(
|
|
`Failed to measure for resource: ${resourceClass.key}`,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|