diff --git a/.changeset/giant-days-run.md b/.changeset/giant-days-run.md new file mode 100644 index 0000000000..1c60a01f66 --- /dev/null +++ b/.changeset/giant-days-run.md @@ -0,0 +1,6 @@ +--- +"livekit-client": patch +--- + +Allow simulcast together with E2EE for supported Safari versions +Also fixes the simulcast behaviour for iOS Chrome prior to 17.2 diff --git a/src/room/participant/LocalParticipant.ts b/src/room/participant/LocalParticipant.ts index d385467d2a..292d1403f7 100644 --- a/src/room/participant/LocalParticipant.ts +++ b/src/room/participant/LocalParticipant.ts @@ -40,9 +40,9 @@ import { import type { DataPublishOptions } from '../types'; import { Future, + isE2EESimulcastSupported, isFireFox, isSVCCodec, - isSafari, isSafari17, isWeb, supportsAV1, @@ -621,10 +621,9 @@ export default class LocalParticipant extends Participant { ...options, }; - // disable simulcast if e2ee is set on safari - if (isSafari() && this.roomOptions.e2ee) { + if (!isE2EESimulcastSupported() && this.roomOptions.e2ee) { this.log.info( - `End-to-end encryption is set up, simulcast publishing will be disabled on Safari`, + `End-to-end encryption is set up, simulcast publishing will be disabled on Safari versions and iOS browsers running iOS < v17.2`, { ...this.logContext, }, diff --git a/src/room/utils.ts b/src/room/utils.ts index 60cd52e038..a5f6898e5f 100644 --- a/src/room/utils.ts +++ b/src/room/utils.ts @@ -132,7 +132,35 @@ export function isSafari17(): boolean { export function isMobile(): boolean { if (!isWeb()) return false; - return /Tablet|iPad|Mobile|Android|BlackBerry/.test(navigator.userAgent); + + return ( + // @ts-expect-error `userAgentData` is not yet part of typescript + navigator.userAgentData?.mobile ?? + /Tablet|iPad|Mobile|Android|BlackBerry/.test(navigator.userAgent) + ); +} + +export function isE2EESimulcastSupported() { + const browser = getBrowser(); + const supportedSafariVersion = '17.2'; // see https://bugs.webkit.org/show_bug.cgi?id=257803 + if (browser) { + if (browser.name !== 'Safari' && browser.os !== 'iOS') { + return true; + } else if ( + browser.os === 'iOS' && + browser.osVersion && + compareVersions(supportedSafariVersion, browser.osVersion) >= 0 + ) { + return true; + } else if ( + browser.name === 'Safari' && + compareVersions(supportedSafariVersion, browser.version) >= 0 + ) { + return true; + } else { + return false; + } + } } export function isWeb(): boolean { diff --git a/src/utils/browserParser.test.ts b/src/utils/browserParser.test.ts index bdad5557cd..91dcc68a4d 100644 --- a/src/utils/browserParser.test.ts +++ b/src/utils/browserParser.test.ts @@ -29,12 +29,14 @@ describe('browser parser', () => { expect(details?.name).toBe('Safari'); expect(details?.version).toBe('16.3'); expect(details?.os).toBe('macOS'); + expect(details?.osVersion).toBe('10.15.7'); }); it('parses Safari iOS correctly', () => { const details = getBrowser(iOSSafariUA, true); expect(details?.name).toBe('Safari'); expect(details?.version).toBe('16.5'); expect(details?.os).toBe('iOS'); + expect(details?.osVersion).toBe('16.5.1'); }); it('parses Firefox correctly', () => { const details = getBrowser(firefoxUA, true); @@ -46,6 +48,7 @@ describe('browser parser', () => { expect(details?.name).toBe('Firefox'); expect(details?.version).toBe('115.0'); expect(details?.os).toBe('iOS'); + expect(details?.osVersion).toBe('13.4.1'); }); it('parses Chrome correctly', () => { const details = getBrowser(chromeUA, true); @@ -57,6 +60,7 @@ describe('browser parser', () => { expect(details?.name).toBe('Chrome'); expect(details?.version).toBe('115.0.5790.130'); expect(details?.os).toBe('iOS'); + expect(details?.osVersion).toBe('16.5'); }); it('detects brave as chromium based', () => { const details = getBrowser(braveUA, true); diff --git a/src/utils/browserParser.ts b/src/utils/browserParser.ts index 6445279a07..061051b713 100644 --- a/src/utils/browserParser.ts +++ b/src/utils/browserParser.ts @@ -10,6 +10,7 @@ export type BrowserDetails = { name: DetectableBrowser; version: string; os?: DetectableOS; + osVersion?: string; }; let browserDetails: BrowserDetails | undefined; @@ -17,7 +18,7 @@ let browserDetails: BrowserDetails | undefined; /** * @internal */ -export function getBrowser(userAgent?: string, force = true) { +export function getBrowser(userAgent?: string, force = true): BrowserDetails | undefined { if (typeof userAgent === 'undefined' && typeof navigator === 'undefined') { return; } @@ -37,6 +38,7 @@ const browsersList = [ name: 'Firefox', version: getMatch(/(?:firefox|iceweasel|fxios)[\s/](\d+(\.?_?\d+)+)/i, ua), os: ua.toLowerCase().includes('fxios') ? 'iOS' : undefined, + osVersion: getOSVersion(ua), }; return browser; }, @@ -48,6 +50,7 @@ const browsersList = [ name: 'Chrome', version: getMatch(/(?:chrome|chromium|crios|crmo)\/(\d+(\.?_?\d+)+)/i, ua), os: ua.toLowerCase().includes('crios') ? 'iOS' : undefined, + osVersion: getOSVersion(ua), }; return browser; @@ -61,6 +64,7 @@ const browsersList = [ name: 'Safari', version: getMatch(commonVersionIdentifier, ua), os: ua.includes('mobile/') ? 'iOS' : 'macOS', + osVersion: getOSVersion(ua), }; return browser; @@ -72,3 +76,9 @@ function getMatch(exp: RegExp, ua: string, id = 1) { const match = ua.match(exp); return (match && match.length >= id && match[id]) || ''; } + +function getOSVersion(ua: string) { + return ua.includes('mac os') + ? getMatch(/\(.+?(\d+_\d+(:?_\d+)?)/, ua, 1).replace(/_/g, '.') + : undefined; +}