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
322 changes: 259 additions & 63 deletions src-tauri/src/network/media_protocol.rs

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion src/app/components/url-preview/UrlPreviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const UrlPreviewCard = as<
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [linkPreviewImageMaxHeight] = useSetting(settingsAtom, 'linkPreviewImageMaxHeight');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');

const [previewStatus, loadPreview] = useAsyncCallback(
useCallback(() => {
Expand Down Expand Up @@ -281,7 +282,7 @@ export const UrlPreviewCard = as<
}}
mediaLayout="contained"
fillsPreviewSlot
autoPlay
autoPlay={mediaAutoLoad}
onAuxClick={handleAuxClick}
body={prev['og:title']}
url={prev['og:image']}
Expand Down
2 changes: 1 addition & 1 deletion src/app/generated/tauri/desktop/DesktopRuntimeState.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type DesktopRuntimeState = { trayAvailable: boolean };
export type DesktopRuntimeState = { trayAvailable: boolean, };
2 changes: 1 addition & 1 deletion src/app/generated/tauri/desktop/DesktopSettings.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type DesktopSettings = { closeToBackgroundOnClose: boolean; showSystemTrayIcon: boolean };
export type DesktopSettings = { closeToBackgroundOnClose: boolean, showSystemTrayIcon: boolean, };
19 changes: 19 additions & 0 deletions src/app/pages/auth/SSOTauri.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type as osType } from '@tauri-apps/plugin-os';
import {
buildTauriSsoRedirectUrl,
parseTauriOidcCallback,
Expand All @@ -12,10 +13,28 @@ vi.mock('@tauri-apps/plugin-os', () => ({
}));

beforeEach(() => {
vi.unstubAllEnvs();
vi.mocked(osType).mockReturnValue('android');
localStorage.clear();
});

describe('buildTauriSsoRedirectUrl', () => {
it('uses the registered deep-link callback in production desktop builds', () => {
vi.stubEnv('DEV', false);
vi.mocked(osType).mockReturnValue('linux');

const url = new URL(buildTauriSsoRedirectUrl('https://hs.example'));

expect(url.protocol).toBe('sable:');
expect(url.hostname).toBe('login');
expect(url.pathname).toBe('/lp/sso-callback');
expect(url.searchParams.get('server')).toBe('https://hs.example');
const nonce = url.searchParams.get('sso_nonce');
expect(nonce).toBeTruthy();
expect(takeTauriSsoNonce()).toBe(nonce);
expect(takeTauriSsoNonce()).toBeUndefined();
});

it('embeds the server and stores the same nonce it puts in the url', () => {
const url = new URL(buildTauriSsoRedirectUrl('https://hs.example'));
expect(url.searchParams.get('server')).toBe('https://hs.example');
Expand Down
2 changes: 1 addition & 1 deletion src/app/pages/auth/SSOTauri.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const getAppBaseUrl = (): string => {
return `${TAURI_SSO_PROTOCOL}//${TAURI_SSO_HOST}`;
}

return 'https://app.sable.moe';
return `${TAURI_SSO_PROTOCOL}//${TAURI_SSO_HOST}`;
};

type TauriSsoCallback = {
Expand Down
4 changes: 2 additions & 2 deletions src/app/pages/client/ClientRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ export function ClientRoot({ children }: ClientRootProps) {
log.log('initClient for', activeSession.userId);
const newMx = await initClient(activeSession);
loadedUserIdRef.current = activeSession.userId;
pushSessionToSW(activeSession.baseUrl, activeSession.accessToken);
await pushSessionToSW(activeSession.baseUrl, activeSession.accessToken);
return newMx;
}, [activeSession, activeSessionId, setActiveSessionId])
);
Expand Down Expand Up @@ -311,7 +311,7 @@ export function ClientRoot({ children }: ClientRootProps) {
activeSession.userId,
'— reloading client'
);
pushSessionToSW(activeSession.baseUrl, activeSession.accessToken);
void pushSessionToSW(activeSession.baseUrl, activeSession.accessToken);
if (mx?.clientRunning) {
stopClient(mx);
}
Expand Down
47 changes: 46 additions & 1 deletion src/app/utils/matrix.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { MatrixClient } from '$types/matrix-sdk';

const tauriApi = vi.hoisted(() => ({
isTauri: vi.fn<() => boolean>(),
Expand All @@ -15,7 +16,7 @@ const mediaTransport = vi.hoisted(() => ({
vi.mock('@tauri-apps/api/core', () => tauriApi);
vi.mock('./mediaTransport', () => mediaTransport);

const { rewriteAuthenticatedMediaUrl } = await import('./matrix');
const { mxcUrlToHttp, rewriteAuthenticatedMediaUrl } = await import('./matrix');

describe('rewriteAuthenticatedMediaUrl', () => {
beforeEach(() => {
Expand Down Expand Up @@ -59,6 +60,36 @@ describe('rewriteAuthenticatedMediaUrl', () => {
);
});

it.each([
'/_matrix/media/v3/download/example.org/abc123',
'/_matrix/media/v3/thumbnail/example.org/abc123?width=96&height=96&method=crop',
'/_matrix/media/r0/download/example.org/abc123',
'/_matrix/media/r0/thumbnail/example.org/abc123?width=96&height=96&method=crop',
])('rewrites legacy media paths under Tauri: %s', (path) => {
tauriApi.isTauri.mockReturnValue(true);
const url = `https://matrix.example.org${path}`;
const separator = url.includes('?') ? '&' : '?';
expect(rewriteAuthenticatedMediaUrl(url)).toBe(
`sable-media://${url}${separator}__sable_media_cache=2&__sable_media_session=%40user%3Aexample.com`
);
});

it('does not rewrite unrelated Matrix media URLs', () => {
tauriApi.isTauri.mockReturnValue(true);
const url = 'https://matrix.example.org/_matrix/media/v3/config';
expect(rewriteAuthenticatedMediaUrl(url)).toBe(url);
expect(tauriApi.convertFileSrc).not.toHaveBeenCalled();
});

it.each([
'https://example.org/avatar.png?next=/_matrix/media/v3/download/example.org/abc123',
'https://example.org/avatar.png#/_matrix/media/r0/thumbnail/example.org/abc123',
])('does not rewrite a media path only present in query or hash: %s', (url) => {
tauriApi.isTauri.mockReturnValue(true);
expect(rewriteAuthenticatedMediaUrl(url)).toBe(url);
expect(tauriApi.convertFileSrc).not.toHaveBeenCalled();
});

it('passes through already-rewritten sable-media:// URLs', () => {
tauriApi.isTauri.mockReturnValue(true);
const url =
Expand All @@ -69,3 +100,17 @@ describe('rewriteAuthenticatedMediaUrl', () => {
expect(tauriApi.convertFileSrc).not.toHaveBeenCalled();
});
});

describe('mxcUrlToHttp', () => {
it('rewrites SDK legacy media URLs under Tauri without useAuthentication', () => {
tauriApi.isTauri.mockReturnValue(true);
const legacyUrl = 'https://matrix.example.org/_matrix/media/v3/download/example.org/video';
const mx = {
mxcUrlToHttp: vi.fn<() => string>(() => legacyUrl),
} as unknown as MatrixClient;

expect(mxcUrlToHttp(mx, 'mxc://example.org/video', false)).toBe(
`sable-media://${legacyUrl}?__sable_media_cache=2&__sable_media_session=%40user%3Aexample.com`
);
});
});
28 changes: 24 additions & 4 deletions src/app/utils/matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ import {

const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/;
const TAURI_MEDIA_CACHE_VERSION = '__sable_media_cache=2';
const TAURI_MEDIA_PATH_PREFIXES = [
'/_matrix/client/v1/media/',
'/_matrix/media/v3/download/',
'/_matrix/media/v3/thumbnail/',
'/_matrix/media/r0/download/',
'/_matrix/media/r0/thumbnail/',
];

export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName);

Expand Down Expand Up @@ -478,7 +485,22 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string):
export const rewriteAuthenticatedMediaUrl = (httpUrl: string | null): string | null => {
if (!httpUrl) return null;
if (!isTauri()) return httpUrl;
if (!httpUrl.includes('/_matrix/client/v1/media/')) return httpUrl;
const sourceUrl = httpUrl.startsWith('sable-media://')
? httpUrl.slice('sable-media://'.length)
: httpUrl;
let parsedUrl: URL;
try {
parsedUrl = new URL(sourceUrl);
} catch {
return httpUrl;
}
if (
(parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') ||
parsedUrl.origin === 'null' ||
!TAURI_MEDIA_PATH_PREFIXES.some((path) => parsedUrl.pathname.startsWith(path))
) {
return httpUrl;
}
if (httpUrl.includes(TAURI_MEDIA_CACHE_VERSION)) return httpUrl;
const mediaUrl = httpUrl.startsWith('sable-media://')
? httpUrl
Expand Down Expand Up @@ -508,9 +530,7 @@ export const mxcUrlToHttp = (
useAuthentication
);

// Authenticated media has no service worker under Tauri to attach the token, so route it
// through the native sable-media:// protocol which injects the token in Rust.
if (httpUrl && useAuthentication) {
if (httpUrl && isTauri()) {
return rewriteAuthenticatedMediaUrl(httpUrl);
}
return httpUrl;
Expand Down
14 changes: 5 additions & 9 deletions src/app/utils/mediaTransport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ describe('fetchMediaBlob', () => {
expect(headersSeen).toEqual([null]);
});

it('retries once on the service worker path without direct auth headers', async () => {
it('fetches once on the service worker path when it returns an auth error', async () => {
platform.hasControllingServiceWorker.mockReturnValue(true);
const { fetchMediaBlob } = await import('./mediaTransport');
const url = 'https://example.org/auth-media.png';
Expand All @@ -365,17 +365,13 @@ describe('fetchMediaBlob', () => {
vi.mocked(fetch).mockImplementation(async (_input, init) => {
const headers = new Headers(init?.headers);
headersSeen.push(headers.get('authorization'));
if (headersSeen.length === 1) {
return new Response('denied', { status: 403 });
}
return new Response('ok', { status: 200 });
return new Response('denied', { status: 403 });
});

const blob = await fetchMediaBlob(url);
await expect(fetchMediaBlob(url)).rejects.toThrow('Failed to fetch media: 403');

expect(await blob.text()).toBe('ok');
expect(headersSeen).toEqual([null, null]);
expect(fetch).toHaveBeenCalledTimes(2);
expect(headersSeen).toEqual([null]);
expect(fetch).toHaveBeenCalledTimes(1);
});

it('bypasses the service worker path when explicit auth overrides are provided', async () => {
Expand Down
4 changes: 0 additions & 4 deletions src/app/utils/mediaTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,10 +296,6 @@ async function fetchMediaBlobInternal(url: string, options?: MediaTransportOptio
};

if (useServiceWorker) {
const response = await fetchMediaResponse(url, undefined, cacheMode);
if (response.ok || !isRetryableAuthError(response)) {
return fetchAndCache(response);
}
return fetchAndCache(await fetchMediaResponse(url, undefined, cacheMode));
}

Expand Down
85 changes: 85 additions & 0 deletions src/app/utils/tauriMediaAuth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const tauriApi = vi.hoisted(() => ({
isTauri: vi.fn<() => boolean>(),
}));
const commands = vi.hoisted(() => ({
clearMediaSession: vi.fn<() => Promise<void>>(),
setMediaSession:
vi.fn<({ baseUrl, token }: { baseUrl: string; token: string }) => Promise<void>>(),
}));
const mediaTransport = vi.hoisted(() => ({
getActiveMediaSession: vi.fn<() => { baseUrl: string; accessToken: string } | undefined>(),
}));

vi.mock('@tauri-apps/api/core', () => tauriApi);
vi.mock('$generated/tauri/commands', () => commands);
vi.mock('./mediaTransport', () => mediaTransport);

const { initTauriMediaSession, updateTauriMediaSession } = await import('./tauriMediaAuth');

describe('Tauri media session coordinator', () => {
beforeEach(() => {
vi.clearAllMocks();
tauriApi.isTauri.mockReturnValue(true);
commands.clearMediaSession.mockResolvedValue();
commands.setMediaSession.mockResolvedValue();
});

it('serializes writes and applies the last requested state', async () => {
let resolveFirst: (() => void) | undefined;
commands.setMediaSession
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveFirst = resolve;
})
)
.mockResolvedValueOnce();

const first = updateTauriMediaSession('https://one.example', 'one');
const second = updateTauriMediaSession('https://two.example', 'two');
const clear = updateTauriMediaSession();

await Promise.resolve();
expect(commands.setMediaSession).toHaveBeenCalledTimes(1);
resolveFirst?.();
await Promise.all([first, second, clear]);

expect(commands.setMediaSession).toHaveBeenNthCalledWith(1, {
baseUrl: 'https://one.example',
token: 'one',
});
expect(commands.setMediaSession).toHaveBeenNthCalledWith(2, {
baseUrl: 'https://two.example',
token: 'two',
});
expect(commands.clearMediaSession).toHaveBeenCalledTimes(1);
});

it('waits for the initial active session write', async () => {
let resolveWrite: (() => void) | undefined;
mediaTransport.getActiveMediaSession.mockReturnValue({
baseUrl: 'https://matrix.example',
accessToken: 'token',
});
commands.setMediaSession.mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveWrite = resolve;
})
);

const ready = initTauriMediaSession();
let complete = false;
void ready.then(() => {
complete = true;
});
await Promise.resolve();
expect(complete).toBe(false);

resolveWrite?.();
await ready;
expect(complete).toBe(true);
});
});
Binary file modified src/app/utils/tauriMediaAuth.ts
Binary file not shown.
26 changes: 26 additions & 0 deletions src/client/initMatrix.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { beforeEach, describe, expect, it } from 'vitest';
import type { Session } from '$state/sessions';
import { ACTIVE_SESSION_KEY, MATRIX_SESSIONS_KEY } from '$state/sessions';
import { ownsActiveMediaSession } from './initMatrix';

const alice = { userId: '@alice:example.org' } as Session;
const bob = { userId: '@bob:example.org' } as Session;

describe('ownsActiveMediaSession', () => {
beforeEach(() => {
localStorage.clear();
localStorage.setItem(MATRIX_SESSIONS_KEY, JSON.stringify([alice, bob]));
});

it('keeps Alice media session while logging out secondary Bob', () => {
localStorage.setItem(ACTIVE_SESSION_KEY, JSON.stringify(alice.userId));

expect(ownsActiveMediaSession(bob)).toBe(false);
});

it('clears the active account media session', () => {
localStorage.setItem(ACTIVE_SESSION_KEY, JSON.stringify(alice.userId));

expect(ownsActiveMediaSession(alice)).toBe(true);
});
});
Loading
Loading