diff --git a/packages/functional-tests/tests/pairing/CLAUDE.md b/packages/functional-tests/tests/pairing/CLAUDE.md index 0c90a5c1f32..f553e8a2c98 100644 --- a/packages/functional-tests/tests/pairing/CLAUDE.md +++ b/packages/functional-tests/tests/pairing/CLAUDE.md @@ -76,7 +76,7 @@ IOS_PAIRING_ENABLED=1 \ ``` iOS v2 (needs the stack started with `PAIRING_VERSION=2`, -`PAIRING_IOS_URL_SCHEME=fennec` and `PAIRING_IOS_HANDOFF=true`): +`PAIRING_IOS_URL_SCHEME=fennec` and `PAIRING_V2_MIN_VERSION_IOS=0`): ```bash cd packages/functional-tests @@ -93,12 +93,12 @@ npx playwright test pairingFlowV2iOS.spec.ts -g 'from a deep link' # test buil npx playwright test pairingFlowV2iOS.spec.ts -g "page's own" # page supplies the link ``` -`PAIRING_IOS_URL_SCHEME` and `PAIRING_IOS_HANDOFF` only matter to the hand-off -delivery, whose link the `/pair` page builds from the served +`PAIRING_IOS_URL_SCHEME` and `PAIRING_V2_MIN_VERSION_IOS` only matter to the +hand-off delivery, whose link the `/pair` page builds from the served `pairing.iosUrlScheme`. The scheme has to name this build (`fennec`) or the link -points at an install that is not there, and without the hand-off enabled the -page sends an iOS browser to `/pair/unsupported` instead of offering a link at -all. The deep-link delivery builds its own URL in `IOSSupplicant` and ignores +points at an install that is not there, and without an iOS minimum configured +(any value, `0` meaning every version) the page sends an iOS browser to +`/pair/unsupported` instead of offering a link at all. The deep-link delivery builds its own URL in `IOSSupplicant` and ignores both. `IOS_DESTINATION` is optional; without it `IOSSupplicant` targets whichever diff --git a/packages/functional-tests/tests/pairing/pairingFlowV2iOS.spec.ts b/packages/functional-tests/tests/pairing/pairingFlowV2iOS.spec.ts index 9759bcaaf80..57cfc3d89b0 100644 --- a/packages/functional-tests/tests/pairing/pairingFlowV2iOS.spec.ts +++ b/packages/functional-tests/tests/pairing/pairingFlowV2iOS.spec.ts @@ -39,7 +39,7 @@ * * Local target only, gated behind IOS_PAIRING_V2_ENABLED, and skipped by default in every * case. Prerequisites: the FxA stack - * started with PAIRING_VERSION=2 and PAIRING_IOS_HANDOFF=true, a booted Simulator, a + * started with PAIRING_VERSION=2 and PAIRING_V2_MIN_VERSION_IOS=0, a booted Simulator, a * firefox-ios checkout built with `build-for-testing` for the SyncIntegrationTestPlan, and * Firefox Nightly for the authority (or FIREFOX_BINARY at a v2-capable build). */ @@ -153,7 +153,7 @@ const DELIVERIES = [ * * The iOS descriptor makes `detectDevice` report iOS. The card renders only once `fxa_status` * goes unanswered, which is a timeout rather than a reply, so it is absent on first paint — - * and only where the stack serves `PAIRING_IOS_HANDOFF=true`. + * and only where the stack serves an iOS minimum (`PAIRING_V2_MIN_VERSION_IOS`). */ async function readHandoffDeepLink( browser: Browser, diff --git a/packages/fxa-content-server/server/lib/beta-settings.js b/packages/fxa-content-server/server/lib/beta-settings.js index c3153a37a83..d742fbb03f7 100644 --- a/packages/fxa-content-server/server/lib/beta-settings.js +++ b/packages/fxa-content-server/server/lib/beta-settings.js @@ -113,10 +113,14 @@ const settingsConfig = { pairing: { browserBuild: config.get('pairing.browser_build'), iosUrlScheme: config.get('pairing.ios_url_scheme'), - iosHandoff: config.get('pairing.ios_handoff'), clients: config.get('pairing.clients'), serverBaseUri: config.get('pairing.server_base_uri'), version: config.get('pairing.version'), + v2MinVersion: { + ios: config.get('pairing.v2_min_version.ios'), + android: config.get('pairing.v2_min_version.android'), + desktop: config.get('pairing.v2_min_version.desktop'), + }, }, mobileStoreLinks: { ios: config.get('mobileStoreLinks.ios'), diff --git a/packages/fxa-content-server/server/lib/configuration.js b/packages/fxa-content-server/server/lib/configuration.js index 52d816d8a16..60d8334bf11 100644 --- a/packages/fxa-content-server/server/lib/configuration.js +++ b/packages/fxa-content-server/server/lib/configuration.js @@ -768,12 +768,6 @@ const conf = (module.exports = convict({ env: 'PAIRING_IOS_URL_SCHEME', format: ['firefox', 'fennec', 'firefox-beta', 'firefox-internal'], }, - ios_handoff: { - default: false, - doc: 'Whether a pairing QR scanned outside Firefox on iOS is handed off to the Firefox app. Disabled while Firefox iOS cannot finish a pairing it did not start, since the hand-off card is then only an extra step in front of /pair/unsupported.', - env: 'PAIRING_IOS_HANDOFF', - format: Boolean, - }, clients: { default: [ '3c49430b43dfba77', // Reference browser @@ -795,6 +789,26 @@ const conf = (module.exports = convict({ env: 'PAIRING_VERSION', format: Number, }, + v2_min_version: { + ios: { + default: undefined, + doc: 'Lowest Firefox iOS major version that takes the v2 flow when pairing.version is 2. Decided from the user agent, so the browser need not advertise pairingVersion 2 in fxa_status; unset defers to what the browser advertises. 0 enables every version. Setting it also turns on handing a pairing QR scanned outside Firefox on iOS to the Firefox app; unset, such a scan lands on /pair/unsupported.', + env: 'PAIRING_V2_MIN_VERSION_IOS', + format: Number, + }, + android: { + default: undefined, + doc: 'Lowest Firefox Android major version that takes the v2 flow when pairing.version is 2. See pairing.v2_min_version.ios.', + env: 'PAIRING_V2_MIN_VERSION_ANDROID', + format: Number, + }, + desktop: { + default: undefined, + doc: 'Lowest Firefox desktop major version that takes the v2 flow when pairing.version is 2. See pairing.v2_min_version.ios.', + env: 'PAIRING_V2_MIN_VERSION_DESKTOP', + format: Number, + }, + }, }, mobileStoreLinks: { ios: { diff --git a/packages/fxa-dev-launcher/README.md b/packages/fxa-dev-launcher/README.md index 59afeb38084..b922a50c733 100644 --- a/packages/fxa-dev-launcher/README.md +++ b/packages/fxa-dev-launcher/README.md @@ -58,6 +58,8 @@ For a content origin that is not `https`, `localhost`, or `127.0.0.1`, the launc Set it to `2` to opt in to the pairing v2 flow. The browser and the server must both advertise version 2, so set `PAIRING_VERSION=2` on the stack as well. Leave it unset for v1. +The stack can also decide on its own from the Firefox version: with `PAIRING_V2_MIN_VERSION_DESKTOP=` set alongside `PAIRING_VERSION=2`, any desktop Firefox at or above that major takes the v2 flow whatever the browser pref says (`0` means every version). `PAIRING_V2_MIN_VERSION_ANDROID` and `PAIRING_V2_MIN_VERSION_IOS` do the same for the mobile apps. + ## `FIREFOX_BIN` Selects the Firefox binary: diff --git a/packages/fxa-settings/src/lib/config.ts b/packages/fxa-settings/src/lib/config.ts index a33180ec1d2..1b60c326d13 100644 --- a/packages/fxa-settings/src/lib/config.ts +++ b/packages/fxa-settings/src/lib/config.ts @@ -98,14 +98,21 @@ export interface Config { browserBuild: 'firefox' | 'fenix'; /** iOS URL scheme the pairing hand-off opens. See `pairing.ios_url_scheme`. */ iosUrlScheme: string; - /** - * Whether a pairing QR scanned outside Firefox on iOS is handed off to the - * Firefox app. See `pairing.ios_handoff`. - */ - iosHandoff: boolean; clients: string[]; serverBaseUri: string; version: number; + /** + * Lowest Firefox major version, per platform, that takes the v2 flow. A + * platform left out defers to the `pairingVersion` the browser reports in + * fxa_status. Setting `ios` is also what enables handing a pairing QR + * scanned outside Firefox on iOS to the Firefox app. See + * `pairing.v2_min_version`. + */ + v2MinVersion: { + ios?: number; + android?: number; + desktop?: number; + }; }; mobileStoreLinks: { ios: string; @@ -229,10 +236,10 @@ export function getDefault() { pairing: { browserBuild: 'firefox', iosUrlScheme: 'firefox', - iosHandoff: false, clients: [], serverBaseUri: 'wss://channelserver.services.mozilla.com', version: 1, + v2MinVersion: {}, }, mobileStoreLinks: { ios: 'https://apps.apple.com/app/firefox-private-safe-browser/id989804926', diff --git a/packages/fxa-settings/src/lib/pairing/handoff.ts b/packages/fxa-settings/src/lib/pairing/handoff.ts index 89c6ffcfc51..a5f25af2438 100644 --- a/packages/fxa-settings/src/lib/pairing/handoff.ts +++ b/packages/fxa-settings/src/lib/pairing/handoff.ts @@ -182,10 +182,11 @@ export function planPairingHandoff({ /** iOS URL scheme to hand off to. See `buildIosDeepLink`. */ iosScheme: string; /** - * Whether Firefox iOS can finish a pairing that started in another browser. - * It cannot until the app supports pairing version 2 from a native-camera - * scan, and a hand-off that only ever lands on /pair/unsupported is worse - * than going there directly — so off unless the deployment opts in. + * Whether Firefox iOS can finish a pairing that started in another browser, + * which is so once the deployment has rolled pairing v2 out to it (see + * `isPairingV2RolledOut`). Before that a hand-off only ever lands on + * /pair/unsupported, which is worse than going there directly — so off + * unless the caller says otherwise. */ iosHandoff?: boolean; }): HandoffPlan { diff --git a/packages/fxa-settings/src/lib/pairing/v2-gate.test.ts b/packages/fxa-settings/src/lib/pairing/v2-gate.test.ts new file mode 100644 index 00000000000..9244a98ef7c --- /dev/null +++ b/packages/fxa-settings/src/lib/pairing/v2-gate.test.ts @@ -0,0 +1,216 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Devices } from '../utilities'; +import { + getFirefoxMajorVersion, + getPairingPlatform, + isPairingV2Enabled, + isPairingV2RolledOut, +} from './v2-gate'; + +const FIREFOX_DESKTOP_147 = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) ' + + 'Gecko/20100101 Firefox/147.0'; +const FIREFOX_ANDROID_147 = + 'Mozilla/5.0 (Android 14; Mobile; rv:147.0) Gecko/147.0 Firefox/147.0'; +const FIREFOX_IOS_147 = + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) ' + + 'AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/147.0 Mobile/15E148 ' + + 'Safari/605.1.15'; +const CHROME_DESKTOP = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'; + +describe('getPairingPlatform', () => { + it.each([ + [Devices.FIREFOX_DESKTOP, 'desktop'], + [Devices.FIREFOX_ANDROID, 'android'], + [Devices.FIREFOX_IOS, 'ios'], + ])('maps %s to %s', (device, platform) => { + expect(getPairingPlatform(device)).toBe(platform); + }); + + it.each([Devices.OTHER_ANDROID, Devices.OTHER_IOS, Devices.OTHER])( + 'has no platform for %s', + (device) => { + expect(getPairingPlatform(device)).toBeUndefined(); + } + ); +}); + +describe('getFirefoxMajorVersion', () => { + it('reads the Firefox token on desktop', () => { + expect(getFirefoxMajorVersion(FIREFOX_DESKTOP_147, 'desktop')).toBe(147); + }); + + it('reads the Firefox token on Android', () => { + expect(getFirefoxMajorVersion(FIREFOX_ANDROID_147, 'android')).toBe(147); + }); + + it('reads the FxiOS token on iOS', () => { + expect(getFirefoxMajorVersion(FIREFOX_IOS_147, 'ios')).toBe(147); + }); + + it('only accepts the FxiOS token for iOS', () => { + expect(getFirefoxMajorVersion(FIREFOX_DESKTOP_147, 'ios')).toBeUndefined(); + }); + + it('only accepts the Firefox token off iOS', () => { + expect(getFirefoxMajorVersion(FIREFOX_IOS_147, 'desktop')).toBeUndefined(); + }); + + it('is undefined for a browser that is not Firefox', () => { + expect(getFirefoxMajorVersion(CHROME_DESKTOP, 'desktop')).toBeUndefined(); + }); +}); + +describe('isPairingV2RolledOut', () => { + it('is true once the platform has a minimum, zero included', () => { + expect( + isPairingV2RolledOut({ version: 2, v2MinVersion: { ios: 0 } }, 'ios') + ).toBe(true); + expect( + isPairingV2RolledOut({ version: 2, v2MinVersion: { ios: 148 } }, 'ios') + ).toBe(true); + }); + + it('is false for a platform without a minimum', () => { + expect(isPairingV2RolledOut({ version: 2 }, 'ios')).toBe(false); + expect( + isPairingV2RolledOut( + { version: 2, v2MinVersion: { android: 0, desktop: 0 } }, + 'ios' + ) + ).toBe(false); + }); + + it('is false while the deployment is on pairing version 1', () => { + expect( + isPairingV2RolledOut({ version: 1, v2MinVersion: { ios: 0 } }, 'ios') + ).toBe(false); + }); + + it('treats a minimum that is not a finite number as unset', () => { + expect( + isPairingV2RolledOut( + { version: 2, v2MinVersion: { ios: null as unknown as number } }, + 'ios' + ) + ).toBe(false); + }); +}); + +describe('isPairingV2Enabled', () => { + const desktop = ( + pairing: Parameters[0]['pairing'], + browserPairingVersion?: number, + userAgent = FIREFOX_DESKTOP_147 + ) => + isPairingV2Enabled({ + pairing, + device: Devices.FIREFOX_DESKTOP, + userAgent, + browserPairingVersion, + }); + + it('is off while the deployment is on pairing version 1', () => { + expect(desktop({ version: 1, v2MinVersion: { desktop: 100 } }, 2)).toBe( + false + ); + }); + + describe('with a minimum for the platform', () => { + const pairing = { version: 2, v2MinVersion: { desktop: 147 } }; + + it('is on at the minimum, whatever the browser reports', () => { + expect(desktop(pairing, 1)).toBe(true); + expect(desktop(pairing, undefined)).toBe(true); + }); + + it('is on above the minimum', () => { + expect( + desktop(pairing, 1, FIREFOX_DESKTOP_147.replace(/147/g, '148')) + ).toBe(true); + }); + + it('is off below the minimum, even when the browser reports v2', () => { + expect( + desktop(pairing, 2, FIREFOX_DESKTOP_147.replace(/147/g, '146')) + ).toBe(false); + }); + + it('is off when the user agent carries no version to compare', () => { + expect(desktop(pairing, 2, CHROME_DESKTOP)).toBe(false); + }); + + it('reads the minimum for the browser platform only', () => { + expect( + isPairingV2Enabled({ + pairing: { version: 2, v2MinVersion: { ios: 147, android: 147 } }, + device: Devices.FIREFOX_IOS, + userAgent: FIREFOX_IOS_147, + browserPairingVersion: 1, + }) + ).toBe(true); + expect( + isPairingV2Enabled({ + pairing: { version: 2, v2MinVersion: { ios: 148, android: 147 } }, + device: Devices.FIREFOX_ANDROID, + userAgent: FIREFOX_ANDROID_147, + browserPairingVersion: 1, + }) + ).toBe(true); + }); + + it('lets zero enable every version of the platform', () => { + expect(desktop({ version: 2, v2MinVersion: { desktop: 0 } }, 1)).toBe( + true + ); + }); + }); + + describe('without a minimum for the platform', () => { + it('defers to the version the browser reports', () => { + expect(desktop({ version: 2 }, 2)).toBe(true); + expect(desktop({ version: 2 }, 1)).toBe(false); + expect(desktop({ version: 2 }, undefined)).toBe(false); + }); + + it('ignores minimums set for other platforms', () => { + const pairing = { version: 2, v2MinVersion: { ios: 0, android: 0 } }; + expect(desktop(pairing, 1)).toBe(false); + expect(desktop(pairing, 2)).toBe(true); + }); + + it('treats a minimum that is not a finite number as unset', () => { + expect(desktop({ version: 2, v2MinVersion: { desktop: NaN } }, 2)).toBe( + true + ); + expect( + desktop( + { + version: 2, + v2MinVersion: { desktop: null as unknown as number }, + }, + 1 + ) + ).toBe(false); + }); + + it('defers for a browser that is not Firefox, whatever is configured', () => { + expect( + isPairingV2Enabled({ + pairing: { + version: 2, + v2MinVersion: { ios: 0, android: 0, desktop: 0 }, + }, + device: Devices.OTHER_IOS, + userAgent: CHROME_DESKTOP, + browserPairingVersion: 1, + }) + ).toBe(false); + }); + }); +}); diff --git a/packages/fxa-settings/src/lib/pairing/v2-gate.ts b/packages/fxa-settings/src/lib/pairing/v2-gate.ts new file mode 100644 index 00000000000..67a776a1072 --- /dev/null +++ b/packages/fxa-settings/src/lib/pairing/v2-gate.ts @@ -0,0 +1,118 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Devices } from '../utilities'; + +/** The Firefox flavours pairing v2 rolls out to, one minimum version each. */ +export type PairingPlatform = 'ios' | 'android' | 'desktop'; + +/** + * Lowest Firefox major version, per platform, that takes the v2 flow. A + * platform left out defers to the browser. See `pairing.v2_min_version`. + */ +export type PairingV2MinVersions = Partial>; + +const PLATFORM_BY_DEVICE: Partial> = { + [Devices.FIREFOX_IOS]: 'ios', + [Devices.FIREFOX_ANDROID]: 'android', + [Devices.FIREFOX_DESKTOP]: 'desktop', +}; + +/** Which Firefox `device` is, or undefined when it is not Firefox at all. */ +export function getPairingPlatform( + device: Devices +): PairingPlatform | undefined { + return PLATFORM_BY_DEVICE[device]; +} + +/** + * Major version of the Firefox behind `userAgent`. Firefox iOS identifies + * itself with an `FxiOS/` token and carries no `Firefox/` token; desktop and + * Android carry `Firefox/` alone. Reading the token for `platform` rather than + * whichever is present keeps a spoofed or hybrid UA from passing as the wrong + * flavour. + */ +export function getFirefoxMajorVersion( + userAgent: string, + platform: PairingPlatform +): number | undefined { + const token = platform === 'ios' ? /FxiOS\/(\d+)/i : /Firefox\/(\d+)/i; + const match = userAgent.match(token); + return match ? parseInt(match[1], 10) : undefined; +} + +export type PairingV2Config = { + version: number; + v2MinVersion?: PairingV2MinVersions; +}; + +/** + * The minimum configured for `platform`, or undefined when the deployment has + * not rolled v2 out to it. A minimum that did not survive the trip from convict + * (NaN serialises to null) counts as unset rather than as "nobody qualifies". + */ +function getPairingV2MinVersion( + pairing: PairingV2Config, + platform: PairingPlatform +): number | undefined { + const minVersion = pairing.v2MinVersion?.[platform]; + return typeof minVersion === 'number' && Number.isFinite(minVersion) + ? minVersion + : undefined; +} + +/** + * Whether the deployment has rolled pairing v2 out to `platform` at all, i.e. + * some version of that Firefox takes the v2 flow on its own say-so. This is + * what makes handing a pairing URL to the Firefox iOS app worth doing: before + * the rollout the app can only land on /pair/unsupported. + */ +export function isPairingV2RolledOut( + pairing: PairingV2Config, + platform: PairingPlatform +): boolean { + return ( + pairing.version === 2 && + getPairingV2MinVersion(pairing, platform) !== undefined + ); +} + +/** + * Whether this browser takes the pairing v2 flow. + * + * `pairing.version` is the deployment-wide switch. Beneath it each Firefox + * platform rolls out on its own: when `v2MinVersion` names the browser's + * platform, the major version in its user agent decides, so the rollout is + * FxA's to control and does not wait on Firefox shipping a flipped + * `identity.fxaccounts.pairing.version` pref. A platform with no minimum, and + * anything that is not Firefox, defers to the `pairingVersion` the browser + * reported in fxa_status, which is how a local build opts in for testing. + */ +export function isPairingV2Enabled({ + pairing, + device, + userAgent, + browserPairingVersion, +}: { + pairing: PairingV2Config; + device: Devices; + userAgent: string; + /** `capabilities.pairingVersion` from fxa_status; undefined until it answers. */ + browserPairingVersion?: number; +}): boolean { + if (pairing.version !== 2) { + return false; + } + + const platform = getPairingPlatform(device); + const minVersion = platform + ? getPairingV2MinVersion(pairing, platform) + : undefined; + if (platform && minVersion !== undefined) { + const major = getFirefoxMajorVersion(userAgent, platform); + return major !== undefined && major >= minVersion; + } + + return browserPairingVersion === 2; +} diff --git a/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.test.tsx b/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.test.tsx index 0e9f45a8635..6835689f492 100644 --- a/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.test.tsx +++ b/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.test.tsx @@ -222,6 +222,65 @@ describe('ConnectAnotherDevice', () => { jest.restoreAllMocks(); }); + // With a minimum configured for the browser's platform, its own version + // decides and the pairingVersion it reports in fxa_status does not. + describe('with a v2 minimum version for desktop', () => { + const FIREFOX_DESKTOP_147 = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:147.0) ' + + 'Gecko/20100101 Firefox/147.0'; + const realUserAgent = navigator.userAgent; + + beforeEach(() => { + Object.defineProperty(navigator, 'userAgent', { + value: FIREFOX_DESKTOP_147, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(navigator, 'userAgent', { + value: realUserAgent, + configurable: true, + }); + }); + + const renderWithDesktopMinimum = ( + fxaStatus: Parameters[0], + desktop: number + ) => + renderWithRouter( + , + { route: MOCK_PAIRING_ELIGIBLE_ROUTE }, + mockPairingAppContext(FXA_PAIRING_V2, { desktop }) + ); + + it('navigates to the v2 flow when the browser meets the minimum but reports version 1', async () => { + renderWithDesktopMinimum({ pairing: true, pairingVersion: 1 }, 147); + + await waitFor(() => + expect(hardNavigate).toHaveBeenCalledWith( + '/pair/authority/scan_qr', + {}, + true + ) + ); + expect(hardNavigate).not.toHaveBeenCalledWith('/pair', {}, true); + }); + + it('navigates to the v1 flow when the browser is below the minimum but reports version 2', async () => { + renderWithDesktopMinimum({ pairing: true, pairingVersion: 2 }, 148); + + await waitFor(() => + expect(hardNavigate).toHaveBeenCalledWith('/pair', {}, true) + ); + expect(hardNavigate).not.toHaveBeenCalledWith( + '/pair/authority/scan_qr', + {}, + true + ); + }); + }); + it('navigates to the v2 pairing flow when FxA and the browser both support version 2', async () => { renderPairingEligible( { pairing: true, pairingVersion: 2 }, diff --git a/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.tsx b/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.tsx index 3560fb33904..3f35200ba10 100644 --- a/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.tsx +++ b/packages/fxa-settings/src/pages/ConnectAnotherDevice/index.tsx @@ -23,6 +23,7 @@ import firefox, { buildSyncOAuthSearch } from '../../lib/channels/firefox'; import GleanMetrics from '../../lib/glean'; import AppLayout from '../../components/AppLayout'; import { detectDevice, Devices } from '../../lib/utilities'; +import { isPairingV2Enabled } from '../../lib/pairing/v2-gate'; import { UseFxAStatusResult } from '../../lib/hooks'; export type ConnectAnotherDeviceProps = { @@ -213,11 +214,17 @@ const ConnectAnotherDevice = ({ signedInUser?.sessionToken && signedInUser.verified ); if (browserSignedIn && isEligibleForPairing()) { - // Both FxA and Firefox have to signal that pairing v2 is enabled! + // The browser has to support pairing at all; which version it gets is + // the same gate as /pair, see isPairingV2Enabled. if ( - config.pairing.version === 2 && fxaStatus.fxaStatus?.capabilities?.pairing === true && - fxaStatus.fxaStatus?.capabilities?.pairingVersion === 2 + isPairingV2Enabled({ + pairing: config.pairing, + device, + userAgent: navigator.userAgent, + browserPairingVersion: + fxaStatus.fxaStatus.capabilities.pairingVersion, + }) ) { hardNavigate('/pair/authority/scan_qr', {}, true); return; diff --git a/packages/fxa-settings/src/pages/ConnectAnotherDevice/mocks.tsx b/packages/fxa-settings/src/pages/ConnectAnotherDevice/mocks.tsx index dce459a781f..664c5ae0b90 100644 --- a/packages/fxa-settings/src/pages/ConnectAnotherDevice/mocks.tsx +++ b/packages/fxa-settings/src/pages/ConnectAnotherDevice/mocks.tsx @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { ENTRYPOINTS } from '../../constants'; -import { getDefault } from '../../lib/config'; +import { Config, getDefault } from '../../lib/config'; import { SignedInUser } from '../../lib/channels/firefox'; import { AppContextValue } from '../../models'; import { UseFxAStatusResult } from '../../lib/hooks'; @@ -86,13 +86,19 @@ export const MOCK_BROWSER_SIGNED_IN_USER: SignedInUser = { verified: true, }; -/** App context with the FxA-side pairing version pinned to `version`. */ -export function mockPairingAppContext(version: number): AppContextValue { +/** + * App context with the FxA-side pairing version pinned to `version`, and the + * per-platform v2 minimums to `v2MinVersion` (none by default). + */ +export function mockPairingAppContext( + version: number, + v2MinVersion: Config['pairing']['v2MinVersion'] = {} +): AppContextValue { const config = getDefault(); return mockAppContext({ config: { ...config, - pairing: { ...config.pairing, version }, + pairing: { ...config.pairing, version, v2MinVersion }, }, } as AppContextValue); } diff --git a/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx b/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx index 481887a675d..158343aca71 100644 --- a/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx +++ b/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx @@ -1024,6 +1024,59 @@ describe('Pair', () => { expect.anything() ); }); + + // With a desktop minimum configured, the browser's own version decides and + // the pairingVersion it reports in fxa_status does not. The suite's UA is + // Firefox 124. + describe('with a v2 minimum version for desktop', () => { + const minVersionAppContext = (desktop: number) => { + const config = getDefault(); + config.pairing.version = 2; + config.pairing.v2MinVersion = { desktop }; + return mockAppContext({ config } as Parameters< + typeof mockAppContext + >[0]); + }; + + it('reloads into the QR scanner when the browser meets the minimum but reports v1', async () => { + const hardNavigateSpy = jest + .spyOn(ReactUtils, 'hardNavigate') + .mockImplementation(() => {}); + renderWithRouter( + , + {}, + minVersionAppContext(124) + ); + + await waitFor(() => + expect(hardNavigateSpy).toHaveBeenCalledWith( + '/pair/authority/scan_qr', + {}, + true + ) + ); + hardNavigateSpy.mockRestore(); + }); + + it('stays on the v1 choice screen when the browser is below the minimum but reports v2', async () => { + const hardNavigateSpy = jest + .spyOn(ReactUtils, 'hardNavigate') + .mockImplementation(() => {}); + renderWithRouter(, {}, minVersionAppContext(125)); + + await screen.findByLabelText( + /I already have Firefox for mobile/, + undefined, + { timeout: 4000 } + ); + expect(hardNavigateSpy).not.toHaveBeenCalledWith( + '/pair/authority/scan_qr', + {}, + true + ); + hardNavigateSpy.mockRestore(); + }); + }); }); }); @@ -1082,10 +1135,12 @@ describe('parseV2PairingHash', () => { fxaStatusResult: mockUseFxAStatus({ fxaStatusState: 'unanswered' }), }; - const v2AppContext = ({ iosHandoff = false } = {}) => { + // The iOS hand-off follows the iOS v2 rollout: any configured minimum + // turns it on. + const v2AppContext = ({ iosRolledOut = false } = {}) => { const config = getDefault(); config.pairing.version = 2; - config.pairing.iosHandoff = iosHandoff; + config.pairing.v2MinVersion = iosRolledOut ? { ios: 0 } : {}; return mockAppContext({ config } as Parameters[0]); }; @@ -1103,19 +1158,19 @@ describe('parseV2PairingHash', () => { jest.clearAllMocks(); }); - // iOS only reaches the download screen once the deployment opts in; the - // case where it does not is covered below. + // iOS only reaches the download screen once the deployment has rolled v2 + // out to Firefox iOS; the case where it has not is covered below. it.each([ ['iOS Safari', IOS_SAFARI, true], ['Android Chrome', ANDROID_CHROME, false], ])( 'routes to the download screen on %s', - async (_label, ua, iosHandoff) => { + async (_label, ua, iosRolledOut) => { setUserAgent(ua); renderWithRouter( , {}, - v2AppContext({ iosHandoff }) + v2AppContext({ iosRolledOut }) ); await waitFor(() => @@ -1129,9 +1184,10 @@ describe('parseV2PairingHash', () => { } ); - // Firefox iOS cannot finish a pairing that started in another browser, so - // the hand-off card would only be a tap in front of the same dead end. - it('sends an iOS browser straight to /pair/unsupported', async () => { + // Before the rollout Firefox iOS cannot finish a pairing that started in + // another browser, so the hand-off card would only be a tap in front of + // the same dead end. + it('sends an iOS browser straight to /pair/unsupported while iOS is not in the rollout', async () => { setUserAgent(IOS_SAFARI); renderWithRouter(, {}, v2AppContext()); @@ -1153,7 +1209,7 @@ describe('parseV2PairingHash', () => { renderWithRouter( , {}, - v2AppContext({ iosHandoff: true }) + v2AppContext({ iosRolledOut: true }) ); await waitFor(() => expect(mockNavigate).toHaveBeenCalled()); diff --git a/packages/fxa-settings/src/pages/Pair/Index/index.tsx b/packages/fxa-settings/src/pages/Pair/Index/index.tsx index 0e1f5c11466..9160ec54a9d 100644 --- a/packages/fxa-settings/src/pages/Pair/Index/index.tsx +++ b/packages/fxa-settings/src/pages/Pair/Index/index.tsx @@ -41,6 +41,10 @@ import { isSendTabEntrypoint, } from '../../../lib/utilities'; import { buildPairUrl, parsePairingHash } from '../../../lib/pairing/pair-url'; +import { + isPairingV2Enabled, + isPairingV2RolledOut, +} from '../../../lib/pairing/v2-gate'; import { getAttemptStorage, HandoffPlan, @@ -175,9 +179,9 @@ const Pair = ({ // carry the flow, so we will hand the pairing URL to the Firefox app instead, // falling back to the app store when it is not installed. // - // iOS gets a plan only where the deployment says Firefox iOS can act on one. - // Without one the flow falls through to /pair/unsupported below, which is - // where the hand-off would have led anyway. + // iOS gets a plan only where the deployment has rolled pairing v2 out to + // Firefox iOS. Without one the flow falls through to /pair/unsupported + // below, which is where the hand-off would have led anyway. // // Read-only, so it is safe to evaluate during render; the auto-attempt token // is only spent by the download screen this routes to. @@ -190,7 +194,7 @@ const Pair = ({ storage: getAttemptStorage(), build: config.pairing.browserBuild, iosScheme: config.pairing.iosUrlScheme, - iosHandoff: config.pairing.iosHandoff, + iosHandoff: isPairingV2RolledOut(config.pairing, 'ios'), }); } @@ -236,17 +240,17 @@ const Pair = ({ return; } - // Switch on pairing version 2! Both FxA and Firefox have to signal that it - // is enabled, same gate as ConnectAnotherDevice. - const pairingVersion = - fxaStatusResult.fxaStatus?.capabilities.pairingVersion; + // Switch on pairing version 2! Same gate as ConnectAnotherDevice; see + // isPairingV2Enabled for what decides it. + const pairingV2 = isPairingV2Enabled({ + pairing: config.pairing, + device, + userAgent: navigator.userAgent, + browserPairingVersion: + fxaStatusResult.fxaStatus?.capabilities.pairingVersion, + }); - if ( - config.pairing.version === 2 && - pairingVersion && - pairingVersion === 2 && - pairingChannelInfo?.version === '2' - ) { + if (pairingV2 && pairingChannelInfo?.version === '2') { navigateWithQuery( '/pair/supplicant/connect_this_device', { state: pairingChannelInfo }, @@ -255,12 +259,7 @@ const Pair = ({ return; } - if ( - isFirefoxDesktop && - config.pairing.version === 2 && - pairingVersion && - pairingVersion === 2 - ) { + if (isFirefoxDesktop && pairingV2) { // Full reload: `useIntegration` is not keyed on location, so only a // fresh page load rebuilds it as a PairingAuthorityIntegration. hardNavigate('/pair/authority/scan_qr', {}, true); @@ -274,12 +273,10 @@ const Pair = ({ return; } - // Switch on pairing version 2! Both FxA and Firefox have to signal that it - // is enabled, same gate as ConnectAnotherDevice. + // Switch on pairing version 2! Same gate as ConnectAnotherDevice; see + // isPairingV2Enabled for what decides it. if ( - config.pairing.version === 2 && - pairingVersion && - pairingVersion === 2 && + pairingV2 && pairingChannelInfo && parseInt(pairingChannelInfo?.version) === 2 ) { @@ -293,12 +290,7 @@ const Pair = ({ return; } - if ( - isFirefoxDesktop && - config.pairing.version === 2 && - pairingVersion && - pairingVersion === 2 - ) { + if (isFirefoxDesktop && pairingV2) { // Full reload: `useIntegration` is not keyed on location, so only a // fresh page load rebuilds it as a PairingAuthorityIntegration. hardNavigate('/pair/authority/scan_qr', {}, true); diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/DownloadFirefox/container.test.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/DownloadFirefox/container.test.tsx index 976501a991e..3a3282c92e4 100644 --- a/packages/fxa-settings/src/pages/Pair2/Supplicant/DownloadFirefox/container.test.tsx +++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/DownloadFirefox/container.test.tsx @@ -115,19 +115,22 @@ describe('Pair2/Supplicant/DownloadFirefox container', () => { it('builds the iOS deep link from the configured URL scheme', () => { jest.spyOn(utilities, 'detectDevice').mockReturnValue(Devices.OTHER_IOS); config.pairing.iosUrlScheme = 'fennec'; - config.pairing.iosHandoff = true; + config.pairing.version = 2; + config.pairing.v2MinVersion = { ios: 0 }; renderWithLocalizationProvider(); expect(getCtaHref()).toContain('fennec://open-url'); }); - // The gate exists because Firefox iOS cannot yet finish a pairing that - // started elsewhere, so a deep link would only land the user on a dead end. - // Reaching this page directly must not route around it. - it('offers the download link on iOS while the deployment gate is off', () => { + // Until the deployment rolls v2 out to Firefox iOS, the app cannot finish + // a pairing that started elsewhere, so a deep link would only land the + // user on a dead end. Reaching this page directly must not route around + // that gate. + it('offers the download link on iOS while iOS is not in the v2 rollout', () => { jest.spyOn(utilities, 'detectDevice').mockReturnValue(Devices.OTHER_IOS); - config.pairing.iosHandoff = false; + config.pairing.version = 2; + config.pairing.v2MinVersion = {}; renderWithLocalizationProvider(); diff --git a/packages/fxa-settings/src/pages/Pair2/Supplicant/DownloadFirefox/container.tsx b/packages/fxa-settings/src/pages/Pair2/Supplicant/DownloadFirefox/container.tsx index 45f0a099dfa..4938377eac0 100644 --- a/packages/fxa-settings/src/pages/Pair2/Supplicant/DownloadFirefox/container.tsx +++ b/packages/fxa-settings/src/pages/Pair2/Supplicant/DownloadFirefox/container.tsx @@ -18,6 +18,7 @@ import { HandoffPlan, planPairingHandoff, } from '../../../../lib/pairing/handoff'; +import { isPairingV2RolledOut } from '../../../../lib/pairing/v2-gate'; import DownloadFirefox from '.'; export const viewName = 'pair-supplicant-download-firefox'; @@ -59,7 +60,7 @@ export const DownloadFirefoxContainer = () => { // A pasted link or a restored history entry reaches this page without // passing through `Pair/Index`, so the gate is re-read here rather than // assumed from the fact we arrived. - iosHandoff: config.pairing.iosHandoff, + iosHandoff: isPairingV2RolledOut(config.pairing, 'ios'), }); // `none` means this device has no Firefox app to open — desktop, or Firefox