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
5 changes: 5 additions & 0 deletions .changeset/fix-encrypted-media-blob-leak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

# Free decrypted images and media from memory instead of holding them until reload
16 changes: 7 additions & 9 deletions src/app/components/message/content/AudioContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
} from '$utils/matrix';
import { setMediaEncryption } from '$utils/tauriMediaEncryption';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useRevokeObjectURL } from '$hooks/useObjectURL';
import { useCreateObjectURL } from '$hooks/useObjectURL';
import { MEDIA_VOLUME_KEY } from '$components/media';
import { hasControllingServiceWorker } from '$utils/platform';

Expand Down Expand Up @@ -60,6 +60,8 @@ export function AudioContent({
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();

const createObjectURL = useCreateObjectURL();

const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
Expand All @@ -71,18 +73,14 @@ export function AudioContent({
await setMediaEncryption(mediaUrl, encInfo, mimeType);
return rewriteAuthenticatedMediaUrl(mediaUrl)!;
}
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo)
return createObjectURL(
downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
);
return URL.createObjectURL(fileContent);
}
const fileContent = await downloadMedia(mediaUrl);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
return createObjectURL(downloadMedia(mediaUrl));
}, [mx, url, useAuthentication, mimeType, encInfo, createObjectURL])
);

useRevokeObjectURL(srcState.status === AsyncStatus.Success ? srcState.data : undefined);

const audioRef = useRef<HTMLAudioElement | null>(null);

useEffect(() => {
Expand Down
30 changes: 16 additions & 14 deletions src/app/components/message/content/FileContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from '$utils/mimeTypes';
import { decryptFile, downloadEncryptedMedia, downloadMedia, mxcUrlToHttp } from '$utils/matrix';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useRevokeObjectURL } from '$hooks/useObjectURL';
import { useCreateObjectURL } from '$hooks/useObjectURL';
import { useDismissOnBack } from '$utils/androidBack';
import { ModalWide } from '$styles/Modal.css';
import { getDownloadFilename, saveFileToDevice } from '$utils/download';
Expand Down Expand Up @@ -154,20 +154,21 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
// Android back closes the PDF viewer instead of navigating away.
useDismissOnBack(() => setPdfViewer(false), pdfViewer);

const createObjectURL = useCreateObjectURL();

const [pdfState, loadPdf] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
? downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: downloadMedia(mediaUrl);
const fileURL = await createObjectURL(fileContent);
setPdfViewer(true);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, createObjectURL])
);

useRevokeObjectURL(pdfState.status === AsyncStatus.Success ? pdfState.data : undefined);

return (
<>
{pdfState.status === AsyncStatus.Success && (
Expand Down Expand Up @@ -223,22 +224,23 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();

const createObjectURL = useCreateObjectURL();

const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid media URL');
const fileContent = encInfo
? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: await downloadMedia(mediaUrl);
const fileContentPromise = encInfo
? downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
: downloadMedia(mediaUrl);
const fileURL = await createObjectURL(fileContentPromise);
const fileContent = await fileContentPromise;

const fileURL = URL.createObjectURL(fileContent);
await saveFileToDevice(fileContent, getDownloadFilename(body), mimeType);
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, body])
}, [mx, url, useAuthentication, mimeType, encInfo, body, createObjectURL])
);

useRevokeObjectURL(downloadState.status === AsyncStatus.Success ? downloadState.data : undefined);

return downloadState.status === AsyncStatus.Error ? (
renderErrorButton(download, `Retry Download (${bytesToSize(info.size ?? 0)})`)
) : (
Expand Down
17 changes: 8 additions & 9 deletions src/app/components/message/content/ImageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import {
} from '../../../../unstable/prefixes';
import { useFavoriteGifs } from '$hooks/useFavoriteGifs';
import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl';
import { useRevokeObjectURL } from '$hooks/useObjectURL';
import { useCreateObjectURL } from '$hooks/useObjectURL';
import { ModalOverlay } from '$components/modal-overlay/ModalOverlay';

export function checkIfGif(url: string, mimetype?: string, body?: string) {
Expand Down Expand Up @@ -155,6 +155,8 @@ export const ImageContent = as<'div', ImageContentProps>(

const resolvedMediaUrl = useRenderableMediaUrl(encInfo ? undefined : rawMediaUrl);

const createObjectURL = useCreateObjectURL();

const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
if (encInfo) {
Expand All @@ -163,13 +165,14 @@ export const ImageContent = as<'div', ImageContentProps>(
await setMediaEncryption(rawMediaUrl, encInfo, mimeType ?? FALLBACK_MIMETYPE);
return rewriteAuthenticatedMediaUrl(rawMediaUrl)!;
}
const fileContent = await downloadEncryptedMedia(rawMediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)
return createObjectURL(
downloadEncryptedMedia(rawMediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType ?? FALLBACK_MIMETYPE, encInfo)
)
);
return URL.createObjectURL(fileContent);
}
return resolvedMediaUrl ?? rawMediaUrl ?? url;
}, [rawMediaUrl, resolvedMediaUrl, url, mimeType, encInfo])
}, [rawMediaUrl, resolvedMediaUrl, url, mimeType, encInfo, createObjectURL])
);

