diff --git a/browser/components/BrowserGlue.sys.mjs b/browser/components/BrowserGlue.sys.mjs index 5092ec85d7a9..bf1c131ad14d 100644 --- a/browser/components/BrowserGlue.sys.mjs +++ b/browser/components/BrowserGlue.sys.mjs @@ -442,6 +442,7 @@ let JSWINDOWACTORS = { events: { "BackupUI:InitWidget": { wantUntrusted: true }, "BackupUI:ToggleScheduledBackups": { wantUntrusted: true }, + "BackupUI:ShowFilepicker": { wantUntrusted: true }, }, }, matches: ["about:preferences*", "about:settings*"], diff --git a/browser/components/backup/BackupService.sys.mjs b/browser/components/backup/BackupService.sys.mjs index 4005f6eb00c2..3219cff51eb3 100644 --- a/browser/components/backup/BackupService.sys.mjs +++ b/browser/components/backup/BackupService.sys.mjs @@ -6,6 +6,7 @@ import * as DefaultBackupResources from "resource:///modules/backup/BackupResour import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +const BACKUP_DIR_PREF_NAME = "browser.backup.location"; const SCHEDULED_BACKUPS_ENABLED_PREF_NAME = "browser.backup.scheduled.enabled"; const lazy = {}; @@ -37,6 +38,13 @@ ChromeUtils.defineLazyGetter(lazy, "ZipWriter", () => Components.Constructor("@mozilla.org/zipwriter;1", "nsIZipWriter", "open") ); +ChromeUtils.defineLazyGetter(lazy, "gFluentStrings", function () { + return new Localization( + ["branding/brand.ftl", "preview/backupSettings.ftl"], + true + ); +}); + XPCOMUtils.defineLazyPreferenceGetter( lazy, "scheduledBackupsPref", @@ -50,6 +58,20 @@ XPCOMUtils.defineLazyPreferenceGetter( } ); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "backupDirPref", + BACKUP_DIR_PREF_NAME, + Services.dirsvc.get("Docs", Ci.nsIFile) + .path /* Default directory is Documents */, + async function onUpdateLocationDirPath(_pref, _prevVal, newVal) { + let bs = BackupService.get(); + if (bs) { + await bs.onUpdateLocationDirPath(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 @@ -71,6 +93,13 @@ export class BackupService extends EventTarget { */ #resources = new Map(); + /** + * The name of the backup folder. Should be localized. + * + * @see BACKUP_DIR_NAME + */ + static #backupFolderName = null; + /** * Set to true if a backup is currently in progress. Causes stateUpdate() * to be called. @@ -112,7 +141,12 @@ export class BackupService extends EventTarget { * @type {object} */ #_state = { - backupFilePath: "Documents", // TODO: make save location configurable (bug 1895943) + backupDirPath: lazy.backupDirPref, + defaultParent: { + path: BackupService.DEFAULT_PARENT_DIR_PATH, + fileName: PathUtils.filename(BackupService.DEFAULT_PARENT_DIR_PATH), + iconURL: this.getIconFromFilePath(BackupService.DEFAULT_PARENT_DIR_PATH), + }, backupInProgress: false, scheduledBackupsEnabled: lazy.scheduledBackupsPref, encryptionEnabled: false, @@ -152,6 +186,29 @@ export class BackupService extends EventTarget { */ #encState = undefined; + /** + * The path of the default parent directory for saving backups. + * The current default is the Documents directory. + * + * @returns {string} The path of the default parent directory + */ + static get DEFAULT_PARENT_DIR_PATH() { + return Services.dirsvc.get("Docs", Ci.nsIFile).path; + } + + /** + * The localized name for the user's backup folder. + * + * @returns {string} The localized backup folder name + */ + static get BACKUP_DIR_NAME() { + if (!BackupService.#backupFolderName) { + BackupService.#backupFolderName = + lazy.gFluentStrings.formatValueSync("backup-folder-name"); + } + return BackupService.#backupFolderName; + } + /** * The name of the folder within the profile folder where this service reads * and writes state to. @@ -924,6 +981,65 @@ export class BackupService extends EventTarget { } } + /** + * Sets the parent directory of the backups folder. Calling this function will update + * browser.backup.location. + * + * @param {string} parentDirPath directory path + */ + setParentDirPath(parentDirPath) { + try { + if (!parentDirPath || !PathUtils.filename(parentDirPath)) { + throw new Error("Parent directory path is invalid."); + } + // Recreate the backups path with the new parent directory. + let fullPath = PathUtils.join( + parentDirPath, + BackupService.BACKUP_DIR_NAME + ); + Services.prefs.setStringPref(BACKUP_DIR_PREF_NAME, fullPath); + } catch (e) { + lazy.logConsole.error( + `Failed to set parent directory ${parentDirPath}. ${e}` + ); + } + } + + /** + * Updates backupDirPath in the backup service state. Should be called every time the value + * for browser.backup.location changes. + * + * @param {string} newDirPath the new directory path for storing backups + */ + async onUpdateLocationDirPath(newDirPath) { + lazy.logConsole.debug(`Updating backup location to ${newDirPath}`); + + this.#_state.backupDirPath = newDirPath; + this.stateUpdate(); + } + + /** + * Returns the moz-icon URL of a file. To get the moz-icon URL, the + * file path is convered to a fileURI. If there is a problem retreiving + * the moz-icon due to an invalid file path, return null instead. + * + * @param {string} path Path of the file to read its icon from. + * @returns {string|null} The moz-icon URL of the specified file, or + * null if the icon cannot be retreived. + */ + getIconFromFilePath(path) { + if (!path) { + return null; + } + + try { + let fileURI = PathUtils.toFileURI(path); + return `moz-icon:${fileURI}?size=16`; + } catch (e) { + return null; + } + } + /** * Sets browser.backup.scheduled.enabled to true or false. * diff --git a/browser/components/backup/actors/BackupUIChild.sys.mjs b/browser/components/backup/actors/BackupUIChild.sys.mjs index b9796425e583..3dfd0b7cd243 100644 --- a/browser/components/backup/actors/BackupUIChild.sys.mjs +++ b/browser/components/backup/actors/BackupUIChild.sys.mjs @@ -19,7 +19,7 @@ export class BackupUIChild extends JSWindowActorChild { * @param {Event} event * The custom event that the widget fired. */ - handleEvent(event) { + async handleEvent(event) { /** * BackupUI:InitWidget sends a message to the parent to request the BackupService state * which will result in a `backupServiceState` property of the widget to be set when that @@ -31,6 +31,31 @@ export class BackupUIChild extends JSWindowActorChild { this.sendAsyncMessage("RequestState"); } else if (event.type == "BackupUI:ToggleScheduledBackups") { this.sendAsyncMessage("ToggleScheduledBackups", event.detail); + } else if (event.type == "BackupUI:ShowFilepicker") { + let targetNodeName = event.target.nodeName; + let { path, filename, iconURL } = await this.sendQuery("ShowFilepicker", { + win: event.detail?.win, + }); + + let widgets = ChromeUtils.nondeterministicGetWeakSetKeys( + this.#inittedWidgets + ); + + for (let widget of widgets) { + if (widget.isConnected && widget.nodeName == targetNodeName) { + widget.dispatchEvent( + new CustomEvent("BackupUI:SelectNewFilepickerPath", { + bubbles: true, + detail: { + path, + filename, + iconURL, + }, + }) + ); + break; + } + } } } diff --git a/browser/components/backup/actors/BackupUIParent.sys.mjs b/browser/components/backup/actors/BackupUIParent.sys.mjs index 908ea4c47781..deebf4a3daf4 100644 --- a/browser/components/backup/actors/BackupUIParent.sys.mjs +++ b/browser/components/backup/actors/BackupUIParent.sys.mjs @@ -66,12 +66,49 @@ export class BackupUIParent extends JSWindowActorParent { * @param {ReceiveMessageArgument} message * The message received from the BackupUIChild. */ - receiveMessage(message) { + async receiveMessage(message) { if (message.name == "RequestState") { this.sendState(); } else if (message.name == "ToggleScheduledBackups") { - this.#bs.setScheduledBackups(message.data?.isScheduledBackupsEnabled); + let { isScheduledBackupsEnabled, parentDirPath } = message.data; + + if (isScheduledBackupsEnabled && parentDirPath) { + this.#bs.setParentDirPath(parentDirPath); + } + + this.#bs.setScheduledBackups(isScheduledBackupsEnabled); + + return true; + + /** + * TODO: (Bug 1900125) we should create a backup at the specified dir path once we turn on + * scheduled backups. The backup folder in the chosen directory should contain + * the archive file, which we create using BackupService.createArchive implemented in + * Bug 1897498. + */ + } else if (message.name == "ShowFilepicker") { + let { win } = message.data; + + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(win, "", Ci.nsIFilePicker.modeGetFolder); + let result = await new Promise(resolve => fp.open(resolve)); + + if (result === Ci.nsIFilePicker.returnCancel) { + return false; + } + + let path = fp.file.path; + let iconURL = this.#bs.getIconFromFilePath(path); + let filename = PathUtils.filename(path); + + return { + path, + filename, + iconURL, + }; } + + return null; } /** diff --git a/browser/components/backup/content/backup-settings.mjs b/browser/components/backup/content/backup-settings.mjs index 2b3a160d585d..ee069647ad00 100644 --- a/browser/components/backup/content/backup-settings.mjs +++ b/browser/components/backup/content/backup-settings.mjs @@ -36,8 +36,9 @@ export default class BackupSettings extends MozLitElement { constructor() { super(); this.backupServiceState = { - backupFilePath: "Documents", // TODO: make save location configurable (bug 1895943) + backupDirPath: "", backupInProgress: false, + defaultParent: {}, scheduledBackupsEnabled: false, }; } @@ -66,6 +67,7 @@ export default class BackupSettings extends MozLitElement { bubbles: true, composed: true, detail: { + ...event.detail, isScheduledBackupsEnabled: true, }, }) @@ -108,9 +110,12 @@ export default class BackupSettings extends MozLitElement { } turnOnScheduledBackupsDialogTemplate() { + let { fileName, path, iconURL } = this.backupServiceState.defaultParent; return html` `; } diff --git a/browser/components/backup/content/backup-settings.stories.mjs b/browser/components/backup/content/backup-settings.stories.mjs index 6a570615c654..01310776c4d6 100644 --- a/browser/components/backup/content/backup-settings.stories.mjs +++ b/browser/components/backup/content/backup-settings.stories.mjs @@ -22,8 +22,12 @@ const Template = ({ backupServiceState }) => html` export const BackingUpNotInProgress = Template.bind({}); BackingUpNotInProgress.args = { backupServiceState: { - backupFilePath: "Documents", + backupDirPath: "/Some/User/Documents", backupInProgress: false, + defaultParent: { + path: "/Some/User/Documents", + fileName: "Documents", + }, scheduledBackupsEnabled: false, }, }; @@ -31,8 +35,12 @@ BackingUpNotInProgress.args = { export const BackingUpInProgress = Template.bind({}); BackingUpInProgress.args = { backupServiceState: { - backupFilePath: "Documents", + backupDirPath: "/Some/User/Documents", backupInProgress: true, + defaultParent: { + path: "/Some/User/Documents", + fileName: "Documents", + }, scheduledBackupsEnabled: false, }, }; @@ -40,8 +48,12 @@ BackingUpInProgress.args = { export const ScheduledBackupsEnabled = Template.bind({}); ScheduledBackupsEnabled.args = { backupServiceState: { - backupFilePath: "Documents", + backupDirPath: "/Some/User/Documents", backupInProgress: false, + defaultParent: { + path: "/Some/User/Documents", + fileName: "Documents", + }, scheduledBackupsEnabled: true, }, }; diff --git a/browser/components/backup/content/turn-on-scheduled-backups.css b/browser/components/backup/content/turn-on-scheduled-backups.css index b9b7fffa41ec..1feac6b917a4 100644 --- a/browser/components/backup/content/turn-on-scheduled-backups.css +++ b/browser/components/backup/content/turn-on-scheduled-backups.css @@ -53,9 +53,22 @@ align-items: center; } - #backup-location-filepicker-input { - margin: 0; + .backup-location-filepicker-input { flex: 1; + margin-block: var(--space-xsmall); + margin-inline-start: 0; + padding-inline-start: var(--space-xxlarge); + background-repeat: no-repeat; + background-size: var(--icon-size-default); + background-position: var(--space-small) 50%; + + /* For the placeholder icon */ + fill: currentColor; + -moz-context-properties: fill; + + &:dir(rtl) { + background-position: calc(100% - var(--space-small)) 50%; + } } } diff --git a/browser/components/backup/content/turn-on-scheduled-backups.mjs b/browser/components/backup/content/turn-on-scheduled-backups.mjs index eb1d80c44f05..a661dd3dc238 100644 --- a/browser/components/backup/content/turn-on-scheduled-backups.mjs +++ b/browser/components/backup/content/turn-on-scheduled-backups.mjs @@ -10,8 +10,15 @@ import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; * scheduled backups. */ export default class TurnOnScheduledBackups extends MozLitElement { + #placeholderIconURL = "chrome://global/skin/icons/page-portrait.svg"; + static properties = { - backupFilePath: { type: String }, + defaultIconURL: { type: String, reflect: true }, + defaultLabel: { type: String, reflect: true }, + defaultPath: { type: String, reflect: true }, + _newIconURL: { type: String }, + _newLabel: { type: String }, + _newPath: { type: String }, showPasswordOptions: { type: Boolean, reflect: true }, }; @@ -19,15 +26,22 @@ export default class TurnOnScheduledBackups extends MozLitElement { return { cancelButtonEl: "#backup-turn-on-scheduled-cancel-button", confirmButtonEl: "#backup-turn-on-scheduled-confirm-button", + filePathButtonEl: "#backup-location-filepicker-button", + filePathInputCustomEl: "#backup-location-filepicker-input-custom", + filePathInputDefaultEl: "#backup-location-filepicker-input-default", passwordOptionsCheckboxEl: "#sensitive-data-checkbox-input", passwordOptionsExpandedEl: "#passwords", - recommendedFolderInputEl: "#backup-location-filepicker-input", }; } constructor() { super(); - this.backupFilePath = null; + this.defaultIconURL = ""; + this.defaultLabel = ""; + this.defaultPath = ""; + this._newIconURL = ""; + this._newLabel = ""; + this._newPath = ""; this.showPasswordOptions = false; } @@ -40,10 +54,28 @@ export default class TurnOnScheduledBackups extends MozLitElement { this.dispatchEvent( new CustomEvent("BackupUI:InitWidget", { bubbles: true }) ); + + this.addEventListener("BackupUI:SelectNewFilepickerPath", this); } - handleChooseLocation() { - // TODO: show file picker (bug 1895943) + handleEvent(event) { + if (event.type == "BackupUI:SelectNewFilepickerPath") { + let { path, filename, iconURL } = event.detail; + this._newPath = path; + this._newLabel = filename; + this._newIconURL = iconURL; + } + } + + async handleChooseLocation() { + this.dispatchEvent( + new CustomEvent("BackupUI:ShowFilepicker", { + bubbles: true, + detail: { + win: window.browsingContext, + }, + }) + ); } handleCancel() { @@ -53,14 +85,12 @@ export default class TurnOnScheduledBackups extends MozLitElement { composed: true, }) ); - this.showPasswordOptions = false; - this.passwordOptionsCheckboxEl.checked = false; + this.resetChanges(); } handleConfirm() { /** * TODO: - * We should pass save location to BackupUIParent here (bug 1895943). * If encryption is enabled via this dialog, ensure a password is set and pass it to BackupUIParent (bug 1895981). * Before confirmation, verify passwords match and FxA format rules (bug 1896772). */ @@ -68,16 +98,62 @@ export default class TurnOnScheduledBackups extends MozLitElement { new CustomEvent("turnOnScheduledBackups", { bubbles: true, composed: true, + detail: { + parentDirPath: this._newPath || this.defaultPath, + }, }) ); - this.showPasswordOptions = false; - this.passwordOptionsCheckboxEl.checked = false; + this.resetChanges(); } handleTogglePasswordOptions() { this.showPasswordOptions = this.passwordOptionsCheckboxEl?.checked; } + resetChanges() { + this._newPath = ""; + this._newIconURL = ""; + this._newLabel = ""; + this.showPasswordOptions = false; + this.passwordOptionsCheckboxEl.checked = false; + } + + defaultFilePathInputTemplate() { + let filename = this.defaultLabel; + let iconURL = this.defaultIconURL || this.#placeholderIconURL; + + return html` + + `; + } + + customFilePathInputTemplate() { + let filename = this._newLabel; + let iconURL = this._newIconURL || this.#placeholderIconURL; + + return html` + + `; + } + allOptionsTemplate() { return html`
@@ -87,18 +163,10 @@ export default class TurnOnScheduledBackups extends MozLitElement { for="backup-location-filepicker-input" data-l10n-id="turn-on-scheduled-backups-location-label" > -
- + ${!this._newPath + ? this.defaultFilePathInputTemplate() + : this.customFilePathInputTemplate()} html` +const Template = ({ defaultPath, _newPath, defaultLabel, _newLabel }) => html` `; -export const RecommendedFolder = Template.bind({}); -RecommendedFolder.args = { - backupFilePath: "Documents", +export const Default = Template.bind({}); +Default.args = { + defaultPath: "/Some/User/Documents", + defaultLabel: "Documents", +}; + +export const CustomLocation = Template.bind({}); +CustomLocation.args = { + ...Default.args, + _newPath: "/Some/Test/Custom/Dir", + _newLabel: "Dir", }; diff --git a/browser/components/backup/tests/browser/browser_settings.js b/browser/components/backup/tests/browser/browser_settings.js index d88400787c7a..ee2c1ac87449 100644 --- a/browser/components/backup/tests/browser/browser_settings.js +++ b/browser/components/backup/tests/browser/browser_settings.js @@ -3,6 +3,19 @@ "use strict"; +const { BackupService } = ChromeUtils.importESModule( + "resource:///modules/backup/BackupService.sys.mjs" +); + +const { MockFilePicker } = SpecialPowers; + +add_setup(async () => { + MockFilePicker.init(window.browsingContext); + registerCleanupFunction(() => { + MockFilePicker.cleanup(); + }); +}); + /** * Tests that the section for controlling backup in about:preferences is * visible, but can also be hidden via a pref. @@ -87,6 +100,119 @@ add_task(async function test_turn_on_scheduled_backups_confirm() { }); }); +/** + * Tests that the turn on scheduled backups dialog displays the default input field + * and a filepicker to choose a custom backup file path, updates the input field to show + * that path, and sets browser.backup.location to the path from the settings page. + */ +add_task(async function test_turn_on_custom_location_filepicker() { + await BrowserTestUtils.withNewTab("about:preferences", async browser => { + const mockCustomParentDir = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "settings-custom-dir-test" + ); + const dummyFile = Cc["@mozilla.org/file/local;1"].createInstance( + Ci.nsIFile + ); + + dummyFile.initWithPath(mockCustomParentDir); + let filePickerShownPromise = new Promise(resolve => { + MockFilePicker.showCallback = () => { + Assert.ok(true, "Filepicker shown"); + MockFilePicker.setFiles([dummyFile]); + resolve(); + }; + }); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + // After setting up mocks, start testing components + let settings = browser.contentDocument.querySelector("backup-settings"); + let turnOnButton = settings.scheduledBackupsButtonEl; + + Assert.ok( + turnOnButton, + "Button to turn on scheduled backups should be found" + ); + + await settings.updateComplete; + let turnOnScheduledBackups = settings.turnOnScheduledBackupsEl; + + Assert.ok( + turnOnScheduledBackups, + "turn-on-scheduled-backups should be found" + ); + + // First verify the default input value and dir path button + let filePathInputDefault = turnOnScheduledBackups.filePathInputDefaultEl; + let filePathButton = turnOnScheduledBackups.filePathButtonEl; + const documentsPath = BackupService.DEFAULT_PARENT_DIR_PATH; + + Assert.ok( + filePathInputDefault, + "Default input for choosing a file path should be found" + ); + Assert.equal( + filePathInputDefault.value, + `${PathUtils.filename(documentsPath)} (recommended)`, + "Default input displays the expected text" + ); + Assert.ok( + filePathButton, + "Button for choosing a file path should be found" + ); + + // Next, verify the filepicker and updated dialog + let inputUpdatePromise = BrowserTestUtils.waitForCondition( + () => turnOnScheduledBackups.filePathInputCustomEl + ); + + filePathButton.click(); + + await filePickerShownPromise; + await turnOnScheduledBackups.updateComplete; + + info("Waiting for file path input to update"); + await inputUpdatePromise; + Assert.ok("Input should have been updated"); + + let filePathInputCustom = turnOnScheduledBackups.filePathInputCustomEl; + Assert.equal( + filePathInputCustom.value, + PathUtils.filename(mockCustomParentDir), + "Input should display file path from filepicker" + ); + + // Now close the dialog by confirming choices and verify that backup settings are saved + let confirmButton = turnOnScheduledBackups.confirmButtonEl; + Assert.ok(confirmButton, "Confirm button should be found"); + + let confirmButtonPromise = BrowserTestUtils.waitForEvent( + window, + "turnOnScheduledBackups" + ); + + confirmButton.click(); + + await confirmButtonPromise; + await settings.updateComplete; + + // Backup folder should be joined with the updated path + let locationPrefVal = Services.prefs.getStringPref( + "browser.backup.location" + ); + Assert.equal( + locationPrefVal, + PathUtils.join(mockCustomParentDir, BackupService.BACKUP_DIR_NAME), + "Backup location pref should be updated" + ); + + await IOUtils.remove(mockCustomParentDir, { + ignoreAbsent: true, + recursive: true, + }); + }); +}); + /** * Tests that the turn off scheduled backups dialog can set * browser.backup.scheduled.enabled to false from the settings page. diff --git a/browser/components/backup/tests/chrome/test_backup_settings.html b/browser/components/backup/tests/chrome/test_backup_settings.html index 2889ec7e4488..3fae66013bf7 100644 --- a/browser/components/backup/tests/chrome/test_backup_settings.html +++ b/browser/components/backup/tests/chrome/test_backup_settings.html @@ -38,8 +38,12 @@ */ add_task(async function test_turnOnScheduledBackupsDialog() { let settings = document.getElementById("test-backup-settings"); + const testDefaultName = "test-default-path"; settings.backupServiceState = { - scheduledBackupsEnabled: false, + defaultParent: { + path: PathUtils.join(PathUtils.tempDir, testDefaultName), + fileName: testDefaultName, + } } await settings.updateComplete; @@ -73,7 +77,12 @@ */ add_task(async function test_turnOffScheduledBackupsDialog() { let settings = document.getElementById("test-backup-settings"); + const testDefaultName = "test-default-path"; settings.backupServiceState = { + defaultParent: { + path: PathUtils.join(PathUtils.tempDir, testDefaultName), + fileName: testDefaultName, + }, scheduledBackupsEnabled: true, } diff --git a/browser/components/backup/tests/chrome/test_turn_on_scheduled_backups.html b/browser/components/backup/tests/chrome/test_turn_on_scheduled_backups.html index f4e93a586f30..4ceb4230b666 100644 --- a/browser/components/backup/tests/chrome/test_turn_on_scheduled_backups.html +++ b/browser/components/backup/tests/chrome/test_turn_on_scheduled_backups.html @@ -9,6 +9,8 @@ src="chrome://browser/content/backup/turn-on-scheduled-backups.mjs" type="module" > + + diff --git a/browser/locales-preview/backupSettings.ftl b/browser/locales-preview/backupSettings.ftl index 86486e26e6dc..fff1616c7dbe 100644 --- a/browser/locales-preview/backupSettings.ftl +++ b/browser/locales-preview/backupSettings.ftl @@ -2,6 +2,11 @@ # 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/. +# This string is used to name the folder that users will save backups to. +# "Restore" is an action and intended for prompting users to select this folder +# when following backup restoration steps. +backup-folder-name = Restore { -brand-product-name } + settings-data-backup-header = Backup settings-data-backup-toggle = Manage backup