fix(voice): record why a screen share stopped (#2664)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-11 12:59:51 +02:00
1 parent 5da256df12
commit 3a862f1484
11 files changed
+594 -36

No files matched your search

@@ -0,0 +1,81 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type VoiceEngineV2AppScreenShareControllerGateway,
VoiceEngineV2AppScreenShareControllerRouting,
} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareControllerRouting';
import type {VoiceEngineV2AppScreenShareExecutionAdapter} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter';
import {
getRecentScreenShares,
recordScreenShareStarted,
resetRecentScreenSharesForTests,
} from '@app/features/voice/utils/ScreenShareLifecycleLog';
import {beforeEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/voice/engine/voice_screen_share_manager/shared', () => ({
logger: {debug: () => undefined, info: () => undefined, warn: () => undefined, error: () => undefined},
}));
vi.mock('@app/features/voice/state/LocalVoiceState', () => ({
default: {getSelfStream: () => true},
}));
const setEnabled = vi.fn(async () => undefined);
function createAdapter(): VoiceEngineV2AppScreenShareExecutionAdapter {
return {liveKitFlows: {setEnabled}} as unknown as VoiceEngineV2AppScreenShareExecutionAdapter;
}
function createGateway(
overrides: Partial<VoiceEngineV2AppScreenShareControllerGateway> = {},
): VoiceEngineV2AppScreenShareControllerGateway {
return {
isScreenCommandRoutable: () => true,
hasScreenPublication: () => true,
hasScreenDesired: () => false,
clearScreenDesired: () => undefined,
executingScreenOperationId: () => null,
isScreenOperationPending: () => true,
publishScreen: async () => undefined,
unpublishScreen: async () => undefined,
...overrides,
};
}
describe('routed screen share stop attribution', () => {
beforeEach(() => {
resetRecentScreenSharesForTests();
setEnabled.mockClear();
});
it('keeps a stop the user asked for as a user stop when the controller carries it out', async () => {
const routing = new VoiceEngineV2AppScreenShareControllerRouting(createAdapter());
let executingOperationId: number | null = null;
routing.setGateway(
createGateway({
executingScreenOperationId: () => executingOperationId,
unpublishScreen: async (onPlanned) => {
onPlanned([7]);
executingOperationId = 7;
await routing.unpublishViaLiveKitFlows(null);
},
}),
);
recordScreenShareStarted();
await routing.setEnabled(null, false, {sendUpdate: true, playSound: true});
expect(setEnabled).toHaveBeenCalledTimes(1);
expect(getRecentScreenShares()[0].stopTrigger).toBe('user');
});
it('records an unpublish the app never asked for as a gateway echo', async () => {
const routing = new VoiceEngineV2AppScreenShareControllerRouting(createAdapter());
routing.setGateway(createGateway());
recordScreenShareStarted();
await routing.unpublishViaLiveKitFlows(null);
expect(getRecentScreenShares()[0].stopTrigger).toBe('gateway-echo');
});
});
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import type {VoiceEngineV2AppScreenShareExecutionAdapter} from '@app/features/voice/engine/v2/VoiceEngineV2AppScreenShareExecutionAdapter';
import {logger} from '@app/features/voice/engine/voice_screen_share_manager/shared';
import LocalVoiceState from '@app/features/voice/state/LocalVoiceState';
import {recordScreenShareStopped} from '@app/features/voice/utils/ScreenShareLifecycleLog';
import type {VoiceEngineV2ScreenOptions} from '@fluxer/voice_engine_v2';
import type {Room, ScreenShareCaptureOptions, TrackPublishOptions, VideoCodec} from 'livekit-client';
@@ -445,6 +446,7 @@ export class VoiceEngineV2AppScreenShareControllerRouting {
async unpublishViaLiveKitFlows(roomFromPort: Room | null): Promise<void> {
const request = this.takeStopRequest(this.executingScreenOperationId());
recordScreenShareStopped(request !== null ? 'user' : 'gateway-echo');
const room = request !== null ? request.room : roomFromPort;
try {
await this.adapter.liveKitFlows.setEnabled(room, false, {
@@ -78,6 +78,15 @@ import {
disarmNativeAudio,
reconfigureLinuxNativeAudioRouting,
} from '@app/features/voice/utils/NativeAudioCaptureBridge';
import {
recordScreenShareEncoderVerification,
recordScreenShareEndedModal,
recordScreenShareRequestedCodec,
recordScreenShareStartError,
recordScreenShareStarted,
recordScreenShareStopped,
type ScreenShareEndedModal,
} from '@app/features/voice/utils/ScreenShareLifecycleLog';
import {SCREEN_SHARE_DEGRADATION_PREFERENCE} from '@app/features/voice/utils/ScreenShareOptions';
import {ScreenShareRollbackIncompleteError} from '@app/features/voice/utils/ScreenShareRollbackIncompleteError';
import {handleScreenShareError} from '@app/features/voice/utils/ScreenShareUtils';
@@ -400,7 +409,8 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
this.transitionScreenShareLifecycleInternal({type: 'share.encoderVerification.cleared'});
}
showScreenShareEndedModalInternal(description: string): void {
showScreenShareEndedModalInternal(description: string, modal: ScreenShareEndedModal): void {
recordScreenShareEndedModal(modal);
ModalCommands.pushWithKey(
ModalCommands.modal(() => (
<GenericErrorModal
@@ -491,9 +501,11 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
i18n._(
cause === 'stalled' ? SCREEN_SHARE_ENCODER_FAILED_DESCRIPTOR : SCREEN_SHARE_CODEC_POLICY_FAILED_DESCRIPTOR,
),
cause === 'stalled' ? 'encoder-failed' : 'codec-policy-failed',
);
if (this.endedScreenShareStopInFlight) return;
if (!this.isScreenShareTrackPublishedInternal(participant, track)) return;
recordScreenShareStopped('codec-republish-failed');
this.transitionScreenShareLifecycleInternal({type: 'share.endedStop.start'});
const stopPromise = this.setScreenShareEnabled(room, false, {sendUpdate: true, playSound: true})
.catch((error) => {
@@ -516,6 +528,7 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
): void {
this.cancelEncoderVerificationInternal();
if (!codec) return;
recordScreenShareRequestedCodec(codec);
const publication = preferredTrack ? undefined : participant.getTrackPublication(Track.Source.ScreenShare);
const track = preferredTrack ?? (publication?.videoTrack as LocalVideoTrack | undefined);
const sender = track?.sender;
@@ -530,8 +543,10 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
const action = resolveScreenShareEncoderVerificationAction(failure);
switch (action.kind) {
case 'ignore-repeated-stall':
recordScreenShareEncoderVerification('ignore-repeated-stall', null);
return;
case 'recover-stalled':
recordScreenShareEncoderVerification('recover-stalled', null);
logger.warn('Screen share encoder verification failed', {
codec: action.codec,
failureReason: 'screen-share-encode-stalled',
@@ -546,12 +561,14 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
);
return;
case 'accept-negotiated':
recordScreenShareEncoderVerification('accept-negotiated', action.negotiated);
logger.info('Screen share publisher negotiated a different codec inside the publish policy', {
requested: action.requested,
negotiated: action.negotiated,
});
return;
case 'correct-negotiated':
recordScreenShareEncoderVerification('correct-negotiated', action.negotiated);
logger.warn('Screen share is sending a codec outside the publish policy', {
requested: action.requested,
negotiated: action.negotiated,
@@ -599,7 +616,8 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
trigger,
readyState: mediaStreamTrack.readyState,
});
this.showScreenShareEndedModalInternal(i18n._(SCREEN_SHARE_SOURCE_STOPPED_DESCRIPTOR));
this.showScreenShareEndedModalInternal(i18n._(SCREEN_SHARE_SOURCE_STOPPED_DESCRIPTOR), 'source-stopped');
recordScreenShareStopped('media-track-ended');
this.transitionScreenShareLifecycleInternal({type: 'share.endedStop.start'});
const stopPromise = this.setScreenShareEnabled(room, false, {sendUpdate: true, playSound: true})
.catch((error) => {
@@ -663,6 +681,7 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
}
handleLocalScreenShareTrackUnpublished(room: Room, playSound: boolean, publication?: LocalTrackPublication): void {
recordScreenShareStopped(this.isScreenSharePending ? 'user' : 'server-unpublish');
this.clearScreenShareKeepAliveSinkInternal();
this.cleanupActiveScreenShareEndListenerInternal();
const participant = room.localParticipant;
@@ -866,7 +885,17 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
publishOptions?: TrackPublishOptions,
): Promise<void> {
assert.equal(typeof enabled, 'boolean');
await this.controllerRouting.setEnabled(room, enabled, options, publishOptions);
if (enabled) {
recordScreenShareStarted();
}
try {
await this.controllerRouting.setEnabled(room, enabled, options, publishOptions);
} catch (error) {
if (enabled) {
recordScreenShareStartError(error);
}
throw error;
}
if (!enabled && options?.preserveStreamAudioPreferences !== true) {
VoiceSettingsCommands.update({
shareAppAudio: true,
@@ -908,7 +937,13 @@ class VoiceEngineV2AppScreenShareExecutionAdapter extends Store {
options?: DeviceScreenShareCaptureOptions,
publishOptions?: TrackPublishOptions,
): Promise<void> {
await this.liveKitFlows.startDeviceScreenShare(room, options, publishOptions);
recordScreenShareStarted();
try {
await this.liveKitFlows.startDeviceScreenShare(room, options, publishOptions);
} catch (error) {
recordScreenShareStartError(error);
throw error;
}
}
async replaceActiveDisplayScreenShare(