useEffect(() => {
Expand Down Expand Up @@ -213,10 +216,6 @@ export const ImageContent = as<'div', ImageContentProps>(
if (autoPlay) loadSrc().catch(() => undefined);
}, [autoPlay, loadSrc]);

useRevokeObjectURL(
encInfo && srcState.status === AsyncStatus.Success ? srcState.data : undefined
);

const imageW = info?.w;
const imageH = info?.h;
const hasDimensions = typeof imageW === 'number' && typeof imageH === 'number';
Expand Down
17 changes: 8 additions & 9 deletions src/app/components/message/content/ThumbnailContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { setMediaEncryption } from '$utils/tauriMediaEncryption';
import { isTauri } from '@tauri-apps/api/core';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl';
import { useRevokeObjectURL } from '$hooks/useObjectURL';
import { useCreateObjectURL } from '$hooks/useObjectURL';
import { FALLBACK_MIMETYPE } from '$utils/mimeTypes';

export type ThumbnailContentProps = {
Expand All @@ -35,6 +35,8 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {

const resolvedMediaUrl = useRenderableMediaUrl(encInfo ? undefined : rawMediaUrl);

const createObjectURL = useCreateObjectURL();

const [thumbSrcState, loadThumbSrc] = useAsyncCallback(
useCallback(async () => {
const thumbInfo = info.thumbnail_info;
Expand All @@ -49,13 +51,14 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
await setMediaEncryption(rawMediaUrl, encInfo, thumbInfo?.mimetype ?? FALLBACK_MIMETYPE);
return rewriteAuthenticatedMediaUrl(rawMediaUrl)!;
}
const fileContent = await downloadEncryptedMedia(rawMediaUrl, (encBuf) =>
decryptFile(encBuf, thumbInfo?.mimetype ?? FALLBACK_MIMETYPE, encInfo)
return createObjectURL(
downloadEncryptedMedia(rawMediaUrl, (encBuf) =>
decryptFile(encBuf, thumbInfo?.mimetype ?? FALLBACK_MIMETYPE, encInfo)
)
);
return URL.createObjectURL(fileContent);
}
return resolvedMediaUrl ?? rawMediaUrl ?? thumbMxcUrl;
}, [info, thumbMxcUrl, rawMediaUrl, resolvedMediaUrl, encInfo])
}, [info, thumbMxcUrl, rawMediaUrl, resolvedMediaUrl, encInfo, createObjectURL])
);

useEffect(() => {
Expand All @@ -64,10 +67,6 @@ export function ThumbnailContent({ info, renderImage }: ThumbnailContentProps) {
loadThumbSrc().catch(() => undefined);
}, [loadThumbSrc]);

useRevokeObjectURL(
encInfo && thumbSrcState.status === AsyncStatus.Success ? thumbSrcState.data : undefined
);

if (thumbSrcState.status === AsyncStatus.Success) return renderImage(thumbSrcState.data);

if (thumbSrcState.status === AsyncStatus.Loading) {
Expand Down
16 changes: 7 additions & 9 deletions src/app/components/message/content/VideoContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
} from '$utils/matrix';
import { setMediaEncryption } from '$utils/tauriMediaEncryption';
import { useMediaAuthentication } from '$hooks/useMediaAuthentication';
import { useRevokeObjectURL } from '$hooks/useObjectURL';
import { useCreateObjectURL } from '$hooks/useObjectURL';
import { validBlurHash } from '$utils/blurHash';
import * as css from './style.css';
import { MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME } from '../../../../unstable/prefixes';
Expand Down Expand Up @@ -86,6 +86,8 @@ export const VideoContent = as<'div', VideoContentProps>(
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
const [isHovered, setIsHovered] = useState(false);

const createObjectURL = useCreateObjectURL();

const [srcState, loadSrc] = useAsyncCallback(
useCallback(async () => {
if (url.startsWith('http')) return url;
Expand All @@ -99,14 +101,12 @@ export const VideoContent = as<'div', VideoContentProps>(
await setMediaEncryption(mediaUrl, encInfo, mimeType);
return rewriteAuthenticatedMediaUrl(mediaUrl)!;
}
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
decryptFile(encBuf, mimeType, encInfo)
return createObjectURL(
downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo))
);
return URL.createObjectURL(fileContent);
}
const fileContent = await downloadMedia(mediaUrl);
return URL.createObjectURL(fileContent);
}, [mx, url, useAuthentication, mimeType, encInfo])
return createObjectURL(downloadMedia(mediaUrl));
}, [mx, url, useAuthentication, mimeType, encInfo, createObjectURL])
);

