diff --git a/browser/components/BrowserGlue.sys.mjs b/browser/components/BrowserGlue.sys.mjs index 4b21c32b64fd..eb17d677bb58 100644 --- a/browser/components/BrowserGlue.sys.mjs +++ b/browser/components/BrowserGlue.sys.mjs @@ -606,7 +606,8 @@ let JSWINDOWACTORS = { esModuleURI: "resource:///actors/MigrationWizardChild.sys.mjs", events: { "MigrationWizard:RequestState": { wantUntrusted: true }, - "MigrationWizard:BeginMigration": { wantsUntrusted: true }, + "MigrationWizard:BeginMigration": { wantUntrusted: true }, + "MigrationWizard:RequestSafariPermissions": { wantUntrusted: true }, }, }, diff --git a/browser/components/migration/MigrationWizardChild.sys.mjs b/browser/components/migration/MigrationWizardChild.sys.mjs index 187ce88790f7..795a159e4c41 100644 --- a/browser/components/migration/MigrationWizardChild.sys.mjs +++ b/browser/components/migration/MigrationWizardChild.sys.mjs @@ -59,17 +59,57 @@ export class MigrationWizardChild extends JSWindowActorChild { } case "MigrationWizard:BeginMigration": { - await this.sendQuery("Migrate", event.detail); - this.#wizardEl.dispatchEvent( - new this.contentWindow.CustomEvent("MigrationWizard:DoneMigration", { - bubbles: true, - }) - ); + let hasPermissions = await this.sendQuery("CheckPermissions", { + key: event.detail.key, + }); + if (!hasPermissions) { + if (event.detail.key == "safari") { + this.setComponentState({ + page: MigrationWizardConstants.PAGES.SAFARI_PERMISSION, + }); + } else { + console.error( + `A migrator with key ${event.detail.key} needs permissions, ` + + "and no UI exists for that right now." + ); + } + return; + } + + await this.beginMigration(event.detail); + break; + } + + case "MigrationWizard:RequestSafariPermissions": { + let success = await this.sendQuery("RequestSafariPermissions"); + if (success) { + await this.beginMigration(event.detail); + } break; } } } + /** + * Sends a message to the parent actor to attempt a migration. + * + * See migration-wizard.mjs for a definition of MigrationDetails. + * + * @param {object} migrationDetails + * A MigrationDetails object. + * @returns {Promise} + * Returns a Promise that resolves after the parent responds to the migration + * message. + */ + async beginMigration(migrationDetails) { + await this.sendQuery("Migrate", migrationDetails); + this.#wizardEl.dispatchEvent( + new this.contentWindow.CustomEvent("MigrationWizard:DoneMigration", { + bubbles: true, + }) + ); + } + /** * General message handler function for messages received from the * associated MigrationWizardParent JSWindowActor. diff --git a/browser/components/migration/MigrationWizardParent.sys.mjs b/browser/components/migration/MigrationWizardParent.sys.mjs index e708bbbf3345..4a23ae00ecdd 100644 --- a/browser/components/migration/MigrationWizardParent.sys.mjs +++ b/browser/components/migration/MigrationWizardParent.sys.mjs @@ -77,6 +77,19 @@ export class MigrationWizardParent extends JSWindowActorParent { message.data.resourceTypes, message.data.profile ); + break; + } + + case "CheckPermissions": { + let migrator = await MigrationUtils.getMigrator(message.data.key); + return migrator.hasPermissions(); + } + + case "RequestSafariPermissions": { + let safariMigrator = await MigrationUtils.getMigrator("safari"); + return safariMigrator.getPermissions( + this.browsingContext.topChromeWindow + ); } } @@ -237,8 +250,11 @@ export class MigrationWizardParent extends JSWindowActorParent { * @returns {Promise} */ async #serializeMigratorAndProfile(migrator, profileObj) { - let profileMigrationData = await migrator.getMigrateData(profileObj); - let lastModifiedDate = await migrator.getLastUsedDate(); + let [profileMigrationData, lastModifiedDate] = await Promise.all([ + migrator.getMigrateData(profileObj), + migrator.getLastUsedDate(), + ]); + let availableResourceTypes = []; for (let resourceType in MigrationUtils.resourceTypes) { diff --git a/browser/components/migration/MigratorBase.sys.mjs b/browser/components/migration/MigratorBase.sys.mjs index d1d84ffac36c..a741924a502b 100644 --- a/browser/components/migration/MigratorBase.sys.mjs +++ b/browser/components/migration/MigratorBase.sys.mjs @@ -196,6 +196,40 @@ export class MigratorBase { return Services.prefs.getBoolPref(`browser.migrate.${key}.enabled`, false); } + /** + * Subclasses should implement this if special checks need to be made to determine + * if certain permissions need to be requested before data can be imported. + * The returned Promise resolves to true if the required permissions have + * been granted and a migration could proceed. + * + * @returns {Promise} + */ + async hasPermissions() { + return Promise.resolve(true); + } + + /** + * Subclasses should implement this if special permissions need to be + * requested from the user or the operating system in order to perform + * a migration with this MigratorBase. This will be called only if + * hasPermissions resolves to false. + * + * The returned Promise will resolve to true if permissions were successfully + * obtained, and false otherwise. Implementors should ensure that if a call + * to getPermissions resolves to true, that the MigratorBase will be able to + * get read access to all of the resources it needs to do a migration. + * + * @param {DOMWindow} win + * The top-level DOM window hosting the UI that is requesting the permission. + * This can be used to, for example, anchor a file picker window to the + * same window that is hosting the migration UI. + * @returns {Promise} + */ + // eslint-disable-next-line no-unused-vars + async getPermissions(win) { + return Promise.resolve(true); + } + /** * This method returns a number that is the bitwise OR of all resource * types that are available in aProfile. See MigrationUtils.resourceTypes diff --git a/browser/components/migration/content/migration-wizard.mjs b/browser/components/migration/content/migration-wizard.mjs index ff2e71d7285f..050d0bbde1f5 100644 --- a/browser/components/migration/content/migration-wizard.mjs +++ b/browser/components/migration/content/migration-wizard.mjs @@ -21,6 +21,7 @@ export class MigrationWizard extends HTMLElement { #resourceTypeList = null; #shadowRoot = null; #importButton = null; + #safariPermissionButton = null; static get markup() { return ` @@ -215,6 +216,11 @@ export class MigrationWizard extends HTMLElement { 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.#shadowRoot = shadow; } @@ -353,6 +359,7 @@ export class MigrationWizard extends HTMLElement { opt.profile = migrator.profile; opt.displayName = migrator.displayName; opt.resourceTypes = migrator.resourceTypes; + opt.hasPermissions = migrator.hasPermissions; // Bug 1823489 - since the panel-list and panel-items are slotted, we // cannot style them directly from migration-wizard.css. We use inline @@ -495,9 +502,45 @@ export class MigrationWizard extends HTMLElement { * 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. + */ + + /** + * 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 profile = panelItem.profile; + let hasPermissions = panelItem.hasPermissions; + let resourceTypeFields = this.#resourceTypeList.querySelectorAll( "label[data-resource-type]" ); @@ -508,14 +551,25 @@ export class MigrationWizard extends HTMLElement { } } + return { + key, + profile, + resourceTypes, + hasPermissions, + }; + } + + /** + * 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:BeginMigration", { + new CustomEvent("MigrationWizard:RequestSafariPermissions", { bubbles: true, - detail: { - key, - profile, - resourceTypes, - }, + detail: migrationEventDetail, }) ); } @@ -629,6 +683,8 @@ export class MigrationWizard extends HTMLElement { event.target != this.#browserProfileSelectorList ) { this.#onBrowserProfileSelectionChanged(event.target); + } else if (event.target == this.#safariPermissionButton) { + this.#requestSafariPermissions(); } break; } diff --git a/browser/components/migration/tests/browser/browser.ini b/browser/components/migration/tests/browser/browser.ini index 5cc116395994..5d439b1ff773 100644 --- a/browser/components/migration/tests/browser/browser.ini +++ b/browser/components/migration/tests/browser/browser.ini @@ -9,3 +9,6 @@ prefs = [browser_dialog_resize.js] [browser_do_migration.js] [browser_entrypoint_telemetry.js] +[browser_safari_permissions.js] +run-if = + os == "mac" diff --git a/browser/components/migration/tests/browser/browser_safari_permissions.js b/browser/components/migration/tests/browser/browser_safari_permissions.js new file mode 100644 index 000000000000..d747878aa6d0 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_safari_permissions.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); +const { SafariProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/SafariProfileMigrator.sys.mjs" +); + +/** + * Tests that if we don't have permission to read the contents + * of ~/Library/Safari, that we can ask for permission to do that. + * + * This involves presenting the user with some instructions, and then + * showing a native folder picker for the user to select the + * ~/Library/Safari folder. This seems to give us read access to the + * folder contents. + * + * Revoking permissions for reading the ~/Library/Safari folder is + * not something that we know how to do just yet. It seems to be + * something involving macOS's System Integrity Protection. This test + * mocks out and simulates the actual permissions mechanism to make + * this test run reliably and repeatably. + */ +add_task(async function test_safari_permissions() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + Assert.ok( + await MigrationUtils.getMigrator(SafariProfileMigrator.key), + "Safari migrator exists." + ); + + sandbox + .stub(SafariProfileMigrator.prototype, "hasPermissions") + .onFirstCall() + .resolves(false) + .onSecondCall() + .resolves(true); + + sandbox + .stub(SafariProfileMigrator.prototype, "getPermissions") + .resolves(true); + + sandbox + .stub(SafariProfileMigrator.prototype, "getResources") + .callsFake(() => { + return Promise.resolve([ + { + type: MigrationUtils.resourceTypes.BOOKMARKS, + migrate: () => {}, + }, + ]); + }); + + let didMigration = new Promise(resolve => { + sandbox + .stub(SafariProfileMigrator.prototype, "migrate") + .callsFake((aResourceTypes, aStartup, aProfile, aProgressCallback) => { + Assert.ok( + !aStartup, + "Migrator should not have been called as a startup migration." + ); + + aProgressCallback(MigrationUtils.resourceTypes.BOOKMARKS); + Services.obs.notifyObservers(null, "Migration:Ended"); + resolve(); + }); + }); + + await withMigrationWizardDialog(async prefsWin => { + let dialogBody = prefsWin.document.body; + let wizard = dialogBody.querySelector("migration-wizard"); + let wizardDone = BrowserTestUtils.waitForEvent( + wizard, + "MigrationWizard:DoneMigration" + ); + + let shadow = wizard.openOrClosedShadowRoot; + + info("Choosing Safari"); + let panelItem = wizard.querySelector( + `panel-item[key="${SafariProfileMigrator.key}"]` + ); + panelItem.click(); + + // Let's just choose "Bookmarks" for now. + let resourceTypeList = shadow.querySelector("#resource-type-list"); + let node = resourceTypeList.querySelector( + `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS}"]` + ); + node.control.checked = true; + + let deck = shadow.querySelector("#wizard-deck"); + let switchedToSafariPermissionPage = BrowserTestUtils.waitForMutationCondition( + deck, + { attributeFilter: ["selected-view"] }, + () => { + return ( + deck.getAttribute("selected-view") == + "page-" + MigrationWizardConstants.PAGES.SAFARI_PERMISSION + ); + } + ); + + let importButton = shadow.querySelector("#import"); + importButton.click(); + await switchedToSafariPermissionPage; + Assert.ok(true, "Went to Safari permission page after attempting import."); + + let requestPermissions = shadow.querySelector( + "#safari-request-permissions" + ); + requestPermissions.click(); + await didMigration; + Assert.ok(true, "Completed migration"); + + let dialog = prefsWin.document.querySelector("#migrationWizardDialog"); + let doneButton = shadow.querySelector("#done-button"); + let dialogClosed = BrowserTestUtils.waitForEvent(dialog, "close"); + + doneButton.click(); + await dialogClosed; + await wizardDone; + }); +});