From 8d8cff9bfc15c8eb854901c17948948f5ce1d597 Mon Sep 17 00:00:00 2001 From: Milosz Filimowski Date: Tue, 4 Aug 2026 13:48:50 +0200 Subject: [PATCH 1/5] mobile overhaul 3/5: harden ReactNativeDeviceManager before it goes live Fixes the real classification bug: react-native-webrtc rejects with a MediaStreamError that is not an Error and names permission denial SecurityError, which used to surface as UNHANDLED_ERROR. Permission warnings move from the navigator.mediaDevices monkey-patch onto the actual acquisition path. getDisplayMedia gains native screen-capture options; onDeviceChange becomes an honest no-op (the fork never emits devicechange), deleting the FCE-3689 cast workaround here. --- .../devices/ReactNativeDeviceManager.test.ts | 102 +++++++++++++++--- .../src/devices/ReactNativeDeviceManager.ts | 102 ++++++++++++------ packages/mobile-client/src/index.ts | 8 +- 3 files changed, 162 insertions(+), 50 deletions(-) diff --git a/packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts b/packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts index 353fe1ac..8794b7c4 100644 --- a/packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts +++ b/packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts @@ -1,23 +1,46 @@ import type { MediaStream as ReactNativeMediaStream } from '@fishjam-cloud/react-native-webrtc'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { InMemoryDevicePersistence } from './InMemoryDevicePersistence'; import { ReactNativeDeviceManager } from './ReactNativeDeviceManager'; const nativeMediaDevices = vi.hoisted(() => ({ - addEventListener: vi.fn(), enumerateDevices: vi.fn(), getDisplayMedia: vi.fn(), getUserMedia: vi.fn(), - removeEventListener: vi.fn(), })); -vi.mock('@fishjam-cloud/react-native-webrtc', () => ({ mediaDevices: nativeMediaDevices })); +const nativePermissions = vi.hoisted(() => ({ + query: vi.fn(), +})); + +const FakeNativeMediaStream = vi.hoisted( + () => + class FakeNativeMediaStream { + constructor(public readonly tracks: unknown[]) {} + getTracks() { + return this.tracks; + } + }, +); + +vi.mock('@fishjam-cloud/react-native-webrtc', () => ({ + mediaDevices: nativeMediaDevices, + permissions: nativePermissions, + MediaStream: FakeNativeMediaStream, +})); + +beforeEach(() => { + nativePermissions.query.mockResolvedValue('granted'); +}); afterEach(() => { vi.clearAllMocks(); }); +// react-native-webrtc's MediaStreamError shape: a plain object, NOT an Error. +const nativeError = (name: string) => ({ name, message: name }); + describe('ReactNativeDeviceManager', () => { it('has inert construction', () => { const persistence = { @@ -30,7 +53,6 @@ describe('ReactNativeDeviceManager', () => { expect(nativeMediaDevices.enumerateDevices).not.toHaveBeenCalled(); expect(nativeMediaDevices.getUserMedia).not.toHaveBeenCalled(); expect(nativeMediaDevices.getDisplayMedia).not.toHaveBeenCalled(); - expect(nativeMediaDevices.addEventListener).not.toHaveBeenCalled(); }); it('shares session-scoped persistence between manager instances by default', () => { @@ -66,29 +88,75 @@ describe('ReactNativeDeviceManager', () => { expect(nativeMediaDevices.getUserMedia).toHaveBeenCalledWith(userConstraints); }); - it('uses native display-media defaults instead of forwarding incompatible browser options', async () => { + it("classifies the native SecurityError (a non-Error object) as permission denial", async () => { + nativeMediaDevices.getUserMedia.mockRejectedValue(nativeError('SecurityError')); + const manager = new ReactNativeDeviceManager(); + + await expect(manager.getUserMedia({ video: true })).rejects.toMatchObject({ name: 'NotAllowedError' }); + }); + + it('classifies other native rejections through the shared name mapping', async () => { + nativeMediaDevices.getUserMedia.mockRejectedValue(nativeError('OverconstrainedError')); + const manager = new ReactNativeDeviceManager(); + + await expect(manager.getUserMedia({ video: true })).rejects.toMatchObject({ name: 'OverconstrainedError' }); + + nativeMediaDevices.getUserMedia.mockRejectedValue('total garbage'); + await expect(manager.getUserMedia({ video: true })).rejects.toMatchObject({ name: 'UNHANDLED_ERROR' }); + }); + + it('warns when acquiring media without granted permissions, but still acquires', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + nativePermissions.query.mockResolvedValue('denied'); + nativeMediaDevices.getUserMedia.mockResolvedValue({} as ReactNativeMediaStream); + const manager = new ReactNativeDeviceManager(); + + await manager.getUserMedia({ video: true, audio: true }); + + expect(warn).toHaveBeenCalledWith('Attempting to access camera with permission status: "denied".'); + expect(warn).toHaveBeenCalledWith('Attempting to access microphone with permission status: "denied".'); + expect(nativeMediaDevices.getUserMedia).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('queries permissions only for the requested kinds', async () => { + nativeMediaDevices.getUserMedia.mockResolvedValue({} as ReactNativeMediaStream); + const manager = new ReactNativeDeviceManager(); + + await manager.getUserMedia({ audio: true }); + + expect(nativePermissions.query).toHaveBeenCalledTimes(1); + expect(nativePermissions.query).toHaveBeenCalledWith({ name: 'microphone' }); + }); + + it('forwards constructor display-media options to the native call', async () => { const displayStream = {} as ReactNativeMediaStream; nativeMediaDevices.getDisplayMedia.mockResolvedValue(displayStream); - const manager = new ReactNativeDeviceManager(); + const displayMediaOptions = { android: { resolutionScale: 0.5 } }; + const manager = new ReactNativeDeviceManager({ displayMediaOptions }); await expect(manager.getDisplayMedia({ video: true })).resolves.toBe(displayStream); - expect(nativeMediaDevices.getDisplayMedia).toHaveBeenCalledWith(); + expect(nativeMediaDevices.getDisplayMedia).toHaveBeenCalledWith(displayMediaOptions); + }); + + it('wraps tracks in a native stream', () => { + const manager = new ReactNativeDeviceManager(); + const track = { id: 'track-1' }; + + const stream = manager.createMediaStream([track as never]); + + expect(stream).toBeInstanceOf(FakeNativeMediaStream); + expect(stream.getTracks()).toEqual([track]); }); - it('removes a device-change listener exactly once', () => { - const callback = vi.fn(); + it('returns an inert cleanup from onDeviceChange without touching the native module', () => { const manager = new ReactNativeDeviceManager(); - const cleanup = manager.onDeviceChange(callback); - const listener = nativeMediaDevices.addEventListener.mock.calls[0][1] as () => void; - listener(); + const cleanup = manager.onDeviceChange(vi.fn()); cleanup(); cleanup(); - expect(callback).toHaveBeenCalledOnce(); - expect(nativeMediaDevices.addEventListener).toHaveBeenCalledWith('devicechange', listener); - expect(nativeMediaDevices.removeEventListener).toHaveBeenCalledOnce(); - expect(nativeMediaDevices.removeEventListener).toHaveBeenCalledWith('devicechange', listener); + expect(nativeMediaDevices.getUserMedia).not.toHaveBeenCalled(); }); }); diff --git a/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts b/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts index 357e5fe2..d405281b 100644 --- a/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts +++ b/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts @@ -2,24 +2,32 @@ import { mediaDevices, MediaStream as ReactNativeMediaStream, type MediaStreamTrack as ReactNativeMediaStreamTrack, + permissions, } from '@fishjam-cloud/react-native-webrtc'; -import type { - DeviceItem, - DeviceType, - IDeviceManager, - IDevicePersistence, - PlatformMediaStreamTrack, +import { + classifyDeviceError, + type DeviceError, + DevicePermissionDeniedError, + type DeviceItem, + type DeviceType, + type IDeviceManager, + type IDevicePersistence, + type PlatformMediaStreamTrack, } from '@fishjam-cloud/tsunami'; import { InMemoryDevicePersistence } from './InMemoryDevicePersistence'; -export type ReactNativeDeviceManagerOptions = { - persistence?: IDevicePersistence; +export type ReactNativeDisplayMediaOptions = { + android?: { + createConfigForDefaultDisplay?: boolean; + resolutionScale?: number; + }; }; -type NativeMediaDevices = typeof mediaDevices & { - addEventListener(type: 'devicechange', listener: () => void): void; - removeEventListener(type: 'devicechange', listener: () => void): void; +export type ReactNativeDeviceManagerOptions = { + persistence?: IDevicePersistence; + /** Forwarded to react-native-webrtc's getDisplayMedia (Android screen-capture tuning). */ + displayMediaOptions?: ReactNativeDisplayMediaOptions; }; type NativeDeviceInfo = { @@ -35,20 +43,46 @@ const inputDeviceKinds: Partial> = { const defaultPersistence = new InMemoryDevicePersistence(); -// TODO: FCE-3689 Fix react-native-webrtc's bundled EventTarget declarations and remove this workaround. -// The runtime object extends EventTarget, but react-native-webrtc's bundled -// declaration does not currently expose the inherited listener methods. -const nativeMediaDevices = mediaDevices as NativeMediaDevices; +// react-native-webrtc rejects with MediaStreamError, which does not extend +// Error and reports permission denial as "SecurityError" — classify by the +// name field before deferring to the shared web-name mapping. +const classifyNativeDeviceError = (error: unknown): DeviceError => { + const name = typeof error === 'object' && error !== null && 'name' in error ? String(error.name) : ''; + if (name === 'SecurityError' || name === 'NotAllowedError') { + return new DevicePermissionDeniedError({ cause: error }); + } + return classifyDeviceError(error instanceof Error ? error : Object.assign(new Error(name), { name })); +}; + +const warnWhenPermissionMissing = async (constraints: MediaStreamConstraints): Promise => { + try { + const [cameraStatus, microphoneStatus] = await Promise.all([ + constraints.video ? permissions.query({ name: 'camera' }) : null, + constraints.audio ? permissions.query({ name: 'microphone' }) : null, + ]); + + if (cameraStatus && cameraStatus !== 'granted') { + console.warn(`Attempting to access camera with permission status: "${cameraStatus}".`); + } + if (microphoneStatus && microphoneStatus !== 'granted') { + console.warn(`Attempting to access microphone with permission status: "${microphoneStatus}".`); + } + } catch (error) { + console.warn('Failed to check permissions before getUserMedia', error); + } +}; export class ReactNativeDeviceManager implements IDeviceManager { public readonly persistence: IDevicePersistence; + private readonly displayMediaOptions?: ReactNativeDisplayMediaOptions; - public constructor({ persistence = defaultPersistence }: ReactNativeDeviceManagerOptions = {}) { + public constructor({ persistence = defaultPersistence, displayMediaOptions }: ReactNativeDeviceManagerOptions = {}) { this.persistence = persistence; + this.displayMediaOptions = displayMediaOptions; } public async enumerateDevices(): Promise { - const devices = (await nativeMediaDevices.enumerateDevices()) as NativeDeviceInfo[]; + const devices = (await mediaDevices.enumerateDevices()) as NativeDeviceInfo[]; return devices.flatMap((device) => { const kind = inputDeviceKinds[device.kind]; @@ -59,29 +93,33 @@ export class ReactNativeDeviceManager implements IDeviceManager { - const nativeConstraints = constraints as Parameters[0]; - return nativeMediaDevices.getUserMedia(nativeConstraints); + await warnWhenPermissionMissing(constraints); + + const nativeConstraints = constraints as Parameters[0]; + try { + return await mediaDevices.getUserMedia(nativeConstraints); + } catch (error) { + throw classifyNativeDeviceError(error); + } } + // The DOM options shape is disjoint from react-native-webrtc's constraint + // shape, so it is ignored; screen-capture tuning comes from the constructor. public async getDisplayMedia(_options?: DisplayMediaStreamOptions): Promise { - return nativeMediaDevices.getDisplayMedia(); + try { + return await mediaDevices.getDisplayMedia(this.displayMediaOptions); + } catch (error) { + throw classifyNativeDeviceError(error); + } } public createMediaStream(tracks: PlatformMediaStreamTrack[]): ReactNativeMediaStream { return new ReactNativeMediaStream(tracks as ReactNativeMediaStreamTrack[]); } - // react-native-webrtc does not currently emit devicechange; this method exists to satisfy IDeviceManager. - public onDeviceChange(callback: () => void): () => void { - const listener = () => callback(); - let subscribed = true; - - nativeMediaDevices.addEventListener('devicechange', listener); - - return () => { - if (!subscribed) return; - subscribed = false; - nativeMediaDevices.removeEventListener('devicechange', listener); - }; + // react-native-webrtc never emits devicechange, so there is nothing to + // subscribe to; device lists refresh on explicit operations instead. + public onDeviceChange(_callback: () => void): () => void { + return () => {}; } } diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index 45ac37aa..6d8b6884 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -39,7 +39,13 @@ export type { } from '@fishjam-cloud/react-native-webrtc'; export { useForegroundService, type ForegroundServiceConfig } from './useForegroundService'; -export { ReactNativeDeviceManager, type ReactNativeDeviceManagerOptions } from './devices/ReactNativeDeviceManager'; +export { InMemoryDevicePersistence } from './devices/InMemoryDevicePersistence'; +export { + ReactNativeDeviceManager, + type ReactNativeDeviceManagerOptions, + type ReactNativeDisplayMediaOptions, +} from './devices/ReactNativeDeviceManager'; +export type { IDevicePersistence } from '@fishjam-cloud/tsunami'; export { useCameraPermissions, useMicrophonePermissions, type PermissionStatus } from './hooks/usePermissions'; export { useCustomAudioSource, From 1ab7f029cea31630ed62c33c6de597c71fdb70e0 Mon Sep 17 00:00:00 2001 From: Milosz Filimowski Date: Tue, 4 Aug 2026 13:52:24 +0200 Subject: [PATCH 2/5] mobile overhaul 4/5: mobile provider runs on the native device manager FishjamProvider moves to its own module and declares what mobile is (clientType 'mobile', ReactNativeDeviceManager) instead of constructing a signalling client per render. Device acquisition no longer flows through the fake navigator.mediaDevices. --- .../mobile-client/src/FishjamProvider.test.ts | 44 +++++++++++++++++++ packages/mobile-client/src/FishjamProvider.ts | 24 ++++++++++ packages/mobile-client/src/index.ts | 17 +------ 3 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 packages/mobile-client/src/FishjamProvider.test.ts create mode 100644 packages/mobile-client/src/FishjamProvider.ts diff --git a/packages/mobile-client/src/FishjamProvider.test.ts b/packages/mobile-client/src/FishjamProvider.test.ts new file mode 100644 index 00000000..da3af1d3 --- /dev/null +++ b/packages/mobile-client/src/FishjamProvider.test.ts @@ -0,0 +1,44 @@ +import { FishjamProvider as ReactClientFishjamProvider } from '@fishjam-cloud/react-client'; +import { describe, expect, it, vi } from 'vitest'; + +import { ReactNativeDeviceManager } from './devices/ReactNativeDeviceManager'; +import { FishjamProvider } from './FishjamProvider'; + +const nativeMediaDevices = vi.hoisted(() => ({ + enumerateDevices: vi.fn(), + getDisplayMedia: vi.fn(), + getUserMedia: vi.fn(), +})); + +vi.mock('@fishjam-cloud/react-native-webrtc', () => ({ + mediaDevices: nativeMediaDevices, + permissions: { query: vi.fn() }, + MediaStream: class {}, +})); + +type RenderedProviderProps = { + clientType?: string; + deviceManager?: unknown; + fishjamId?: string; +}; + +const renderedProps = (element: ReturnType): RenderedProviderProps => + element.props as RenderedProviderProps; + +describe('FishjamProvider (mobile)', () => { + it('always runs on the native device manager with the mobile client type', () => { + const element = FishjamProvider({ fishjamId: 'test-fishjam-id' }); + + expect(element.type).toBe(ReactClientFishjamProvider); + expect(renderedProps(element).clientType).toBe('mobile'); + expect(renderedProps(element).deviceManager).toBeInstanceOf(ReactNativeDeviceManager); + expect(renderedProps(element).fishjamId).toBe('test-fishjam-id'); + }); + + it('reuses one device manager across provider instances', () => { + const first = FishjamProvider({ fishjamId: 'a' }); + const second = FishjamProvider({ fishjamId: 'b' }); + + expect(renderedProps(first).deviceManager).toBe(renderedProps(second).deviceManager); + }); +}); diff --git a/packages/mobile-client/src/FishjamProvider.ts b/packages/mobile-client/src/FishjamProvider.ts new file mode 100644 index 00000000..556c0444 --- /dev/null +++ b/packages/mobile-client/src/FishjamProvider.ts @@ -0,0 +1,24 @@ +import { + FishjamProvider as ReactClientFishjamProvider, + type FishjamProviderProps as ReactClientFishjamProviderProps, +} from '@fishjam-cloud/react-client'; +import React from 'react'; + +import { ReactNativeDeviceManager } from './devices/ReactNativeDeviceManager'; + +// The native device manager owns persistence (in-memory for the app session), +// so persistLastDevice does not apply on mobile. +const deviceManager = new ReactNativeDeviceManager(); + +export type FishjamProviderProps = Omit< + ReactClientFishjamProviderProps, + 'persistLastDevice' | 'fishjamClient' | 'deviceManager' | 'clientType' +>; + +export function FishjamProvider(props: FishjamProviderProps) { + return React.createElement(ReactClientFishjamProvider, { + ...props, + clientType: 'mobile', + deviceManager, + }); +} diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index 6d8b6884..46811742 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -9,12 +9,6 @@ /* eslint-disable import/first */ // TODO: FCE-2464 Investigate order import './webrtc-polyfill'; -import React from 'react'; -import { - FishjamProvider as ReactClientFishjamProvider, - type FishjamProviderProps as ReactClientFishjamProviderProps, -} from '@fishjam-cloud/react-client'; -import { FishjamClient } from '@fishjam-cloud/ts-client'; export { RTCView, RTCPIPView, type RTCVideoViewProps, type RTCPIPViewProps } from './overrides/RTCView'; export { @@ -129,13 +123,4 @@ export type { TrackBandwidthLimit, } from '@fishjam-cloud/react-client'; -// persistLastDevice is not supported on mobile -export type FishjamProviderProps = Omit; -export function FishjamProvider(props: FishjamProviderProps) { - const fishjamClient = new FishjamClient({ reconnect: props.reconnect, debug: props.debug, clientType: 'mobile' }); - return React.createElement(ReactClientFishjamProvider, { - ...props, - persistLastDevice: false, - fishjamClient, - }); -} +export { FishjamProvider, type FishjamProviderProps } from './FishjamProvider'; From 532cf7567c8685f8a767e3d8aacea34913c4678a Mon Sep 17 00:00:00 2001 From: Milosz Filimowski Date: Tue, 4 Aug 2026 13:55:01 +0200 Subject: [PATCH 3/5] =?UTF-8?q?mobile=20overhaul=205/5:=20polyfill=20diet?= =?UTF-8?q?=20=E2=80=94=20three=20engine=20globals,=20everything=20else=20?= =?UTF-8?q?dies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit registerGlobals() is gone; globals.ts installs RTCPeerConnection (the getConfiguration-caching subclass), RTCIceCandidate, and MediaStream from direct fork imports — the exact set the legacy connection core resolves from globals. The navigator.mediaDevices fake, the in-memory localStorage, the EventTarget global, and the getUserMedia permission monkey-patch are deleted. Breaking for apps that used the removed globals directly (README note). --- packages/mobile-client/README.md | 8 +++ packages/mobile-client/src/globals.test.ts | 63 +++++++++++++++++++ .../src/{webrtc-polyfill.ts => globals.ts} | 29 +++++---- packages/mobile-client/src/index.ts | 2 +- .../src/overrides/getUserMedia.ts | 28 --------- .../src/polyfills/local-storage.ts | 30 --------- packages/mobile-client/vitest.config.ts | 6 ++ 7 files changed, 94 insertions(+), 72 deletions(-) create mode 100644 packages/mobile-client/src/globals.test.ts rename packages/mobile-client/src/{webrtc-polyfill.ts => globals.ts} (66%) delete mode 100644 packages/mobile-client/src/overrides/getUserMedia.ts delete mode 100644 packages/mobile-client/src/polyfills/local-storage.ts create mode 100644 packages/mobile-client/vitest.config.ts diff --git a/packages/mobile-client/README.md b/packages/mobile-client/README.md index 45254077..cb46c985 100644 --- a/packages/mobile-client/README.md +++ b/packages/mobile-client/README.md @@ -10,6 +10,14 @@ npm install @fishjam-cloud/react-native-client yarn add @fishjam-cloud/react-native-client ``` +## Browser globals (breaking change) + +The SDK no longer installs the browser-compatibility globals it used to register at import time. Only three WebRTC engine classes remain global: `RTCPeerConnection`, `RTCIceCandidate`, and `MediaStream`. + +Removed: `navigator.mediaDevices` (with `getUserMedia`/`getDisplayMedia`/`enumerateDevices`), `localStorage`, `EventTarget`, `MediaStreamTrack`, `MediaStreamTrackEvent`, `RTCSessionDescription`, `RTCCertificate`, `RTCErrorEvent`, `RTCRtpSender`, `RTCRtpReceiver`, `RTCRtpTransceiver`. + +If your app used any of these globals directly, use the SDK hooks instead, or import what you need from `@fishjam-cloud/react-native-webrtc` (e.g. `import { mediaDevices } from '@fishjam-cloud/react-native-webrtc'`). + ## Local Development with WebRTC Fork This package depends on `@fishjam-cloud/react-native-webrtc`, a fork of `react-native-webrtc`. The fork lives in [its own GitHub repo](https://github.com/fishjam-cloud/fishjam-react-native-webrtc) and is included in this monorepo as a git submodule at `packages/react-native-webrtc/`, wired up as a yarn workspace. No manual linking is required. diff --git a/packages/mobile-client/src/globals.test.ts b/packages/mobile-client/src/globals.test.ts new file mode 100644 index 00000000..e50292fa --- /dev/null +++ b/packages/mobile-client/src/globals.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const ForkRTCPeerConnection = vi.hoisted( + () => + class ForkRTCPeerConnection { + setConfiguration() {} + }, +); +const ForkRTCIceCandidate = vi.hoisted(() => class ForkRTCIceCandidate {}); +const ForkMediaStream = vi.hoisted(() => class ForkMediaStream {}); + +vi.mock('@fishjam-cloud/react-native-webrtc', () => ({ + RTCPeerConnection: ForkRTCPeerConnection, + RTCIceCandidate: ForkRTCIceCandidate, + MediaStream: ForkMediaStream, +})); +vi.mock('react-native', () => ({ NativeModules: { WebRTCModule: {} } })); +vi.mock('fast-text-encoding', () => ({})); +vi.mock('react-native-get-random-values', () => ({})); +vi.mock('react-native-url-polyfill/auto', () => ({})); + +const removedGlobals = [ + 'MediaStreamTrack', + 'RTCSessionDescription', + 'RTCCertificate', + 'RTCErrorEvent', + 'MediaStreamTrackEvent', + 'RTCRtpTransceiver', + 'RTCRtpSender', + 'RTCRtpReceiver', +] as const; + +const globalRecord = globalThis as Record; + +describe('globals', () => { + afterEach(() => { + for (const name of ['RTCPeerConnection', 'RTCIceCandidate', 'MediaStream']) { + delete globalRecord[name]; + } + vi.resetModules(); + }); + + it('installs exactly the three engine globals, nothing else', async () => { + await import('./globals'); + + // RTCPeerConnection is the SDK subclass (getConfiguration cache), built on the fork class. + const InstalledPeerConnection = globalRecord.RTCPeerConnection as new (config: object) => { + getConfiguration(): object; + }; + expect(Object.getPrototypeOf(InstalledPeerConnection)).toBe(ForkRTCPeerConnection); + const connection = new InstalledPeerConnection({ iceServers: [] }); + expect(connection.getConfiguration()).toEqual({ iceServers: [] }); + + expect(globalRecord.RTCIceCandidate).toBe(ForkRTCIceCandidate); + expect(globalRecord.MediaStream).toBe(ForkMediaStream); + + for (const name of removedGlobals) { + expect(globalRecord[name], `${name} should not be installed`).toBeUndefined(); + } + expect(globalRecord.localStorage, 'localStorage polyfill should be gone').toBeUndefined(); + expect(globalThis.navigator?.mediaDevices, 'navigator.mediaDevices fake should be gone').toBeUndefined(); + }); +}); diff --git a/packages/mobile-client/src/webrtc-polyfill.ts b/packages/mobile-client/src/globals.ts similarity index 66% rename from packages/mobile-client/src/webrtc-polyfill.ts rename to packages/mobile-client/src/globals.ts index 52ae6252..d605baac 100644 --- a/packages/mobile-client/src/webrtc-polyfill.ts +++ b/packages/mobile-client/src/globals.ts @@ -2,21 +2,10 @@ import 'fast-text-encoding'; import 'react-native-get-random-values'; import 'react-native-url-polyfill/auto'; -import { EventTarget, registerGlobals } from '@fishjam-cloud/react-native-webrtc'; +import { MediaStream, RTCIceCandidate } from '@fishjam-cloud/react-native-webrtc'; import { NativeModules } from 'react-native'; -import { patchGetUserMediaWithPermissionWarnings } from './overrides/getUserMedia'; import { RTCPeerConnection } from './overrides/RTCPeerConnection'; -import { LocalStoragePolyfill } from './polyfills/local-storage'; - -const registerGlobalsPolyfill = () => { - (global as unknown as { EventTarget: typeof EventTarget }).EventTarget = EventTarget; - (global as unknown as { localStorage: typeof localStorage }).localStorage = new LocalStoragePolyfill(); - registerGlobals(); - // Custom overrides - (globalThis.RTCPeerConnection as unknown as typeof RTCPeerConnection) = RTCPeerConnection; - patchGetUserMediaWithPermissionWarnings(); -}; const assertReactNativeWebRTCNativeModule = () => { if (NativeModules.WebRTCModule) return; @@ -48,8 +37,22 @@ const assertGetRandomValuesPolyfill = () => { } }; +/** + * The SDK's connection core resolves exactly three WebRTC classes from + * globals; everything else reaches react-native-webrtc through direct imports + * (device acquisition goes through ReactNativeDeviceManager). Consumers: + * - RTCPeerConnection: webrtc-client ConnectionManager, ts-client livestream + * - RTCIceCandidate: webrtc-client webRTCEndpoint + * - MediaStream: ts-client FishjamClient, webrtc-client webRTCEndpoint + */ +const installWebRtcGlobals = () => { + globalThis.RTCPeerConnection = RTCPeerConnection as unknown as typeof globalThis.RTCPeerConnection; + globalThis.RTCIceCandidate = RTCIceCandidate as unknown as typeof globalThis.RTCIceCandidate; + globalThis.MediaStream = MediaStream as unknown as typeof globalThis.MediaStream; +}; + if (__DEV__) { assertReactNativeWebRTCNativeModule(); assertGetRandomValuesPolyfill(); } -registerGlobalsPolyfill(); +installWebRtcGlobals(); diff --git a/packages/mobile-client/src/index.ts b/packages/mobile-client/src/index.ts index 46811742..9e5c02d7 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -8,7 +8,7 @@ /* eslint-disable simple-import-sort/exports */ /* eslint-disable import/first */ // TODO: FCE-2464 Investigate order -import './webrtc-polyfill'; +import './globals'; export { RTCView, RTCPIPView, type RTCVideoViewProps, type RTCPIPViewProps } from './overrides/RTCView'; export { diff --git a/packages/mobile-client/src/overrides/getUserMedia.ts b/packages/mobile-client/src/overrides/getUserMedia.ts deleted file mode 100644 index 0662d4af..00000000 --- a/packages/mobile-client/src/overrides/getUserMedia.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { permissions } from '@fishjam-cloud/react-native-webrtc'; - -export const patchGetUserMediaWithPermissionWarnings = () => { - const original = globalThis.navigator?.mediaDevices?.getUserMedia; - if (!original) return; - - const boundOriginal = original.bind(globalThis.navigator.mediaDevices); - - globalThis.navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => { - try { - const [cameraStatus, micStatus] = await Promise.all([ - constraints?.video ? permissions.query({ name: 'camera' }) : null, - constraints?.audio ? permissions.query({ name: 'microphone' }) : null, - ]); - - if (cameraStatus && cameraStatus !== 'granted') { - console.warn(`Attempting to access camera with permission status: "${cameraStatus}".`); - } - if (micStatus && micStatus !== 'granted') { - console.warn(`Attempting to access microphone with permission status: "${micStatus}".`); - } - } catch (error) { - console.warn('Failed to check permissions before getUserMedia', error); - } - - return boundOriginal(constraints); - }; -}; diff --git a/packages/mobile-client/src/polyfills/local-storage.ts b/packages/mobile-client/src/polyfills/local-storage.ts deleted file mode 100644 index 6324eff0..00000000 --- a/packages/mobile-client/src/polyfills/local-storage.ts +++ /dev/null @@ -1,30 +0,0 @@ -// LocalStorage polyfill for mobile. -// Device persistence is not supported on mobile, so an in-memory implementation is used instead of actual persistent storage. -export class LocalStoragePolyfill { - private storage: Map = new Map(); - - getItem(key: string): string | null { - return this.storage.get(key) ?? null; - } - - setItem(key: string, value: string): void { - this.storage.set(key, String(value)); - } - - removeItem(key: string): void { - this.storage.delete(key); - } - - clear(): void { - this.storage.clear(); - } - - get length(): number { - return this.storage.size; - } - - key(index: number): string | null { - const keys = Array.from(this.storage.keys()); - return keys[index] ?? null; - } -} diff --git a/packages/mobile-client/vitest.config.ts b/packages/mobile-client/vitest.config.ts new file mode 100644 index 00000000..a835474a --- /dev/null +++ b/packages/mobile-client/vitest.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + // React Native defines __DEV__ at runtime; tests run the production branch. + define: { __DEV__: 'false' }, +}); From 154fed4c16da7befbcedce84a5cb87d9192649a8 Mon Sep 17 00:00:00 2001 From: Milosz Filimowski Date: Tue, 4 Aug 2026 16:23:10 +0200 Subject: [PATCH 4/5] release: align tsunami version with the SDK and add it to the bump script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsunami sat at 0.0.0 while every other package tracks 0.29.0, and the release script's package list omitted it entirely — so each release would have left it further behind. It stays private:true; that flag is the remaining switch to flip when it actually goes out. --- packages/tsunami/package.json | 2 +- release-automation/bump-version.sh | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/tsunami/package.json b/packages/tsunami/package.json index 21db5e76..80ebc6e5 100644 --- a/packages/tsunami/package.json +++ b/packages/tsunami/package.json @@ -1,6 +1,6 @@ { "name": "@fishjam-cloud/tsunami", - "version": "0.0.0", + "version": "0.29.0", "description": "Framework-agnostic SDK core for Fishjam clients", "license": "Apache-2.0", "author": "Fishjam Team", diff --git a/release-automation/bump-version.sh b/release-automation/bump-version.sh index 66e51def..5879c71c 100755 --- a/release-automation/bump-version.sh +++ b/release-automation/bump-version.sh @@ -53,6 +53,9 @@ echo "Updated webrtc-client to $VERSION" corepack yarn workspace @fishjam-cloud/ts-client version "$VERSION" echo "Updated ts-client to $VERSION" +corepack yarn workspace @fishjam-cloud/tsunami version "$VERSION" +echo "Updated tsunami to $VERSION" + corepack yarn workspace @fishjam-cloud/react-client version "$VERSION" echo "Updated react-client to $VERSION" From 7141af3f2eb4f81314e5178757253050f55a8928 Mon Sep 17 00:00:00 2001 From: Milosz Filimowski Date: Tue, 11 Aug 2026 11:31:37 +0200 Subject: [PATCH 5/5] lint: align cherry-picked mobile-client files with the repo eslint config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import sorting, quote style, and no-shadow on the hoisted fake classes — no behavior change. --- .../src/devices/ReactNativeDeviceManager.test.ts | 4 ++-- .../mobile-client/src/devices/ReactNativeDeviceManager.ts | 2 +- packages/mobile-client/src/globals.test.ts | 6 +++--- packages/react-client/src/hooks/devices/useCamera.ts | 2 +- packages/react-client/src/hooks/devices/useMicrophone.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts b/packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts index 8794b7c4..a014984e 100644 --- a/packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts +++ b/packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts @@ -16,7 +16,7 @@ const nativePermissions = vi.hoisted(() => ({ const FakeNativeMediaStream = vi.hoisted( () => - class FakeNativeMediaStream { + class { constructor(public readonly tracks: unknown[]) {} getTracks() { return this.tracks; @@ -88,7 +88,7 @@ describe('ReactNativeDeviceManager', () => { expect(nativeMediaDevices.getUserMedia).toHaveBeenCalledWith(userConstraints); }); - it("classifies the native SecurityError (a non-Error object) as permission denial", async () => { + it('classifies the native SecurityError (a non-Error object) as permission denial', async () => { nativeMediaDevices.getUserMedia.mockRejectedValue(nativeError('SecurityError')); const manager = new ReactNativeDeviceManager(); diff --git a/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts b/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts index d405281b..13ef760a 100644 --- a/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts +++ b/packages/mobile-client/src/devices/ReactNativeDeviceManager.ts @@ -7,8 +7,8 @@ import { import { classifyDeviceError, type DeviceError, - DevicePermissionDeniedError, type DeviceItem, + DevicePermissionDeniedError, type DeviceType, type IDeviceManager, type IDevicePersistence, diff --git a/packages/mobile-client/src/globals.test.ts b/packages/mobile-client/src/globals.test.ts index e50292fa..89af648d 100644 --- a/packages/mobile-client/src/globals.test.ts +++ b/packages/mobile-client/src/globals.test.ts @@ -2,12 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const ForkRTCPeerConnection = vi.hoisted( () => - class ForkRTCPeerConnection { + class { setConfiguration() {} }, ); -const ForkRTCIceCandidate = vi.hoisted(() => class ForkRTCIceCandidate {}); -const ForkMediaStream = vi.hoisted(() => class ForkMediaStream {}); +const ForkRTCIceCandidate = vi.hoisted(() => class {}); +const ForkMediaStream = vi.hoisted(() => class {}); vi.mock('@fishjam-cloud/react-native-webrtc', () => ({ RTCPeerConnection: ForkRTCPeerConnection, diff --git a/packages/react-client/src/hooks/devices/useCamera.ts b/packages/react-client/src/hooks/devices/useCamera.ts index a8770108..a3faaa34 100644 --- a/packages/react-client/src/hooks/devices/useCamera.ts +++ b/packages/react-client/src/hooks/devices/useCamera.ts @@ -1,4 +1,4 @@ -import { useContext, useMemo } from "react"; +import { useContext } from "react"; import { CameraContext } from "../../contexts/camera"; diff --git a/packages/react-client/src/hooks/devices/useMicrophone.ts b/packages/react-client/src/hooks/devices/useMicrophone.ts index 8331d105..1e672a59 100644 --- a/packages/react-client/src/hooks/devices/useMicrophone.ts +++ b/packages/react-client/src/hooks/devices/useMicrophone.ts @@ -1,4 +1,4 @@ -import { useContext, useMemo } from "react"; +import { useContext } from "react"; import { MicrophoneContext } from "../../contexts/microphone";