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
Original file line number Diff line number Diff line change
@@ -1,6 +1,35 @@
import { describe, expect, it, vi } from 'vitest';
import { resolveMatrixCameraVideoConstraints } from '../call-video-capture-constraints';
import { requestLocalCameraAccess } from '../call-camera-access';
import {
isLocalCameraPermissionDenied,
requestLocalCameraAccess,
} from '../call-camera-access';

describe('isLocalCameraPermissionDenied', () => {
it('returns true when the Permissions API reports denied', async () => {
Object.defineProperty(navigator, 'permissions', {
configurable: true,
value: {
query: vi.fn().mockResolvedValue({ state: 'denied' }),
},
});
await expect(isLocalCameraPermissionDenied()).resolves.toBe(true);
});

it('returns false when permission is granted or prompt', async () => {
Object.defineProperty(navigator, 'permissions', {
configurable: true,
value: {
query: vi
.fn()
.mockResolvedValueOnce({ state: 'granted' })
.mockResolvedValueOnce({ state: 'prompt' }),
},
});
await expect(isLocalCameraPermissionDenied()).resolves.toBe(false);
await expect(isLocalCameraPermissionDenied()).resolves.toBe(false);
});
});
Comment on lines +8 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Restore navigator.permissions after each test to prevent global-state bleed.

These tests overwrite a global but never reset it, which can cause order-dependent failures as this file grows.

Suggested fix
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
 ...
