Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions packages/functional-tests/tests/pairing/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion packages/fxa-content-server/server/lib/beta-settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
26 changes: 20 additions & 6 deletions packages/fxa-content-server/server/lib/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions packages/fxa-dev-launcher/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<major>` 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:
Expand Down
19 changes: 13 additions & 6 deletions packages/fxa-settings/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
9 changes: 5 additions & 4 deletions packages/fxa-settings/src/lib/pairing/handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
216 changes: 216 additions & 0 deletions packages/fxa-settings/src/lib/pairing/v2-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof isPairingV2Enabled>[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);
});
});
});
Loading
Loading