diff --git a/src/main/userMessaging/calls/CallOverlayHost.test.tsx b/src/main/userMessaging/calls/CallOverlayHost.test.tsx index 63ef288eb5..980b97fcaf 100644 --- a/src/main/userMessaging/calls/CallOverlayHost.test.tsx +++ b/src/main/userMessaging/calls/CallOverlayHost.test.tsx @@ -58,12 +58,10 @@ import { closeCall, getCallOverlay, openCall } from './callOverlayStore'; const makeClient = () => Object.assign(new EventEmitter(), { - baseUrl: 'https://hs.example', getUserId: () => '@me:hs.example', getDeviceId: () => 'DEV1', - getAccessToken: () => 'access-token', _unstable_sendDelayedStateEvent: vi.fn(async () => ({ delay_id: 'leave-1' })), - _unstable_updateDelayedEvent: vi.fn(async () => ({})), + http: { authedRequest: vi.fn(async () => ({})) }, }); const CALL_MEMBER = 'org.matrix.msc3401.call.member'; @@ -269,23 +267,42 @@ describe('CallOverlayHost', () => { }); it('leaves the call at once when the call UI goes away before it could leave by itself', async () => { - const fetchMock = vi.fn(async () => new Response('{}')); - vi.stubGlobal('fetch', fetchMock); + const { rerender } = render(); + const api = await startCall(); + await api.driver.sendDelayedEvent(18_000, CALL_MEMBER, {}, OWN_MEMBERSHIP_KEY); + const client = harness.client as ReturnType; + harness.client = null; + rerender(); + + expect(api.stop).toHaveBeenCalled(); + expect(client.http.authedRequest).toHaveBeenCalledTimes(1); + expect(client.http.authedRequest).toHaveBeenCalledWith( + 'POST', + '/delayed_events/leave-1', + undefined, + { action: 'send' }, + { prefix: '/_matrix/client/unstable/org.matrix.msc4140', keepAlive: true } + ); + }); + + it('hides the widget library’s failed-download error while the call is open, and only that', async () => { + const original = console.error; + const sink = vi.fn(); + console.error = sink; try { - const { rerender } = render(); - const api = await startCall(); - await api.driver.sendDelayedEvent(18_000, CALL_MEMBER, {}, OWN_MEMBERSHIP_KEY); - harness.client = null; - rerender(); - - expect(api.stop).toHaveBeenCalled(); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - 'https://hs.example/_matrix/client/unstable/org.matrix.msc4140/delayed_events/leave-1', - expect.objectContaining({ method: 'POST', keepalive: true, body: JSON.stringify({ action: 'send' }) }) - ); + render(); + await startCall(); + const failure = new Error('Downloading the media failed with status 500'); + console.error('error while downloading a file', failure); + console.error('error updating delayed event: ', failure); + expect(sink.mock.calls).toEqual([['error updating delayed event: ', failure]]); + + act(() => closeCurrentCall()); + expect(console.error).toBe(sink); + console.error('error while downloading a file', failure); + expect(sink).toHaveBeenCalledTimes(2); } finally { - vi.unstubAllGlobals(); + console.error = original; } }); diff --git a/src/main/userMessaging/calls/CallWidgetFrame.tsx b/src/main/userMessaging/calls/CallWidgetFrame.tsx index e20e77af96..2094c8fdae 100644 --- a/src/main/userMessaging/calls/CallWidgetFrame.tsx +++ b/src/main/userMessaging/calls/CallWidgetFrame.tsx @@ -3,6 +3,7 @@ import { ClientWidgetApi, type IWidgetApiRequest, Widget } from 'matrix-widget-a import { type RefObject, useEffect, useRef, useState } from 'react'; import { closeCall } from './callOverlayStore'; import { ElementCallWidgetDriver, toRoomEvent } from './ElementCallWidgetDriver'; +import { quietDownloadErrors } from './quietDownloadErrors'; // matrix-js-sdk ClientEvent.Event and RoomStateEvent.Events, as strings so the SDK stays a lazily loaded chunk. const CLIENT_EVENT = 'event'; @@ -61,6 +62,7 @@ export default function CallWidgetFrame({ }); const driver = new ElementCallWidgetDriver({ client, matrixRoomId, livekitServiceUrl }); const api = new ClientWidgetApi(widget, iframe, driver); + const releaseConsoleFilter = quietDownloadErrors(); api.setViewedRoomId(matrixRoomId); const onPageHide = () => driver.sendPendingLeavesNow(); window.addEventListener('pagehide', onPageHide); @@ -121,6 +123,7 @@ export default function CallWidgetFrame({ api.off(`action:${HANGUP_ACTION}`, onWidgetLeft); api.off(`action:${CLOSE_ACTION}`, onWidgetLeft); api.stop(); + releaseConsoleFilter(); }; }, [client, matrixRoomId, livekitServiceUrl, widgetId, frameSrc, hangupRef]); diff --git a/src/main/userMessaging/calls/ElementCallWidgetDriver.test.ts b/src/main/userMessaging/calls/ElementCallWidgetDriver.test.ts index 65e51f06d3..1af487a510 100644 --- a/src/main/userMessaging/calls/ElementCallWidgetDriver.test.ts +++ b/src/main/userMessaging/calls/ElementCallWidgetDriver.test.ts @@ -1,4 +1,4 @@ -import { getHttpUriForMxc } from 'matrix-js-sdk'; +import { createClient, getHttpUriForMxc } from 'matrix-js-sdk'; import { EventDirection, OpenIDRequestState, WidgetEventCapability } from 'matrix-widget-api'; import { describe, expect, it, vi } from 'vitest'; import { allowedCapabilitiesFor, ElementCallWidgetDriver } from './ElementCallWidgetDriver'; @@ -132,10 +132,15 @@ const makeClient = (timeline: FakeEvent[] = [], state: FakeEvent[] = []) => { : (state.find(event => event.getType() === type && event.getStateKey() === stateKey) ?? null), }, }; + // The chat SDK's own request layer, sending through whatever `fetch` the test stubs. + const { http } = createClient({ + baseUrl: 'https://matrix.example.org', + accessToken: 'access-token', + fetchFn: ((...args: Parameters) => globalThis.fetch(...args)) as typeof fetch, + }); + vi.spyOn(http, 'authedRequest'); return { room, - baseUrl: 'https://matrix.example.org', - getAccessToken: () => 'access-token', getUserId: () => USER, getDeviceId: () => DEVICE, getRoom: vi.fn((roomId: string) => (roomId === ROOM ? room : null)), @@ -153,7 +158,7 @@ const makeClient = (timeline: FakeEvent[] = [], state: FakeEvent[] = []) => { redactEvent: vi.fn(async () => ({ event_id: '$redaction' })), _unstable_sendDelayedStateEvent: vi.fn(async () => ({ delay_id: 'delay-state' })), _unstable_sendDelayedEvent: vi.fn(async () => ({ delay_id: 'delay-event' })), - _unstable_updateDelayedEvent: vi.fn(async () => ({})), + http, }; }; @@ -260,36 +265,85 @@ describe('ElementCallWidgetDriver — host behaviour', () => { }); it('maps delayed-event updates to the body-form update the pinned chat server serves', async () => { - const client = makeClient(); - const driver = driverFor(client); - await driver.cancelScheduledDelayedEvent('d1'); - await driver.restartScheduledDelayedEvent('d2'); - await driver.sendScheduledDelayedEvent('d3'); - expect(client._unstable_updateDelayedEvent.mock.calls).toEqual([ - ['d1', 'cancel'], - ['d2', 'restart'], - ['d3', 'send'], - ]); + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json({})) + ); + try { + const client = makeClient(); + const driver = driverFor(client); + await driver.cancelScheduledDelayedEvent('d1'); + await driver.restartScheduledDelayedEvent('d2'); + await driver.sendScheduledDelayedEvent('d/3'); + const opts = { prefix: '/_matrix/client/unstable/org.matrix.msc4140', keepAlive: false }; + expect(vi.mocked(client.http.authedRequest).mock.calls).toEqual([ + ['POST', '/delayed_events/d1', undefined, { action: 'cancel' }, opts], + ['POST', '/delayed_events/d2', undefined, { action: 'restart' }, opts], + ['POST', '/delayed_events/d%2F3', undefined, { action: 'send' }, opts], + ]); + } finally { + vi.unstubAllGlobals(); + } + }); + + it.each([ + ['cancel', 'cancelScheduledDelayedEvent'], + ['restart', 'restartScheduledDelayedEvent'], + ['send', 'sendScheduledDelayedEvent'], + ] as const)('sends the %s update of a delayed event once, with the session token', async (action, method) => { + // The real chat client, so a request it sends without the token is visible here. + const fetchFn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes('/_matrix/client/versions')) { + return Response.json({ versions: ['v1.11'], unstable_features: { 'org.matrix.msc4140': true } }); + } + // The pinned chat server refuses an update without the token. + return new Headers(init?.headers).has('Authorization') + ? Response.json({}) + : Response.json({ errcode: 'M_MISSING_TOKEN', error: 'Missing access token' }, { status: 401 }); + }); + const client = createClient({ + baseUrl: 'https://matrix.example.org', + accessToken: 'access-token', + userId: USER, + deviceId: DEVICE, + fetchFn: fetchFn as unknown as typeof fetch, + }); + const driver = new ElementCallWidgetDriver({ client, matrixRoomId: ROOM, livekitServiceUrl: TOKEN_SERVICE }); + + await driver[method]('delay-1'); + + const updates = fetchFn.mock.calls.filter(([input]) => String(input).includes('/delayed_events/')); + expect(updates).toHaveLength(1); + const [input, init] = updates[0]; + expect(String(input)).toBe( + 'https://matrix.example.org/_matrix/client/unstable/org.matrix.msc4140/delayed_events/delay-1' + ); + expect(init?.method).toBe('POST'); + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer access-token'); + expect(JSON.parse(String(init?.body))).toEqual({ action }); }); it('fires the scheduled leave at once, in a request that outlives the page, when the page goes away', async () => { const client = makeClient(); - const fetchMock = vi.fn(async () => new Response('{}')); + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => Response.json({})); vi.stubGlobal('fetch', fetchMock); try { const driver = driverFor(client); await driver.sendDelayedEvent(3600000, CALL_MEMBER, {}, `_${USER}_${DEVICE}_m.call`, ROOM); driver.sendPendingLeavesNow(); + // The request leaves within the page-exit task: only already-settled steps run before it, no timer. + for (let step = 0; step < 10; step++) { + await Promise.resolve(); + } expect(fetchMock).toHaveBeenCalledTimes(1); - expect(fetchMock).toHaveBeenCalledWith( - 'https://matrix.example.org/_matrix/client/unstable/org.matrix.msc4140/delayed_events/delay-state', - { - method: 'POST', - keepalive: true, - headers: { Authorization: 'Bearer access-token', 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'send' }), - } + const [input, init] = fetchMock.mock.calls[0]; + expect(String(input)).toBe( + 'https://matrix.example.org/_matrix/client/unstable/org.matrix.msc4140/delayed_events/delay-state' ); + expect(init?.method).toBe('POST'); + expect(init?.keepalive).toBe(true); + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer access-token'); + expect(JSON.parse(String(init?.body))).toEqual({ action: 'send' }); } finally { vi.unstubAllGlobals(); } @@ -308,7 +362,9 @@ describe('ElementCallWidgetDriver — host behaviour', () => { await driver.sendDelayedEvent(8000, CALL_MEMBER, {}, `_${USER}_${DEVICE}_m.call`, ROOM); await driver.sendScheduledDelayedEvent('sent'); await driver.sendDelayedEvent(500, 'm.reaction', { key: 'x' }); + fetchMock.mockClear(); driver.sendPendingLeavesNow(); + await new Promise(resolve => setTimeout(resolve, 0)); expect(fetchMock).not.toHaveBeenCalled(); } finally { vi.unstubAllGlobals(); @@ -440,16 +496,17 @@ describe('ElementCallWidgetDriver — host behaviour', () => { }); it('downloads media of the chat server through its authenticated media endpoint, as the platform session', async () => { - const fetchMock = vi.fn(async () => new Response('avatar-bytes')); + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response('avatar-bytes')); vi.stubGlobal('fetch', fetchMock); try { const { file } = await driverFor(makeClient()).downloadFile('mxc://matrix.example.org/abc123'); - expect(fetchMock).toHaveBeenCalledWith( - 'https://matrix.example.org/_matrix/client/v1/media/download/matrix.example.org/abc123?allow_redirect=true', - { - headers: { Authorization: 'Bearer access-token' }, - } + expect(fetchMock).toHaveBeenCalledTimes(1); + const [input, init] = fetchMock.mock.calls[0]; + expect(String(input)).toBe( + 'https://matrix.example.org/_matrix/client/v1/media/download/matrix.example.org/abc123?allow_redirect=true' ); + expect(init?.method).toBe('GET'); + expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer access-token'); // fetch's Blob and jsdom's global Blob can come from different realms, so // `instanceof Blob` depends on the runtime; check the brand instead. expect(Object.prototype.toString.call(file)).toBe('[object Blob]'); @@ -472,6 +529,118 @@ describe('ElementCallWidgetDriver — host behaviour', () => { } }); + it.each([ + ['refused', () => Promise.resolve(new Response('{}', { status: 500 }))], + ['unreachable', () => Promise.reject(new TypeError('Failed to fetch'))], + [ + 'cut off while reading', + () => + Promise.resolve( + new Response( + new ReadableStream({ + start(controller) { + controller.error(new TypeError('network error')); + }, + }) + ) + ), + ], + ])('fails a download that was %s again for a while, without a request', async (_, answer) => { + const fetchMock = vi.fn(answer); + vi.stubGlobal('fetch', fetchMock); + try { + const driver = driverFor(makeClient()); + await expect(driver.downloadFile('mxc://matrix.example.org/broken')).rejects.toThrow(); + await expect(driver.downloadFile('mxc://matrix.example.org/broken')).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + // Other media of the call, and the same media in another call, are still fetched. + fetchMock.mockImplementation(async () => new Response('avatar-bytes')); + await expect(driver.downloadFile('mxc://matrix.example.org/fine')).resolves.toBeDefined(); + await expect(driverFor(makeClient()).downloadFile('mxc://matrix.example.org/broken')).resolves.toBeDefined(); + expect(fetchMock).toHaveBeenCalledTimes(3); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('shares one request among downloads of the same media that overlap, and fetches it afresh after a success', async () => { + let answer: (response: Response) => void = () => undefined; + const fetchMock = vi.fn(() => new Promise(resolve => (answer = resolve))); + vi.stubGlobal('fetch', fetchMock); + try { + const driver = driverFor(makeClient()); + const first = driver.downloadFile('mxc://matrix.example.org/broken'); + const second = driver.downloadFile('mxc://matrix.example.org/broken'); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + answer(new Response('{}', { status: 500 })); + await expect(first).rejects.toMatchObject({ httpStatus: 500 }); + await expect(second).rejects.toMatchObject({ httpStatus: 500 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const third = driver.downloadFile('mxc://matrix.example.org/fine'); + const fourth = driver.downloadFile('mxc://matrix.example.org/fine'); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + answer(new Response('avatar-bytes')); + await expect(third).resolves.toBeDefined(); + await expect(fourth).resolves.toBeDefined(); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const fifth = driver.downloadFile('mxc://matrix.example.org/fine'); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + answer(new Response('avatar-bytes')); + await expect(fifth).resolves.toBeDefined(); + expect(fetchMock).toHaveBeenCalledTimes(3); + } finally { + vi.unstubAllGlobals(); + } + }); + + it('fetches a failed download again once the hold has passed', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const fetchMock = vi.fn(async () => new Response('{}', { status: 500 })); + vi.stubGlobal('fetch', fetchMock); + try { + const driver = driverFor(makeClient()); + await expect(driver.downloadFile('mxc://matrix.example.org/flaky')).rejects.toThrow(); + vi.advanceTimersByTime(59_000); + await expect(driver.downloadFile('mxc://matrix.example.org/flaky')).rejects.toThrow(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1_000); + fetchMock.mockImplementation(async () => new Response('avatar-bytes')); + await expect(driver.downloadFile('mxc://matrix.example.org/flaky')).resolves.toBeDefined(); + expect(fetchMock).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); + + it('gives up a download that hangs, so later asks are not held behind it', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }); + const fetchMock = vi.fn( + (_: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))); + }) + ); + vi.stubGlobal('fetch', fetchMock); + try { + const driver = driverFor(makeClient()); + const pending = driver.downloadFile('mxc://matrix.example.org/stuck'); + const settled = vi.fn(); + pending.then(settled, settled); + await vi.advanceTimersByTimeAsync(19_999); + expect(settled).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await expect(pending).rejects.toThrow(); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); + it('fails the download when the chat server refuses it, so the call UI falls back to initials', async () => { vi.stubGlobal( 'fetch', diff --git a/src/main/userMessaging/calls/ElementCallWidgetDriver.ts b/src/main/userMessaging/calls/ElementCallWidgetDriver.ts index 70e0f96116..9fa7c08e8c 100644 --- a/src/main/userMessaging/calls/ElementCallWidgetDriver.ts +++ b/src/main/userMessaging/calls/ElementCallWidgetDriver.ts @@ -1,4 +1,4 @@ -import type { MatrixClient, MatrixEvent } from 'matrix-js-sdk'; +import type { MatrixClient, MatrixEvent, Method } from 'matrix-js-sdk'; import { type Capability, type IOpenIDUpdate, @@ -61,10 +61,18 @@ const allowedCapabilitiesFor = (roomId: string, userId: string, deviceId: string type DelayedEventAction = 'cancel' | 'restart' | 'send'; +// The unstable path prefix of the chat server's delayed events. +const DELAYED_EVENTS_PREFIX = '/_matrix/client/unstable/org.matrix.msc4140'; +// The chat SDK's request methods, as strings: the SDK is loaded lazily and only its types are imported here. +const POST = 'POST' as Method; +const GET = 'GET' as Method; +/** A failed download is not retried for this long, so tile resizes don't repeat it; then it may recover. */ +const FAILED_DOWNLOAD_HOLD_MS = 60_000; +/** A download that hangs is given up after this long, so later asks are not held behind it. */ +const DOWNLOAD_TIMEOUT_MS = 20_000; + /** The part of the platform's Matrix client the driver uses, typed loosely enough for arbitrary event types. */ type CallHostClient = { - baseUrl: string; - getAccessToken(): string | null; getUserId(): string | null; getDeviceId(): string | null; getRoom: MatrixClient['getRoom']; @@ -88,8 +96,7 @@ type CallHostClient = { eventType: string, content: unknown ): Promise<{ delay_id: string }>; - // The update form with the action in the body is the one the pinned chat server serves. - _unstable_updateDelayedEvent(delayId: string, action: DelayedEventAction): Promise; + http: Pick; }; type DriverOptions = { @@ -119,6 +126,13 @@ class ElementCallWidgetDriver extends WidgetDriver { private readonly allowed: Set; /** Delayed leaves of this device's call membership that are still waiting on the chat server. */ private readonly pendingLeaves = new Set(); + /** + * Downloads still in flight, and those that failed recently. The call UI asks + * for an avatar again every time its tile is resized; overlapping asks share + * one request, and a broken one fails at once for a while instead of hitting + * the chat server again. + */ + private readonly downloads = new Map>(); constructor({ client, matrixRoomId, livekitServiceUrl }: DriverOptions) { super(); @@ -178,18 +192,38 @@ class ElementCallWidgetDriver extends WidgetDriver { return { roomId: target, delayId: response.delay_id }; } + /** + * The update form with the action in the body, the one the pinned chat server + * serves, sent with the session's token from the start. The chat SDK sends it + * without a token first and retries with one only after the chat server + * refuses, which logs a failed request on every call heartbeat. + */ + private async updateDelayedEvent( + delayId: string, + action: DelayedEventAction, + { keepAlive = false }: { keepAlive?: boolean } = {} + ): Promise { + await this.client.http.authedRequest( + POST, + `/delayed_events/${encodeURIComponent(delayId)}`, + undefined, + { action }, + { prefix: DELAYED_EVENTS_PREFIX, keepAlive } + ); + } + public async cancelScheduledDelayedEvent(delayId: string): Promise { this.pendingLeaves.delete(delayId); - await this.client._unstable_updateDelayedEvent(delayId, 'cancel'); + await this.updateDelayedEvent(delayId, 'cancel'); } public async restartScheduledDelayedEvent(delayId: string): Promise { - await this.client._unstable_updateDelayedEvent(delayId, 'restart'); + await this.updateDelayedEvent(delayId, 'restart'); } public async sendScheduledDelayedEvent(delayId: string): Promise { this.pendingLeaves.delete(delayId); - await this.client._unstable_updateDelayedEvent(delayId, 'send'); + await this.updateDelayedEvent(delayId, 'send'); } /** @@ -197,21 +231,12 @@ class ElementCallWidgetDriver extends WidgetDriver { * page unloads (reload, close, navigation) or the host tears the call UI * down. Its own leave does not survive that, and its delayed leave may be * set to fire much later. A leave the call UI already sent or cancelled is - * not in the set any more. `keepalive` lets the browser finish the request - * after the page is gone, which the chat client's own requests can't. + * not in the set any more. `keepAlive` lets the browser finish the request + * after the page is gone. */ public sendPendingLeavesNow(): void { - const accessToken = this.client.getAccessToken(); for (const delayId of this.pendingLeaves) { - fetch( - `${this.client.baseUrl}/_matrix/client/unstable/org.matrix.msc4140/delayed_events/${encodeURIComponent(delayId)}`, - { - method: 'POST', - keepalive: true, - headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: 'send' }), - } - ).catch(() => { + this.updateDelayedEvent(delayId, 'send', { keepAlive: true }).catch(() => { // The page is going away; a leave that does not arrive still fires when its delay runs out. }); } @@ -369,11 +394,34 @@ class ElementCallWidgetDriver extends WidgetDriver { if (!url) { throw new Error('Only chat server media can be downloaded'); } - const response = await fetch(url, { headers: { Authorization: `Bearer ${this.client.getAccessToken()}` } }); - if (!response.ok) { - throw new Error(`Downloading the media failed with status ${response.status}`); + const known = this.downloads.get(contentUri); + if (known) { + return known; } - return { file: await response.blob() }; + const download = this.fetchMedia(url); + this.downloads.set(contentUri, download); + // A successful download is fetched afresh next time; a failed one is held for a while, then retried. + const forget = () => { + if (this.downloads.get(contentUri) === download) { + this.downloads.delete(contentUri); + } + }; + download.then(forget, () => { + setTimeout(forget, FAILED_DOWNLOAD_HOLD_MS); + }); + return download; + } + + private async fetchMedia(url: string): Promise<{ file: XMLHttpRequestBodyInit }> { + const { origin, pathname, searchParams } = new URL(url); + const file = await this.client.http.authedRequest( + GET, + pathname, + Object.fromEntries(searchParams), + undefined, + { baseUrl: origin, prefix: '', rawResponseBody: true, localTimeoutMs: DOWNLOAD_TIMEOUT_MS } + ); + return { file }; } public getRtcLivekitToken(): Promise { diff --git a/src/main/userMessaging/calls/quietDownloadErrors.test.ts b/src/main/userMessaging/calls/quietDownloadErrors.test.ts new file mode 100644 index 0000000000..b5eb421096 --- /dev/null +++ b/src/main/userMessaging/calls/quietDownloadErrors.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { quietDownloadErrors } from './quietDownloadErrors'; + +const MESSAGE = 'error while downloading a file'; + +describe('quietDownloadErrors', () => { + const original = console.error; + let sink: ReturnType void>>; + + beforeEach(() => { + sink = vi.fn<(...args: unknown[]) => void>(); + console.error = sink; + }); + afterEach(() => { + console.error = original; + }); + + it('drops exactly the failed-download message and passes everything else on with its this and arguments', () => { + const release = quietDownloadErrors(); + const failure = new Error('500'); + console.error(MESSAGE, failure); + console.error(`${MESSAGE}!`, failure); + console.error(failure, MESSAGE); + const target = { name: 'caller' }; + console.error.call(target, 'other', 1, 2); + expect(sink.mock.calls).toEqual([ + [`${MESSAGE}!`, failure], + [failure, MESSAGE], + ['other', 1, 2], + ]); + expect(sink.mock.contexts[2]).toBe(target); + release(); + expect(console.error).toBe(sink); + console.error(MESSAGE); + expect(sink).toHaveBeenLastCalledWith(MESSAGE); + }); + + it('never removes a wrapper installed over it, and passes everything through once released', () => { + const release = quietDownloadErrors(); + const ours = console.error; + const later = vi.fn((...args: unknown[]) => ours(...args)); + console.error = later; + release(); + expect(console.error).toBe(later); + console.error(MESSAGE); + expect(sink).toHaveBeenCalledWith(MESSAGE); + }); + + it('filters again for a call opened after one was replaced over it', () => { + quietDownloadErrors(); + const replacement = vi.fn<(...args: unknown[]) => void>(); + console.error = replacement; + const release = quietDownloadErrors(); + console.error(MESSAGE); + expect(replacement).not.toHaveBeenCalled(); + release(); + expect(console.error).toBe(replacement); + }); +}); diff --git a/src/main/userMessaging/calls/quietDownloadErrors.ts b/src/main/userMessaging/calls/quietDownloadErrors.ts new file mode 100644 index 0000000000..0bc6e8c902 --- /dev/null +++ b/src/main/userMessaging/calls/quietDownloadErrors.ts @@ -0,0 +1,31 @@ +/** + * The widget library logs this, with the error, every time the call UI fails to + * download a file through the host. The call UI asks for an avatar again on + * every tile resize, so one broken avatar floods the console for the whole + * call. A temporary hide until the failing downloads themselves are fixed. + */ +const DOWNLOAD_ERROR_MESSAGE = 'error while downloading a file'; + +/** + * Hides the widget library's "error while downloading a file" console error + * until the returned release is called; every other console error passes + * through unchanged. Once released, the filter passes everything through, and + * it is taken out of `console.error` if nothing was wrapped over it since. + */ +export const quietDownloadErrors = (): (() => void) => { + const previous = console.error; + let active = true; + const wrapper = function (this: unknown, ...args: unknown[]) { + if (active && args[0] === DOWNLOAD_ERROR_MESSAGE) { + return; + } + return previous.apply(this, args); + }; + console.error = wrapper; + return () => { + active = false; + if (console.error === wrapper) { + console.error = previous; + } + }; +};