diff --git a/packages/mobile-client/README.md b/packages/mobile-client/README.md index 45254077e..cb46c9851 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/FishjamProvider.test.ts b/packages/mobile-client/src/FishjamProvider.test.ts new file mode 100644 index 000000000..da3af1d37 --- /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 000000000..556c0444e --- /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/devices/ReactNativeDeviceManager.test.ts b/packages/mobile-client/src/devices/ReactNativeDeviceManager.test.ts index 353fe1ac4..a014984e7 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 { + 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 357e5fe22..13ef760a2 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, + type DeviceItem, + DevicePermissionDeniedError, + 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/globals.test.ts b/packages/mobile-client/src/globals.test.ts new file mode 100644 index 000000000..89af648d5 --- /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 { + setConfiguration() {} + }, +); +const ForkRTCIceCandidate = vi.hoisted(() => class {}); +const ForkMediaStream = vi.hoisted(() => class {}); + +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 52ae62522..d605baace 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 45ac37aa1..9e5c02d7b 100644 --- a/packages/mobile-client/src/index.ts +++ b/packages/mobile-client/src/index.ts @@ -8,13 +8,7 @@ /* eslint-disable simple-import-sort/exports */ /* 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'; +import './globals'; export { RTCView, RTCPIPView, type RTCVideoViewProps, type RTCPIPViewProps } from './overrides/RTCView'; export { @@ -39,7 +33,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, @@ -123,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'; diff --git a/packages/mobile-client/src/overrides/getUserMedia.ts b/packages/mobile-client/src/overrides/getUserMedia.ts deleted file mode 100644 index 0662d4af2..000000000 --- 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 6324eff07..000000000 --- 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 000000000..a835474aa --- /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' }, +}); diff --git a/packages/react-client/src/hooks/devices/useCamera.ts b/packages/react-client/src/hooks/devices/useCamera.ts index a87701083..a3faaa34f 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 8331d1055..1e672a596 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"; diff --git a/packages/tsunami/package.json b/packages/tsunami/package.json index 21db5e764..80ebc6e5a 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 66e51def9..5879c71c1 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"