// When the source download succeeds, reset video-element error state so the
Expand Down Expand Up @@ -145,8 +145,6 @@ export const VideoContent = as<'div', VideoContentProps>(
if (autoPlay) loadSrc().catch(() => undefined);
}, [autoPlay, loadSrc]);

useRevokeObjectURL(srcState.status === AsyncStatus.Success ? srcState.data : undefined);

return (
<Box
className={classNames(css.RelativeBase, className)}
Expand Down
76 changes: 76 additions & 0 deletions src/app/hooks/useObjectURL.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { useCreateObjectURL } from './useObjectURL';

describe('useCreateObjectURL', () => {
let created: number;

beforeEach(() => {
vi.restoreAllMocks();
created = 0;
vi.spyOn(URL, 'createObjectURL').mockImplementation(() => {
created += 1;
return `blob:url-${created}`;
});
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
});

it('revokes the owned url on unmount', async () => {
const { result, unmount } = renderHook(() => useCreateObjectURL());
await act(async () => {
await result.current(new Blob(['a']));
});

expect(URL.revokeObjectURL).not.toHaveBeenCalled();
unmount();
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:url-1');
});

it('revokes the previous url when a new one replaces it', async () => {
const { result } = renderHook(() => useCreateObjectURL());
await act(async () => {
await result.current(new Blob(['a']));
});
await act(async () => {
await result.current(new Blob(['b']));
});

expect(URL.revokeObjectURL).toHaveBeenCalledExactlyOnceWith('blob:url-1');
});

it('revokes immediately when the blob resolves after unmount', async () => {
const { result, unmount } = renderHook(() => useCreateObjectURL());
unmount();

await result.current(new Blob(['late']));

expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:url-1');
});

it('does not revoke the current url when an older request resolves last', async () => {
let resolveOlder!: (blob: Blob) => void;
const olderBlob = new Promise<Blob>((resolve) => {
resolveOlder = resolve;
});
const { result } = renderHook(() => useCreateObjectURL());

const olderUrl = result.current(olderBlob);
await act(async () => {
await result.current(new Blob(['newer']));
});
resolveOlder(new Blob(['older']));
await olderUrl;

expect(URL.revokeObjectURL).toHaveBeenCalledExactlyOnceWith('blob:url-2');
expect(URL.revokeObjectURL).not.toHaveBeenCalledWith('blob:url-1');
});

it('keeps a stable identity so it can sit in effect dependencies', () => {
const { result, rerender } = renderHook(() => useCreateObjectURL());
const first = result.current;
rerender();

expect(result.current).toBe(first);
});
});
40 changes: 32 additions & 8 deletions src/app/hooks/useObjectURL.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';

export const useObjectURL = (object?: Blob): string | undefined => {
const url = useMemo(() => {
Expand All @@ -16,11 +16,35 @@ export const useObjectURL = (object?: Blob): string | undefined => {
return url;
};

export const useRevokeObjectURL = (url?: string): void => {
useEffect(
() => () => {
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
},
[url]
);
// For blob URLs created inside an async callback, which can resolve after unmount
// with no effect left to revoke them. Passing the pending blob reserves ownership
// so an older request that resolves late cannot replace or revoke the current URL.
export const useCreateObjectURL = (): ((object: Blob | Promise<Blob>) => Promise<string>) => {
const urlRef = useRef<string | undefined>(undefined);
const mountedRef = useRef(true);
const requestRef = useRef(0);

useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
urlRef.current = undefined;
};
}, []);

return useCallback(async (object: Blob | Promise<Blob>) => {
const request = ++requestRef.current;
const resolvedObject = await object;
const url = URL.createObjectURL(resolvedObject);

if (!mountedRef.current || request !== requestRef.current) {
URL.revokeObjectURL(url);
return url;
}

if (urlRef.current) URL.revokeObjectURL(urlRef.current);
urlRef.current = url;
return url;
}, []);
};