+const originalPermissions = navigator.permissions;
+
+afterEach(() => {
+  Object.defineProperty(navigator, 'permissions', {
+    configurable: true,
+    value: originalPermissions,
+  });
+});
+
 describe('isLocalCameraPermissionDenied', () => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/matrix/client/hooks/__tests__/call-camera-access.test.ts`
around lines 8 - 32, The tests for isLocalCameraPermissionDenied mutate the
global navigator.permissions and never restore it; capture the original
navigator.permissions reference before mutating (e.g., in the describe block or
beforeAll) and add an afterEach (or afterAll) that restores it using
Object.defineProperty or deletes the property if it was originally undefined so
other tests don't see the modified value; apply this to the tests that mock
navigator.permissions in the describe('isLocalCameraPermissionDenied') block so
each test resets global state.


describe('requestLocalCameraAccess', () => {
it('returns unavailable when getUserMedia is missing', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';

import {
HYPHA_SCREEN_SHARE_CAPTURE_ROOT_ID,
HYPHA_SCREEN_SHARE_MAIN_CONTENT_ID,
applyScreenShareCaptureRootRestriction,
clearScreenShareCaptureRootRestriction,
} from '../screenshare-capture-exclusion';
Expand Down Expand Up @@ -50,6 +51,46 @@ describe('screenshare capture exclusion', () => {
).RestrictionTarget;
});

it('prefers the main content column over the legacy shell root', async () => {
const shell = document.createElement('div');
shell.id = HYPHA_SCREEN_SHARE_CAPTURE_ROOT_ID;
const main = document.createElement('div');
main.id = HYPHA_SCREEN_SHARE_MAIN_CONTENT_ID;
shell.appendChild(main);
document.body.appendChild(shell);

const restrictTo = vi.fn().mockResolvedValue(undefined);
const target = {};
const fromElement = vi.fn().mockResolvedValue(target);
(
globalThis as typeof globalThis & {
RestrictionTarget: {
fromElement: (element: Element) => Promise<object>;
};
}
).RestrictionTarget = { fromElement };

const track = {
readyState: 'live',
getSettings: () => ({ displaySurface: 'browser' }),
restrictTo,
} as unknown as MediaStreamTrack;

const ok = await applyScreenShareCaptureRootRestriction({
getVideoTracks: () => [track],
} as MediaStream);

expect(ok).toBe(true);
expect(fromElement).toHaveBeenCalledWith(main);

shell.remove();
delete (
globalThis as typeof globalThis & {
RestrictionTarget?: unknown;
}
).RestrictionTarget;
});

it('skips restriction for monitor/window capture', async () => {
const root = document.createElement('div');
root.id = HYPHA_SCREEN_SHARE_CAPTURE_ROOT_ID;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ describe('buildDisplayMediaConstraints', () => {
audio: {
suppressLocalAudioPlayback: false,
},
preferCurrentTab: false,
selfBrowserSurface: 'exclude',
systemAudio: 'include',
});
});
Expand Down
23 changes: 21 additions & 2 deletions packages/core/src/matrix/client/hooks/call-camera-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,27 @@ export type LocalCameraAccessResult =
| { ok: false; reason: 'unavailable' | 'permission_denied' | 'failed' };

/**
* Prompt for camera access so the browser registers the site permission and
* Matrix can acquire a fresh video track afterward.
* Read camera permission without opening a second getUserMedia stream.
* When state is `prompt` or `granted`, Matrix `enter()` should be the only
* capture prompt during join.
*/
export async function isLocalCameraPermissionDenied(): Promise<boolean> {
if (typeof navigator === 'undefined') return false;
try {
const permissions = navigator.permissions;
if (!permissions?.query) return false;
const status = await permissions.query({
name: 'camera' as PermissionName,
});
return status.state === 'denied';
} catch {
return false;
}
}

/**
* Prompt for camera access when the user turns the camera on mid-call.
* Avoid calling this before `GroupCall.enter()` — it causes a duplicate prompt.
*/
export async function requestLocalCameraAccess(): Promise<LocalCameraAccessResult> {
if (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,128 @@ type InboundRtpVideoStats = {
ssrc?: number;
};

type IceTransportSummary = {
iceConnectionState: RTCIceConnectionState;
iceGatheringState: RTCIceGatheringState;
connectionState: RTCPeerConnectionState;
localCandidateType?: string;
remoteCandidateType?: string;
pairState?: string;
inboundAudioBytes?: number;
inboundVideoBytes?: number;
};

function readIceTransportSummary(
peerConnection: RTCPeerConnection,
stats: RTCStatsReport,
): IceTransportSummary {
const candidates = new Map<string, string>();
let selectedPairId: string | undefined;
let nominatedPairId: string | undefined;
let nominatedLocalCandidateId: string | undefined;
let nominatedRemoteCandidateId: string | undefined;

for (const report of stats.values()) {
if (
report.type === 'local-candidate' ||
report.type === 'remote-candidate'
) {
const candidateType = report.candidateType;
if (typeof candidateType === 'string') {
candidates.set(report.id, candidateType);
}
continue;
}
if (report.type === 'transport' && 'selectedCandidatePairId' in report) {
const id = report.selectedCandidatePairId;
if (typeof id === 'string' && id.length > 0) {
selectedPairId = id;
}
continue;
}
if (report.type !== 'candidate-pair') continue;
if (report.nominated === true && report.state === 'succeeded') {
nominatedPairId = report.id;
nominatedLocalCandidateId =
typeof report.localCandidateId === 'string'
? report.localCandidateId
: undefined;
nominatedRemoteCandidateId =
typeof report.remoteCandidateId === 'string'
? report.remoteCandidateId
: undefined;
}
}

const pairId = selectedPairId ?? nominatedPairId;
let localCandidateType: string | undefined;
let remoteCandidateType: string | undefined;
let pairState: string | undefined;

if (pairId) {
const pair = stats.get(pairId);
if (pair?.type === 'candidate-pair') {
pairState = typeof pair.state === 'string' ? pair.state : undefined;
const localId =
typeof pair.localCandidateId === 'string'
? pair.localCandidateId
: nominatedLocalCandidateId;
const remoteId =
typeof pair.remoteCandidateId === 'string'
? pair.remoteCandidateId
: nominatedRemoteCandidateId;
if (localId) localCandidateType = candidates.get(localId);
if (remoteId) remoteCandidateType = candidates.get(remoteId);
}
}

let inboundAudioBytes = 0;
let inboundVideoBytes = 0;
stats.forEach((report) => {
if (report.type !== 'inbound-rtp') return;
const bytes = report.bytesReceived;
if (typeof bytes !== 'number' || bytes <= 0) return;
if (report.kind === 'audio') inboundAudioBytes += bytes;
if (report.kind === 'video') inboundVideoBytes += bytes;
});

return {
iceConnectionState: peerConnection.iceConnectionState,
iceGatheringState: peerConnection.iceGatheringState,
connectionState: peerConnection.connectionState,
localCandidateType,
remoteCandidateType,
pairState,
inboundAudioBytes: inboundAudioBytes || undefined,
inboundVideoBytes: inboundVideoBytes || undefined,
};
}

async function logIceTransportForPeerConnection(options: {
roomId: string;
groupCallId: string;
userId: string | null;
peerConnection: RTCPeerConnection;
}): Promise<void> {
const { roomId, groupCallId, userId, peerConnection } = options;
const stats = await peerConnection.getStats();
const transport = readIceTransportSummary(peerConnection, stats);
Comment on lines +239 to +240

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Can RTCPeerConnection.getStats() reject after a connection is closed or during teardown, and what error behavior is documented across major browsers?

💡 Result:

The behavior of RTCPeerConnection.getStats when called on a closed PeerConnection varies by browser implementation, despite the W3C WebRTC Statistics API specification intending for stats to remain available even after a connection is closed [1][2]. According to the W3C specification, monitored objects should generally persist for the duration of the associated RTCPeerConnection, allowing their statistics to be accessed via getStats even after the connection has been closed [2]. However, implementation has historically been inconsistent across major browsers [1][3][4][5]. In Firefox, historically, calling getStats on a closed RTCPeerConnection resulted in a promise rejection with an InvalidStateError or similar DOMException [3][4]. Recent updates (circa April 2026) have moved toward allowing getStats on closed connections, mirroring the intended specification behavior and avoiding the previous rejection/hanging behavior [1][6][5]. In Chromium-based browsers, the behavior generally allows calling getStats on a closed connection, which typically returns a resolution with the final state of the statistics, rather than an error [5]. Due to these historical inconsistencies, many WebRTC applications use polyfills (such as those found in the adapter.js library) to normalize this behavior across browsers, often by guarding the call or manually resolving a promise with an empty map if the connection state is 'closed' [4]. Summary of error behavior: - Specification: Intends for stats to be accessible after closing; getStats should not reject solely because the connection is closed [2]. - Chromium: Generally allows the call and returns the final available statistics [5]. - Firefox: Historically rejected with an InvalidStateError, but recent versions have been updated to support calling getStats after closure without throwing [1][4][6]. If your application relies on consistent behavior, check the connectionState of the RTCPeerConnection before invoking getStats and handle the state accordingly [4].

Citations:


Handle RTCPeerConnection.getStats() rejections in fire-and-forget logging

RTCPeerConnection.getStats() has historically been inconsistent across browsers when the connection is closed/tearing down (e.g., Firefox has rejected with InvalidStateError in past versions). Since callers fire-and-forget with void, a rejection can surface as unhandled/noisy async errors—wrap the getStats() call with a local try/catch.

Proposed fix
 async function logIceTransportForPeerConnection(options: {
   roomId: string;
   groupCallId: string;
   userId: string | null;
   peerConnection: RTCPeerConnection;
 }): Promise<void> {
   const { roomId, groupCallId, userId, peerConnection } = options;
-  const stats = await peerConnection.getStats();
-  const transport = readIceTransportSummary(peerConnection, stats);
-  logSpaceGroupCallEvent({
-    name: 'hypha.group_call.ice_transport',
-    roomId,
-    groupCallId,
-    remoteUserId: userId ?? undefined,
-    iceConnectionState: transport.iceConnectionState,
-    iceGatherState: transport.iceGatheringState,
-    connectionState: transport.connectionState,
-    localCandidateType: transport.localCandidateType,
-    remoteCandidateType: transport.remoteCandidateType,
-    pairState: transport.pairState,
-    inboundAudioBytes: transport.inboundAudioBytes,
-    inboundVideoBytes: transport.inboundVideoBytes,
-  });
+  try {
+    const stats = await peerConnection.getStats();
+    const transport = readIceTransportSummary(peerConnection, stats);
+    logSpaceGroupCallEvent({
+      name: 'hypha.group_call.ice_transport',
+      roomId,
+      groupCallId,
+      remoteUserId: userId ?? undefined,
+      iceConnectionState: transport.iceConnectionState,
+      iceGatherState: transport.iceGatheringState,
+      connectionState: transport.connectionState,
+      localCandidateType: transport.localCandidateType,
+      remoteCandidateType: transport.remoteCandidateType,
+      pairState: transport.pairState,
+      inboundAudioBytes: transport.inboundAudioBytes,
+      inboundVideoBytes: transport.inboundVideoBytes,
+    });
+  } catch {
+    // Peer connection may close between interval ticks; ignore transient diagnostics failure.
+  }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/matrix/client/hooks/group-call-webrtc-diagnostics.ts`
around lines 239 - 240, peerConnection.getStats() can reject during teardown and
currently runs as fire-and-forget, so wrap the call in a local try/catch inside
the function that calls it (the block invoking peerConnection.getStats() and
readIceTransportSummary) to prevent unhandled rejections; specifically, surround
the await peerConnection.getStats() call with try/catch, log or silently ignore
the error (using the existing diagnostics logger) and return early so
readIceTransportSummary is only called on success.

logSpaceGroupCallEvent({
name: 'hypha.group_call.ice_transport',
roomId,
groupCallId,
remoteUserId: userId ?? undefined,
iceConnectionState: transport.iceConnectionState,
iceGatherState: transport.iceGatheringState,
connectionState: transport.connectionState,
localCandidateType: transport.localCandidateType,
remoteCandidateType: transport.remoteCandidateType,
pairState: transport.pairState,
inboundAudioBytes: transport.inboundAudioBytes,
inboundVideoBytes: transport.inboundVideoBytes,
});
}

