From 36d22d82aa202bb199967e9512281e9a53db42c9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 21:33:14 +0200 Subject: Adding upstream version 115.7.0esr. Signed-off-by: Daniel Baumann --- .../migration/content/aboutWelcomeBack.xhtml | 126 +++ .../components/migration/content/brands/360.png | Bin 0 -> 21075 bytes .../components/migration/content/brands/brave.png | Bin 0 -> 7099 bytes .../components/migration/content/brands/canary.png | Bin 0 -> 7463 bytes .../components/migration/content/brands/chrome.png | Bin 0 -> 8353 bytes .../migration/content/brands/chromium.png | Bin 0 -> 6408 bytes .../components/migration/content/brands/edge.png | Bin 0 -> 11899 bytes .../migration/content/brands/edgebeta.png | Bin 0 -> 12273 bytes browser/components/migration/content/brands/ie.png | Bin 0 -> 6871 bytes .../components/migration/content/brands/opera.png | Bin 0 -> 5403 bytes .../migration/content/brands/operagx.png | Bin 0 -> 8222 bytes .../components/migration/content/brands/safari.png | Bin 0 -> 20520 bytes .../migration/content/brands/vivaldi.png | Bin 0 -> 7535 bytes .../migration/content/migration-dialog-window.html | 34 + .../migration/content/migration-dialog-window.js | 82 ++ .../content/migration-wizard-constants.mjs | 76 ++ .../migration/content/migration-wizard.mjs | 1088 ++++++++++++++++++++ browser/components/migration/content/migration.js | 812 +++++++++++++++ .../components/migration/content/migration.xhtml | 113 ++ 19 files changed, 2331 insertions(+) create mode 100644 browser/components/migration/content/aboutWelcomeBack.xhtml create mode 100644 browser/components/migration/content/brands/360.png create mode 100644 browser/components/migration/content/brands/brave.png create mode 100644 browser/components/migration/content/brands/canary.png create mode 100644 browser/components/migration/content/brands/chrome.png create mode 100644 browser/components/migration/content/brands/chromium.png create mode 100644 browser/components/migration/content/brands/edge.png create mode 100644 browser/components/migration/content/brands/edgebeta.png create mode 100644 browser/components/migration/content/brands/ie.png create mode 100644 browser/components/migration/content/brands/opera.png create mode 100644 browser/components/migration/content/brands/operagx.png create mode 100644 browser/components/migration/content/brands/safari.png create mode 100644 browser/components/migration/content/brands/vivaldi.png create mode 100644 browser/components/migration/content/migration-dialog-window.html create mode 100644 browser/components/migration/content/migration-dialog-window.js create mode 100644 browser/components/migration/content/migration-wizard-constants.mjs create mode 100644 browser/components/migration/content/migration-wizard.mjs create mode 100644 browser/components/migration/content/migration.js create mode 100644 browser/components/migration/content/migration.xhtml (limited to 'browser/components/migration/content') diff --git a/browser/components/migration/content/aboutWelcomeBack.xhtml b/browser/components/migration/content/aboutWelcomeBack.xhtml new file mode 100644 index 0000000000..0777cc56e9 --- /dev/null +++ b/browser/components/migration/content/aboutWelcomeBack.xhtml @@ -0,0 +1,126 @@ + + + +%htmlDTD; ]> + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/migration/content/migration-dialog-window.js b/browser/components/migration/content/migration-dialog-window.js new file mode 100644 index 0000000000..feaa70f565 --- /dev/null +++ b/browser/components/migration/content/migration-dialog-window.js @@ -0,0 +1,82 @@ +/* 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/. */ + +"use strict"; + +/** + * This file manages a MigrationWizard embedded in a dialog that runs + * in a top-level dialog window. It's main responsibility is to listen + * for dialog-specific events from the embedded MigrationWizard and to + * respond appropriately to them. + * + * A single object argument is expected to be passed when opening + * this dialog. + * + * @param {object} window.arguments.0 + * @param {Function} window.arguments.0.onResize + * A callback to resize the container of this document when the + * MigrationWizard resizes. + * @param {object} window.arguments.0.options + * A series of options for configuring the dialog. See + * MigrationUtils.showMigrationWizard for a description of this + * object. + */ + +const MigrationDialog = { + _wiz: null, + + init() { + addEventListener("load", this); + }, + + onLoad() { + this._wiz = document.getElementById("wizard"); + this._wiz.addEventListener("MigrationWizard:Close", this); + document.addEventListener("keypress", this); + + let args = window.arguments[0]; + // When opened via nsIWindowWatcher.openWindow, the arguments are + // passed through C++, and they arrive to us wrapped as an XPCOM + // object. We use wrappedJSObject to get at the underlying JS + // object. + if (args instanceof Ci.nsISupports) { + args = args.wrappedJSObject; + } + + // We have to inform the container of this document that the + // MigrationWizard has changed size in order for it to update + // its dimensions too. + let observer = new ResizeObserver(() => { + args.onResize(); + }); + observer.observe(this._wiz); + + let panelList = this._wiz.querySelector("panel-list"); + let panel = document.createXULElement("panel"); + panel.appendChild(panelList); + this._wiz.appendChild(panel); + this._wiz.requestState(); + }, + + handleEvent(event) { + switch (event.type) { + case "load": { + this.onLoad(); + break; + } + case "keypress": { + if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { + window.close(); + } + break; + } + case "MigrationWizard:Close": { + window.close(); + break; + } + } + }, +}; + +MigrationDialog.init(); diff --git a/browser/components/migration/content/migration-wizard-constants.mjs b/browser/components/migration/content/migration-wizard-constants.mjs new file mode 100644 index 0000000000..30e1af3d79 --- /dev/null +++ b/browser/components/migration/content/migration-wizard-constants.mjs @@ -0,0 +1,76 @@ +/* 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/. */ + +export const MigrationWizardConstants = Object.freeze({ + MIGRATOR_TYPES: Object.freeze({ + BROWSER: "browser", + FILE: "file", + }), + + /** + * A mapping of a page identification string to the IDs used by the + * various wizard pages. These are used by MigrationWizard.setState + * to set the current page. + * + * @type {Object} + */ + PAGES: Object.freeze({ + LOADING: "loading", + SELECTION: "selection", + PROGRESS: "progress", + FILE_IMPORT_PROGRESS: "file-import-progress", + SAFARI_PERMISSION: "safari-permission", + SAFARI_PASSWORD_PERMISSION: "safari-password-permission", + NO_BROWSERS_FOUND: "no-browsers-found", + }), + + /** + * Returns a mapping of a resource type to a string used to identify + * the associated resource group in the wizard via a data-resource-type + * attribute. The keys are used to set which items should be shown and + * in what state in #onShowingProgress. + * + * @type {Object} + */ + DISPLAYED_RESOURCE_TYPES: Object.freeze({ + // The DISPLAYED_RESOURCE_TYPES should have their keys match those + // in MigrationUtils.resourceTypes. + + // This is a little silly, but JavaScript doesn't have a notion of + // enums. The advantage of this set-up is that these constants values + // can be used to access the MigrationUtils.resourceTypes constants, + // are reasonably readable as DOM attributes, and easily serialize / + // deserialize. + HISTORY: "HISTORY", + FORMDATA: "FORMDATA", + PASSWORDS: "PASSWORDS", + BOOKMARKS: "BOOKMARKS", + PAYMENT_METHODS: "PAYMENT_METHODS", + + // We don't yet show OTHERDATA or SESSION resources. + }), + + DISPLAYED_FILE_RESOURCE_TYPES: Object.freeze({ + // When migrating passwords from a file, we first show the progress + // for a single PASSWORDS_FROM_FILE resource type, and then upon + // completion, show two different resource types - one for new + // passwords imported from the file, and one for existing passwords + // that were updated from the file. + PASSWORDS_FROM_FILE: "PASSWORDS_FROM_FILE", + PASSWORDS_NEW: "PASSWORDS_NEW", + PASSWORDS_UPDATED: "PASSWORDS_UPDATED", + BOOKMARKS_FROM_FILE: "BOOKMARKS_FROM_FILE", + }), + + /** + * The set of keys that maps to migrators that use the term "favorites" + * in the place of "bookmarks". This tends to be browsers from Microsoft. + */ + USES_FAVORITES: Object.freeze([ + "chromium-edge", + "chromium-edge-beta", + "edge", + "ie", + ]), +}); diff --git a/browser/components/migration/content/migration-wizard.mjs b/browser/components/migration/content/migration-wizard.mjs new file mode 100644 index 0000000000..e1462ab9d1 --- /dev/null +++ b/browser/components/migration/content/migration-wizard.mjs @@ -0,0 +1,1088 @@ +/* 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/. */ + +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-button-group.mjs"; +import { MigrationWizardConstants } from "chrome://browser/content/migration/migration-wizard-constants.mjs"; + +/** + * This component contains the UI that steps users through migrating their + * data from other browsers to this one. This component only contains very + * basic logic and structure for the UI, and most of the state management + * occurs in the MigrationWizardChild JSWindowActor. + */ +export class MigrationWizard extends HTMLElement { + static #template = null; + + #deck = null; + #browserProfileSelector = null; + #browserProfileSelectorList = null; + #resourceTypeList = null; + #shadowRoot = null; + #importButton = null; + #importFromFileButton = null; + #chooseImportFromFile = null; + #safariPermissionButton = null; + #safariPasswordImportSkipButton = null; + #safariPasswordImportSelectButton = null; + #selectAllCheckbox = null; + #resourceSummary = null; + #expandedDetails = false; + + static get markup() { + return ` + + `; + } + + static get fragment() { + if (!MigrationWizard.#template) { + let parser = new DOMParser(); + let doc = parser.parseFromString(MigrationWizard.markup, "text/html"); + MigrationWizard.#template = document.importNode( + doc.querySelector("template"), + true + ); + } + let fragment = MigrationWizard.#template.content.cloneNode(true); + if (window.IS_STORYBOOK) { + // If we're using Storybook, load the CSS from the static local file + // system rather than chrome:// to take advantage of auto-reloading. + fragment.querySelector("link[rel=stylesheet]").href = + "./migration/migration-wizard.css"; + } + return fragment; + } + + constructor() { + super(); + const shadow = this.attachShadow({ mode: "closed" }); + + if (window.MozXULElement) { + window.MozXULElement.insertFTLIfNeeded("branding/brand.ftl"); + window.MozXULElement.insertFTLIfNeeded("browser/migrationWizard.ftl"); + } + document.l10n.connectRoot(shadow); + + shadow.appendChild(MigrationWizard.fragment); + + this.#deck = shadow.querySelector("#wizard-deck"); + this.#browserProfileSelector = shadow.querySelector( + "#browser-profile-selector" + ); + this.#resourceSummary = shadow.querySelector("#resource-selection-summary"); + this.#resourceSummary.addEventListener("click", this); + + let cancelCloseButtons = shadow.querySelectorAll(".cancel-close"); + for (let button of cancelCloseButtons) { + button.addEventListener("click", this); + } + + let finishButtons = shadow.querySelectorAll(".finish-button"); + for (let button of finishButtons) { + button.addEventListener("click", this); + } + + this.#importButton = shadow.querySelector("#import"); + this.#importButton.addEventListener("click", this); + this.#importFromFileButton = shadow.querySelector("#import-from-file"); + this.#importFromFileButton.addEventListener("click", this); + this.#chooseImportFromFile = shadow.querySelector( + "#choose-import-from-file" + ); + this.#chooseImportFromFile.addEventListener("click", this); + + this.#browserProfileSelector.addEventListener("click", this); + this.#resourceTypeList = shadow.querySelector("#resource-type-list"); + this.#resourceTypeList.addEventListener("change", this); + + this.#safariPermissionButton = shadow.querySelector( + "#safari-request-permissions" + ); + this.#safariPermissionButton.addEventListener("click", this); + + this.#selectAllCheckbox = shadow.querySelector("#select-all").control; + + this.#safariPasswordImportSkipButton = shadow.querySelector( + "#safari-password-import-skip" + ); + this.#safariPasswordImportSkipButton.addEventListener("click", this); + + this.#safariPasswordImportSelectButton = shadow.querySelector( + "#safari-password-import-select" + ); + this.#safariPasswordImportSelectButton.addEventListener("click", this); + + this.#shadowRoot = shadow; + } + + connectedCallback() { + if (this.hasAttribute("auto-request-state")) { + this.requestState(); + } + } + + requestState() { + this.dispatchEvent( + new CustomEvent("MigrationWizard:RequestState", { bubbles: true }) + ); + } + + /** + * This setter can be used in the event that the MigrationWizard is being + * inserted via Lit, and the caller wants to set state declaratively using + * a property expression. + * + * @param {object} state + * The state object to pass to setState. + * @see MigrationWizard.setState. + */ + set state(state) { + this.setState(state); + } + + /** + * This is the main entrypoint for updating the state and appearance of + * the wizard. + * + * @param {object} state The state to be represented by the component. + * @param {string} state.page The page of the wizard to display. This should + * be one of the MigrationWizardConstants.PAGES constants. + */ + setState(state) { + switch (state.page) { + case MigrationWizardConstants.PAGES.SELECTION: { + this.#onShowingSelection(state); + break; + } + case MigrationWizardConstants.PAGES.PROGRESS: { + this.#onShowingProgress(state); + break; + } + case MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS: { + this.#onShowingFileImportProgress(state); + break; + } + case MigrationWizardConstants.PAGES.NO_BROWSERS_FOUND: { + this.#onShowingNoBrowsersFound(state); + break; + } + } + + this.#deck.toggleAttribute( + "aria-busy", + state.page == MigrationWizardConstants.PAGES.LOADING + ); + this.#deck.setAttribute("selected-view", `page-${state.page}`); + + if (window.IS_STORYBOOK) { + this.#updateForStorybook(); + } + } + + get #dialogMode() { + return this.hasAttribute("dialog-mode"); + } + + #ensureSelectionDropdown() { + if (this.#browserProfileSelectorList) { + return; + } + this.#browserProfileSelectorList = this.querySelector("panel-list"); + if (!this.#browserProfileSelectorList) { + throw new Error( + "Could not find a under the MigrationWizard during initialization." + ); + } + this.#browserProfileSelectorList.toggleAttribute( + "min-width-from-anchor", + true + ); + this.#browserProfileSelectorList.addEventListener("click", this); + // Until bug 1823489 is fixed, this is the easiest way for the + // migration wizard to style the selector dropdown so that it more + // closely lines up with the edges of the selector button. + this.#browserProfileSelectorList.style.boxSizing = "border-box"; + this.#browserProfileSelectorList.style.overflowY = "auto"; + } + + /** + * Reacts to changes to the browser / profile selector dropdown. This + * should update the list of resource types to match what's supported + * by the selected migrator and profile. + * + * @param {Element} panelItem the selected + */ + #onBrowserProfileSelectionChanged(panelItem) { + this.#browserProfileSelector.selectedPanelItem = panelItem; + this.#browserProfileSelector.querySelector("#migrator-name").textContent = + panelItem.displayName; + this.#browserProfileSelector.querySelector("#profile-name").textContent = + panelItem.profile?.name || ""; + + if (panelItem.brandImage) { + this.#browserProfileSelector.querySelector( + ".migrator-icon" + ).style.content = `url(${panelItem.brandImage})`; + } else { + this.#browserProfileSelector.querySelector( + ".migrator-icon" + ).style.content = "url(chrome://global/skin/icons/defaultFavicon.svg)"; + } + + let key = panelItem.getAttribute("key"); + let resourceTypes = panelItem.resourceTypes; + + for (let child of this.#resourceTypeList.querySelectorAll( + "label[data-resource-type]" + )) { + child.hidden = true; + child.control.checked = false; + } + + for (let resourceType of resourceTypes) { + let resourceLabel = this.#resourceTypeList.querySelector( + `label[data-resource-type="${resourceType}"]` + ); + if (resourceLabel) { + resourceLabel.hidden = false; + resourceLabel.control.checked = true; + + let labelSpan = resourceLabel.querySelector( + "span[default-data-l10n-id]" + ); + if (labelSpan) { + if (MigrationWizardConstants.USES_FAVORITES.includes(key)) { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("ie-edge-data-l10n-id") + ); + } else { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("default-data-l10n-id") + ); + } + } + } + } + let selectAll = this.#shadowRoot.querySelector("#select-all").control; + selectAll.checked = true; + + this.#displaySelectedResources(); + this.#browserProfileSelector.selectedPanelItem = panelItem; + + let selectionPage = this.#shadowRoot.querySelector( + "div[name='page-selection']" + ); + selectionPage.setAttribute("migrator-type", panelItem.getAttribute("type")); + selectionPage.toggleAttribute( + "no-resources", + panelItem.getAttribute("type") == + MigrationWizardConstants.MIGRATOR_TYPES.BROWSER && !resourceTypes.length + ); + } + + /** + * Called when showing the browser/profile selection page of the wizard. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string[]} state.migrators An array of source browser names that + * can be migrated from. + */ + #onShowingSelection(state) { + this.#ensureSelectionDropdown(); + this.#browserProfileSelectorList.textContent = ""; + + let selectionPage = this.#shadowRoot.querySelector( + "div[name='page-selection']" + ); + + let details = this.#shadowRoot.querySelector("details"); + selectionPage.toggleAttribute("show-import-all", state.showImportAll); + details.open = !state.showImportAll; + + this.#expandedDetails = false; + + for (let migrator of state.migrators) { + let opt = document.createElement("panel-item"); + opt.setAttribute("key", migrator.key); + opt.setAttribute("type", migrator.type); + opt.profile = migrator.profile; + opt.displayName = migrator.displayName; + opt.resourceTypes = migrator.resourceTypes; + opt.hasPermissions = migrator.hasPermissions; + opt.brandImage = migrator.brandImage; + + // Bug 1823489 - since the panel-list and panel-items are slotted, we + // cannot style them directly from migration-wizard.css. We use inline + // styles for now to achieve the desired appearance, but bug 1823489 + // will investigate having MigrationWizard own the , + // and 's so that styling can be done in the + // stylesheet instead. + let button = opt.shadowRoot.querySelector("button"); + button.style.minHeight = "40px"; + if (migrator.brandImage) { + button.style.backgroundImage = `url(${migrator.brandImage})`; + } else { + button.style.backgroundImage = `url("chrome://global/skin/icons/defaultFavicon.svg")`; + } + + // Bug 1823489 - since the panel-list and panel-items are slotted, we + // cannot style them or their children in migration-wizard.css. We use + // inline styles for now to achieve the desired appearance, but bug 1823489 + // will investigate having MigrationWizard own the , + // and 's so that styling can be done in the + // stylesheet instead. + if (migrator.type == MigrationWizardConstants.MIGRATOR_TYPES.FILE) { + button.style.backgroundSize = "20px"; + button.style.backgroundPosition = "6px center"; + if (this.#browserProfileSelectorList.isDocumentRTL()) { + button.style.backgroundPositionX = "right 6px"; + } + } + + if (migrator.profile) { + document.l10n.setAttributes( + opt, + "migration-wizard-selection-option-with-profile", + { + sourceBrowser: migrator.displayName, + profileName: migrator.profile.name, + } + ); + } else { + document.l10n.setAttributes( + opt, + "migration-wizard-selection-option-without-profile", + { + sourceBrowser: migrator.displayName, + } + ); + } + + this.#browserProfileSelectorList.appendChild(opt); + } + + if (state.migrators.length) { + this.#onBrowserProfileSelectionChanged( + this.#browserProfileSelectorList.firstElementChild + ); + } + + // Since this is called before the named-deck actually switches to + // show the selection page, we cannot focus this button immediately. + // Instead, we use a rAF to queue this up for focusing before the + // next paint. + requestAnimationFrame(() => { + this.#browserProfileSelector.focus({ focusVisible: false }); + }); + } + + /** + * @typedef {object} ProgressState + * The migration progress state for a resource. + * @property {boolean} inProgress + * True if progress is still underway. + * @property {string} [message=undefined] + * An optional message to display underneath the resource in + * the progress dialog. This message is only shown when inProgress + * is `false`. + */ + + /** + * Called when showing the progress / success page of the wizard. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string} state.key + * The key of the migrator being used. + * @param {Object} state.progress + * An object whose keys match one of DISPLAYED_RESOURCE_TYPES. + * + * Any resource type not included in state.progress will be hidden. + */ + #onShowingProgress(state) { + // Any resource progress group not included in state.progress is hidden. + let progressPage = this.#shadowRoot.querySelector( + "div[name='page-progress']" + ); + let resourceGroups = progressPage.querySelectorAll( + ".resource-progress-group" + ); + let totalProgressGroups = Object.keys(state.progress).length; + let remainingProgressGroups = totalProgressGroups; + + for (let group of resourceGroups) { + let resourceType = group.dataset.resourceType; + if (!state.progress.hasOwnProperty(resourceType)) { + group.hidden = true; + continue; + } + group.hidden = false; + + let progressIcon = group.querySelector(".progress-icon"); + let successText = group.querySelector(".success-text"); + + let labelSpan = group.querySelector("span[default-data-l10n-id]"); + if (labelSpan) { + if (MigrationWizardConstants.USES_FAVORITES.includes(state.key)) { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("ie-edge-data-l10n-id") + ); + } else { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("default-data-l10n-id") + ); + } + } + + if (state.progress[resourceType].inProgress) { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-in-progress" + ); + progressIcon.classList.remove("completed"); + successText.textContent = ""; + // With no status text, we re-insert the   so that the status + // text area does not fully collapse. + successText.appendChild(document.createTextNode("\u00A0")); + } else { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.classList.add("completed"); + successText.textContent = state.progress[resourceType].message; + remainingProgressGroups--; + } + } + + let migrationDone = remainingProgressGroups == 0; + let headerL10nID = migrationDone + ? "migration-wizard-progress-done-header" + : "migration-wizard-progress-header"; + let header = this.#shadowRoot.getElementById("progress-header"); + document.l10n.setAttributes(header, headerL10nID); + + let finishButtons = progressPage.querySelectorAll(".finish-button"); + let cancelButton = progressPage.querySelector(".cancel-close"); + + for (let finishButton of finishButtons) { + finishButton.hidden = !migrationDone; + } + + cancelButton.hidden = migrationDone; + + if (migrationDone) { + // Since this might be called before the named-deck actually switches to + // show the progress page, we cannot focus this button immediately. + // Instead, we use a rAF to queue this up for focusing before the + // next paint. + requestAnimationFrame(() => { + let button = this.#dialogMode + ? progressPage.querySelector(".done-button") + : progressPage.querySelector(".continue-button"); + button.focus({ focusVisible: false }); + }); + } + } + + /** + * Called when showing the progress / success page of the wizard for + * files. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string} state.title + * The string to display in the header. + * @param {Object} state.progress + * An object whose keys match one of DISPLAYED_FILE_RESOURCE_TYPES. + * + * Any resource type not included in state.progress will be hidden. + */ + #onShowingFileImportProgress(state) { + // Any resource progress group not included in state.progress is hidden. + let progressPage = this.#shadowRoot.querySelector( + "div[name='page-file-import-progress']" + ); + let resourceGroups = progressPage.querySelectorAll( + ".resource-progress-group" + ); + let totalProgressGroups = Object.keys(state.progress).length; + let remainingProgressGroups = totalProgressGroups; + + for (let group of resourceGroups) { + let resourceType = group.dataset.resourceType; + if (!state.progress.hasOwnProperty(resourceType)) { + group.hidden = true; + continue; + } + group.hidden = false; + + let progressIcon = group.querySelector(".progress-icon"); + let successText = group.querySelector(".success-text"); + + if (state.progress[resourceType].inProgress) { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-in-progress" + ); + progressIcon.classList.remove("completed"); + successText.textContent = ""; + // With no status text, we re-insert the   so that the status + // text area does not fully collapse. + successText.appendChild(document.createTextNode("\u00A0")); + } else { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.classList.add("completed"); + successText.textContent = state.progress[resourceType].message; + remainingProgressGroups--; + } + } + + let migrationDone = remainingProgressGroups == 0; + let header = this.#shadowRoot.getElementById("file-import-progress-header"); + header.textContent = state.title; + + let doneButton = progressPage.querySelector(".primary"); + let cancelButton = progressPage.querySelector(".cancel-close"); + doneButton.hidden = !migrationDone; + cancelButton.hidden = migrationDone; + + if (migrationDone) { + // Since this might be called before the named-deck actually switches to + // show the progress page, we cannot focus this button immediately. + // Instead, we use a rAF to queue this up for focusing before the + // next paint. + requestAnimationFrame(() => { + doneButton.focus({ focusVisible: false }); + }); + } + } + + /** + * Called when showing the "no browsers found" page of the wizard. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string} state.hasFileMigrators + * True if at least one FileMigrator is available for use. + */ + #onShowingNoBrowsersFound(state) { + this.#chooseImportFromFile.hidden = !state.hasFileMigrators; + } + + /** + * Certain parts of the MigrationWizard need to be modified slightly + * in order to work properly with Storybook. This method should be called + * to apply those changes after changing state. + */ + #updateForStorybook() { + // The CSS mask used for the progress spinner cannot be loaded via + // chrome:// URIs in Storybook. We work around this by exposing the + // progress elements as custom parts that the MigrationWizard story + // can style on its own. + this.#shadowRoot.querySelectorAll(".progress-icon").forEach(progressEl => { + if (progressEl.classList.contains("completed")) { + progressEl.removeAttribute("part"); + } else { + progressEl.setAttribute("part", "progress-spinner"); + } + }); + } + + /** + * Takes the current state of the selections page and bundles them + * up into a MigrationWizard:BeginMigration event that can be handled + * externally to perform the actual migration. + */ + #doImport() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + + this.dispatchEvent( + new CustomEvent("MigrationWizard:BeginMigration", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * @typedef {object} MigrationDetails + * @property {string} key + * The key for a MigratorBase subclass. + * @property {object|null} profile + * A representation of a browser profile. This is serialized and originally + * sent down from the parent via the GetAvailableMigrators message. + * @property {string[]} resourceTypes + * An array of resource types that the user is attempted to import. These + * strings should be from MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @property {boolean} hasPermissions + * True if this MigrationWizardChild told us that the associated + * MigratorBase subclass for the key has enough permission to read + * the requested resources. + * @property {boolean} expandedDetails + * True if the user clicked on the element to expand the resource + * type list. + */ + + /** + * Pulls information from the DOM state of the MigrationWizard and constructs + * and returns an object that can be used to begin migration via and event + * sent to the MigrationWizardChild. + * + * @returns {MigrationDetails} details + */ + #gatherMigrationEventDetails() { + let panelItem = this.#browserProfileSelector.selectedPanelItem; + let key = panelItem.getAttribute("key"); + let type = panelItem.getAttribute("type"); + let profile = panelItem.profile; + let hasPermissions = panelItem.hasPermissions; + + let resourceTypeFields = this.#resourceTypeList.querySelectorAll( + "label[data-resource-type]" + ); + let resourceTypes = []; + for (let resourceTypeField of resourceTypeFields) { + if (resourceTypeField.control.checked) { + resourceTypes.push(resourceTypeField.dataset.resourceType); + } + } + + return { + key, + type, + profile, + resourceTypes, + hasPermissions, + expandedDetails: this.#expandedDetails, + }; + } + + /** + * Sends a request to gain read access to the Safari profile folder on + * macOS, and upon gaining access, performs a migration using the current + * settings as gathered by #gatherMigrationEventDetails + */ + #requestSafariPermissions() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + this.dispatchEvent( + new CustomEvent("MigrationWizard:RequestSafariPermissions", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * Sends a request to get a string path for a passwords file exported + * from Safari. + */ + #selectSafariPasswordFile() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + this.dispatchEvent( + new CustomEvent("MigrationWizard:SelectSafariPasswordFile", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * Changes selected-data-header text and selected-data text based on + * how many resources are checked + */ + async #displaySelectedResources() { + let resourceTypeLabels = this.#resourceTypeList.querySelectorAll( + "label:not([hidden])[data-resource-type]" + ); + let panelItem = this.#browserProfileSelector.selectedPanelItem; + let key = panelItem.getAttribute("key"); + + let totalResources = resourceTypeLabels.length; + let checkedResources = 0; + + let selectedData = this.#shadowRoot.querySelector(".selected-data"); + let selectedDataArray = []; + let resourceTypeToLabelIDs = { + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: + "migration-list-bookmark-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS]: + "migration-list-password-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY]: + "migration-list-history-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA]: + "migration-list-autofill-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PAYMENT_METHODS]: + "migration-list-payment-methods-label", + }; + + if (MigrationWizardConstants.USES_FAVORITES.includes(key)) { + resourceTypeToLabelIDs[ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS + ] = "migration-list-favorites-label"; + } + + let resourceTypes = Object.keys(resourceTypeToLabelIDs); + let labelIds = Object.values(resourceTypeToLabelIDs).map(id => { + return { id }; + }); + let labels = await document.l10n.formatValues(labelIds); + let resourceTypeLabelMapping = new Map(); + for (let i = 0; i < resourceTypes.length; ++i) { + let resourceType = resourceTypes[i]; + resourceTypeLabelMapping.set(resourceType, labels[i]); + } + let formatter = new Intl.ListFormat(undefined, { + style: "long", + type: "conjunction", + }); + for (let resourceTypeLabel of resourceTypeLabels) { + if (resourceTypeLabel.control.checked) { + selectedDataArray.push( + resourceTypeLabelMapping.get(resourceTypeLabel.dataset.resourceType) + ); + checkedResources++; + } + } + if (selectedDataArray.length) { + selectedDataArray[0] = + selectedDataArray[0].charAt(0).toLocaleUpperCase() + + selectedDataArray[0].slice(1); + selectedData.textContent = formatter.format(selectedDataArray); + } else { + selectedData.textContent = "\u00A0"; + } + + let selectedDataHeader = this.#shadowRoot.querySelector( + ".selected-data-header" + ); + + let importButton = this.#shadowRoot.querySelector("#import"); + importButton.disabled = checkedResources == 0; + + if (checkedResources == 0) { + document.l10n.setAttributes( + selectedDataHeader, + "migration-no-selected-data-label" + ); + } else if (checkedResources < totalResources) { + document.l10n.setAttributes( + selectedDataHeader, + "migration-selected-data-label" + ); + } else { + document.l10n.setAttributes( + selectedDataHeader, + "migration-all-available-data-label" + ); + } + + let selectionPage = this.#shadowRoot.querySelector( + "div[name='page-selection']" + ); + selectionPage.toggleAttribute("single-item", totalResources == 1); + + this.dispatchEvent( + new CustomEvent("MigrationWizard:ResourcesUpdated", { bubbles: true }) + ); + } + + handleEvent(event) { + switch (event.type) { + case "click": { + if ( + event.target == this.#importButton || + event.target == this.#importFromFileButton + ) { + this.#doImport(); + } else if ( + event.target.classList.contains("cancel-close") || + event.target.classList.contains("finish-button") + ) { + this.dispatchEvent( + new CustomEvent("MigrationWizard:Close", { bubbles: true }) + ); + } else if (event.target == this.#browserProfileSelector) { + this.#browserProfileSelectorList.show(event); + } else if ( + event.currentTarget == this.#browserProfileSelectorList && + event.target != this.#browserProfileSelectorList + ) { + this.#onBrowserProfileSelectionChanged(event.target); + // If the user selected a file migration type from the selector, we'll + // help the user out by immediately starting the file migration flow, + // rather than waiting for them to click the "Select File". + if ( + event.target.getAttribute("type") == + MigrationWizardConstants.MIGRATOR_TYPES.FILE + ) { + this.#doImport(); + } + } else if (event.target == this.#safariPermissionButton) { + this.#requestSafariPermissions(); + } else if (event.currentTarget == this.#resourceSummary) { + this.#expandedDetails = true; + } else if (event.target == this.#chooseImportFromFile) { + this.dispatchEvent( + new CustomEvent("MigrationWizard:RequestState", { + bubbles: true, + detail: { + allowOnlyFileMigrators: true, + }, + }) + ); + } else if (event.target == this.#safariPasswordImportSkipButton) { + // If the user chose to skip importing passwords from Safari, we + // programmatically uncheck the PASSWORDS resource type and re-request + // import. + let checkbox = this.#shadowRoot.querySelector( + `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS}"]` + ).control; + checkbox.checked = false; + + // If there are no other checked checkboxes, go back to the selection + // screen. + let checked = this.#shadowRoot.querySelectorAll( + `label[data-resource-type] > input:checked` + ).length; + + if (!checked) { + this.requestState(); + } else { + this.#doImport(); + } + } else if (event.target == this.#safariPasswordImportSelectButton) { + this.#selectSafariPasswordFile(); + } + break; + } + case "change": { + if (event.target == this.#browserProfileSelector) { + this.#onBrowserProfileSelectionChanged(); + } else if (event.target == this.#selectAllCheckbox) { + let checkboxes = this.#shadowRoot.querySelectorAll( + 'label[data-resource-type]:not([hidden]) > input[type="checkbox"]' + ); + for (let checkbox of checkboxes) { + checkbox.checked = this.#selectAllCheckbox.checked; + } + this.#displaySelectedResources(); + } else { + let checkboxes = this.#shadowRoot.querySelectorAll( + 'label[data-resource-type]:not([hidden]) > input[type="checkbox"]' + ); + + let allVisibleChecked = Array.from(checkboxes).every(checkbox => { + return checkbox.checked; + }); + + this.#selectAllCheckbox.checked = allVisibleChecked; + this.#displaySelectedResources(); + } + break; + } + } + } +} + +if (globalThis.customElements) { + customElements.define("migration-wizard", MigrationWizard); +} diff --git a/browser/components/migration/content/migration.js b/browser/components/migration/content/migration.js new file mode 100644 index 0000000000..bc27bb4c9d --- /dev/null +++ b/browser/components/migration/content/migration.js @@ -0,0 +1,812 @@ +/* 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/. */ + +"use strict"; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { MigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/MigrationUtils.sys.mjs" +); +const { MigratorBase } = ChromeUtils.importESModule( + "resource:///modules/MigratorBase.sys.mjs" +); + +/** + * Map from data types that match Ci.nsIBrowserProfileMigrator's types to + * prefixes for strings used to label these data types in the migration + * dialog. We use these strings with -checkbox and -label suffixes for the + * checkboxes on the "importItems" page, and for the labels on the "migrating" + * and "done" pages, respectively. + */ +const kDataToStringMap = new Map([ + ["cookies", "browser-data-cookies"], + ["history", "browser-data-history"], + ["formdata", "browser-data-formdata"], + ["passwords", "browser-data-passwords"], + ["bookmarks", "browser-data-bookmarks"], + ["otherdata", "browser-data-otherdata"], + ["session", "browser-data-session"], + ["payment_methods", "browser-data-payment-methods"], +]); + +var MigrationWizard = { + /* exported MigrationWizard */ + _source: "", // Source Profile Migrator ContractID suffix + _itemsFlags: MigrationUtils.resourceTypes.ALL, // Selected Import Data Sources (16-bit bitfield) + _selectedProfile: null, // Selected Profile name to import from + _wiz: null, + _migrator: null, + _autoMigrate: null, + _receivedPermissions: new Set(), + _succeededMigrationEventArgs: null, + + init() { + Services.telemetry.setEventRecordingEnabled("browser.migration", true); + + let os = Services.obs; + os.addObserver(this, "Migration:Started"); + os.addObserver(this, "Migration:ItemBeforeMigrate"); + os.addObserver(this, "Migration:ItemAfterMigrate"); + os.addObserver(this, "Migration:ItemError"); + os.addObserver(this, "Migration:Ended"); + + this._wiz = document.querySelector("wizard"); + + let args = window.arguments[0]?.wrappedJSObject || {}; + let entrypoint = + args.entrypoint || MigrationUtils.MIGRATION_ENTRYPOINTS.UNKNOWN; + Services.telemetry + .getHistogramById("FX_MIGRATION_ENTRY_POINT_CATEGORICAL") + .add(entrypoint); + + // The legacy entrypoint Histogram wasn't categorical, so we translate to the right + // numeric value before writing it. We'll keep this Histogram around to ensure a + // smooth transition to the new FX_MIGRATION_ENTRY_POINT_CATEGORICAL categorical + // histogram. + let entryPointId = MigrationUtils.getLegacyMigrationEntrypoint(entrypoint); + Services.telemetry + .getHistogramById("FX_MIGRATION_ENTRY_POINT") + .add(entryPointId); + + this.isInitialMigration = + entrypoint == MigrationUtils.MIGRATION_ENTRYPOINTS.FIRSTRUN; + + // Record that the uninstaller requested a profile refresh + if (Services.env.get("MOZ_UNINSTALLER_PROFILE_REFRESH")) { + Services.env.set("MOZ_UNINSTALLER_PROFILE_REFRESH", ""); + Services.telemetry.scalarSet( + "migration.uninstaller_profile_refresh", + true + ); + } + + this._source = args.migratorKey; + this._migrator = + args.migrator instanceof MigratorBase ? args.migrator : null; + this._autoMigrate = !!args.isStartupMigration; + this._skipImportSourcePage = !!args.skipSourceSelection; + + if (this._migrator && args.profileId) { + let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + this._selectedProfile = sourceProfiles.find( + profile => profile.id == args.profileId + ); + } + + if (this._autoMigrate) { + // Show the "nothing" option in the automigrate case to provide an + // easily identifiable way to avoid migration and create a new profile. + document.getElementById("nothing").hidden = false; + } + + this._setSourceForDataLocalization(); + + document.addEventListener("wizardcancel", function () { + MigrationWizard.onWizardCancel(); + }); + + document + .getElementById("selectProfile") + .addEventListener("pageshow", function () { + MigrationWizard.onSelectProfilePageShow(); + }); + document + .getElementById("importItems") + .addEventListener("pageshow", function () { + MigrationWizard.onImportItemsPageShow(); + }); + document + .getElementById("migrating") + .addEventListener("pageshow", function () { + MigrationWizard.onMigratingPageShow(); + }); + document.getElementById("done").addEventListener("pageshow", function () { + MigrationWizard.onDonePageShow(); + }); + + document + .getElementById("selectProfile") + .addEventListener("pagerewound", function () { + MigrationWizard.onSelectProfilePageRewound(); + }); + document + .getElementById("importItems") + .addEventListener("pagerewound", function () { + MigrationWizard.onImportItemsPageRewound(); + }); + + document + .getElementById("selectProfile") + .addEventListener("pageadvanced", function () { + MigrationWizard.onSelectProfilePageAdvanced(); + }); + document + .getElementById("importItems") + .addEventListener("pageadvanced", function () { + MigrationWizard.onImportItemsPageAdvanced(); + }); + document + .getElementById("importPermissions") + .addEventListener("pageadvanced", function (e) { + MigrationWizard.onImportPermissionsPageAdvanced(e); + }); + document + .getElementById("importSource") + .addEventListener("pageadvanced", function (e) { + MigrationWizard.onImportSourcePageAdvanced(e); + }); + + this.recordEvent("opened"); + + this.onImportSourcePageShow(); + }, + + uninit() { + var os = Services.obs; + os.removeObserver(this, "Migration:Started"); + os.removeObserver(this, "Migration:ItemBeforeMigrate"); + os.removeObserver(this, "Migration:ItemAfterMigrate"); + os.removeObserver(this, "Migration:ItemError"); + os.removeObserver(this, "Migration:Ended"); + os.notifyObservers(this, "MigrationWizard:Destroyed"); + MigrationUtils.finishMigration(); + }, + + /** + * Used for recording telemetry in the migration wizard. + * + * @param {string} type + * The type of event being recorded. + * @param {object} args + * The data to pass to telemetry when the event is recorded. + */ + recordEvent(type, args = null) { + Services.telemetry.recordEvent( + "browser.migration", + type, + "legacy_wizard", + null, + args + ); + }, + + spinResolve(promise) { + let canAdvance = this._wiz.canAdvance; + let canRewind = this._wiz.canRewind; + this._wiz.canAdvance = false; + this._wiz.canRewind = false; + let result = MigrationUtils.spinResolve(promise); + this._wiz.canAdvance = canAdvance; + this._wiz.canRewind = canRewind; + return result; + }, + + _setSourceForDataLocalization() { + this._sourceForDataLocalization = this._source; + // Ensure consistency for various channels, brandings and versions of + // Chromium and MS Edge. + if (this._sourceForDataLocalization) { + this._sourceForDataLocalization = this._sourceForDataLocalization + .replace(/^(chromium-edge-beta|chromium-edge)$/, "edge") + .replace(/^(canary|chromium|chrome-beta|chrome-dev)$/, "chrome"); + } + }, + + onWizardCancel() { + MigrationUtils.forceExitSpinResolve(); + return true; + }, + + // 1 - Import Source + onImportSourcePageShow() { + this._wiz.canRewind = false; + + var selectedMigrator = null; + this._availableMigrators = []; + + // Figure out what source apps are are available to import from: + var group = document.getElementById("importSourceGroup"); + for (var i = 0; i < group.childNodes.length; ++i) { + var migratorKey = group.childNodes[i].id; + if (migratorKey != "nothing") { + var migrator = this.spinResolve( + MigrationUtils.getMigrator(migratorKey) + ); + + if (migrator?.enabled) { + // Save this as the first selectable item, if we don't already have + // one, or if it is the migrator that was passed to us. + if (!selectedMigrator || this._source == migratorKey) { + selectedMigrator = group.childNodes[i]; + } + + let profiles = this.spinResolve(migrator.getSourceProfiles()); + if (profiles?.length) { + Services.telemetry.keyedScalarAdd( + "migration.discovered_migrators", + migratorKey, + profiles.length + ); + } else { + Services.telemetry.keyedScalarAdd( + "migration.discovered_migrators", + migratorKey, + 1 + ); + } + + this._availableMigrators.push([migratorKey, migrator]); + } else { + // Hide this option + group.childNodes[i].hidden = true; + } + } + } + if (this.isInitialMigration) { + Services.telemetry + .getHistogramById("FX_STARTUP_MIGRATION_BROWSER_COUNT") + .add(this._availableMigrators.length); + let defaultBrowser = MigrationUtils.getMigratorKeyForDefaultBrowser(); + // This will record 0 for unknown default browser IDs. + defaultBrowser = MigrationUtils.getSourceIdForTelemetry(defaultBrowser); + Services.telemetry + .getHistogramById("FX_STARTUP_MIGRATION_EXISTING_DEFAULT_BROWSER") + .add(defaultBrowser); + } + + if (selectedMigrator) { + group.selectedItem = selectedMigrator; + } else { + this.recordEvent("no_browsers_found"); + // We didn't find a migrator, notify the user + document.getElementById("noSources").hidden = false; + + this._wiz.canAdvance = false; + + document.getElementById("importAll").hidden = true; + } + + // Advance to the next page if the caller told us to. + if (this._migrator && this._skipImportSourcePage) { + this._wiz.advance(); + this._wiz.canRewind = false; + } + }, + + onImportSourcePageAdvanced(event) { + var newSource = + document.getElementById("importSourceGroup").selectedItem.id; + + this.recordEvent("browser_selected", { migrator_key: newSource }); + + if (newSource == "nothing") { + // Need to do telemetry here because we're closing the dialog before we get to + // do actual migration. For actual migration, this doesn't happen until after + // migration takes place. + Services.telemetry + .getHistogramById("FX_MIGRATION_SOURCE_BROWSER") + .add(MigrationUtils.getSourceIdForTelemetry("nothing")); + this._wiz.cancel(); + event.preventDefault(); + } + + if (!this._migrator || newSource != this._source) { + // Create the migrator for the selected source. + this._migrator = this.spinResolve(MigrationUtils.getMigrator(newSource)); + + this._itemsFlags = MigrationUtils.resourceTypes.ALL; + this._selectedProfile = null; + } + this._source = newSource; + this._setSourceForDataLocalization(); + + // check for more than one source profile + var sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + if (this._skipImportSourcePage) { + this._updateNextPageForPermissions(); + } else if (sourceProfiles && sourceProfiles.length > 1) { + this._wiz.currentPage.next = "selectProfile"; + } else { + if (this._autoMigrate) { + this._updateNextPageForPermissions(); + } else { + this._wiz.currentPage.next = "importItems"; + } + + if (sourceProfiles && sourceProfiles.length == 1) { + this._selectedProfile = sourceProfiles[0]; + } else { + this._selectedProfile = null; + } + } + }, + + // 2 - [Profile Selection] + onSelectProfilePageShow() { + // Disabling this for now, since we ask about import sources in automigration + // too and don't want to disable the back button + // if (this._autoMigrate) + // document.documentElement.getButton("back").disabled = true; + + var profiles = document.getElementById("profiles"); + while (profiles.hasChildNodes()) { + profiles.firstChild.remove(); + } + + // Note that this block is still reached even if the user chose 'From File' + // and we canceled the dialog. When that happens, _migrator will be null. + if (this._migrator) { + var sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + + for (let profile of sourceProfiles) { + var item = document.createXULElement("radio"); + item.id = profile.id; + item.setAttribute("label", profile.name); + profiles.appendChild(item); + } + } + + profiles.selectedItem = this._selectedProfile + ? document.getElementById(this._selectedProfile.id) + : profiles.firstChild; + }, + + onSelectProfilePageRewound() { + var profiles = document.getElementById("profiles"); + let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + this._selectedProfile = + sourceProfiles.find(profile => profile.id == profiles.selectedItem.id) || + null; + }, + + onSelectProfilePageAdvanced() { + this.recordEvent("profile_selected", { + migrator_key: this._source, + }); + var profiles = document.getElementById("profiles"); + let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + this._selectedProfile = + sourceProfiles.find(profile => profile.id == profiles.selectedItem.id) || + null; + + // If we're automigrating or just doing bookmarks don't show the item selection page + if (this._autoMigrate) { + this._updateNextPageForPermissions(); + } + }, + + // 3 - ImportItems + onImportItemsPageShow() { + var dataSources = document.getElementById("dataSources"); + while (dataSources.hasChildNodes()) { + dataSources.firstChild.remove(); + } + + var items = this.spinResolve( + this._migrator.getMigrateData(this._selectedProfile) + ); + + for (let itemType of kDataToStringMap.keys()) { + let itemValue = MigrationUtils.resourceTypes[itemType.toUpperCase()]; + if (items & itemValue) { + let checkbox = document.createXULElement("checkbox"); + checkbox.id = itemValue; + checkbox.setAttribute("native", true); + document.l10n.setAttributes( + checkbox, + kDataToStringMap.get(itemType) + "-checkbox", + { browser: this._sourceForDataLocalization } + ); + dataSources.appendChild(checkbox); + if (!this._itemsFlags || this._itemsFlags & itemValue) { + checkbox.checked = true; + } + } + } + }, + + onImportItemsPageRewound() { + this._wiz.canAdvance = true; + this.onImportItemsPageAdvanced(true /* viaRewind */); + }, + + onImportItemsPageAdvanced(viaRewind = false) { + let extraKeys = { + migrator_key: this._source, + history: "0", + formdata: "0", + passwords: "0", + bookmarks: "0", + payment_methods: "0", + + // "other" will get incremented, so we keep this as a number for + // now, and will cast to a string before submitting to Event telemetry. + other: 0, + + configured: "0", + }; + + var dataSources = document.getElementById("dataSources"); + this._itemsFlags = 0; + + for (var i = 0; i < dataSources.childNodes.length; ++i) { + var checkbox = dataSources.childNodes[i]; + if (checkbox.localName == "checkbox" && checkbox.checked) { + let flag = parseInt(checkbox.id); + + switch (flag) { + case MigrationUtils.resourceTypes.HISTORY: + extraKeys.history = "1"; + break; + case MigrationUtils.resourceTypes.FORMDATA: + extraKeys.formdata = "1"; + break; + case MigrationUtils.resourceTypes.PASSWORDS: + extraKeys.passwords = "1"; + break; + case MigrationUtils.resourceTypes.BOOKMARKS: + extraKeys.bookmarks = "1"; + break; + case MigrationUtils.resourceTypes.PAYMENT_METHODS: + extraKeys.payment_methods = "1"; + break; + default: + extraKeys.other++; + } + + this._itemsFlags |= parseInt(checkbox.id); + } + } + + extraKeys.other = String(extraKeys.other); + + if (!viaRewind) { + this.recordEvent("resources_selected", extraKeys); + } + + this._updateNextPageForPermissions(); + }, + + onImportItemCommand() { + var items = document.getElementById("dataSources"); + var checkboxes = items.getElementsByTagName("checkbox"); + + var oneChecked = false; + for (var i = 0; i < checkboxes.length; ++i) { + if (checkboxes[i].checked) { + oneChecked = true; + break; + } + } + + this._wiz.canAdvance = oneChecked; + + this._updateNextPageForPermissions(); + }, + + _updateNextPageForPermissions() { + // We would like to just go straight to work: + this._wiz.currentPage.next = "migrating"; + // If we already have permissions, this is easy: + if (this._receivedPermissions.has(this._source)) { + return; + } + + // Otherwise, if we're on mojave or later and importing from + // Safari, prompt for the bookmarks file. + // We may add other browser/OS combos here in future. + if ( + this._source == "safari" && + AppConstants.isPlatformAndVersionAtLeast("macosx", "18") && + (this._itemsFlags & MigrationUtils.resourceTypes.BOOKMARKS || + this._itemsFlags == MigrationUtils.resourceTypes.ALL) + ) { + let havePermissions = this.spinResolve(this._migrator.hasPermissions()); + + if (!havePermissions) { + this._wiz.currentPage.next = "importPermissions"; + this.recordEvent("safari_perms"); + } + } + }, + + // 3b: permissions. This gets invoked when the user clicks "Next" + async onImportPermissionsPageAdvanced(event) { + // We're done if we have permission: + if (this._receivedPermissions.has(this._source)) { + return; + } + // The wizard helper is sync, and we need to check some stuff, so just stop + // advancing for now and prompt the user, then advance the wizard if everything + // worked. + event.preventDefault(); + + await this._migrator.getPermissions(window); + if (await this._migrator.hasPermissions()) { + this._receivedPermissions.add(this._source); + // Re-enter (we'll then allow the advancement through the early return above) + this._wiz.advance(); + } + // if we didn't have permissions after the `getPermissions` call, the user + // cancelled the dialog. Just no-op out now; the user can re-try by clicking + // the 'Continue' button again, or go back and pick a different browser. + }, + + // 4 - Migrating + onMigratingPageShow() { + this._wiz.getButton("cancel").disabled = true; + this._wiz.canRewind = false; + this._wiz.canAdvance = false; + + // When automigrating, show all of the data that can be received from this source. + if (this._autoMigrate) { + this._itemsFlags = this.spinResolve( + this._migrator.getMigrateData(this._selectedProfile) + ); + } + + this._listItems("migratingItems"); + setTimeout(() => this.onMigratingMigrate(), 0); + }, + + async onMigratingMigrate() { + await this._migrator.migrate( + this._itemsFlags, + this._autoMigrate, + this._selectedProfile + ); + + Services.telemetry + .getHistogramById("FX_MIGRATION_SOURCE_BROWSER") + .add(MigrationUtils.getSourceIdForTelemetry(this._source)); + if (!this._autoMigrate) { + let hist = Services.telemetry.getKeyedHistogramById("FX_MIGRATION_USAGE"); + let exp = 0; + let items = this._itemsFlags; + while (items) { + if (items & 1) { + hist.add(this._source, exp); + } + items = items >> 1; + exp++; + } + } + }, + + _listItems(aID) { + var items = document.getElementById(aID); + while (items.hasChildNodes()) { + items.firstChild.remove(); + } + + for (let itemType of kDataToStringMap.keys()) { + let itemValue = MigrationUtils.resourceTypes[itemType.toUpperCase()]; + if (this._itemsFlags & itemValue) { + var label = document.createXULElement("label"); + label.id = itemValue + "_migrated"; + try { + document.l10n.setAttributes( + label, + kDataToStringMap.get(itemType) + "-label", + { browser: this._sourceForDataLocalization } + ); + items.appendChild(label); + } catch (e) { + // if the block above throws, we've enumerated all the import data types we + // currently support and are now just wasting time, break. + break; + } + } + } + }, + + recordResourceMigration(obj, resourceType) { + // Sometimes, the resourceType that gets passed here is a string, which + // is bizarre. We'll hold our nose and accept either a string or a + // number. + resourceType = parseInt(resourceType, 10); + + switch (resourceType) { + case MigrationUtils.resourceTypes.HISTORY: + obj.history = "1"; + break; + case MigrationUtils.resourceTypes.FORMDATA: + obj.formdata = "1"; + break; + case MigrationUtils.resourceTypes.PASSWORDS: + obj.passwords = "1"; + break; + case MigrationUtils.resourceTypes.BOOKMARKS: + obj.bookmarks = "1"; + break; + case MigrationUtils.resourceTypes.PAYMENT_METHODS: + obj.payment_methods = "1"; + break; + default: + obj.other++; + } + }, + + recordMigrationStartEvent(resourceFlags) { + let extraKeys = { + migrator_key: this._source, + history: "0", + formdata: "0", + passwords: "0", + bookmarks: "0", + payment_methods: "0", + // "other" will get incremented, so we keep this as a number for + // now, and will cast to a string before submitting to Event telemetry. + other: 0, + }; + + for (let resourceTypeKey in MigrationUtils.resourceTypes) { + let resourceType = MigrationUtils.resourceTypes[resourceTypeKey]; + if (resourceFlags & resourceType) { + this.recordResourceMigration(extraKeys, resourceType); + } + } + + extraKeys.other = String(extraKeys.other); + this.recordEvent("migration_started", extraKeys); + }, + + observe(aSubject, aTopic, aData) { + var label; + switch (aTopic) { + case "Migration:Started": + this._succeededMigrationEventArgs = { + migrator_key: this._source, + history: "0", + formdata: "0", + passwords: "0", + bookmarks: "0", + payment_methods: "0", + // "other" will get incremented, so we keep this as a number for + // now, and will cast to a string before submitting to Event telemetry. + other: 0, + }; + this.recordMigrationStartEvent(this._itemsFlags); + break; + case "Migration:ItemBeforeMigrate": + label = document.getElementById(aData + "_migrated"); + if (label) { + label.setAttribute("style", "font-weight: bold"); + } + break; + case "Migration:ItemAfterMigrate": + this.recordResourceMigration(this._succeededMigrationEventArgs, aData); + label = document.getElementById(aData + "_migrated"); + if (label) { + label.removeAttribute("style"); + } + break; + case "Migration:Ended": + this._succeededMigrationEventArgs.other = String( + this._succeededMigrationEventArgs.other + ); + this.recordEvent( + "migration_finished", + this._succeededMigrationEventArgs + ); + + if (this.isInitialMigration) { + // Ensure errors in reporting data recency do not affect the rest of the migration. + try { + this.reportDataRecencyTelemetry(); + } catch (ex) { + console.error(ex); + } + } + if (this._autoMigrate) { + // We're done now. + this._wiz.canAdvance = true; + this._wiz.advance(); + + setTimeout(close, 5000); + } else { + this._wiz.canAdvance = true; + var nextButton = this._wiz.getButton("next"); + nextButton.click(); + } + break; + case "Migration:ItemError": + let type = "undefined"; + let numericType = parseInt(aData); + switch (numericType) { + case MigrationUtils.resourceTypes.COOKIES: + type = "cookies"; + break; + case MigrationUtils.resourceTypes.HISTORY: + type = "history"; + break; + case MigrationUtils.resourceTypes.FORMDATA: + type = "form data"; + break; + case MigrationUtils.resourceTypes.PASSWORDS: + type = "passwords"; + break; + case MigrationUtils.resourceTypes.BOOKMARKS: + type = "bookmarks"; + break; + case MigrationUtils.resourceTypes.PAYMENT_METHODS: + type = "payment methods"; + break; + case MigrationUtils.resourceTypes.OTHERDATA: + type = "misc. data"; + break; + } + Services.console.logStringMessage( + "some " + type + " did not successfully migrate." + ); + Services.telemetry + .getKeyedHistogramById("FX_MIGRATION_ERRORS") + .add(this._source, Math.log2(numericType)); + break; + } + }, + + onDonePageShow() { + this._wiz.getButton("cancel").disabled = true; + this._wiz.canRewind = false; + this._listItems("doneItems"); + }, + + reportDataRecencyTelemetry() { + let histogram = Services.telemetry.getKeyedHistogramById( + "FX_STARTUP_MIGRATION_DATA_RECENCY" + ); + let lastUsedPromises = []; + for (let [key, migrator] of this._availableMigrators) { + // No block-scoped let in for...of loop conditions, so get the source: + let localKey = key; + lastUsedPromises.push( + migrator.getLastUsedDate().then(date => { + const ONE_YEAR = 24 * 365; + let diffInHours = Math.round((Date.now() - date) / (60 * 60 * 1000)); + if (diffInHours > ONE_YEAR) { + diffInHours = ONE_YEAR; + } + histogram.add(localKey, diffInHours); + return [localKey, diffInHours]; + }) + ); + } + Promise.all(lastUsedPromises).then(migratorUsedTimeDiff => { + // Sort low to high. + migratorUsedTimeDiff.sort( + ([keyA, diffA], [keyB, diffB]) => diffA - diffB + ); /* eslint no-unused-vars: off */ + let usedMostRecentBrowser = + migratorUsedTimeDiff.length && + this._source == migratorUsedTimeDiff[0][0]; + let usedRecentBrowser = Services.telemetry.getKeyedHistogramById( + "FX_STARTUP_MIGRATION_USED_RECENT_BROWSER" + ); + usedRecentBrowser.add(this._source, usedMostRecentBrowser); + }); + }, +}; diff --git a/browser/components/migration/content/migration.xhtml b/browser/components/migration/content/migration.xhtml new file mode 100644 index 0000000000..be6c06c6fb --- /dev/null +++ b/browser/components/migration/content/migration.xhtml @@ -0,0 +1,113 @@ + +# 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/. + + + + + + + + + + +