mirror of
https://github.com/vector-im/element-call.git
synced 2026-11-07 00:52:28 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eaffa5b04 | ||
|
|
495be71ddc |
+12
-2
@@ -30,7 +30,7 @@ import { HomePage } from "./home/HomePage";
|
||||
import { LoginPage } from "./auth/LoginPage";
|
||||
import { RegisterPage } from "./auth/RegisterPage";
|
||||
import { RoomPage } from "./room/RoomPage";
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { type ClientLoader, ClientProvider } from "./ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { Initializer } from "./initializer";
|
||||
import { type WidgetHelpers } from "./widget";
|
||||
@@ -57,6 +57,16 @@ import { LeaveToHomeProvider } from "./LeaveToHomeContext";
|
||||
|
||||
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
||||
|
||||
// Element Call's own session, for when no host supplies a client.
|
||||
// - Passed in here, not imported by ClientProvider: this module is only in the
|
||||
// app builds, so the client setup and the store worker behind it stay out of
|
||||
// the component package (see the `loadClient` prop).
|
||||
// - Imported on demand, as it was before, so that a widget, which gets its
|
||||
// client from the host, never downloads it.
|
||||
// - Module-level, so that the provider's effect sees one stable loader.
|
||||
const loadStandaloneClient: ClientLoader = async (...args) =>
|
||||
(await import("./utils/spa")).initSPA(...args);
|
||||
|
||||
interface SimpleProviderProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
@@ -146,7 +156,7 @@ export const App: FC<Props> = ({ vm, widget }) => {
|
||||
|
||||
const content =
|
||||
loaded && clientReady ? (
|
||||
<ClientProvider client={widgetClient}>
|
||||
<ClientProvider client={widgetClient} loadClient={loadStandaloneClient}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
|
||||
<Sentry.ErrorBoundary
|
||||
|
||||
@@ -11,7 +11,11 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC } from "react";
|
||||
|
||||
import { ClientProvider, useClientState } from "./ClientContext";
|
||||
import {
|
||||
type ClientLoader,
|
||||
ClientProvider,
|
||||
useClientState,
|
||||
} from "./ClientContext";
|
||||
|
||||
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
({
|
||||
@@ -95,13 +99,13 @@ test("follows the client when the host swaps it", () => {
|
||||
|
||||
test("finds a client of its own when the host supplies none", async () => {
|
||||
const client = mockClient();
|
||||
vi.doMock("./utils/spa", () => ({
|
||||
initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }),
|
||||
}));
|
||||
const loadClient = vi
|
||||
.fn<ClientLoader>()
|
||||
.mockResolvedValue({ client, passwordlessUser: true });
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider>
|
||||
<ClientProvider loadClient={loadClient}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
@@ -110,6 +114,21 @@ test("finds a client of its own when the host supplies none", async () => {
|
||||
// Nothing to show until a session has been restored or created
|
||||
expect(container.textContent).toBe("loading");
|
||||
await waitFor(() => expect(container.textContent).toBe("@alice:example.org"));
|
||||
// Given the session store to restore from and to clear on a stale session
|
||||
expect(loadClient).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
vi.doUnmock("./utils/spa");
|
||||
test("has no one signed in when given neither a client nor a way to load one", async () => {
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(container.textContent).toBe("unauthenticated"));
|
||||
});
|
||||
|
||||
+32
-12
@@ -143,9 +143,34 @@ interface Props {
|
||||
* own.
|
||||
*/
|
||||
client?: MatrixClient;
|
||||
/**
|
||||
* How Element Call finds a client when none is supplied: the standalone app
|
||||
* restores or creates a session of its own. Stable, as a hook dependency.
|
||||
* Left out along with the client, no one is signed in.
|
||||
*
|
||||
* A prop, rather than this module importing `utils/spa` itself, because:
|
||||
* - This module is in the component build, and so is everything it imports,
|
||||
* dynamically or not: a dynamic import becomes a chunk in the package,
|
||||
* not a module left out of it.
|
||||
* - Behind `utils/spa` sits `utils/initClient` and the client store worker,
|
||||
* which the bundler emits as soon as it loads the import (see there).
|
||||
* - `App.tsx`, which the component never imports, is where the standalone
|
||||
* app passes the loader in; the component passes nothing, having the
|
||||
* host's client.
|
||||
*/
|
||||
loadClient?: ClientLoader;
|
||||
}
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
/**
|
||||
* Restores or creates a session of Element Call's own, kept where the given
|
||||
* functions keep it. Resolves to null when no one is signed in.
|
||||
*/
|
||||
export type ClientLoader = (
|
||||
loadSession: () => Session | undefined,
|
||||
clearSession: () => void,
|
||||
) => Promise<InitResult | null>;
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children, client, loadClient }) => {
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
@@ -179,9 +204,13 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
// React does in strict mode), we need to make sure not to doubly initialize
|
||||
// the client.
|
||||
if (initializing.current) return;
|
||||
if (loadClient === undefined) {
|
||||
setInitClientState(null);
|
||||
return;
|
||||
}
|
||||
initializing.current = true;
|
||||
|
||||
loadClient()
|
||||
loadClient(loadSession, clearSession)
|
||||
.then((initResult) => {
|
||||
setInitClientState(initResult);
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
@@ -189,7 +218,7 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
})
|
||||
.catch((err) => logger.error(err))
|
||||
.finally(() => (initializing.current = false));
|
||||
}, [client]);
|
||||
}, [client, loadClient]);
|
||||
|
||||
const changePassword = useCallback(
|
||||
async (password: string) => {
|
||||
@@ -360,15 +389,6 @@ export type InitResult = {
|
||||
passwordlessUser: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores or creates a session of Element Call's own. Only reached when no
|
||||
* client was supplied for it to use.
|
||||
*/
|
||||
async function loadClient(): Promise<InitResult | null> {
|
||||
const { initSPA } = await import("./utils/spa");
|
||||
return initSPA(loadSession, clearSession);
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type MatrixClient,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { initClient } from "../utils/initClient";
|
||||
import { type Session } from "../ClientContext";
|
||||
/**
|
||||
* This provides the login method to login using user credentials.
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { initClient } from "../utils/initClient";
|
||||
import { type Session } from "../ClientContext";
|
||||
import { Config } from "../config/Config";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "events";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
type Mock,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
type ICreateClientOpts,
|
||||
IndexedDBStore,
|
||||
MemoryStore,
|
||||
} from "matrix-js-sdk";
|
||||
import type * as MatrixSdk from "matrix-js-sdk";
|
||||
|
||||
import { initClient } from "./initClient";
|
||||
import { flushPromises } from "./test";
|
||||
|
||||
interface Store {
|
||||
startup: Mock<() => Promise<void>>;
|
||||
getSavedSyncToken: Mock<() => Promise<string | null>>;
|
||||
}
|
||||
|
||||
const store = (): Store => ({
|
||||
startup: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
getSavedSyncToken: vi
|
||||
.fn<() => Promise<string | null>>()
|
||||
.mockResolvedValue(null),
|
||||
});
|
||||
|
||||
// The client is the SDK's business; what these tests are about is how it is
|
||||
// set up: its stores, its crypto, and when it counts as ready.
|
||||
vi.mock("matrix-js-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof MatrixSdk>()),
|
||||
createClient: vi.fn(),
|
||||
IndexedDBStore: vi.fn(function (this: Store) {
|
||||
Object.assign(this, store());
|
||||
}),
|
||||
MemoryStore: vi.fn(function (this: Store) {
|
||||
Object.assign(this, store());
|
||||
}),
|
||||
}));
|
||||
|
||||
interface FakeClient extends EventEmitter {
|
||||
store: Store;
|
||||
clearStores: Mock<() => Promise<void>>;
|
||||
initRustCrypto: Mock<() => Promise<void>>;
|
||||
startClient: Mock<() => Promise<void>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A client that, unless told otherwise, reports its first sync a tick after
|
||||
* being started, as the real one does once the network answers.
|
||||
*/
|
||||
function fakeClient(opts: ICreateClientOpts, syncs = true): FakeClient {
|
||||
const client = Object.assign(new EventEmitter(), {
|
||||
store: opts.store as unknown as Store,
|
||||
clearStores: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
initRustCrypto: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
startClient: vi.fn(async () => {
|
||||
if (!syncs) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
client.emit(ClientEvent.Sync, "PREPARED", null);
|
||||
}),
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
const options: ICreateClientOpts = { baseUrl: "https://hs.example.org" };
|
||||
const clients = vi.mocked(createClient) as unknown as Mock<
|
||||
(opts: ICreateClientOpts) => FakeClient
|
||||
>;
|
||||
const indexedDBStores = vi.mocked(IndexedDBStore);
|
||||
const memoryStores = vi.mocked(MemoryStore);
|
||||
|
||||
describe("initClient", () => {
|
||||
beforeEach(() => {
|
||||
clients.mockImplementation((opts) => fakeClient(opts));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("hands back the client once its first sync is done", async () => {
|
||||
const client = await initClient(options, true);
|
||||
|
||||
expect(client).toBe(clients.mock.results[0].value);
|
||||
expect(client.startClient).toHaveBeenCalledWith({
|
||||
clientWellKnownPollPeriod: 600,
|
||||
});
|
||||
expect(clients).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://hs.example.org",
|
||||
useAuthorizationHeader: true,
|
||||
localTimeoutMs: 5000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the stores when restoring a session", async () => {
|
||||
const client = (await initClient(options, true)) as unknown as FakeClient;
|
||||
expect(client.clearStores).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the stores before starting them when not restoring", async () => {
|
||||
const client = (await initClient(options, false)) as unknown as FakeClient;
|
||||
|
||||
expect(client.clearStores).toHaveBeenCalled();
|
||||
expect(client.clearStores.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
client.store.startup.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("syncs into memory where there is no IndexedDB", async () => {
|
||||
// jsdom has no indexedDB
|
||||
const client = await initClient(options, true);
|
||||
|
||||
expect(client.store).toBe(memoryStores.mock.instances[0]);
|
||||
expect(indexedDBStores).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("syncs into IndexedDB through a worker, where it was bundled", async () => {
|
||||
vi.stubGlobal("indexedDB", {});
|
||||
vi.stubEnv("DEV", false);
|
||||
|
||||
const client = await initClient(options, true);
|
||||
|
||||
expect(client.store).toBe(indexedDBStores.mock.instances[0]);
|
||||
const [storeOptions] = indexedDBStores.mock.calls[0];
|
||||
expect(storeOptions.dbName).toBe("element-call-sync");
|
||||
expect(storeOptions.workerFactory).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("syncs into IndexedDB without a worker in development", async () => {
|
||||
vi.stubGlobal("indexedDB", {});
|
||||
vi.stubEnv("DEV", true);
|
||||
|
||||
await initClient(options, true);
|
||||
|
||||
// The dev server does not bundle the worker
|
||||
expect(indexedDBStores.mock.calls[0][0].workerFactory).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to memory when the IndexedDB store cannot start", async () => {
|
||||
vi.stubGlobal("indexedDB", {});
|
||||
const failing = store();
|
||||
failing.startup.mockRejectedValue(new Error("quota exceeded"));
|
||||
indexedDBStores.mockImplementationOnce(function (this: IndexedDBStore) {
|
||||
Object.assign(this, failing);
|
||||
});
|
||||
|
||||
const client = await initClient(options, true);
|
||||
|
||||
expect(client.store).toBe(memoryStores.mock.instances[0]);
|
||||
expect(client.store.startup).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries on without crypto when it cannot be initialised", async () => {
|
||||
clients.mockImplementation((opts) => {
|
||||
const client = fakeClient(opts);
|
||||
client.initRustCrypto.mockRejectedValue(new Error("stores in use"));
|
||||
return client;
|
||||
});
|
||||
|
||||
await expect(initClient(options, true)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("waits for a sync from the network, past one restored from the store", async () => {
|
||||
let client!: FakeClient;
|
||||
clients.mockImplementation((opts) => {
|
||||
client = fakeClient(opts, false);
|
||||
client.store.getSavedSyncToken.mockResolvedValue("saved");
|
||||
return client;
|
||||
});
|
||||
let ready = false;
|
||||
const result = initClient(options, true).then(() => (ready = true));
|
||||
await flushPromises();
|
||||
|
||||
// The first sync the client reports is the one restored from the store
|
||||
client.emit(ClientEvent.Sync, "PREPARED", null);
|
||||
await flushPromises();
|
||||
expect(ready).toBe(false);
|
||||
|
||||
client.emit(ClientEvent.Sync, "PREPARED", null);
|
||||
await result;
|
||||
expect(ready).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when the first sync does", async () => {
|
||||
let client!: FakeClient;
|
||||
clients.mockImplementation((opts) => (client = fakeClient(opts, false)));
|
||||
const result = initClient(options, true);
|
||||
await flushPromises();
|
||||
|
||||
client.emit(ClientEvent.Sync, "ERROR", null, {
|
||||
error: new Error("offline"),
|
||||
});
|
||||
|
||||
await expect(result).rejects.toThrow("offline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
Copyright 2022-2024 New Vector Ltd.
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
type ICreateClientOpts,
|
||||
IndexedDBStore,
|
||||
type MatrixClient,
|
||||
MemoryStore,
|
||||
} from "matrix-js-sdk";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
// This import is why `initClient` has a module of its own:
|
||||
// - The bundler emits the worker file (11 MB: the Matrix SDK and its crypto
|
||||
// wasm) the moment it loads this import — whether or not anything ever calls
|
||||
// `new IndexedDBWorker()`. Tree shaking drops the call, not the file.
|
||||
// - So it may only sit in a module the component build never loads. It used
|
||||
// to be in `./matrix`, which the component reaches for the room URL helpers,
|
||||
// and the worker shipped in the package, unused and unusable by any host.
|
||||
import IndexedDBWorker from "../IndexedDBWorker?worker";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { Config } from "../config/Config";
|
||||
import { fallbackICEServerAllowed } from "./matrix";
|
||||
|
||||
const SYNC_STORE_NAME = "element-call-sync";
|
||||
|
||||
/**
|
||||
* Initialises and returns a new standalone Matrix Client.
|
||||
* This can only be called safely if no other client is running
|
||||
* otherwise rust crypto will throw since it is not ready to initialize a new session.
|
||||
* If another client is running make sure `.logout()` is called before executing this function.
|
||||
* @param clientOptions Object of options passed through to the client
|
||||
* @param restore If the rust crypto should be reset before the client initialization or
|
||||
* if the initialization should try to restore the crypto state from the indexDB.
|
||||
* @returns The MatrixClient instance
|
||||
*/
|
||||
export async function initClient(
|
||||
clientOptions: ICreateClientOpts,
|
||||
restore: boolean,
|
||||
): Promise<MatrixClient> {
|
||||
let indexedDB: IDBFactory | undefined;
|
||||
try {
|
||||
indexedDB = window.indexedDB;
|
||||
} catch (e) {
|
||||
logger.warn("Could not get indexDB from window.", e);
|
||||
}
|
||||
|
||||
// options we always pass to the client (stuff that we need in order to work)
|
||||
const baseOpts = {
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
isVoipWithNoMediaAllowed:
|
||||
Config.get().features?.feature_group_calls_without_video_and_audio,
|
||||
} as ICreateClientOpts;
|
||||
|
||||
if (indexedDB && localStorage) {
|
||||
baseOpts.store = new IndexedDBStore({
|
||||
indexedDB: window.indexedDB,
|
||||
localStorage,
|
||||
dbName: SYNC_STORE_NAME,
|
||||
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
|
||||
// in dev mode: it expects them to use native modules. Ours don't, and even then only
|
||||
// Chrome supports it. (It bundles them fine in production mode.)
|
||||
workerFactory: import.meta.env.DEV
|
||||
? undefined
|
||||
: (): Worker => new IndexedDBWorker(),
|
||||
});
|
||||
} else if (localStorage) {
|
||||
baseOpts.store = new MemoryStore({ localStorage });
|
||||
}
|
||||
|
||||
// XXX: we read from the URL params in RoomPage too:
|
||||
// it would be much better to read them in one place and pass
|
||||
// the values around, but we initialise the matrix client in
|
||||
// many different places so we'd have to pass it into all of
|
||||
// them.
|
||||
const { e2eEnabled } = getUrlParams();
|
||||
if (!e2eEnabled) {
|
||||
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
|
||||
}
|
||||
|
||||
const client = createClient({
|
||||
...baseOpts,
|
||||
...clientOptions,
|
||||
useAuthorizationHeader: true,
|
||||
// Use a relatively low timeout for API calls: this is a realtime app
|
||||
// so we don't want API calls taking ages, we'd rather they just fail.
|
||||
localTimeoutMs: 5000,
|
||||
useE2eForGroupCall: e2eEnabled,
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
});
|
||||
|
||||
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
|
||||
// - We lost the auth tokens and cannot restore the client resulting in registering a new user.
|
||||
// - We start the sign in flow but are registered with a guest user. (It should additionally log out the guest before)
|
||||
// - A new account is created because of missing LocalStorage: "matrix-auth-store", but the crypto IndexDB is still available.
|
||||
if (!restore) {
|
||||
await client.clearStores();
|
||||
}
|
||||
|
||||
// Start client store.
|
||||
// Note: The `client.store` is used to store things like sync results. It's independent of
|
||||
// the cryptostore, and uses a separate indexeddb database.
|
||||
try {
|
||||
await client.store.startup();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error starting matrix client indexDB store. Falling back to memory store.",
|
||||
error,
|
||||
);
|
||||
client.store = new MemoryStore({ localStorage });
|
||||
await client.store.startup();
|
||||
}
|
||||
|
||||
// Also creates and starts any crypto related stores.
|
||||
try {
|
||||
await client.initRustCrypto();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
err,
|
||||
"Make sure to clear client stores before initializing the rust crypto.",
|
||||
);
|
||||
}
|
||||
|
||||
// Once startClient is called, syncs are run asynchronously.
|
||||
// Also, sync completion is communicated only via events.
|
||||
// So, apply the event listener *before* starting the client.
|
||||
// Otherwise, a sync may complete before the listener gets applied,
|
||||
// and we will miss it.
|
||||
const syncPromise = waitForSync(client);
|
||||
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
|
||||
await syncPromise;
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
async function waitForSync(client: MatrixClient): Promise<void> {
|
||||
// If there is a saved sync, the client will fire an additional sync event
|
||||
// for restoring it before it runs the first network sync.
|
||||
// However, the sync we want to wait for is the network sync,
|
||||
// as the saved sync may be missing some state.
|
||||
// Thus, don't resolve on the first sync when we know it's for the saved sync.
|
||||
let waitForSavedSync = !!(await client.store.getSavedSyncToken());
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onSync = (
|
||||
state: SyncState,
|
||||
_old: SyncState | null,
|
||||
data?: ISyncStateData,
|
||||
): void => {
|
||||
if (state === "PREPARED") {
|
||||
if (waitForSavedSync) {
|
||||
waitForSavedSync = false;
|
||||
} else {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
}
|
||||
} else if (state === "ERROR") {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
reject(data?.error);
|
||||
}
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
});
|
||||
}
|
||||
+8
-152
@@ -5,21 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
IndexedDBStore,
|
||||
MemoryStore,
|
||||
Preset,
|
||||
Visibility,
|
||||
} from "matrix-js-sdk";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { ClientEvent, Preset, Visibility } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { secureRandomBase64Url } from "matrix-js-sdk/lib/randomstring";
|
||||
|
||||
import type { ICreateClientOpts, MatrixClient, Room } from "matrix-js-sdk";
|
||||
import IndexedDBWorker from "../IndexedDBWorker?worker";
|
||||
import { generateUrlSearchParams, getUrlParams } from "../UrlParams";
|
||||
import type { MatrixClient, Room } from "matrix-js-sdk";
|
||||
import { generateUrlSearchParams } from "../UrlParams";
|
||||
import { Config } from "../config/Config";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import {
|
||||
@@ -27,149 +18,14 @@ import {
|
||||
saveKeyForRoom,
|
||||
} from "../e2ee/sharedKeyManagement";
|
||||
|
||||
// Shared with the component, which reaches this module for the room URLs. So
|
||||
// nothing in here may import the client store worker: the bundler emits the
|
||||
// worker file on loading the import, used or not. The one function that needs
|
||||
// it, `initClient`, lives in `./initClient` for that reason.
|
||||
|
||||
export const fallbackICEServerAllowed =
|
||||
import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true";
|
||||
|
||||
const SYNC_STORE_NAME = "element-call-sync";
|
||||
|
||||
async function waitForSync(client: MatrixClient): Promise<void> {
|
||||
// If there is a saved sync, the client will fire an additional sync event
|
||||
// for restoring it before it runs the first network sync.
|
||||
// However, the sync we want to wait for is the network sync,
|
||||
// as the saved sync may be missing some state.
|
||||
// Thus, don't resolve on the first sync when we know it's for the saved sync.
|
||||
let waitForSavedSync = !!(await client.store.getSavedSyncToken());
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const onSync = (
|
||||
state: SyncState,
|
||||
_old: SyncState | null,
|
||||
data?: ISyncStateData,
|
||||
): void => {
|
||||
if (state === "PREPARED") {
|
||||
if (waitForSavedSync) {
|
||||
waitForSavedSync = false;
|
||||
} else {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
}
|
||||
} else if (state === "ERROR") {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
reject(data?.error);
|
||||
}
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialises and returns a new standalone Matrix Client.
|
||||
* This can only be called safely if no other client is running
|
||||
* otherwise rust crypto will throw since it is not ready to initialize a new session.
|
||||
* If another client is running make sure `.logout()` is called before executing this function.
|
||||
* @param clientOptions Object of options passed through to the client
|
||||
* @param restore If the rust crypto should be reset before the client initialization or
|
||||
* if the initialization should try to restore the crypto state from the indexDB.
|
||||
* @returns The MatrixClient instance
|
||||
*/
|
||||
export async function initClient(
|
||||
clientOptions: ICreateClientOpts,
|
||||
restore: boolean,
|
||||
): Promise<MatrixClient> {
|
||||
let indexedDB: IDBFactory | undefined;
|
||||
try {
|
||||
indexedDB = window.indexedDB;
|
||||
} catch (e) {
|
||||
logger.warn("Could not get indexDB from window.", e);
|
||||
}
|
||||
|
||||
// options we always pass to the client (stuff that we need in order to work)
|
||||
const baseOpts = {
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
isVoipWithNoMediaAllowed:
|
||||
Config.get().features?.feature_group_calls_without_video_and_audio,
|
||||
} as ICreateClientOpts;
|
||||
|
||||
if (indexedDB && localStorage) {
|
||||
baseOpts.store = new IndexedDBStore({
|
||||
indexedDB: window.indexedDB,
|
||||
localStorage,
|
||||
dbName: SYNC_STORE_NAME,
|
||||
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
|
||||
// in dev mode: it expects them to use native modules. Ours don't, and even then only
|
||||
// Chrome supports it. (It bundles them fine in production mode.)
|
||||
workerFactory: import.meta.env.DEV
|
||||
? undefined
|
||||
: (): Worker => new IndexedDBWorker(),
|
||||
});
|
||||
} else if (localStorage) {
|
||||
baseOpts.store = new MemoryStore({ localStorage });
|
||||
}
|
||||
|
||||
// XXX: we read from the URL params in RoomPage too:
|
||||
// it would be much better to read them in one place and pass
|
||||
// the values around, but we initialise the matrix client in
|
||||
// many different places so we'd have to pass it into all of
|
||||
// them.
|
||||
const { e2eEnabled } = getUrlParams();
|
||||
if (!e2eEnabled) {
|
||||
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
|
||||
}
|
||||
|
||||
const client = createClient({
|
||||
...baseOpts,
|
||||
...clientOptions,
|
||||
useAuthorizationHeader: true,
|
||||
// Use a relatively low timeout for API calls: this is a realtime app
|
||||
// so we don't want API calls taking ages, we'd rather they just fail.
|
||||
localTimeoutMs: 5000,
|
||||
useE2eForGroupCall: e2eEnabled,
|
||||
fallbackICEServerAllowed: fallbackICEServerAllowed,
|
||||
});
|
||||
|
||||
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
|
||||
// - We lost the auth tokens and cannot restore the client resulting in registering a new user.
|
||||
// - We start the sign in flow but are registered with a guest user. (It should additionally log out the guest before)
|
||||
// - A new account is created because of missing LocalStorage: "matrix-auth-store", but the crypto IndexDB is still available.
|
||||
if (!restore) {
|
||||
await client.clearStores();
|
||||
}
|
||||
|
||||
// Start client store.
|
||||
// Note: The `client.store` is used to store things like sync results. It's independent of
|
||||
// the cryptostore, and uses a separate indexeddb database.
|
||||
try {
|
||||
await client.store.startup();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
"Error starting matrix client indexDB store. Falling back to memory store.",
|
||||
error,
|
||||
);
|
||||
client.store = new MemoryStore({ localStorage });
|
||||
await client.store.startup();
|
||||
}
|
||||
|
||||
// Also creates and starts any crypto related stores.
|
||||
try {
|
||||
await client.initRustCrypto();
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
err,
|
||||
"Make sure to clear client stores before initializing the rust crypto.",
|
||||
);
|
||||
}
|
||||
|
||||
// Once startClient is called, syncs are run asynchronously.
|
||||
// Also, sync completion is communicated only via events.
|
||||
// So, apply the event listener *before* starting the client.
|
||||
// Otherwise, a sync may complete before the listener gets applied,
|
||||
// and we will miss it.
|
||||
const syncPromise = waitForSync(client);
|
||||
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
|
||||
await syncPromise;
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export function roomAliasLocalpartFromRoomName(roomName: string): string {
|
||||
return roomName
|
||||
.trim()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { type MatrixClient, MatrixError } from "matrix-js-sdk";
|
||||
|
||||
import { initClient } from "./initClient";
|
||||
import { initSPA } from "./spa";
|
||||
import { Config } from "../config/Config";
|
||||
import { type Session } from "../ClientContext";
|
||||
|
||||
vi.mock("./initClient", () => ({ initClient: vi.fn() }));
|
||||
|
||||
const session: Session = {
|
||||
user_id: "@alice:example.org",
|
||||
device_id: "AAAA",
|
||||
access_token: "secret",
|
||||
passwordlessUser: true,
|
||||
};
|
||||
const client = {} as MatrixClient;
|
||||
|
||||
describe("initSPA", () => {
|
||||
const clearSession = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
|
||||
"https://hs.example.org",
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("continues without a client when no session is stored", async () => {
|
||||
await expect(initSPA(() => undefined, clearSession)).resolves.toBeNull();
|
||||
|
||||
expect(initClient).not.toHaveBeenCalled();
|
||||
expect(clearSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the stored session into a client", async () => {
|
||||
vi.mocked(initClient).mockResolvedValue(client);
|
||||
|
||||
await expect(initSPA(() => session, clearSession)).resolves.toEqual({
|
||||
client,
|
||||
passwordlessUser: true,
|
||||
});
|
||||
|
||||
expect(initClient).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: "https://hs.example.org",
|
||||
accessToken: "secret",
|
||||
userId: "@alice:example.org",
|
||||
deviceId: "AAAA",
|
||||
}),
|
||||
// Restoring: the crypto store is the session's, not to be wiped
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a session the homeserver no longer knows", async () => {
|
||||
vi.mocked(initClient).mockRejectedValue(
|
||||
new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Unknown token" }),
|
||||
);
|
||||
|
||||
await expect(initSPA(() => session, clearSession)).resolves.toBeNull();
|
||||
expect(clearSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops the session and fails on any other error", async () => {
|
||||
vi.mocked(initClient).mockRejectedValue(new Error("offline"));
|
||||
|
||||
await expect(initSPA(() => session, clearSession)).rejects.toThrow(
|
||||
"offline",
|
||||
);
|
||||
expect(clearSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -9,7 +9,8 @@ import { type ICreateClientOpts, MatrixError } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { Config } from "../config/Config";
|
||||
import { fallbackICEServerAllowed, initClient } from "./matrix";
|
||||
import { fallbackICEServerAllowed } from "./matrix";
|
||||
import { initClient } from "./initClient";
|
||||
import type { InitResult, Session } from "../ClientContext";
|
||||
|
||||
export async function initSPA(
|
||||
|
||||
Reference in New Issue
Block a user