export function readInboundRtpVideoFrameSizes(
stats: RTCStatsReport,
): InboundRtpVideoStats[] {
Expand Down Expand Up @@ -247,9 +369,28 @@ export function attachGroupCallWebRtcDiagnostics(options: {
}
};

const logIceTransport = () => {
if (!enumeratePeerConnections) return;
for (const { userId, peerConnection } of enumeratePeerConnections(gc)) {
void logIceTransportForPeerConnection({
roomId,
groupCallId: gc.groupCallId,
userId,
peerConnection,
});
}
};

if (inboundRtpFrameLogIntervalMs > 0 && enumeratePeerConnections) {
logFrameSizes();
frameLogInterval = setInterval(logFrameSizes, inboundRtpFrameLogIntervalMs);
logIceTransport();
frameLogInterval = setInterval(() => {
logFrameSizes();
logIceTransport();
}, inboundRtpFrameLogIntervalMs);
Comment on lines +386 to +390

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Avoid double getStats() calls per peer per interval tick.

In the support-debug interval path, logFrameSizes() and logIceTransport() each call getStats() for the same peer. Reusing one stats snapshot per peer/tick would cut diagnostics overhead and keep measurements time-aligned.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/matrix/client/hooks/group-call-webrtc-diagnostics.ts`
around lines 386 - 390, The interval currently calls logFrameSizes() and
logIceTransport() separately causing two getStats() calls per peer per tick;
change the implementation to collect a single RTCPeerConnection.getStats()
snapshot per peer/tick and pass that snapshot into both logFrameSizes and
logIceTransport (or refactor those helpers to accept a stats parameter) so each
peer uses the same time-aligned stats object for both diagnostics and avoids
duplicate getStats() calls.

} else if (summaryStatsIntervalMs > 0 && enumeratePeerConnections) {
logIceTransport();
frameLogInterval = setInterval(logIceTransport, summaryStatsIntervalMs);
}

return () => {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/matrix/client/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export {
} from './call-reactions-client';
export {
HYPHA_SCREEN_SHARE_CAPTURE_ROOT_ID,
HYPHA_SCREEN_SHARE_MAIN_CONTENT_ID,
applyScreenShareCaptureRootRestriction,
applyScreenShareCaptureRootRestrictionWithRetry,
clearScreenShareCaptureRootRestriction,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
/** Main app shell root — screen share is restricted to this element when supported. */
/** Legacy app shell wrapper (includes side panels). */
export const HYPHA_SCREEN_SHARE_CAPTURE_ROOT_ID =
'hypha-screen-share-capture-root';

/**
* Main content column — tab capture is cropped here so Human Chat / call UI in the
* right sidebar is not transmitted to remote participants.
*/
export const HYPHA_SCREEN_SHARE_MAIN_CONTENT_ID =
'hypha-screen-share-main-content';

/** Opaque handle from Element Capture `RestrictionTarget.fromElement` (not in all TS DOM libs). */
type ScreenShareRestrictionTarget = object;

Expand All @@ -11,7 +18,10 @@ type RestrictableMediaStreamTrack = MediaStreamTrack & {

function getCaptureRootElement(): HTMLElement | null {
if (typeof document === 'undefined') return null;
return document.getElementById(HYPHA_SCREEN_SHARE_CAPTURE_ROOT_ID);
return (
document.getElementById(HYPHA_SCREEN_SHARE_MAIN_CONTENT_ID) ??
document.getElementById(HYPHA_SCREEN_SHARE_CAPTURE_ROOT_ID)
);
}

function isTabCaptureTrack(track: MediaStreamTrack): boolean {
Expand All @@ -22,8 +32,8 @@ function isTabCaptureTrack(track: MediaStreamTrack): boolean {
}

/**
* Crop tab self-capture to the main Hypha shell so the floating call dock (a
* sibling outside this root) is not transmitted to remote participants.
* Crop tab self-capture to the main content column so side panels, call chrome,
* and the floating call dock are not transmitted to remote participants.
*/
export async function applyScreenShareCaptureRootRestriction(
stream: MediaStream | null | undefined,
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/matrix/client/hooks/screenshare-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,11 @@ export function buildDisplayMediaConstraints(

switch (surfaceMode) {
case 'browser':
return base;
return {
...base,
preferCurrentTab: false,
selfBrowserSurface: 'exclude',
};
case 'window':
return {
...base,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type SpaceGroupCallTelemetryEvent = {
| 'hypha.group_call.remote_media_recover'
| 'hypha.group_call.turn_probe'
| 'hypha.group_call.ice_gather_probe'
| 'hypha.group_call.ice_transport'
| 'hypha.group_call.webrtc_summary'
| 'hypha.group_call.inbound_rtp_frame_size'
| 'hypha.group_call.simulcast_audit'
Expand Down Expand Up @@ -73,6 +74,13 @@ export type SpaceGroupCallTelemetryEvent = {
forceTurn?: boolean;
fallbackIceAllowed?: boolean;
iceGatherState?: RTCIceGatheringState | 'unsupported' | 'timeout';
iceConnectionState?: RTCIceConnectionState;
connectionState?: RTCPeerConnectionState;
localCandidateType?: string;
remoteCandidateType?: string;
pairState?: string;
inboundAudioBytes?: number;
inboundVideoBytes?: number;
/** Matrix SDK summary stats (subset); see `SummaryStatsReport`. */
percentageReceivedMedia?: number;
percentageReceivedAudioMedia?: number;
Expand Down
Loading
Loading