/* Copyright 2021-2024 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { merge } from "lodash-es"; import { logger } from "matrix-js-sdk/lib/logger"; import { getUrlParams } from "../UrlParams"; import { DEFAULT_CONFIG, type ConfigOptions, type ResolvedConfigOptions, } from "./ConfigOptions"; import { isFailure } from "../utils/fetch"; import { MatrixRTCMode } from "./ConfigOptions"; const VALID_MATRIX_RTC_MODES: ReadonlySet = new Set( Object.values(MatrixRTCMode), ); export class Config { private static internalInstance: Config | undefined; public static get(): ResolvedConfigOptions { if (!this.internalInstance?.config) throw new Error("Config instance read before config got initialized"); return this.internalInstance.config; } /** * Initializes the config by fetching `config.json`, locating it relative to * the current page. * * Does nothing if the config has already been initialized, including by * {@link Config.initWith}, so that the regular startup path can run unchanged * when an embedder has already supplied the config. */ public static async init(): Promise { if (!Config.internalInstance?.initPromise) { const internalInstance = new Config(); Config.internalInstance = internalInstance; let fetchTarget: string; if ( window.location.pathname.endsWith("/room/") || window.location.pathname.endsWith("/room") ) { // it looks like we are running in standalone mode so use the config at the root fetchTarget = new URL("/config.json", window.location.href).href; } else { // otherwise we are probably running as a widget so use the config in the same directory fetchTarget = "config.json"; } Config.internalInstance.initPromise = downloadConfig(fetchTarget).then( (config) => { internalInstance.config = resolveConfig(config); }, ); } return Config.internalInstance.initPromise; } /** * Initializes the config from an object supplied by the embedder, instead of * fetching `config.json`. * * {@link Config.init} derives the location of `config.json` from * `window.location`, which only makes sense while Element Call owns the page. * When it is embedded in a host application the host owns the configuration * and passes it in here. * * The config goes through the same validation and defaulting as a fetched * one, so that an injected config behaves identically to a hosted one. * * Replaces any config initialized earlier. */ public static initWith(config: ConfigOptions): void { const internalInstance = new Config(); internalInstance.config = resolveConfig(config); // Mark initialization as already done, so that a later init() resolves // immediately rather than fetching config.json over the top of this. internalInstance.initPromise = Promise.resolve(); Config.internalInstance = internalInstance; } /** * This is a alternative initializer that does not load anything * from a hosted config file but instead just initializes the config using the * default config. * * It is supposed to only be used in tests. (It is executed in `vite.setup.js`) */ public static initDefault(): void { Config.initWith({}); } // Convenience accessors public static defaultHomeserverUrl(): string | undefined { return ( getUrlParams().homeserver ?? Config.get().default_server_config?.["m.homeserver"].base_url ); } public static defaultServerName(): string | undefined { const homeserver = getUrlParams().homeserver; if (homeserver) { const url = new URL(homeserver); return url.hostname; } return Config.get().default_server_config?.["m.homeserver"].server_name; } public config?: ResolvedConfigOptions; private initPromise?: Promise; } /** * Applies validation and the built-in defaults to a config, however it was * obtained. Deep-merges onto a fresh object so that the result never shares * nested state with {@link DEFAULT_CONFIG}. */ function resolveConfig(config: ConfigOptions): ResolvedConfigOptions { return merge({}, DEFAULT_CONFIG, validateConfig(config)); } export function validateConfig(config: ConfigOptions): ConfigOptions { const mode = config.matrix_rtc_mode; if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) { logger.warn( `Ignoring invalid matrix_rtc_mode in config.json: ${String(mode)}`, ); delete config.matrix_rtc_mode; } return config; } async function downloadConfig(fetchTarget: string): Promise { const response = await fetch(fetchTarget); if (isFailure(response)) { // Lack of a config isn't an error, we should just use the defaults. // Also treat a blank config as no config, assuming the status code is 0, because we don't get 404s from file: // URIs so this is the only way we can not fail if the file doesn't exist when loading from a file:// URI. return DEFAULT_CONFIG; } return response.json(); }