diff --git a/apps/desktop/.eslintrc.cjs b/apps/desktop/.eslintrc.cjs index 7a1d06729c2..2c595014d5b 100644 --- a/apps/desktop/.eslintrc.cjs +++ b/apps/desktop/.eslintrc.cjs @@ -21,6 +21,19 @@ module.exports = { "n/file-extension-in-import": ["error", "always"], "unicorn/prefer-node-protocol": ["error"], + + "no-restricted-imports": [ + "error", + { + paths: [ + { + name: "electron", + importNames: ["ipcMain"], + message: "Use typedIpcMain instead.", + }, + ], + }, + ], }, overrides: [ { diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 55bd6f4f568..37ff6800eb6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -102,6 +102,7 @@ "mkdirp": "^3.0.0", "pacote": "^22.0.0", "rimraf": "^6.0.0", + "shared-types": "workspace:*", "tar": "^7.5.8", "typescript": "catalog:", "vitest": "catalog:" diff --git a/apps/desktop/src/@types/matrix-seshat.d.ts b/apps/desktop/src/@types/matrix-seshat.d.ts index e93147b6736..cb3dea02f3b 100644 --- a/apps/desktop/src/@types/matrix-seshat.d.ts +++ b/apps/desktop/src/@types/matrix-seshat.d.ts @@ -18,6 +18,7 @@ declare module "matrix-seshat" { room_id: string; origin_server_ts: number; content: Record; + type: string; } interface IMatrixProfile { @@ -26,7 +27,7 @@ declare module "matrix-seshat" { } interface ISearchArgs { - searchTerm: number; + search_term: string; limit: number; before_limit: number; after_limit: number; @@ -54,7 +55,7 @@ declare module "matrix-seshat" { interface ICheckpoint { roomId: string; token: string; - fullCrawl: boolean; + fullCrawl?: boolean; direction: "b" | "f"; } @@ -67,13 +68,13 @@ declare module "matrix-seshat" { interface ILoadArgs { roomId: string; limit: number; - fromEvent: string; - direction: "b" | "f"; + fromEvent?: string; + direction?: "b" | "f"; } interface ILoadResult { event: IMatrixEvent; - matrixProfile: IMatrixProfile; + profile: IMatrixProfile; } export class Seshat { @@ -92,12 +93,12 @@ declare module "matrix-seshat" { orderByRecency?: boolean, ): ISearchResult; public addHistoricEventsSync( - events: IMatrixEvent[], + events: ILoadResult[], newCheckpoint?: ICheckpoint, oldCheckpoint?: ICheckpoint, ): boolean; public addHistoricEvents( - events: IMatrixEvent[], + events: ILoadResult[], newCheckpoint?: ICheckpoint, oldCheckpoint?: ICheckpoint, ): Promise; diff --git a/apps/desktop/src/badge.ts b/apps/desktop/src/badge.ts index c28ae7f5cbe..65f753004aa 100644 --- a/apps/desktop/src/badge.ts +++ b/apps/desktop/src/badge.ts @@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { app, ipcMain, type IpcMainEvent, nativeImage } from "electron"; +import { app, nativeImage } from "electron"; import { _t } from "./language-helper.js"; +import { typedIpcMain } from "./ipc.js"; // Handles calculating the correct "badge" for the window, for notifications and error states. // Tray icon updates are handled in tray.ts @@ -16,30 +17,27 @@ if (process.platform === "win32") { // We only use setOverlayIcon on Windows as it's only supported on that platform, but has good support // from all the Windows variants we support. // https://www.electronjs.org/docs/latest/api/browser-window#winsetoverlayiconoverlay-description-windows - ipcMain.on( - "setBadgeCount", - function (_ev: IpcMainEvent, count: number, imageBuffer?: Buffer, isError?: boolean): void { - if (count === 0) { - // Flash frame is set to true in ipc.ts "loudNotification" - global.mainWindow?.flashFrame(false); - } - if (imageBuffer) { - global.mainWindow?.setOverlayIcon( - nativeImage.createFromBuffer(Buffer.from(imageBuffer)), - isError - ? _t("icon_overlay|description_error") - : _t("icon_overlay|description_notifications", { count }), - ); - } else { - global.mainWindow?.setOverlayIcon(null, ""); - } - }, - ); + typedIpcMain.on("setBadgeCount", (_, count: number, imageBuffer?: ArrayBuffer, isError?: boolean): void => { + if (count === 0) { + // Flash frame is set to true in ipc.ts "loudNotification" + global.mainWindow?.flashFrame(false); + } + if (imageBuffer) { + global.mainWindow?.setOverlayIcon( + nativeImage.createFromBuffer(Buffer.from(imageBuffer)), + isError + ? _t("icon_overlay|description_error") + : _t("icon_overlay|description_notifications", { count }), + ); + } else { + global.mainWindow?.setOverlayIcon(null, ""); + } + }); } else { // only set badgeCount on Mac/Linux, the docs say that only those platforms support it but turns out Electron // has some Windows support too, and in some Windows environments this leads to two badges rendering atop // each other. See https://github.com/vector-im/element-web/issues/16942 - ipcMain.on("setBadgeCount", function (_ev: IpcMainEvent, count: number): void { + typedIpcMain.on("setBadgeCount", function (_, count: number): void { if (count === 0) { // Flash frame is set to true in ipc.ts "loudNotification" global.mainWindow?.flashFrame(false); diff --git a/apps/desktop/src/build-config.ts b/apps/desktop/src/build-config.ts index b4d52865a37..5533e0b774c 100644 --- a/apps/desktop/src/build-config.ts +++ b/apps/desktop/src/build-config.ts @@ -7,8 +7,9 @@ Please see LICENSE files in the repository root for full details. import path, { dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { type JsonObject } from "shared-types"; -import { type JsonObject, loadJsonFile } from "./utils.js"; +import { loadJsonFile } from "./utils.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); diff --git a/apps/desktop/src/config.ts b/apps/desktop/src/config.ts index 5f25066b637..09d34463002 100644 --- a/apps/desktop/src/config.ts +++ b/apps/desktop/src/config.ts @@ -7,27 +7,12 @@ Please see LICENSE files in the repository root for full details. import { app, dialog } from "electron"; import path from "node:path"; +import { type ResolveDefaults, type DesktopConfigJson, type JsonDocument } from "shared-types"; import { getAsarPath } from "./asar.js"; -import { type Json, loadJsonFile } from "./utils.js"; - -export interface ConfigOptions { - brand: string; - help_url: string; - web_base_url: string; - modules?: string[]; - sentry?: { - dsn?: string; - environment?: string; - }; - update_base_url?: string; - - // homeserver props - default_is_url?: string; - default_hs_url?: string; - default_server_name?: string; - default_server_config?: object; -} +import { loadJsonFile } from "./utils.js"; + +export type ConfigOptions = ResolveDefaults; const ConfigFilename = "config.json"; @@ -35,7 +20,7 @@ let config: ConfigOptions; const homeserverProps = ["default_is_url", "default_hs_url", "default_server_name", "default_server_config"] as const; -function loadLocalConfigFile(location: string | undefined): Json { +function loadLocalConfigFile(location: string | undefined): JsonDocument { if (location) { console.log("Loading local config: " + location); return loadJsonFile(location); @@ -50,9 +35,9 @@ const DEFAULTS = { brand: "Element", help_url: "https://element.io/help", web_base_url: "https://app.element.io/", -} satisfies ConfigOptions; +} satisfies DesktopConfigJson; -function applyDefaults(conf: ConfigOptions): void { +function applyDefaults(conf: DesktopConfigJson): asserts conf is ConfigOptions { for (const k in DEFAULTS) { const key = k as keyof typeof DEFAULTS; conf[key] ||= DEFAULTS[key]; @@ -71,7 +56,9 @@ export function loadConfig(localConfigPath: string | undefined): Promise = Promise | T; + +export const typedIpcMain = { + handle: ( + channel: K, + listener: ( + event: IpcMainInvokeEvent, + ...args: Parameters + ) => MaybePromise>, + ): void => { + ipcMain.handle(channel, listener as any); + }, + + emit: (channel: K, ...args: Parameters): void => { + ipcMain.emit(channel, ...args); + }, + + on: ( + channel: K, + listener: (event: IpcMainEvent, ...args: Parameters) => void, + ): void => { + ipcMain.on(channel, listener as any); + }, + once: ( + channel: K, + listener: (event: IpcMainEvent, ...args: Parameters) => void, + ): void => { + ipcMain.once(channel, listener as any); + }, +}; + let focusHandlerAttached = false; -ipcMain.on("loudNotification", function (): void { +typedIpcMain.on("loudNotification", function (): void { if (process.platform === "win32" || process.platform === "linux") { if (global.mainWindow && !global.mainWindow.isFocused() && !focusHandlerAttached) { global.mainWindow.flashFrame(true); @@ -28,201 +70,157 @@ ipcMain.on("loudNotification", function (): void { }); let powerSaveBlockerId: number | null = null; -ipcMain.on("app_onAction", function (_ev: IpcMainEvent, payload) { - switch (payload.action) { - case "call_state": { - if (powerSaveBlockerId !== null && powerSaveBlocker.isStarted(powerSaveBlockerId)) { - if (payload.state === "ended") { - powerSaveBlocker.stop(powerSaveBlockerId); - powerSaveBlockerId = null; - } - } else { - if (powerSaveBlockerId === null && payload.state === "connected") { - powerSaveBlockerId = powerSaveBlocker.start("prevent-display-sleep"); - } - } - break; - } +typedIpcMain.on("prevent_display_sleep", function (_ev, prevent) { + if (powerSaveBlockerId !== null && powerSaveBlocker.isStarted(powerSaveBlockerId) && !prevent) { + powerSaveBlocker.stop(powerSaveBlockerId); + powerSaveBlockerId = null; + } else if (powerSaveBlockerId === null && prevent) { + powerSaveBlockerId = powerSaveBlocker.start("prevent-display-sleep"); } }); -ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) { - const store = Store.instance; - if (!global.mainWindow || !store) return; - - const args = payload.args || []; - let ret: any; - - switch (payload.name) { - case "getUpdateFeedUrl": - ret = autoUpdater.getFeedURL(); - break; - case "setLanguage": - global.appLocalization.setAppLocale(args[0]); - break; - case "getAppVersion": - ret = app.getVersion(); - break; - case "focusWindow": - if (global.mainWindow.isMinimized()) { - global.mainWindow.restore(); - } else { - global.mainWindow.show(); - global.mainWindow.focus(); - } - break; - - case "navigateBack": - if (global.mainWindow.webContents.canGoBack()) { - global.mainWindow.webContents.goBack(); - } - break; - case "navigateForward": - if (global.mainWindow.webContents.canGoForward()) { - global.mainWindow.webContents.goForward(); - } - break; - case "setSpellCheckEnabled": - if (typeof args[0] !== "boolean") return; - - global.mainWindow.webContents.session.setSpellCheckerEnabled(args[0]); - store.set("spellCheckerEnabled", args[0]); - break; - - case "getSpellCheckEnabled": - ret = store.get("spellCheckerEnabled"); - break; - - case "setSpellCheckLanguages": - try { - global.mainWindow.webContents.session.setSpellCheckerLanguages(args[0]); - } catch (er) { - console.log("There were problems setting the spellcheck languages", er); - } - break; - - case "getSpellCheckLanguages": - ret = global.mainWindow.webContents.session.getSpellCheckerLanguages(); - break; - case "getAvailableSpellCheckLanguages": - ret = global.mainWindow.webContents.session.availableSpellCheckerLanguages; - break; - - case "getPickleKey": - try { - ret = await store.getSecret(`${args[0]}|${args[1]}`); - } catch { - // if an error is thrown (e.g. we can't initialise safeStorage), - // then return null, which means the default pickle key will be used - ret = null; - } - break; - - case "createPickleKey": - try { - const pickleKey = await randomArray(32); - await store.setSecret(`${args[0]}|${args[1]}`, pickleKey); - ret = pickleKey; - } catch (e) { - console.error("Failed to create pickle key", e); - ret = null; - } - break; - - case "destroyPickleKey": - try { - await store.deleteSecret(`${args[0]}|${args[1]}`); - } catch (e) { - console.error("Failed to destroy pickle key", e); - } - break; - case "getDesktopCapturerSources": - ret = (await desktopCapturer.getSources(args[0])).map((source) => ({ - id: source.id, - name: source.name, - thumbnailURL: source.thumbnail.toDataURL(), - })); - break; - case "callDisplayMediaCallback": - await getDisplayMediaCallback()?.({ video: args[0] }); - setDisplayMediaCallback(null); - ret = null; - break; - - case "clearStorage": - await clearDataAndRelaunch(global.mainWindow.webContents.session); - return; // the app is about to stop, we don't need to reply to the IPC - - case "breadcrumbs": { - if (process.platform === "darwin") { - const { TouchBarPopover, TouchBarButton } = TouchBar; - - const recentsBar = new TouchBar({ - items: args[0].map((r: { roomId: string; avatarUrl: string | null; initial: string }) => { - const defaultColors = ["#0DBD8B", "#368bd6", "#ac3ba8"]; - let total = 0; - for (let i = 0; i < r.roomId.length; ++i) { - total += r.roomId.charCodeAt(i); - } - - const button = new TouchBarButton({ - label: r.initial, - backgroundColor: defaultColors[total % defaultColors.length], - click: (): void => { - void global.mainWindow?.loadURL(`vector://vector/webapp/#/room/${r.roomId}`); - }, - }); - if (r.avatarUrl) { - void fetch(r.avatarUrl) - .then((resp) => { - if (!resp.ok) return; - return resp.arrayBuffer(); - }) - .then((arrayBuffer) => { - if (!arrayBuffer) return; - const buffer = Buffer.from(arrayBuffer); - button.icon = nativeImage.createFromBuffer(buffer); - button.label = ""; - button.backgroundColor = ""; - }); - } - return button; - }), - }); +typedIpcMain.handle("getAppVersion", () => app.getVersion()); +typedIpcMain.handle("setLanguage", (_, lang) => { + global.appLocalization.setAppLocale(lang); +}); +typedIpcMain.handle("focusWindow", () => { + if (global.mainWindow?.isMinimized()) { + global.mainWindow.restore(); + } else { + global.mainWindow?.show(); + global.mainWindow?.focus(); + } +}); +typedIpcMain.handle("getUpdateFeedUrl", () => autoUpdater.getFeedURL()); +typedIpcMain.handle("navigateBack", () => { + if (global.mainWindow?.webContents.canGoBack()) { + global.mainWindow.webContents.goBack(); + } +}); +typedIpcMain.handle("navigateForward", () => { + if (global.mainWindow?.webContents.canGoForward()) { + global.mainWindow.webContents.goForward(); + } +}); +typedIpcMain.handle("setSpellCheckEnabled", (_, enabled) => { + global.mainWindow?.webContents.session.setSpellCheckerEnabled(enabled); + Store.instance?.set("spellCheckerEnabled", enabled); +}); +typedIpcMain.handle("getSpellCheckEnabled", () => Store.instance?.get("spellCheckerEnabled") ?? false); +typedIpcMain.handle("setSpellCheckLanguages", (_, langs) => { + try { + global.mainWindow!.webContents.session.setSpellCheckerLanguages(langs); + } catch (er) { + console.log("There were problems setting the spellcheck languages", er); + } +}); +typedIpcMain.handle( + "getSpellCheckLanguages", + () => global.mainWindow?.webContents.session.getSpellCheckerLanguages() ?? [], +); +typedIpcMain.handle( + "getAvailableSpellCheckLanguages", + () => global.mainWindow?.webContents.session.availableSpellCheckerLanguages ?? [], +); +typedIpcMain.handle("getPickleKey", async (_, userId, deviceId) => { + try { + return (await Store.instance!.getSecret(`${userId}|${deviceId}`)) ?? null; + } catch { + // if an error is thrown (e.g. we can't initialise safeStorage), + // then return null, which means the default pickle key will be used + return null; + } +}); +typedIpcMain.handle("createPickleKey", async (_, userId, deviceId) => { + try { + const pickleKey = await randomArray(32); + await Store.instance!.setSecret(`${userId}|${deviceId}`, pickleKey); + return pickleKey; + } catch (e) { + console.error("Failed to create pickle key", e); + return null; + } +}); +typedIpcMain.handle("destroyPickleKey", async (_, userId, deviceId) => { + try { + await Store.instance!.deleteSecret(`${userId}|${deviceId}`); + } catch (e) { + console.error("Failed to destroy pickle key", e); + } +}); +typedIpcMain.handle("getDesktopCapturerSources", async (_, options) => { + const sources = await desktopCapturer.getSources(options); + return sources.map((source) => ({ + id: source.id, + name: source.name, + thumbnailURL: source.thumbnail.toDataURL(), + })); +}); +typedIpcMain.handle("callDisplayMediaCallback", async (_, video) => { + await getDisplayMediaCallback()?.({ video }); + setDisplayMediaCallback(null); +}); - const touchBar = new TouchBar({ - items: [ - new TouchBarPopover({ - label: "Recents", - showCloseButton: true, - items: recentsBar, - }), - ], - }); - global.mainWindow.setTouchBar(touchBar); - } - break; - } +typedIpcMain.once("clearStorage", () => { + // the app is about to stop, we don't need to reply to the IPC + clearDataAndRelaunch(global.mainWindow!.webContents.session); +}); - default: - global.mainWindow.webContents.send("ipcReply", { - id: payload.id, - error: "Unknown IPC Call: " + payload.name, - }); - return; - } +if (process.platform === "darwin") { + typedIpcMain.on("breadcrumbs", (_, rooms) => { + const { TouchBarPopover, TouchBarButton } = TouchBar; + + const recentsBar = new TouchBar({ + items: rooms.map((r) => { + const defaultColors = ["#0DBD8B", "#368bd6", "#ac3ba8"]; + let total = 0; + for (let i = 0; i < r.roomId.length; ++i) { + total += r.roomId.charCodeAt(i); + } - global.mainWindow?.webContents.send("ipcReply", { - id: payload.id, - reply: ret, + const button = new TouchBarButton({ + label: r.initial, + backgroundColor: defaultColors[total % defaultColors.length], + click: (): void => { + void global.mainWindow?.loadURL(`vector://vector/webapp/#/room/${r.roomId}`); + }, + }); + if (r.avatarUrl) { + void fetch(r.avatarUrl) + .then((resp) => { + if (!resp.ok) return; + return resp.arrayBuffer(); + }) + .then((arrayBuffer) => { + if (!arrayBuffer) return; + const buffer = Buffer.from(arrayBuffer); + button.icon = nativeImage.createFromBuffer(buffer); + button.label = ""; + button.backgroundColor = ""; + }); + } + return button; + }), + }); + + const touchBar = new TouchBar({ + items: [ + new TouchBarPopover({ + label: "Recents", + showCloseButton: true, + items: recentsBar, + }), + ], + }); + global.mainWindow?.setTouchBar(touchBar); }); -}); +} -ipcMain.handle("getConfig", getConfig); +typedIpcMain.handle("getConfig", getConfig); const initialisePromiseWithResolvers = Promise.withResolvers(); export const initialisePromise = initialisePromiseWithResolvers.promise; -ipcMain.once("initialise", () => { +typedIpcMain.once("initialise", () => { initialisePromiseWithResolvers.resolve(); }); diff --git a/apps/desktop/src/media-auth.ts b/apps/desktop/src/media-auth.ts index c2b015f7053..0567c297c4c 100644 --- a/apps/desktop/src/media-auth.ts +++ b/apps/desktop/src/media-auth.ts @@ -5,7 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type BrowserWindow, ipcMain, session } from "electron"; +import { type BrowserWindow, session } from "electron"; + +import { typedIpcMain } from "./ipc.js"; /** * Check for feature support from the server. @@ -13,7 +15,7 @@ import { type BrowserWindow, ipcMain, session } from "electron"; */ async function getSupportedVersions(window: BrowserWindow): Promise { return new Promise((resolve) => { - ipcMain.once("serverSupportedVersions", (_, versionsResponse) => { + typedIpcMain.once("serverSupportedVersions", (_, versionsResponse) => { resolve(versionsResponse?.versions || []); }); window.webContents.send("serverSupportedVersions"); // ping now that the listener exists @@ -26,7 +28,7 @@ async function getSupportedVersions(window: BrowserWindow): Promise { */ async function getAccessToken(window: BrowserWindow): Promise { return new Promise((resolve) => { - ipcMain.once("userAccessToken", (_, accessToken) => { + typedIpcMain.once("userAccessToken", (_, accessToken) => { resolve(accessToken); }); window.webContents.send("userAccessToken"); // ping now that the listener exists @@ -37,9 +39,9 @@ async function getAccessToken(window: BrowserWindow): Promise { +async function getHomeserverUrl(window: BrowserWindow): Promise { return new Promise((resolve) => { - ipcMain.once("homeserverUrl", (_, homeserver) => { + typedIpcMain.once("homeserverUrl", (_, homeserver) => { resolve(homeserver); }); window.webContents.send("homeserverUrl"); // ping now that the listener exists diff --git a/apps/desktop/src/preload.cts b/apps/desktop/src/preload.cts index 18a9afe2756..1e1450fe18c 100644 --- a/apps/desktop/src/preload.cts +++ b/apps/desktop/src/preload.cts @@ -8,50 +8,108 @@ Please see LICENSE files in the repository root for full details. // This file is compiled to CommonJS rather than ESM otherwise the browser chokes on the import statement. -import { ipcRenderer, contextBridge, IpcRendererEvent } from "electron"; +import { ipcRenderer, contextBridge } from "electron"; +import type { + ElectronChannel, + Electron, + ElectronSettings, + IpcHandles, + MainRendererEvents, + RendererMainEvents, +} from "shared-types" with { + "resolution-mode": "import", +}; import type { ConfigOptions } from "./config.js" with { "resolution-mode": "import" }; // Expose only expected IPC wrapper APIs to the renderer process to avoid // handing out generalised messaging access. -const CHANNELS = [ - "app_onAction", +const CHANNELS: ElectronChannel[] = [ + "prevent_display_sleep", "before-quit", "check_updates", "install_update", - "ipcCall", - "ipcReply", "loudNotification", "preferences", - "seshat", - "seshatReply", "setBadgeCount", "update-downloaded", "userDownloadCompleted", "userDownloadAction", "openDesktopCapturerSourcePicker", + "showToast", + "getUpdateFeedUrl", + "setLanguage", + "getAppVersion", + "focusWindow", + "navigateBack", + "navigateForward", + "setSpellCheckEnabled", + "getSpellCheckEnabled", + "setSpellCheckLanguages", + "getSpellCheckLanguages", + "getAvailableSpellCheckLanguages", + "getPickleKey", + "createPickleKey", + "destroyPickleKey", + "getDesktopCapturerSources", + "callDisplayMediaCallback", + "clearStorage", + "breadcrumbs", + + // Media auth "userAccessToken", "homeserverUrl", "serverSupportedVersions", - "showToast", + + // Seshat + "seshat.supportsEventIndexing", + "seshat.initEventIndex", + "seshat.closeEventIndex", + "seshat.deleteEventIndex", + "seshat.isEventIndexEmpty", + "seshat.isRoomIndexed", + "seshat.addEventToIndex", + "seshat.deleteEvent", + "seshat.commitLiveEvents", + "seshat.searchEventIndex", + "seshat.addHistoricEvents", + "seshat.getStats", + "seshat.removeCrawlerCheckpoint", + "seshat.addCrawlerCheckpoint", + "seshat.loadFileEvents", + "seshat.loadCheckpoints", + "seshat.setUserVersion", + "seshat.getUserVersion", ]; contextBridge.exposeInMainWorld("electron", { - on(channel: string, listener: (event: IpcRendererEvent, ...args: any[]) => void): void { + on( + channel: K, + listener: (...args: Parameters) => void, + ): void { if (!CHANNELS.includes(channel)) { - console.error(`Unknown IPC channel ${channel} ignored`); - return; + throw new Error(`Unknown IPC channel ${channel} ignored`); } - ipcRenderer.on(channel, listener); + ipcRenderer.on(channel, (_, ...args: Parameters) => listener(...args)); }, - send(channel: string, ...args: any[]): void { + + send(channel: K, ...args: Parameters): void { if (!CHANNELS.includes(channel)) { - console.error(`Unknown IPC channel ${channel} ignored`); - return; + throw new Error(`Unknown IPC channel ${channel} ignored`); } ipcRenderer.send(channel, ...args); }, + call( + channel: K, + ...args: Parameters + ): Promise>> { + if (!CHANNELS.includes(channel)) { + throw new Error(`Unknown IPC channel ${channel} ignored`); + } + return ipcRenderer.invoke(channel, ...args); + }, + async initialise(): Promise<{ protocol: string; sessionId: string; @@ -71,10 +129,12 @@ contextBridge.exposeInMainWorld("electron", { return { protocol, sessionId, config, supportedSettings, supportsBadgeOverlay: process.platform === "win32" }; }, - async setSettingValue(settingName: string, value: any): Promise { + async setSettingValue(settingName: K, value: ElectronSettings[K]): Promise { return ipcRenderer.invoke("setSettingValue", settingName, value); }, - async getSettingValue(settingName: string): Promise { + async getSettingValue(settingName: K): Promise { return ipcRenderer.invoke("getSettingValue", settingName); }, -}); +} satisfies Electron); + +export {}; diff --git a/apps/desktop/src/protocol.test.ts b/apps/desktop/src/protocol.test.ts index 2f85301623b..43c47dab5ab 100644 --- a/apps/desktop/src/protocol.test.ts +++ b/apps/desktop/src/protocol.test.ts @@ -32,6 +32,8 @@ vi.mock("electron", () => { }, ipcMain: { handle: vi.fn(), + on: vi.fn(), + once: vi.fn(), }, }; }); diff --git a/apps/desktop/src/protocol.ts b/apps/desktop/src/protocol.ts index 7d2cb527f11..e36647ba063 100644 --- a/apps/desktop/src/protocol.ts +++ b/apps/desktop/src/protocol.ts @@ -6,13 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { app, ipcMain } from "electron"; +import { app } from "electron"; import { URL } from "node:url"; import path from "node:path"; import fs from "node:fs"; import { randomUUID } from "node:crypto"; import { type Args, getArgsForProtocolRegistration } from "./args.js"; +import { typedIpcMain } from "./ipc.js"; const LEGACY_PROTOCOL = "element"; const SEARCH_PARAM = "element-desktop-ssoid"; @@ -45,7 +46,7 @@ export default class ProtocolHandler { this.store = this.readStore(); this.sessionId = randomUUID(); - ipcMain.handle("getProtocol", this.onGetProtocol); + typedIpcMain.handle("getProtocol", this.onGetProtocol); } private checkArgIsUrl = (arg: string): boolean => { diff --git a/apps/desktop/src/seshat.ts b/apps/desktop/src/seshat.ts index 0a8537f108a..93117792e6d 100644 --- a/apps/desktop/src/seshat.ts +++ b/apps/desktop/src/seshat.ts @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { app, ipcMain } from "electron"; +import { app } from "electron"; import { promises as afs } from "node:fs"; import path from "node:path"; @@ -14,9 +14,9 @@ import type { SeshatRecovery as SeshatRecoveryType, ReindexError as ReindexErrorType, } from "matrix-seshat"; // Hak dependency type -import IpcMainEvent = Electron.IpcMainEvent; import { randomArray } from "./utils.js"; -import Store from "./store.js"; +import type Store from "./store.js"; +import { typedIpcMain } from "./ipc.js"; let seshatSupported = false; let Seshat: typeof SeshatType; @@ -76,240 +76,83 @@ const deleteContents = async (p: string): Promise => { } }; -ipcMain.on("seshat", async function (_ev: IpcMainEvent, payload): Promise { - const store = Store.instance; - if (!global.mainWindow || !store) return; - +export function setupListeners(store: Store): void { // We do this here to ensure we get the path after --profile has been resolved const eventStorePath = path.join(app.getPath("userData"), "EventStore"); - const sendError = (id: string, e: Error): void => { - const error = { - message: e.message, - }; - - global.mainWindow?.webContents.send("seshatReply", { id, error }); - }; - - const args = payload.args || []; - let ret: any; - - switch (payload.name) { - case "supportsEventIndexing": - ret = seshatSupported; - break; - - case "initEventIndex": - if (eventIndex === null) { - const userId = args[0]; - const deviceId = args[1]; - const passphraseKey = `seshat|${userId}|${deviceId}`; - - const passphrase = await getOrCreatePassphrase(store, passphraseKey); - - try { - await afs.mkdir(eventStorePath, { recursive: true }); - eventIndex = new Seshat(eventStorePath, { passphrase }); - } catch (e) { - if (e instanceof ReindexError) { - // If this is a reindex error, the index schema - // changed. Try to open the database in recovery mode, - // reindex the database and finally try to open the - // database again. - const recoveryIndex = new SeshatRecovery(eventStorePath, { - passphrase, - }); - - const userVersion = await recoveryIndex.getUserVersion(); - - // If our user version is 0 we'll delete the db - // anyways so reindexing it is a waste of time. - if (userVersion === 0) { - await recoveryIndex.shutdown(); - await deleteContents(eventStorePath); - } else { - await recoveryIndex.reindex(); - } - - eventIndex = new Seshat(eventStorePath, { passphrase }); - } else { - sendError(payload.id, e); - return; - } + typedIpcMain.handle("seshat.supportsEventIndexing", () => seshatSupported); + typedIpcMain.handle("seshat.initEventIndex", async (_, userId, deviceId) => { + if (eventIndex !== null) return; + + const passphraseKey = `seshat|${userId}|${deviceId}`; + + const passphrase = await getOrCreatePassphrase(store, passphraseKey); + + try { + await afs.mkdir(eventStorePath, { recursive: true }); + eventIndex = new Seshat(eventStorePath, { passphrase }); + } catch (e) { + if (e instanceof ReindexError) { + // If this is a reindex error, the index schema + // changed. Try to open the database in recovery mode, + // reindex the database and finally try to open the + // database again. + const recoveryIndex = new SeshatRecovery(eventStorePath, { + passphrase, + }); + + const userVersion = await recoveryIndex.getUserVersion(); + + // If our user version is 0 we'll delete the db + // anyways so reindexing it is a waste of time. + if (userVersion === 0) { + await recoveryIndex.shutdown(); + await deleteContents(eventStorePath); + } else { + await recoveryIndex.reindex(); } - } - break; - - case "closeEventIndex": - if (eventIndex !== null) { - const index = eventIndex; - eventIndex = null; - try { - await index.shutdown(); - } catch (e) { - sendError(payload.id, e); - return; - } + eventIndex = new Seshat(eventStorePath, { passphrase }); + } else { + throw e; } - break; - - case "deleteEventIndex": { - await deleteContents(eventStorePath); - break; } + }); + typedIpcMain.handle("seshat.closeEventIndex", async () => { + if (eventIndex === null) return; - case "isEventIndexEmpty": - if (eventIndex === null) ret = true; - else ret = await eventIndex.isEmpty(); - break; - - case "isRoomIndexed": - if (eventIndex === null) ret = false; - else ret = await eventIndex.isRoomIndexed(args[0]); - break; - - case "addEventToIndex": - try { - eventIndex?.addEvent(args[0], args[1]); - } catch (e) { - sendError(payload.id, e); - return; - } - break; - - case "deleteEvent": - try { - ret = await eventIndex?.deleteEvent(args[0]); - } catch (e) { - sendError(payload.id, e); - return; - } - break; - - case "commitLiveEvents": - try { - ret = await eventIndex?.commit(); - } catch (e) { - sendError(payload.id, e); - return; - } - break; - - case "searchEventIndex": - try { - ret = await eventIndex?.search(args[0]); - } catch (e) { - sendError(payload.id, e); - return; - } - break; - - case "addHistoricEvents": - if (eventIndex === null) ret = false; - else { - try { - ret = await eventIndex.addHistoricEvents(args[0], args[1], args[2]); - } catch (e) { - sendError(payload.id, e); - return; - } - } - break; - - case "getStats": - if (eventIndex === null) ret = 0; - else { - try { - ret = await eventIndex.getStats(); - } catch (e) { - sendError(payload.id, e); - return; - } - } - break; - - case "removeCrawlerCheckpoint": - if (eventIndex === null) ret = false; - else { - try { - ret = await eventIndex.removeCrawlerCheckpoint(args[0]); - } catch (e) { - sendError(payload.id, e); - return; - } - } - break; - - case "addCrawlerCheckpoint": - if (eventIndex === null) ret = false; - else { - try { - ret = await eventIndex.addCrawlerCheckpoint(args[0]); - } catch (e) { - sendError(payload.id, e); - return; - } - } - break; - - case "loadFileEvents": - if (eventIndex === null) ret = []; - else { - try { - ret = await eventIndex.loadFileEvents(args[0]); - } catch (e) { - sendError(payload.id, e); - return; - } - } - break; - - case "loadCheckpoints": - if (eventIndex === null) ret = []; - else { - try { - ret = await eventIndex.loadCheckpoints(); - } catch { - ret = []; - } - } - break; - - case "setUserVersion": - if (eventIndex === null) break; - else { - try { - await eventIndex.setUserVersion(args[0]); - } catch (e) { - sendError(payload.id, e); - return; - } - } - break; - - case "getUserVersion": - if (eventIndex === null) ret = 0; - else { - try { - ret = await eventIndex.getUserVersion(); - } catch (e) { - sendError(payload.id, e); - return; - } - } - break; - - default: - global.mainWindow?.webContents.send("seshatReply", { - id: payload.id, - error: "Unknown IPC Call: " + payload.name, - }); - return; - } + const index = eventIndex; + eventIndex = null; - global.mainWindow?.webContents.send("seshatReply", { - id: payload.id, - reply: ret, + await index.shutdown(); }); -}); + typedIpcMain.handle("seshat.deleteEventIndex", () => deleteContents(eventStorePath)); + typedIpcMain.handle("seshat.isEventIndexEmpty", () => eventIndex?.isEmpty() ?? true); + typedIpcMain.handle("seshat.isRoomIndexed", (_, roomId) => eventIndex?.isRoomIndexed(roomId) ?? false); + typedIpcMain.handle("seshat.addEventToIndex", (_, matrixEvent, profile) => + eventIndex?.addEvent(matrixEvent, profile), + ); + typedIpcMain.handle("seshat.deleteEvent", (_, eventId) => eventIndex?.deleteEvent(eventId) ?? false); + typedIpcMain.handle("seshat.commitLiveEvents", () => eventIndex?.commit() ?? 0); + typedIpcMain.handle("seshat.searchEventIndex", (_, searchArgs) => eventIndex?.search(searchArgs)); + typedIpcMain.handle( + "seshat.addHistoricEvents", + (_, events, newCheckpoint, oldCheckpoint) => + eventIndex?.addHistoricEvents(events, newCheckpoint ?? undefined, oldCheckpoint ?? undefined) ?? false, + ); + typedIpcMain.handle("seshat.getStats", () => eventIndex?.getStats()); + typedIpcMain.handle("seshat.removeCrawlerCheckpoint", (_, checkpoint) => + eventIndex?.removeCrawlerCheckpoint(checkpoint), + ); + typedIpcMain.handle("seshat.addCrawlerCheckpoint", (_, checkpoint) => eventIndex?.addCrawlerCheckpoint(checkpoint)); + typedIpcMain.handle("seshat.loadFileEvents", (_, args) => eventIndex?.loadFileEvents(args) ?? []); + typedIpcMain.handle("seshat.loadCheckpoints", async () => { + try { + return (await eventIndex?.loadCheckpoints()) ?? []; + } catch { + return []; + } + }); + typedIpcMain.handle("seshat.setUserVersion", (_, version) => eventIndex?.setUserVersion(version)); + typedIpcMain.handle("seshat.getUserVersion", () => eventIndex?.getUserVersion()); +} diff --git a/apps/desktop/src/settings.ts b/apps/desktop/src/settings.ts index d0bb1d2f091..099a772f93b 100644 --- a/apps/desktop/src/settings.ts +++ b/apps/desktop/src/settings.ts @@ -5,11 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { ipcMain } from "electron"; +import { type ElectronSettings } from "shared-types"; import * as tray from "./tray.js"; import Store from "./store.js"; import { AutoLaunch, type AutoLaunchState } from "./auto-launch.js"; +import { typedIpcMain } from "./ipc.js"; interface Setting { read(): Promise; @@ -90,14 +91,14 @@ const Settings: Record = { }, }; -ipcMain.handle("getSupportedSettings", async () => { - const supportedSettings: Record = {}; +typedIpcMain.handle("getSupportedSettings", async () => { + const supportedSettings: Partial> = {}; for (const [key, setting] of Object.entries(Settings)) { - supportedSettings[key] = setting.supported?.() ?? true; + supportedSettings[key as keyof ElectronSettings] = setting.supported?.() ?? true; } - return supportedSettings; + return supportedSettings as Record; }); -ipcMain.handle("setSettingValue", async (_ev, settingName: string, value: any) => { +typedIpcMain.handle("setSettingValue", async (_ev, settingName: string, value: any) => { const setting = Settings[settingName]; if (!setting) { throw new Error(`Unknown setting: ${settingName}`); @@ -105,7 +106,7 @@ ipcMain.handle("setSettingValue", async (_ev, settingName: string, value: any) = console.debug(`Writing setting value for: ${settingName} = ${value}`); await setting.write(value); }); -ipcMain.handle("getSettingValue", async (_ev, settingName: string) => { +typedIpcMain.handle("getSettingValue", async (_ev, settingName: string) => { const setting = Settings[settingName]; if (!setting) { throw new Error(`Unknown setting: ${settingName}`); diff --git a/apps/desktop/src/updater.ts b/apps/desktop/src/updater.ts index e6702d38c58..f0d092c69ac 100644 --- a/apps/desktop/src/updater.ts +++ b/apps/desktop/src/updater.ts @@ -5,13 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { app, autoUpdater, ipcMain } from "electron"; +import { app, autoUpdater } from "electron"; import fs from "node:fs/promises"; import os from "node:os"; import { getSquirrelExecutable } from "./squirrelhooks.js"; import { _t } from "./language-helper.js"; -import { initialisePromise } from "./ipc.js"; +import { initialisePromise, typedIpcMain } from "./ipc.js"; import { getConfig } from "./config.js"; const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000; @@ -148,7 +148,7 @@ async function available(): Promise { // If the macOS version is too old for modern Electron support then disable auto update to prevent the app updating and bricking itself. // The oldest macOS version supported by Chromium/Electron 38 is Monterey (12.x) which started with Darwin 21.0 initialisePromise.then(() => { - ipcMain.emit("showToast", { + typedIpcMain.emit("showToast", { title: _t("eol|title"), description: _t("eol|no_more_updates", { brand: getConfig().brand }), }); @@ -159,7 +159,7 @@ async function available(): Promise { // If the macOS version is EOL then show a warning message. // The oldest macOS version still supported by Apple is Ventura (13.x) which started with Darwin 22.0 initialisePromise.then(() => { - ipcMain.emit("showToast", { + typedIpcMain.emit("showToast", { title: _t("eol|title"), description: _t("eol|warning", { brand: getConfig().brand }), }); @@ -170,8 +170,8 @@ async function available(): Promise { return true; } -ipcMain.on("install_update", installUpdate); -ipcMain.on("check_updates", pollForUpdates); +typedIpcMain.on("install_update", installUpdate); +typedIpcMain.on("check_updates", pollForUpdates); function ipcChannelSendUpdateStatus(status: boolean | string): void { global.mainWindow?.webContents.send("check_updates", status); diff --git a/apps/desktop/src/utils.ts b/apps/desktop/src/utils.ts index 6adb91ffa81..eedabf5237b 100644 --- a/apps/desktop/src/utils.ts +++ b/apps/desktop/src/utils.ts @@ -9,6 +9,7 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import afs from "node:fs/promises"; +import { type JsonDocument } from "shared-types"; /** * Returns a random array of a specified size in unpadded base64 @@ -26,19 +27,12 @@ export async function randomArray(size: number): Promise { }); } -type JsonValue = null | string | number; -type JsonArray = Array; -export interface JsonObject { - [key: string]: JsonObject | JsonArray | JsonValue; -} -export type Json = JsonArray | JsonObject; - /** * Synchronously load a JSON file from the local filesystem. * Unlike `require`, will never execute any javascript in a loaded file. * @param paths - An array of path segments which will be joined using the system's path delimiter. */ -export function loadJsonFile(...paths: string[]): T { +export function loadJsonFile(...paths: string[]): T { const joinedPaths = path.join(...paths); if (!fs.existsSync(joinedPaths)) { diff --git a/apps/desktop/src/webcontents-handler.ts b/apps/desktop/src/webcontents-handler.ts index c2e66ad5a03..57afb0577ce 100644 --- a/apps/desktop/src/webcontents-handler.ts +++ b/apps/desktop/src/webcontents-handler.ts @@ -12,13 +12,11 @@ import { MenuItem, shell, dialog, - ipcMain, type NativeImage, type WebContents, type ContextMenuParams, type DownloadItem, type MenuItemConstructorOptions, - type IpcMainEvent, type Event, } from "electron"; import url from "node:url"; @@ -28,6 +26,7 @@ import path from "node:path"; import { _t } from "./language-helper.js"; import { getConfig } from "./config.js"; +import { typedIpcMain } from "./ipc.js"; const MAILTO_PREFIX = "mailto:"; @@ -266,7 +265,7 @@ function onEditableContextMenu(ev: Event, params: ContextMenuParams, webContents let userDownloadIndex = 0; const userDownloadMap = new Map(); // Map from id to path -ipcMain.on("userDownloadAction", function (ev: IpcMainEvent, { id, open = false }) { +typedIpcMain.on("userDownloadAction", (_, { id, open = false }) => { const path = userDownloadMap.get(id); if (open && path) { void shell.openPath(path); diff --git a/apps/web/package.json b/apps/web/package.json index 1b61489422f..9b8b51e64a0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -217,6 +217,7 @@ "postcss-simple-vars": "7.0.1", "process": "^0.11.10", "semver": "^7.5.2", + "shared-types": "workspace:*", "source-map-loader": "^5.0.0", "stylelint": "^17.0.0", "stylelint-config-standard": "^40.0.0", diff --git a/apps/web/playwright/element-web-test.ts b/apps/web/playwright/element-web-test.ts index b77a027241b..c1b66e551c3 100644 --- a/apps/web/playwright/element-web-test.ts +++ b/apps/web/playwright/element-web-test.ts @@ -20,7 +20,6 @@ import { type ToMatchScreenshotOptions, } from "@element-hq/element-web-playwright-common"; -import type { IConfigOptions } from "../src/IConfigOptions"; import { type Credentials } from "./plugins/homeserver"; import { ElementAppPage } from "./pages/ElementAppPage"; import { Crypto } from "./pages/crypto"; @@ -32,11 +31,6 @@ import { type WorkerOptions, type Services, test as base } from "./services"; // See https://playwright.dev/docs/service-workers-experimental#how-to-enable process.env["PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS"] = "1"; -declare module "@element-hq/element-web-playwright-common" { - // Improve the type for the config fixture based on the real type - export interface Config extends Omit {} -} - export interface CredentialsWithDisplayName extends Credentials { displayName: string; } diff --git a/apps/web/src/@types/common.ts b/apps/web/src/@types/common.ts index bfb4f64c16b..024e9cef86f 100644 --- a/apps/web/src/@types/common.ts +++ b/apps/web/src/@types/common.ts @@ -10,55 +10,8 @@ import { type JSX, type JSXElementConstructor } from "react"; export type { NonEmptyArray, XOR, Writeable } from "matrix-js-sdk/src/matrix"; +export type * from "shared-types/lib/utils"; + export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor; export type { Leaves } from "matrix-web-i18n"; - -export type KeysStartingWith = { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - [P in keyof Input]: P extends `${Str}${infer _X}` ? P : never; // we don't use _X -}[keyof Input]; - -export type Defaultize = P extends any - ? string extends keyof P - ? P - : Pick> & - Partial>> & - Partial>> - : never; - -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -export type DeepReadonly = T extends (infer R)[] - ? DeepReadonlyArray - : T extends Function - ? T - : T extends object - ? DeepReadonlyObject - : T; -/* eslint-enable @typescript-eslint/no-unsafe-function-type */ - -interface DeepReadonlyArray extends ReadonlyArray> {} - -type DeepReadonlyObject = { - readonly [P in keyof T]: DeepReadonly; -}; - -export type AtLeastOne }> = Partial & U[keyof U]; - -/** - * Returns a union type of the keys of the input Object type whose values are assignable to the given Item type. - * Based on https://stackoverflow.com/a/57862073 - */ -export type Assignable = { - [Key in keyof Object]: Object[Key] extends Item ? Key : never; -}[keyof Object]; - -/** - * Like `Partial` but for applied to all nested objects. - * Based on https://dev.to/perennialautodidact/adventures-in-typescript-deeppartial-2f2a - */ -export type DeepPartial = T extends object - ? { - [P in keyof T]?: DeepPartial; - } - : T; diff --git a/apps/web/src/@types/global.d.ts b/apps/web/src/@types/global.d.ts index 4a5d5e89cfb..df06f6f2d8c 100644 --- a/apps/web/src/@types/global.d.ts +++ b/apps/web/src/@types/global.d.ts @@ -46,27 +46,6 @@ import type { RoomListStoreV3Class } from "../stores/room-list-v3/RoomListStoreV /* eslint-disable @typescript-eslint/naming-convention */ -type ElectronChannel = - | "app_onAction" - | "before-quit" - | "check_updates" - | "install_update" - | "ipcCall" - | "ipcReply" - | "loudNotification" - | "preferences" - | "seshat" - | "seshatReply" - | "setBadgeCount" - | "update-downloaded" - | "userDownloadCompleted" - | "userDownloadAction" - | "openDesktopCapturerSourcePicker" - | "userAccessToken" - | "homeserverUrl" - | "serverSupportedVersions" - | "showToast"; - declare global { // use `number` as the return type in all cases for globalThis.set{Interval,Timeout}, // so we don't accidentally use the methods on NodeJS.Timeout - they only exist in a subset of environments. @@ -118,9 +97,6 @@ declare global { mxOnRecaptchaLoaded?: () => void; mxModuleLoader: ModuleLoader; mxModuleApi: ModuleApiType; - - // electron-only - electron?: Electron; // opera-only opera?: any; @@ -128,38 +104,6 @@ declare global { InstallTrigger: any; } - interface Electron { - // Legacy - on(channel: ElectronChannel, listener: (event: Event, ...args: any[]) => void): void; - send(channel: ElectronChannel, ...args: any[]): void; - // Initialisation - initialise(): Promise<{ - protocol: string; - sessionId: string; - supportsBadgeOverlay: boolean; - config: IConfigOptions; - supportedSettings: Record; - }>; - // Settings - setSettingValue(settingName: string, value: any): Promise; - getSettingValue(settingName: string): Promise; - } - - interface DesktopCapturerSource { - id: string; - name: string; - thumbnailURL: string; - } - - interface GetSourcesOptions { - types: Array; - thumbnailSize?: { - height: number; - width: number; - }; - fetchWindowIcons?: boolean; - } - interface StorageEstimate { usageDetails?: { [key: string]: number }; } diff --git a/apps/web/src/BasePlatform.ts b/apps/web/src/BasePlatform.ts index 8f2ee414446..9058234df9c 100644 --- a/apps/web/src/BasePlatform.ts +++ b/apps/web/src/BasePlatform.ts @@ -19,6 +19,7 @@ import { MatrixEventEvent, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; +import { type DesktopCapturerSource, type GetSourcesOptions } from "shared-types"; import dis from "./dispatcher/dispatcher"; import type BaseEventIndexManager from "./indexing/BaseEventIndexManager"; diff --git a/apps/web/src/IConfigOptions.ts b/apps/web/src/IConfigOptions.ts index a3a6a32c91d..16f28075ff3 100644 --- a/apps/web/src/IConfigOptions.ts +++ b/apps/web/src/IConfigOptions.ts @@ -7,15 +7,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type IClientWellKnown } from "matrix-js-sdk/src/matrix"; +import { type ResolveDefaults, type WebConfigJson } from "shared-types"; import { type ValidatedServerConfig } from "./utils/ValidatedServerConfig"; - -// Convention decision: All config options are lower_snake_case -// We use an isolated file for the interface so we can mess around with the eslint options. - -/* eslint-disable camelcase */ -/* eslint @typescript-eslint/naming-convention: ["error", { "selector": "property", "format": ["snake_case"] } ] */ +import { type DEFAULTS } from "./SdkConfig.ts"; /** * Bug reports are enabled but must only be locally @@ -23,202 +18,14 @@ import { type ValidatedServerConfig } from "./utils/ValidatedServerConfig"; */ export const BugReportEndpointURLLocal = "local"; -// see element-web config.md for non-developer docs -export interface IConfigOptions { - // dev note: while true that this is arbitrary JSON, it's valuable to enforce that all - // config options are documented for "find all usages" sort of searching. - // [key: string]: any; - - // Properties of this interface are roughly grouped by their subject matter, such as - // "instance customisation", "login stuff", "branding", etc. Use blank lines to denote - // a logical separation of properties, but keep similar ones near each other. - - // Exactly one of the following must be supplied - default_server_config?: IClientWellKnown; // copy/paste of client well-known - default_server_name?: string; // domain to do well-known lookup on - default_hs_url?: string; // http url - - default_is_url?: string; // used in combination with default_hs_url, but for the identity server - - // This is intended to be overridden by app startup and not specified by the user - // This is also why it's allowed to have an interface that isn't snake_case - validated_server_config?: ValidatedServerConfig; - - fallback_hs_url?: string; - - disable_custom_urls?: boolean; - disable_guests?: boolean; - disable_login_language_selector?: boolean; - disable_3pid_login?: boolean; - - brand: string; - branding: { - welcome_background_url: string | string[]; // chosen at random if array - logo_link_url: string; - auth_header_logo_url?: string; - auth_footer_links?: { text: string; url: string }[]; - }; - - force_verification?: boolean; // if true, users must verify new logins - - map_style_url?: string; // for location-shared maps - - embedded_pages?: { - welcome_url?: string; - home_url?: string; - login_for_welcome?: boolean; - }; - - permalink_prefix?: string; - - update_base_url?: string; - desktop_builds: { - available: boolean; - logo: string; // url - url: string; // download url - url_macos?: string; - url_win64?: string; - url_win64arm?: string; - url_linux?: string; - }; - mobile_builds: { - ios: string | null; // download url - android: string | null; // download url - fdroid: string | null; // download url - }; - - mobile_guide_toast?: boolean; - mobile_guide_app_variant?: "element" | "element-classic" | "element-pro"; - - default_theme?: "light" | "dark" | string; // custom themes are strings - default_country_code?: string; // ISO 3166 alpha2 country code - default_federate?: boolean; - default_device_display_name?: string; // for device naming on login+registration - - setting_defaults?: Record; // - - integrations_ui_url?: string; - integrations_rest_url?: string; - integrations_widgets_urls?: string[]; - default_widget_container_height?: number; // height in pixels - - show_labs_settings: boolean; - features?: Record; // - +export interface ConfigOptions extends WebConfigJson { /** - * Bug report endpoint URL. "local" means the logs should not be uploaded. + * This is not a real config field, we're just abusing the config structure to pass around a validated server config */ - bug_report_endpoint_url?: typeof BugReportEndpointURLLocal | string; // omission disables bug reporting - sentry?: { - dsn: string; - environment?: string; // "production", etc - }; - - widget_build_url?: string; // url called to replace jitsi/call widget creation - widget_build_url_ignore_dm?: boolean; - audio_stream_url?: string; - jitsi?: { - preferred_domain: string; - }; - jitsi_widget?: { - skip_built_in_welcome_screen?: boolean; - }; - voip?: { - obey_asserted_identity?: boolean; // MSC3086 - }; - element_call: { - guest_spa_url?: string; - use_exclusively?: boolean; - brand?: string; - }; - - logout_redirect_url?: string; - - sso_redirect_options?: ISsoRedirectOptions; - - custom_translations_url?: string; - - report_event?: { - admin_message_md: string; // message for how to contact the server owner when reporting an event - }; - - room_directory?: { - servers: string[]; - }; - - posthog?: { - project_api_key: string; - api_host: string; // hostname - }; - analytics_owner?: string; // defaults to `brand` - privacy_policy_url?: string; // location for cookie policy - - enable_presence_by_hs_url?: Record; // - - terms_and_conditions_links?: { url: string; text: string }[]; - help_url: string; - help_encryption_url: string; - help_key_storage_url: string; - - latex_maths_delims?: { - inline?: { - left?: string; - right?: string; - pattern?: { - tex?: string; - latex?: string; - }; - }; - display?: { - left?: string; - right?: string; - pattern?: { - tex?: string; - latex?: string; - }; - }; - }; - - sync_timeline_limit?: number; - dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option - - user_notice?: { - title: string; - description: string; - show_once?: boolean; - }; - - feedback: { - existing_issues_url: string; - new_issue_url: string; - }; - - /** - * Configuration for OIDC issuers where a static client_id has been issued for the app. - * Otherwise dynamic client registration is attempted. - * The issuer URL must have a trailing `/`. - * OPTIONAL - */ - oidc_static_clients?: { - [issuer: string]: { client_id: string }; - }; - - /** - * Configuration for OIDC dynamic registration where a static OIDC client is not configured. - */ - oidc_metadata?: { - client_uri?: string; - logo_uri?: string; - tos_uri?: string; - policy_uri?: string; - contacts?: string[]; - }; - - modules?: string[]; + validated_server_config?: ValidatedServerConfig; } -export interface ISsoRedirectOptions { - immediate?: boolean; - on_welcome_page?: boolean; - on_login_page?: boolean; -} +/** + * Type representing the effective config.json structure after DEFAULTS has been merged in + */ +export type IConfigOptions = ResolveDefaults; diff --git a/apps/web/src/PosthogAnalytics.ts b/apps/web/src/PosthogAnalytics.ts index 814d840f192..54e14ce0281 100644 --- a/apps/web/src/PosthogAnalytics.ts +++ b/apps/web/src/PosthogAnalytics.ts @@ -139,10 +139,10 @@ export class PosthogAnalytics { } public constructor(private readonly posthog: PostHog) { - const posthogConfig = SdkConfig.getObject("posthog"); - if (posthogConfig) { - this.posthog.init(posthogConfig.get("project_api_key"), { - api_host: posthogConfig.get("api_host"), + const posthogConfig = SdkConfig.get("posthog"); + if (posthogConfig?.project_api_key && posthogConfig?.api_host) { + this.posthog.init(posthogConfig.project_api_key, { + api_host: posthogConfig.api_host, autocapture: false, mask_all_text: true, mask_all_element_attributes: true, diff --git a/apps/web/src/SdkConfig.ts b/apps/web/src/SdkConfig.ts index 84b87e040c3..76e4d47b398 100644 --- a/apps/web/src/SdkConfig.ts +++ b/apps/web/src/SdkConfig.ts @@ -8,15 +8,15 @@ Please see LICENSE files in the repository root for full details. */ import { mergeWith } from "lodash"; +import { type DeepReadonly } from "shared-types"; import { SnakedObject } from "./utils/SnakedObject"; -import { type IConfigOptions } from "./IConfigOptions"; +import { type IConfigOptions, type ConfigOptions } from "./IConfigOptions"; import { isObject, objectClone } from "./utils/objects"; -import { type DeepPartial, type DeepReadonly, type Defaultize } from "./@types/common"; import ElementDesktopLogoSvg from "../res/img/element-desktop-logo.svg"; // see element-web config.md for docs, or the IConfigOptions interface for dev docs -export const DEFAULTS: DeepReadonly = { +export const DEFAULTS = { brand: "Element", branding: { logo_link_url: "https://element.io", @@ -69,13 +69,13 @@ export const DEFAULTS: DeepReadonly = { android: "https://play.google.com/store/apps/details?id=im.vector.app", fdroid: "https://f-droid.org/repository/browse/?fdid=im.vector.app", }, -}; +} satisfies ConfigOptions; -export type ConfigOptions = Defaultize; +export type { ConfigOptions }; function mergeConfig( config: DeepReadonly, - changes: DeepReadonly>, + changes: DeepReadonly, ): DeepReadonly { // return { ...config, ...changes }; return mergeWith(objectClone(config), changes, (objValue, srcValue) => { @@ -141,7 +141,7 @@ export default class SdkConfig { SdkConfig.setInstance(mergeConfig(DEFAULTS, {})); // safe to cast - defaults will be applied } - public static add(cfg: DeepPartial): void { + public static add(cfg: DeepReadonly): void { SdkConfig.put(mergeConfig(SdkConfig.get(), cfg)); } } diff --git a/apps/web/src/components/views/elements/DesktopCapturerSourcePicker.tsx b/apps/web/src/components/views/elements/DesktopCapturerSourcePicker.tsx index fd20db7ef61..6bff64f954c 100644 --- a/apps/web/src/components/views/elements/DesktopCapturerSourcePicker.tsx +++ b/apps/web/src/components/views/elements/DesktopCapturerSourcePicker.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import classNames from "classnames"; +import { type DesktopCapturerSource, type GetSourcesOptions } from "shared-types"; import { _t, _td } from "../../../languageHandler"; import BaseDialog from "..//dialogs/BaseDialog"; diff --git a/apps/web/src/indexing/BaseEventIndexManager.ts b/apps/web/src/indexing/BaseEventIndexManager.ts index 8f0e5a1651f..b59fd39fe1c 100644 --- a/apps/web/src/indexing/BaseEventIndexManager.ts +++ b/apps/web/src/indexing/BaseEventIndexManager.ts @@ -10,58 +10,28 @@ import { type IMatrixProfile, type IEventWithRoomId as IMatrixEvent, type IResultRoomEvents, - type Direction, } from "matrix-js-sdk/src/matrix"; +import { + type SeshatSearchArgs, + type SeshatLoadArgs, + type SeshatIndexStats, + type SeshatCheckpoint, + type SeshatEventAndProfile, +} from "shared-types"; // The following interfaces take their names and member names from seshat and the spec /* eslint-disable camelcase */ /** A record of a place to resume crawling events in a given room. */ -export interface ICrawlerCheckpoint { - /** The room to be indexed */ - roomId: string; - - /** The pagination index to resume crawling from. */ - token: string; - - /** - * If `fullCrawl` is false (or absent) and we find that we have already indexed the events we find, then we stop crawling. - * - * If `fullCrawl` is true, then we keep going until we reach the end of the room history. - */ - fullCrawl?: boolean; +export type ICrawlerCheckpoint = SeshatCheckpoint; - /** Whether we should crawl in the forward or backward direction. */ - direction: Direction; -} - -export interface ISearchArgs { - search_term: string; - before_limit: number; - after_limit: number; - order_by_recency: boolean; - room_id?: string; - limit: number; - next_batch?: string; -} +export type ISearchArgs = SeshatSearchArgs; -export interface IEventAndProfile { - event: IMatrixEvent; - profile: IMatrixProfile; -} +export type IEventAndProfile = SeshatEventAndProfile; -export interface ILoadArgs { - roomId: string; - limit: number; - fromEvent?: string; - direction?: string; -} +export type ILoadArgs = SeshatLoadArgs; -export interface IIndexStats { - size: number; - eventCount: number; - roomCount: number; -} +export type IIndexStats = SeshatIndexStats; /** * Base class for classes that provide platform-specific event indexing. @@ -136,7 +106,7 @@ export default abstract class BaseEventIndexManager { * @return {Promise} A promise that will resolve to the index * statistics. */ - public async getStats(): Promise { + public async getStats(): Promise { throw new Error("Unimplemented"); } @@ -145,7 +115,7 @@ export default abstract class BaseEventIndexManager { * @return {Promise} A promise that will resolve to the user stored * version number. */ - public async getUserVersion(): Promise { + public async getUserVersion(): Promise { throw new Error("Unimplemented"); } @@ -168,7 +138,7 @@ export default abstract class BaseEventIndexManager { * @return {Promise} A promise that will resolve once the queued up events * were added to the index. */ - public async commitLiveEvents(): Promise { + public async commitLiveEvents(): Promise { throw new Error("Unimplemented"); } diff --git a/apps/web/src/indexing/EventIndex.test.ts b/apps/web/src/indexing/EventIndex.test.ts index 3b284cd0760..9be6810cc1e 100644 --- a/apps/web/src/indexing/EventIndex.test.ts +++ b/apps/web/src/indexing/EventIndex.test.ts @@ -130,6 +130,7 @@ describe("EventIndex", () => { const commitLiveEventsCalled = Promise.withResolvers(); mockIndexingManager.commitLiveEvents.mockImplementation(async () => { commitLiveEventsCalled.resolve(); + return 4; }); const indexer = new EventIndex(); diff --git a/apps/web/src/indexing/EventIndex.ts b/apps/web/src/indexing/EventIndex.ts index 97913bf8452..40cd1abc47f 100644 --- a/apps/web/src/indexing/EventIndex.ts +++ b/apps/web/src/indexing/EventIndex.ts @@ -506,7 +506,7 @@ export default class EventIndex extends EventEmitter { checkpoint.roomId, checkpoint.token, EVENTS_PER_CRAWL, - checkpoint.direction, + checkpoint.direction as Direction, ); } catch (e) { if (e instanceof HTTPError && e.httpStatus === 403) { @@ -744,7 +744,7 @@ export default class EventIndex extends EventEmitter { room: Room, limit = 10, fromEvent?: string, - direction: string = EventTimeline.BACKWARDS, + direction: Direction = EventTimeline.BACKWARDS, ): Promise { const client = MatrixClientPeg.safeGet(); const indexManager = PlatformPeg.get()?.getEventIndexingManager(); @@ -842,7 +842,7 @@ export default class EventIndex extends EventEmitter { room: Room, limit = 10, fromEvent?: string, - direction: string = EventTimeline.BACKWARDS, + direction: Direction = EventTimeline.BACKWARDS, ): Promise { const matrixEvents = await this.loadFileEvents(room, limit, fromEvent, direction); diff --git a/apps/web/src/models/Call.ts b/apps/web/src/models/Call.ts index 6cf95a06812..617bacfb2a5 100644 --- a/apps/web/src/models/Call.ts +++ b/apps/web/src/models/Call.ts @@ -707,7 +707,11 @@ export class ElementCall extends Call { */ private static appendAnalyticsParams(params: URLSearchParams, client: MatrixClient): void { const posthogConfig = SdkConfig.get("posthog"); - if (!posthogConfig || PosthogAnalytics.instance.getAnonymity() === Anonymity.Disabled) { + if ( + !posthogConfig?.project_api_key || + !posthogConfig?.api_host || + PosthogAnalytics.instance.getAnonymity() === Anonymity.Disabled + ) { return; } @@ -725,7 +729,7 @@ export class ElementCall extends Call { // We gate passing sentry behind analytics consent as EC shares data automatically without user-consent, // unlike EW where data is shared upon an intentional user action (rageshake). const sentryConfig = SdkConfig.get("sentry"); - if (sentryConfig) { + if (sentryConfig?.dsn) { params.append("sentryDsn", sentryConfig.dsn); params.append("sentryEnvironment", sentryConfig.environment ?? ""); } diff --git a/apps/web/src/settings/Settings.tsx b/apps/web/src/settings/Settings.tsx index bd29b7484b0..f379bead931 100644 --- a/apps/web/src/settings/Settings.tsx +++ b/apps/web/src/settings/Settings.tsx @@ -10,6 +10,7 @@ Please see LICENSE files in the repository root for full details. import React, { type ReactNode } from "react"; import { STABLE_MSC4133_EXTENDED_PROFILES, UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix"; +import { type JsonDocument, type JsonValue } from "shared-types"; // Import these directly from shared-components to avoid circular deps import { _t, _td } from "@element-hq/web-shared-components"; @@ -44,7 +45,6 @@ import FallbackIceServerController from "./controllers/FallbackIceServerControll import { type IRightPanelForRoomStored } from "../stores/right-panel/RightPanelStoreIPanelState.ts"; import { type ILayoutSettings } from "../stores/widgets/WidgetLayoutStore.ts"; import { type ReleaseAnnouncementData } from "../stores/ReleaseAnnouncementStore.ts"; -import { type Json, type JsonValue } from "../@types/json.ts"; import { type RecentEmojiData } from "../emojipicker/recent.ts"; import { type Assignable } from "../@types/common.ts"; import { SortingAlgorithm } from "../stores/room-list-v3/skip-list/sorters/index.ts"; @@ -121,7 +121,7 @@ export const labGroupNames: Record = { [LabGroup.Ui]: _td("labs|group_ui"), }; -export type SettingValueType = Json | JsonValue | Record | Record[]; +export type SettingValueType = JsonDocument | JsonValue | Record | Record[]; export interface IBaseSetting { isFeature?: false | undefined; diff --git a/apps/web/src/utils/device/clientInformation.test.ts b/apps/web/src/utils/device/clientInformation.test.ts index e79ce79fad2..3b639ec9288 100644 --- a/apps/web/src/utils/device/clientInformation.test.ts +++ b/apps/web/src/utils/device/clientInformation.test.ts @@ -11,6 +11,7 @@ Please see LICENSE files in the repository root for full details. import { vi, describe, it, expect, afterAll, beforeEach } from "vitest"; import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import { getMockClientWithEventEmitter } from "test-utils"; +import { type Electron } from "shared-types"; import type BasePlatform from "../../BasePlatform"; import { type IConfigOptions } from "../../IConfigOptions"; diff --git a/apps/web/src/vector/platform/ElectronPlatform.tsx b/apps/web/src/vector/platform/ElectronPlatform.tsx index 941963602e1..01498ebf430 100644 --- a/apps/web/src/vector/platform/ElectronPlatform.tsx +++ b/apps/web/src/vector/platform/ElectronPlatform.tsx @@ -19,6 +19,14 @@ import { import React from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { uniqueId } from "lodash"; +import { + type Electron, + type SquirrelUpdate, + type DesktopCapturerSource, + type GetSourcesOptions, + type ElectronSettings, + type DesktopConfigJson, +} from "shared-types"; import BasePlatform, { UpdateCheckStatus, type UpdateStatus } from "../../BasePlatform"; import type BaseEventIndexManager from "../../indexing/BaseEventIndexManager"; @@ -41,18 +49,10 @@ import { avatarUrlForRoom, getInitialLetter } from "../../Avatar"; import DesktopCapturerSourcePicker from "../../components/views/elements/DesktopCapturerSourcePicker"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { SeshatIndexManager } from "./SeshatIndexManager"; -import { IPCManager } from "./IPCManager"; import { _t } from "../../languageHandler"; import { BadgeOverlayRenderer } from "../../favicon"; import GenericToast from "../../components/views/toasts/GenericToast.tsx"; -interface SquirrelUpdate { - releaseNotes: string; - releaseName: string; - releaseDate: Date; - updateURL: string; -} - const SSO_ID_KEY = "element-desktop-ssoid"; function platformFriendlyName(): string { @@ -88,14 +88,13 @@ function getUpdateCheckStatus(status: boolean | string): UpdateStatus { } export default class ElectronPlatform extends BasePlatform { - private readonly ipc = new IPCManager("ipcCall", "ipcReply"); - private readonly eventIndexManager: BaseEventIndexManager = new SeshatIndexManager(); + private readonly eventIndexManager: BaseEventIndexManager; public readonly initialised: Promise; private readonly electron: Electron; private protocol!: string; private sessionId!: string; private badgeOverlayRenderer?: BadgeOverlayRenderer; - private config!: IConfigOptions; + private config!: DesktopConfigJson; private supportedSettings?: Record; private clientStartedPromiseWithResolvers = Promise.withResolvers(); @@ -106,6 +105,7 @@ export default class ElectronPlatform extends BasePlatform { throw new Error("Cannot instantiate ElectronPlatform, window.electron is not set"); } this.electron = window.electron; + this.eventIndexManager = new SeshatIndexManager(this.electron); /* IPC Call `check_updates` returns: @@ -113,7 +113,7 @@ export default class ElectronPlatform extends BasePlatform { false if there is not or the error if one is encountered */ - this.electron.on("check_updates", (event, status) => { + this.electron.on("check_updates", (status) => { dis.dispatch({ action: Action.CheckUpdates, ...getUpdateCheckStatus(status), @@ -123,7 +123,7 @@ export default class ElectronPlatform extends BasePlatform { // `userAccessToken` (IPC) is requested by the main process when appending authentication // to media downloads. A reply is sent over the same channel. this.electron.on("userAccessToken", () => { - this.electron.send("userAccessToken", MatrixClientPeg.get()?.getAccessToken()); + this.electron.send("userAccessToken", MatrixClientPeg.get()?.getAccessToken() ?? undefined); }); // `homeserverUrl` (IPC) is requested by the main process. A reply is sent over the same channel. @@ -150,7 +150,7 @@ export default class ElectronPlatform extends BasePlatform { dis.fire(Action.ViewUserSettings); }); - this.electron.on("userDownloadCompleted", (ev, { id, name }) => { + this.electron.on("userDownloadCompleted", ({ id, name }) => { const key = `DOWNLOAD_TOAST_${id}`; const onAccept = (): void => { @@ -182,10 +182,10 @@ export default class ElectronPlatform extends BasePlatform { const { finished } = Modal.createDialog(DesktopCapturerSourcePicker); const [source] = await finished; // getDisplayMedia promise does not return if no dummy is passed here as source - await this.ipc.call("callDisplayMediaCallback", source ?? { id: "", name: "", thumbnailURL: "" }); + await this.electron.call("callDisplayMediaCallback", source ?? { id: "", name: "", thumbnailURL: "" }); }); - this.electron.on("showToast", async (ev, { title, description, priority = 40 }) => { + this.electron.on("showToast", async ({ title, description, priority = 40 }) => { await this.clientStartedPromiseWithResolvers.promise; const key = uniqueId("electron_showToast_"); @@ -213,9 +213,16 @@ export default class ElectronPlatform extends BasePlatform { protected onAction(payload: ActionPayload): void { super.onAction(payload); - // Whitelist payload actions, no point sending most across - if (["call_state"].includes(payload.action)) { - this.electron.send("app_onAction", payload); + + switch (payload.action) { + case "call_state": { + if (payload.state === "connected") { + this.electron.send("prevent_display_sleep", true); + } else if (payload.state === "ended") { + this.electron.send("prevent_display_sleep", false); + } + break; + } } if (payload.action === Action.ClientStarted) { @@ -237,7 +244,7 @@ export default class ElectronPlatform extends BasePlatform { public async getConfig(): Promise { await this.initialised; - return this.config; + return this.config as IConfigOptions; } private onBreadcrumbsUpdate = (): void => { @@ -251,10 +258,10 @@ export default class ElectronPlatform extends BasePlatform { ), initial: getInitialLetter(r.name), })); - void this.ipc.call("breadcrumbs", rooms); + void this.electron.send("breadcrumbs", rooms); }; - private onUpdateDownloaded = async (ev: Event, { releaseNotes, releaseName }: SquirrelUpdate): Promise => { + private onUpdateDownloaded = async ({ releaseNotes, releaseName }: SquirrelUpdate): Promise => { dis.dispatch({ action: Action.CheckUpdates, status: UpdateCheckStatus.Ready, @@ -287,7 +294,7 @@ export default class ElectronPlatform extends BasePlatform { this.badgeOverlayRenderer .render(count) .then((buffer) => { - this.electron.send("setBadgeCount", count, buffer); + this.electron.send("setBadgeCount", count, buffer ?? undefined); }) .catch((ex) => { logger.warn("Unable to generate badge overlay", ex); @@ -313,7 +320,7 @@ export default class ElectronPlatform extends BasePlatform { } promise .then((buffer) => { - this.electron.send("setBadgeCount", this.notificationCount, buffer, errorDidOccur); + this.electron.send("setBadgeCount", this.notificationCount, buffer ?? undefined, errorDidOccur); }) .catch((ex) => { logger.warn("Unable to generate badge overlay", ex); @@ -351,7 +358,7 @@ export default class ElectronPlatform extends BasePlatform { const handler = notification.onclick as () => void; notification.onclick = (): void => { handler?.(); - void this.ipc.call("focusWindow"); + void this.electron.call("focusWindow"); }; return notification; @@ -366,26 +373,29 @@ export default class ElectronPlatform extends BasePlatform { } public async getAppVersion(): Promise { - return this.ipc.call("getAppVersion"); + return this.electron.call("getAppVersion"); } - public supportsSetting(settingName?: string): boolean { + public supportsSetting(settingName?: keyof ElectronSettings): boolean { if (settingName === undefined) return true; return this.supportedSettings?.[settingName] === true; } - public async getSettingValue(settingName: string): Promise { + public async getSettingValue(settingName: K): Promise { await this.initialised; return this.electron.getSettingValue(settingName); } - public async setSettingValue(settingName: string, value: any): Promise { + public async setSettingValue( + settingName: K, + value: ElectronSettings[K], + ): Promise { await this.initialised; return this.electron.setSettingValue(settingName, value); } public async canSelfUpdate(): Promise { - const feedUrl = await this.ipc.call("getUpdateFeedUrl"); + const feedUrl = await this.electron.call("getUpdateFeedUrl"); return Boolean(feedUrl); } @@ -422,33 +432,33 @@ export default class ElectronPlatform extends BasePlatform { } public async setLanguage(preferredLangs: string[]): Promise { - return this.ipc.call("setLanguage", preferredLangs); + return this.electron.call("setLanguage", preferredLangs); } public setSpellCheckEnabled(enabled: boolean): void { - this.ipc.call("setSpellCheckEnabled", enabled).catch((error) => { + this.electron.call("setSpellCheckEnabled", enabled).catch((error) => { logger.log("Failed to send setSpellCheckEnabled IPC to Electron"); logger.error(error); }); } public async getSpellCheckEnabled(): Promise { - return this.ipc.call("getSpellCheckEnabled"); + return this.electron.call("getSpellCheckEnabled"); } public setSpellCheckLanguages(preferredLangs: string[]): void { - this.ipc.call("setSpellCheckLanguages", preferredLangs).catch((error) => { + this.electron.call("setSpellCheckLanguages", preferredLangs).catch((error) => { logger.log("Failed to send setSpellCheckLanguages IPC to Electron"); logger.error(error); }); } public async getSpellCheckLanguages(): Promise { - return this.ipc.call("getSpellCheckLanguages"); + return this.electron.call("getSpellCheckLanguages"); } public async getDesktopCapturerSources(options: GetSourcesOptions): Promise> { - return this.ipc.call("getDesktopCapturerSources", options); + return this.electron.call("getDesktopCapturerSources", options); } public supportsDesktopCapturer(): boolean { @@ -461,7 +471,7 @@ export default class ElectronPlatform extends BasePlatform { } public async getAvailableSpellCheckLanguages(): Promise { - return this.ipc.call("getAvailableSpellCheckLanguages"); + return this.electron.call("getAvailableSpellCheckLanguages"); } public getSSOCallbackUrl(fragmentAfterLogin?: string): URL { @@ -486,7 +496,7 @@ export default class ElectronPlatform extends BasePlatform { } public navigateForwardBack(back: boolean): void { - void this.ipc.call(back ? "navigateBack" : "navigateForward"); + void this.electron.call(back ? "navigateBack" : "navigateForward"); } public overrideBrowserShortcuts(): boolean { @@ -495,7 +505,7 @@ export default class ElectronPlatform extends BasePlatform { public async getPickleKey(userId: string, deviceId: string): Promise { try { - return await this.ipc.call("getPickleKey", userId, deviceId); + return await this.electron.call("getPickleKey", userId, deviceId); } catch { // if we can't connect to the password storage, assume there's no // pickle key @@ -505,7 +515,7 @@ export default class ElectronPlatform extends BasePlatform { public async createPickleKey(userId: string, deviceId: string): Promise { try { - return await this.ipc.call("createPickleKey", userId, deviceId); + return await this.electron.call("createPickleKey", userId, deviceId); } catch { // if we can't connect to the password storage, assume there's no // pickle key @@ -515,14 +525,14 @@ export default class ElectronPlatform extends BasePlatform { public async destroyPickleKey(userId: string, deviceId: string): Promise { try { - await this.ipc.call("destroyPickleKey", userId, deviceId); + await this.electron.call("destroyPickleKey", userId, deviceId); } catch {} } public async clearStorage(): Promise { try { await super.clearStorage(); - await this.ipc.call("clearStorage"); + await this.electron.send("clearStorage"); } catch {} } diff --git a/apps/web/src/vector/platform/IPCManager.ts b/apps/web/src/vector/platform/IPCManager.ts deleted file mode 100644 index 5a5bb9b0fb4..00000000000 --- a/apps/web/src/vector/platform/IPCManager.ts +++ /dev/null @@ -1,66 +0,0 @@ -/* -Copyright 2022-2024 New Vector Ltd. - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { logger } from "matrix-js-sdk/src/logger"; - -import { type ElectronChannel } from "../../@types/global"; - -interface IPCPayload { - id?: number; - error?: string | { message: string }; - reply?: any; -} - -export class IPCManager { - private pendingIpcCalls: { [ipcCallId: number]: PromiseWithResolvers } = {}; - private nextIpcCallId = 0; - - public constructor( - private readonly sendChannel: ElectronChannel = "ipcCall", - private readonly recvChannel: ElectronChannel = "ipcReply", - ) { - if (!window.electron) { - throw new Error("Cannot instantiate ElectronPlatform, window.electron is not set"); - } - window.electron.on(this.recvChannel, this.onIpcReply); - } - - public async call(name: string, ...args: any[]): Promise { - // TODO this should be moved into the preload.js file. - const ipcCallId = ++this.nextIpcCallId; - const deferred = Promise.withResolvers(); - this.pendingIpcCalls[ipcCallId] = deferred; - // Maybe add a timeout to these? Probably not necessary. - window.electron!.send(this.sendChannel, { id: ipcCallId, name, args }); - return deferred.promise; - } - - private onIpcReply = (_ev: Event, payload: IPCPayload): void => { - if (payload.id === undefined) { - logger.warn("Ignoring IPC reply with no ID"); - return; - } - - if (this.pendingIpcCalls[payload.id] === undefined) { - logger.warn("Unknown IPC payload ID: " + payload.id); - return; - } - - const callbacks = this.pendingIpcCalls[payload.id]; - delete this.pendingIpcCalls[payload.id]; - if (payload.error) { - // `seshat.ts` sends a JavaScript object with a `message` property. Turn it into a proper Error. - let error = payload.error; - if (typeof error === "object" && error.message) { - error = new Error(error.message); - } - callbacks.reject(error); - } else { - callbacks.resolve(payload.reply); - } - }; -} diff --git a/apps/web/src/vector/platform/SeshatIndexManager.ts b/apps/web/src/vector/platform/SeshatIndexManager.ts index 88a8889b722..f5da7ce5c82 100644 --- a/apps/web/src/vector/platform/SeshatIndexManager.ts +++ b/apps/web/src/vector/platform/SeshatIndexManager.ts @@ -11,6 +11,7 @@ import { type IEventWithRoomId as IMatrixEvent, type IResultRoomEvents, } from "matrix-js-sdk/src/@types/search"; +import { type Electron } from "shared-types"; import BaseEventIndexManager, { type ICrawlerCheckpoint, @@ -19,41 +20,42 @@ import BaseEventIndexManager, { type ISearchArgs, type ILoadArgs, } from "../../indexing/BaseEventIndexManager"; -import { IPCManager } from "./IPCManager"; export class SeshatIndexManager extends BaseEventIndexManager { - private readonly ipc = new IPCManager("seshat", "seshatReply"); + public constructor(private readonly electron: Electron) { + super(); + } public async supportsEventIndexing(): Promise { - return this.ipc.call("supportsEventIndexing"); + return this.electron.call("seshat.supportsEventIndexing"); } public async initEventIndex(userId: string, deviceId: string): Promise { - return this.ipc.call("initEventIndex", userId, deviceId); + return this.electron.call("seshat.initEventIndex", userId, deviceId); } public async addEventToIndex(ev: IMatrixEvent, profile: IMatrixProfile): Promise { - return this.ipc.call("addEventToIndex", ev, profile); + return this.electron.call("seshat.addEventToIndex", ev, profile); } public async deleteEvent(eventId: string): Promise { - return this.ipc.call("deleteEvent", eventId); + return this.electron.call("seshat.deleteEvent", eventId); } public async isEventIndexEmpty(): Promise { - return this.ipc.call("isEventIndexEmpty"); + return this.electron.call("seshat.isEventIndexEmpty"); } public async isRoomIndexed(roomId: string): Promise { - return this.ipc.call("isRoomIndexed", roomId); + return this.electron.call("seshat.isRoomIndexed", roomId); } - public async commitLiveEvents(): Promise { - return this.ipc.call("commitLiveEvents"); + public async commitLiveEvents(): Promise { + return this.electron.call("seshat.commitLiveEvents"); } public async searchEventIndex(searchConfig: ISearchArgs): Promise { - return this.ipc.call("searchEventIndex", searchConfig); + return (await this.electron.call("seshat.searchEventIndex", searchConfig)) ?? {}; } public async addHistoricEvents( @@ -61,42 +63,42 @@ export class SeshatIndexManager extends BaseEventIndexManager { checkpoint: ICrawlerCheckpoint | null, oldCheckpoint: ICrawlerCheckpoint | null, ): Promise { - return this.ipc.call("addHistoricEvents", events, checkpoint, oldCheckpoint); + return this.electron.call("seshat.addHistoricEvents", events, checkpoint, oldCheckpoint); } public async addCrawlerCheckpoint(checkpoint: ICrawlerCheckpoint): Promise { - return this.ipc.call("addCrawlerCheckpoint", checkpoint); + return this.electron.call("seshat.addCrawlerCheckpoint", checkpoint); } public async removeCrawlerCheckpoint(checkpoint: ICrawlerCheckpoint): Promise { - return this.ipc.call("removeCrawlerCheckpoint", checkpoint); + return this.electron.call("seshat.removeCrawlerCheckpoint", checkpoint); } public async loadFileEvents(args: ILoadArgs): Promise { - return this.ipc.call("loadFileEvents", args); + return this.electron.call("seshat.loadFileEvents", args); } public async loadCheckpoints(): Promise { - return this.ipc.call("loadCheckpoints"); + return this.electron.call("seshat.loadCheckpoints"); } public async closeEventIndex(): Promise { - return this.ipc.call("closeEventIndex"); + return this.electron.call("seshat.closeEventIndex"); } - public async getStats(): Promise { - return this.ipc.call("getStats"); + public async getStats(): Promise { + return this.electron.call("seshat.getStats"); } - public async getUserVersion(): Promise { - return this.ipc.call("getUserVersion"); + public async getUserVersion(): Promise { + return this.electron.call("seshat.getUserVersion"); } public async setUserVersion(version: number): Promise { - return this.ipc.call("setUserVersion", version); + return this.electron.call("seshat.setUserVersion", version); } public async deleteEventIndex(): Promise { - return this.ipc.call("deleteEventIndex"); + return this.electron.call("seshat.deleteEventIndex"); } } diff --git a/apps/web/test/unit-tests/vector/platform/ElectronPlatform-test.ts b/apps/web/test/unit-tests/vector/platform/ElectronPlatform-test.ts index f6ff2db07fc..62bdf6d5269 100644 --- a/apps/web/test/unit-tests/vector/platform/ElectronPlatform-test.ts +++ b/apps/web/test/unit-tests/vector/platform/ElectronPlatform-test.ts @@ -10,6 +10,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { mocked, type MockedObject } from "jest-mock"; import { waitFor } from "jest-matrix-react"; +import { type Electron, type MainRendererEvents } from "shared-types"; import { UpdateCheckStatus } from "../../../../src/BasePlatform"; import { Action } from "../../../../src/dispatcher/actions"; @@ -26,6 +27,13 @@ jest.mock("../../../../src/rageshake/rageshake", () => ({ flush: jest.fn(), })); +declare module "shared-types" { + interface ElectronSettings { + setting1: number; + setting2: string; + } +} + describe("ElectronPlatform", () => { const initialiseValues = jest.fn().mockReturnValue({ protocol: "io.element.desktop", @@ -40,6 +48,7 @@ describe("ElectronPlatform", () => { const mockElectron = { on: jest.fn(), send: jest.fn(), + call: jest.fn(), initialise: initialiseValues, setSettingValue: jest.fn().mockResolvedValue(undefined), getSettingValue: jest.fn().mockResolvedValue(undefined), @@ -58,16 +67,14 @@ describe("ElectronPlatform", () => { Object.defineProperty(window, "navigator", { value: { userAgent: defaultUserAgent }, writable: true }); }); - const getElectronEventHandlerCall = ( - eventType: string, - ): [type: string, handler: (...args: any) => void] | undefined => - mockElectron.on.mock.calls.find(([type]) => type === eventType); + const getElectronEventHandlerCall = ( + eventType: K, + ): ((...args: Parameters) => void) | undefined => + (mockElectron.on.mock.calls as any).find(([type]: [type: string]) => type === eventType)?.at(1); it("flushes rageshake before quitting", () => { new ElectronPlatform(); - const [event, handler] = getElectronEventHandlerCall("before-quit")!; - // correct event bound - expect(event).toBeTruthy(); + const handler = getElectronEventHandlerCall("before-quit")!; handler(); @@ -88,9 +95,7 @@ describe("ElectronPlatform", () => { it("dispatches view settings action on preferences event", () => { new ElectronPlatform(); - const [event, handler] = getElectronEventHandlerCall("preferences")!; - // correct event bound - expect(event).toBeTruthy(); + const handler = getElectronEventHandlerCall("preferences")!; handler(); @@ -111,19 +116,18 @@ describe("ElectronPlatform", () => { res = r; }); // @ts-ignore mock - jest.spyOn(plat.ipc, "call").mockImplementation(() => { + jest.spyOn(plat.electron, "call").mockImplementation(() => { res(); }); - const [event, handler] = getElectronEventHandlerCall("openDesktopCapturerSourcePicker")!; + const handler = getElectronEventHandlerCall("openDesktopCapturerSourcePicker")!; handler(); await waitForIPCSend; - expect(event).toBeTruthy(); expect(Modal.createDialog).toHaveBeenCalledWith(DesktopCapturerSourcePicker); // @ts-ignore mock - expect(plat.ipc.call).toHaveBeenCalledWith("callDisplayMediaCallback", "source"); + expect(plat.electron.call).toHaveBeenCalledWith("callDisplayMediaCallback", "source"); }); it("should show a toast when showToast is fired", async () => { @@ -136,10 +140,9 @@ describe("ElectronPlatform", () => { ); const spy = jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast"); - const [event, handler] = getElectronEventHandlerCall("showToast")!; - handler({} as any, { title: "title", description: "description" }); + const handler = getElectronEventHandlerCall("showToast")!; + handler({ title: "title", description: "description" }); - expect(event).toBeTruthy(); await waitFor(() => expect(spy).toHaveBeenCalledWith( expect.objectContaining({ @@ -153,11 +156,9 @@ describe("ElectronPlatform", () => { describe("updates", () => { it("dispatches on check updates action", () => { new ElectronPlatform(); - const [event, handler] = getElectronEventHandlerCall("check_updates")!; - // correct event bound - expect(event).toBeTruthy(); + const handler = getElectronEventHandlerCall("check_updates")!; - handler({}, true); + handler(true); expect(dispatchSpy).toHaveBeenCalledWith({ action: Action.CheckUpdates, status: UpdateCheckStatus.Downloading, @@ -166,9 +167,9 @@ describe("ElectronPlatform", () => { it("dispatches on check updates action when update not available", () => { new ElectronPlatform(); - const [, handler] = getElectronEventHandlerCall("check_updates")!; + const handler = getElectronEventHandlerCall("check_updates")!; - handler({}, false); + handler(false); expect(dispatchSpy).toHaveBeenCalledWith({ action: Action.CheckUpdates, status: UpdateCheckStatus.NotAvailable, @@ -283,42 +284,41 @@ describe("ElectronPlatform", () => { it("gets available spellcheck languages", () => { const platform = new ElectronPlatform(); - mockElectron.send.mockClear(); + mockElectron.call.mockClear(); platform.getAvailableSpellCheckLanguages(); - const [channel, { name }] = mockElectron.send.mock.calls[0]; - expect(channel).toEqual("ipcCall"); - expect(name).toEqual("getAvailableSpellCheckLanguages"); + const [channel] = mockElectron.call.mock.calls[0]; + expect(channel).toEqual("getAvailableSpellCheckLanguages"); }); }); describe("pickle key", () => { it("makes correct ipc call to get pickle key", () => { const platform = new ElectronPlatform(); - mockElectron.send.mockClear(); + mockElectron.call.mockClear(); platform.getPickleKey(userId, deviceId); - const [, { name, args }] = mockElectron.send.mock.calls[0]; + const [name, ...args] = mockElectron.call.mock.calls[0]; expect(name).toEqual("getPickleKey"); expect(args).toEqual([userId, deviceId]); }); it("makes correct ipc call to create pickle key", () => { const platform = new ElectronPlatform(); - mockElectron.send.mockClear(); + mockElectron.call.mockClear(); platform.createPickleKey(userId, deviceId); - const [, { name, args }] = mockElectron.send.mock.calls[0]; + const [name, ...args] = mockElectron.call.mock.calls[0]; expect(name).toEqual("createPickleKey"); expect(args).toEqual([userId, deviceId]); }); it("makes correct ipc call to destroy pickle key", () => { const platform = new ElectronPlatform(); - mockElectron.send.mockClear(); + mockElectron.call.mockClear(); platform.destroyPickleKey(userId, deviceId); - const [, { name, args }] = mockElectron.send.mock.calls[0]; + const [name, ...args] = mockElectron.call.mock.calls[0]; expect(name).toEqual("destroyPickleKey"); expect(args).toEqual([userId, deviceId]); }); @@ -340,12 +340,7 @@ describe("ElectronPlatform", () => { const cb = spy.mock.calls[0][1]; cb(); - expect(mockElectron.send).toHaveBeenCalledWith( - "ipcCall", - expect.objectContaining({ - name: "breadcrumbs", - }), - ); + expect(mockElectron.send).toHaveBeenCalledWith("breadcrumbs", expect.any(Array)); }); }); @@ -361,20 +356,18 @@ describe("ElectronPlatform", () => { new ElectronPlatform(); - const userAccessTokenCall = mockElectron.on.mock.calls.find((call) => call[0] === "userAccessToken"); - userAccessTokenCall![1]({} as any); + const userAccessTokenCall = getElectronEventHandlerCall("userAccessToken")!; + userAccessTokenCall(); const userAccessTokenResponse = mockElectron.send.mock.calls.find((call) => call[0] === "userAccessToken"); expect(userAccessTokenResponse![1]).toBe("access_token"); - const homeserverUrlCall = mockElectron.on.mock.calls.find((call) => call[0] === "homeserverUrl"); - homeserverUrlCall![1]({} as any); + const homeserverUrlCall = getElectronEventHandlerCall("homeserverUrl")!; + homeserverUrlCall(); const homeserverUrlResponse = mockElectron.send.mock.calls.find((call) => call[0] === "homeserverUrl"); expect(homeserverUrlResponse![1]).toBe("homeserver_url"); - const serverSupportedVersionsCall = mockElectron.on.mock.calls.find( - (call) => call[0] === "serverSupportedVersions", - ); - await (serverSupportedVersionsCall![1]({} as any) as unknown as Promise); + const serverSupportedVersionsCall = getElectronEventHandlerCall("serverSupportedVersions")!; + await (serverSupportedVersionsCall() as unknown as Promise); const serverSupportedVersionsResponse = mockElectron.send.mock.calls.find( (call) => call[0] === "serverSupportedVersions", ); @@ -390,10 +383,6 @@ describe("ElectronPlatform", () => { await platform.getConfig(); // await init }); - it("supportsSetting should return true for the platform", () => { - expect(platform.supportsSetting()).toBe(true); - }); - it("supportsSetting should return true for available settings", () => { expect(platform.supportsSetting("setting2")).toBe(true); }); @@ -425,11 +414,8 @@ describe("ElectronPlatform", () => { true, ); - const ipcMessage = mockElectron.send.mock.calls.find((call) => call[0] === "app_onAction"); - expect(ipcMessage![1]).toEqual({ - action: "call_state", - state: "connected", - }); + const ipcMessage = mockElectron.send.mock.calls.find((call) => call[0] === "prevent_display_sleep"); + expect(ipcMessage![1]).toEqual(true); }); describe("Notification overlay badges", () => { @@ -453,8 +439,8 @@ describe("ElectronPlatform", () => { // Badges are sent asynchronously await waitFor(() => { const ipcMessage = mockElectron.send.mock.lastCall; - expect(ipcMessage?.[1]).toEqual(1); - expect(ipcMessage?.[2].constructor.name).toEqual("ArrayBuffer"); + expect(ipcMessage![1]).toEqual(1); + expect(ipcMessage![2]!.constructor.name).toEqual("ArrayBuffer"); }); }); @@ -470,11 +456,11 @@ describe("ElectronPlatform", () => { (call) => call[0] === "setBadgeCount", ); - expect(ipcMessageA?.[1]).toEqual(1); - expect(ipcMessageA?.[2].constructor.name).toEqual("ArrayBuffer"); + expect(ipcMessageA![1]).toEqual(1); + expect(ipcMessageA![2]!.constructor.name).toEqual("ArrayBuffer"); - expect(ipcMessageB?.[1]).toEqual(2); - expect(ipcMessageB?.[2].constructor.name).toEqual("ArrayBuffer"); + expect(ipcMessageB![1]).toEqual(2); + expect(ipcMessageB![2]!.constructor.name).toEqual("ArrayBuffer"); }); }); it("should remove badge when notification count zeros", async () => { @@ -488,11 +474,11 @@ describe("ElectronPlatform", () => { (call) => call[0] === "setBadgeCount", ); - expect(ipcMessageA?.[1]).toEqual(1); - expect(ipcMessageA?.[2].constructor.name).toEqual("ArrayBuffer"); + expect(ipcMessageA![1]).toEqual(1); + expect(ipcMessageA![2]!.constructor.name).toEqual("ArrayBuffer"); - expect(ipcMessageB?.[1]).toEqual(0); - expect(ipcMessageB?.[2]).toBeNull(); + expect(ipcMessageB![1]).toEqual(0); + expect(ipcMessageB![2]).toBeUndefined(); }); }); it("should show an error badge when the application errors", async () => { @@ -503,9 +489,9 @@ describe("ElectronPlatform", () => { await waitFor(() => { const ipcMessage = mockElectron.send.mock.calls.find((call) => call[0] === "setBadgeCount"); - expect(ipcMessage?.[1]).toEqual(0); - expect(ipcMessage?.[2].constructor.name).toEqual("ArrayBuffer"); - expect(ipcMessage?.[3]).toEqual(true); + expect(ipcMessage![1]).toEqual(0); + expect(ipcMessage![2]!.constructor.name).toEqual("ArrayBuffer"); + expect(ipcMessage![3]).toEqual(true); }); }); it("should restore after error is resolved", async () => { @@ -519,12 +505,12 @@ describe("ElectronPlatform", () => { (call) => call[0] === "setBadgeCount", ); - expect(ipcMessageA?.[1]).toEqual(0); - expect(ipcMessageA?.[2].constructor.name).toEqual("ArrayBuffer"); - expect(ipcMessageA?.[3]).toEqual(true); + expect(ipcMessageA![1]).toEqual(0); + expect(ipcMessageA![2]!.constructor.name).toEqual("ArrayBuffer"); + expect(ipcMessageA![3]).toEqual(true); - expect(ipcMessageB?.[1]).toEqual(0); - expect(ipcMessageB?.[2]).toBeNull(); + expect(ipcMessageB![1]).toEqual(0); + expect(ipcMessageB![2]).toBeUndefined(); }); }); }); diff --git a/packages/module-api/api-extractor.json b/packages/module-api/api-extractor.json index e945efc023d..dd2aafef653 100644 --- a/packages/module-api/api-extractor.json +++ b/packages/module-api/api-extractor.json @@ -67,7 +67,7 @@ * * "bundledPackages": [ "@my-company/*" ], */ - "bundledPackages": [], + "bundledPackages": ["shared-types"], /** * Specifies what type of newlines API Extractor should use when writing output files. By default, the output files diff --git a/packages/module-api/element-web-module-api.api.md b/packages/module-api/element-web-module-api.api.md index c23218f60ef..2318c1028a4 100644 --- a/packages/module-api/element-web-module-api.api.md +++ b/packages/module-api/element-web-module-api.api.md @@ -125,8 +125,10 @@ export type ComposerApiTarget = { view: "thread"; }; +// Warning: (ae-forgotten-export) The symbol "WebConfigJson" needs to be exported by the entry point index.d.ts +// // @public -export interface Config { +export interface Config extends WebConfigJson { // (undocumented) brand: string; } diff --git a/packages/module-api/package.json b/packages/module-api/package.json index 1b6cea2a2f5..ceaf85978f2 100644 --- a/packages/module-api/package.json +++ b/packages/module-api/package.json @@ -46,6 +46,7 @@ "matrix-widget-api": "^1.17.0", "rollup-plugin-external-globals": "^0.13.0", "semver": "^7.6.3", + "shared-types": "workspace:*", "typescript": "catalog:", "unplugin-dts": "catalog:", "vite": "catalog:", diff --git a/packages/module-api/project.json b/packages/module-api/project.json index 2b7e0be792e..e7979345be6 100644 --- a/packages/module-api/project.json +++ b/packages/module-api/project.json @@ -5,14 +5,10 @@ "targets": { "build": { "cache": true, - "executor": "nx:run-commands", "inputs": ["src"], "outputs": ["{projectRoot}/lib"], - "options": { - "commands": ["vite build", "api-extractor run"], - "parallel": false, - "cwd": "packages/module-api" - } + "command": "vite build", + "options": { "cwd": "packages/module-api" } }, "start": { "command": "vite build --watch", diff --git a/packages/module-api/src/api/config.ts b/packages/module-api/src/api/config.ts index 5f708b4983c..84e3e4cdb1f 100644 --- a/packages/module-api/src/api/config.ts +++ b/packages/module-api/src/api/config.ts @@ -5,16 +5,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ +import { type WebConfigJson } from "shared-types"; + /** * The configuration for the application. * Should be extended via declaration merging. * @public */ -export interface Config { +export interface Config extends WebConfigJson { // The branding name of the application brand: string; - // Other config options are available but not specified in the types as that would make it difficult to change for element-web - // they are accessible at runtime all the same, see list at https://github.com/element-hq/element-web/blob/develop/docs/config.md } /** diff --git a/packages/module-api/vite.config.ts b/packages/module-api/vite.config.ts index 305a4829832..5e01e921b31 100644 --- a/packages/module-api/vite.config.ts +++ b/packages/module-api/vite.config.ts @@ -27,7 +27,11 @@ export default defineConfig({ sourcemap: true, }, plugins: [ - dts(), + dts({ + bundleTypes: { + configPath: "./api-extractor.json", + }, + }), externalGlobals({ // Reuse React from the host app react: "window.React", diff --git a/packages/playwright-common/src/index.ts b/packages/playwright-common/src/index.ts index abbee033165..8fbf36e2120 100644 --- a/packages/playwright-common/src/index.ts +++ b/packages/playwright-common/src/index.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import { type Config as BaseConfig } from "@element-hq/element-web-module-api"; +import { type Config } from "@element-hq/element-web-module-api"; import { test as base } from "./fixtures/index.js"; import { routeConfigJson } from "./utils/config_json.js"; @@ -22,27 +22,11 @@ export { populateLocalStorageWithCredentials } from "./fixtures/user.js"; // See https://playwright.dev/docs/service-workers-experimental#how-to-enable process.env["PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS"] = "1"; -// We extend the Module API Config interface so that all modules -// which use declaration merging will have their config types correctly applied. -export interface Config extends BaseConfig { - default_server_config: { - "m.homeserver"?: { - base_url: string; - server_name?: string; - }; - "m.identity_server"?: { - base_url: string; - server_name?: string; - }; - }; - enable_presence_by_hs_url?: Record; - setting_defaults: Record; - map_style_url?: string; - features: Record; - modules?: string[]; -} +export type { Config }; // This is deliberately quite a minimal config.json, so that we can test that the default settings actually work. +// We use the Module API Config interface so that all modules +// which use declaration merging will have their config types correctly applied. export const CONFIG_JSON: Partial = { default_server_config: {}, diff --git a/packages/shared-types/lib/config.json.d.ts b/packages/shared-types/lib/config.json.d.ts new file mode 100644 index 00000000000..b9992ef5fb0 --- /dev/null +++ b/packages/shared-types/lib/config.json.d.ts @@ -0,0 +1,217 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { DeepPartial } from "./utils"; +import { ClientWellKnown } from "./matrix"; + +// Convention decision: All config options are lower_snake_case +// see docs/config.md for non-developer docs + +/** + * Type describing the `config.json` format for Element Web + * All fields are optional here, consumers should validate that fields are present before assuming otherwise + */ +export interface WebConfigJson { + // dev note: while true that this is arbitrary JSON, it's valuable to enforce that all + // config options are documented for "find all usages" sort of searching. + + // Properties of this interface are roughly grouped by their subject matter, such as + // "instance customisation", "login stuff", "branding", etc. Use blank lines to denote + // a logical separation of properties, but keep similar ones near each other. + + // Exactly one of the following must be supplied + default_server_config?: DeepPartial>; + default_server_name?: string; // domain to do well-known lookup on + default_hs_url?: string; // http url + + default_is_url?: string; // used in combination with default_hs_url, but for the identity server + + fallback_hs_url?: string; + + disable_custom_urls?: boolean; + disable_guests?: boolean; + disable_login_language_selector?: boolean; + disable_3pid_login?: boolean; + + brand?: string; + branding?: { + welcome_background_url?: string | string[]; // chosen at random if array + logo_link_url?: string; + auth_header_logo_url?: string; + auth_footer_links?: { text: string; url: string }[]; + }; + + force_verification?: boolean; // if true, users must verify new logins + + map_style_url?: string; // for location-shared maps + + embedded_pages?: { + welcome_url?: string; + home_url?: string; + login_for_welcome?: boolean; + }; + + permalink_prefix?: string; + + desktop_builds?: { + available?: boolean; + logo?: string; // url + url?: string; // download url + url_macos?: string; + url_win64?: string; + url_win64arm?: string; + url_linux?: string; + }; + mobile_builds?: { + ios?: string; // download url + android?: string; // download url + fdroid?: string; // download url + }; + + mobile_guide_toast?: boolean; + mobile_guide_app_variant?: "element" | "element-classic" | "element-pro"; + + default_theme?: "light" | "dark" | string; // custom themes are strings + default_country_code?: string; // ISO 3166 alpha2 country code + default_federate?: boolean; + default_device_display_name?: string; // for device naming on login+registration + + setting_defaults?: Record; // + + integrations_ui_url?: string; + integrations_rest_url?: string; + integrations_widgets_urls?: string[]; + default_widget_container_height?: number; // height in pixels + + show_labs_settings?: boolean; + features?: Record; // + + /** + * Bug report endpoint URL. "local" means the logs should not be uploaded. + * Omission disables bug reporting + */ + bug_report_endpoint_url?: string; + sentry?: { + dsn?: string; + environment?: string; // "production", etc + }; + + widget_build_url?: string; // url called to replace jitsi/call widget creation + widget_build_url_ignore_dm?: boolean; + audio_stream_url?: string; + jitsi?: { + preferred_domain?: string; + }; + jitsi_widget?: { + skip_built_in_welcome_screen?: boolean; + }; + voip?: { + obey_asserted_identity?: boolean; // MSC3086 + }; + element_call?: { + guest_spa_url?: string; + use_exclusively?: boolean; + brand?: string; + }; + + logout_redirect_url?: string; + + sso_redirect_options?: { + immediate?: boolean; + on_welcome_page?: boolean; + on_login_page?: boolean; + }; + + custom_translations_url?: string; + + report_event?: { + admin_message_md?: string; // message for how to contact the server owner when reporting an event + }; + + room_directory?: { + servers?: string[]; + }; + + posthog?: { + project_api_key?: string; + api_host?: string; // hostname + }; + analytics_owner?: string; // defaults to `brand` + privacy_policy_url?: string; // location for cookie policy + + enable_presence_by_hs_url?: Record; // + + terms_and_conditions_links?: { url: string; text: string }[]; + help_url?: string; + help_encryption_url?: string; + help_key_storage_url?: string; + + latex_maths_delims?: { + inline?: { + left?: string; + right?: string; + pattern?: { + tex?: string; + latex?: string; + }; + }; + display?: { + left?: string; + right?: string; + pattern?: { + tex?: string; + latex?: string; + }; + }; + }; + + sync_timeline_limit?: number; + dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option + + user_notice?: { + title?: string; + description?: string; + show_once?: boolean; + }; + + feedback?: { + existing_issues_url?: string; + new_issue_url?: string; + }; + + /** + * Configuration for OIDC issuers where a static client_id has been issued for the app. + * Otherwise dynamic client registration is attempted. + * The issuer URL must have a trailing `/`. + * OPTIONAL + */ + oidc_static_clients?: { + [issuer: string]: { client_id: string }; + }; + + /** + * Configuration for OIDC dynamic registration where a static OIDC client is not configured. + */ + oidc_metadata?: { + client_uri?: string; + logo_uri?: string; + tos_uri?: string; + policy_uri?: string; + contacts?: string[]; + }; + + modules?: string[]; +} + +/** + * Type describing the `config.json` format for Element Desktop, a superset of Element Web's config. + * All fields are optional here, consumers should validate that fields are present before assuming otherwise + */ +export interface DesktopConfigJson extends WebConfigJson { + web_base_url?: string; + update_base_url?: string; +} diff --git a/packages/shared-types/lib/electron.d.ts b/packages/shared-types/lib/electron.d.ts new file mode 100644 index 00000000000..afb91dcc954 --- /dev/null +++ b/packages/shared-types/lib/electron.d.ts @@ -0,0 +1,248 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import type { DesktopConfigJson } from "./config.json"; + +type Event = { + preventDefault: () => void; + readonly defaultPrevented: boolean; +} & Params; + +export interface SeshatMatrixEvent { + event_id: string; + sender: string; + room_id: string; + type: string; + origin_server_ts: number; + content: Record; +} + +export interface SeshatMatrixProfile { + displayname?: string; + avatar_url?: string; +} + +export interface SeshatCheckpoint { + /** The room to be indexed */ + roomId: string; + + /** The pagination index to resume crawling from. */ + token: string; + + /** + * If `fullCrawl` is false (or absent) and we find that we have already indexed the events we find, then we stop crawling. + * + * If `fullCrawl` is true, then we keep going until we reach the end of the room history. + */ + fullCrawl?: boolean; + + /** Whether we should crawl in the forward or backward direction. */ + direction: "b" | "f"; +} + +export interface SeshatEventAndProfile { + event: SeshatMatrixEvent; + profile: SeshatMatrixProfile; +} + +export interface SeshatSearchArgs { + search_term: string; + before_limit: number; + after_limit: number; + order_by_recency: boolean; + room_id?: string; + limit: number; + next_batch?: string; +} + +export interface SeshatIndexStats { + size: number; + eventCount: number; + roomCount: number; +} + +export interface SeshatLoadArgs { + roomId: string; + limit: number; + fromEvent?: string; + direction?: "b" | "f"; +} + +export interface SeshatSearchContext { + events_before: SeshatMatrixEvent[]; + events_after: SeshatMatrixEvent[]; + profile_info: { [userId: string]: SeshatMatrixProfile }; +} + +export interface SeshatSearchResult { + next_batch: string; + count: number; + results: { + rank: number; + result: SeshatMatrixEvent; + context: SeshatSearchContext; + }[]; +} + +// IPC definitions for Renderer->Main->Renderer calls +export interface IpcHandles { + "getConfig"(): DesktopConfigJson; + "getUpdateFeedUrl"(): string; + "getAppVersion"(): string; + "focusWindow"(): void; + "navigateBack"(): void; + "navigateForward"(): void; + "setLanguage"(langs: string[]): void; + "getUpdateFeedUrl"(): string; + "setSpellCheckEnabled"(enabled: boolean): void; + "getSpellCheckEnabled"(): boolean; + "setSpellCheckLanguages"(langs: string[]): void; + "getSpellCheckLanguages"(): string[]; + "getAvailableSpellCheckLanguages"(): string[]; + "getPickleKey"(userId: string, deviceId: string): string | null; + "createPickleKey"(userId: string, deviceId: string): string | null; + "destroyPickleKey"(userId: string, deviceId: string): void; + "getDesktopCapturerSources"(options: GetSourcesOptions): DesktopCapturerSource[]; + "callDisplayMediaCallback"(video: DesktopCapturerSource): void; + "getProtocol"(): { + protocol: string; + sessionId: string; + }; + + // Settings + "getSupportedSettings"(): Record; + "setSettingValue"(settingName: K, value: ElectronSettings[K]): void; + "getSettingValue"(settingName: K): ElectronSettings[K]; + + // Seshat + "seshat.supportsEventIndexing"(): boolean; + "seshat.initEventIndex"(userId: string, deviceId: string): void; + "seshat.closeEventIndex"(): void; + "seshat.deleteEventIndex"(): void; + "seshat.isEventIndexEmpty"(): boolean; + "seshat.isRoomIndexed"(roomId: string): boolean; + "seshat.addEventToIndex"(matrixEvent: SeshatMatrixEvent, profile: SeshatMatrixProfile): void; + "seshat.deleteEvent"(eventId: string): boolean; + "seshat.commitLiveEvents"(): number; + "seshat.searchEventIndex"(searchArgs: SeshatSearchArgs): SeshatSearchResult | undefined; + "seshat.addHistoricEvents"( + events: SeshatEventAndProfile[], + newCheckpoint: SeshatCheckpoint | null, + oldCheckpoint: SeshatCheckpoint | null, + ): boolean; + "seshat.getStats"(): SeshatIndexStats | undefined; + "seshat.removeCrawlerCheckpoint"(checkpoint: SeshatCheckpoint): void; + "seshat.addCrawlerCheckpoint"(checkpoint: SeshatCheckpoint): void; + "seshat.loadFileEvents"(args: SeshatLoadArgs): SeshatEventAndProfile[]; + "seshat.loadCheckpoints"(): SeshatCheckpoint[]; + "seshat.setUserVersion"(version: number): void; + "seshat.getUserVersion"(): number | undefined; +} + +// Renderer -> Main events +export type RendererMainEvents = { + "initialise"(): void; + "loudNotification"(): void; + "clearStorage"(): void; + "breadcrumbs"(rooms: { roomId: string; avatarUrl: string | null; initial: string | undefined }[]): void; + "setBadgeCount"(count: number, imageBuffer?: ArrayBuffer, isError?: boolean): void; + "userDownloadAction"(download: { id: number; open?: boolean }): void; + "install_update"(): void; + "check_updates"(): void; + "prevent_display_sleep"(prevent: boolean): void; +} & FlipCallAndResponse; + +// Main -> Renderer events +export type MainRendererEvents = { + "showToast"(toast: { title: string; description: string; priority?: number }): void; + "before-quit"(): void; + "preferences"(): void; + "openDesktopCapturerSourcePicker"(): void; + "update-downloaded"(update: SquirrelUpdate): void; + "userDownloadCompleted"(download: { id: number; name: string }): void; + "check_updates"(status: boolean | string): void; +} & CallAndResponse; + +type FlipCallAndResponse = { + [K in keyof T]: T[K] extends (...args: infer Args) => infer Ret ? (response: Ret) => Args : never; +}; + +export interface CallAndResponse { + "userAccessToken"(): string | undefined; + "homeserverUrl"(): string | undefined; + "serverSupportedVersions"(): + | { + versions: string[]; + } + | undefined; +} + +export type ElectronChannel = keyof IpcHandles | keyof RendererMainEvents | keyof MainRendererEvents; + +export interface ElectronSettings { + "Electron.autoLaunch": "enabled" | "minimised" | "disabled"; + "Electron.warnBeforeExit": boolean; + "Electron.alwaysShowMenuBar": boolean; + "Electron.showTrayIcon": boolean; + "Electron.enableHardwareAcceleration": boolean; + "Electron.enableContentProtection": boolean; +} + +export interface SquirrelUpdate { + releaseNotes: string; + releaseName: string; + releaseDate: Date; + updateURL: string; +} + +export interface Electron { + on( + channel: K, + listener: (...args: Parameters) => void, + ): void; + send(channel: K, ...args: Parameters): void; + + // Renderer -> Main calls with return values back to Renderer + call( + command: K, + ...args: Parameters + ): Promise>>; + + // Initialisation + initialise(): Promise<{ + protocol: string; + sessionId: string; + supportsBadgeOverlay: boolean; + config: DesktopConfigJson; + supportedSettings: Record; + }>; + + // Settings + setSettingValue(settingName: K, value: ElectronSettings[K]): Promise; + getSettingValue(settingName: K): Promise; +} + +export interface DesktopCapturerSource { + id: string; + name: string; + thumbnailURL: string; +} + +export interface GetSourcesOptions { + types: Array<"screen" | "window">; + thumbnailSize?: { + height: number; + width: number; + }; + fetchWindowIcons?: boolean; +} + +declare global { + interface Window { + electron?: Electron; + } +} diff --git a/packages/shared-types/lib/index.d.ts b/packages/shared-types/lib/index.d.ts new file mode 100644 index 00000000000..1a1f700efb2 --- /dev/null +++ b/packages/shared-types/lib/index.d.ts @@ -0,0 +1,12 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +export type * from "./config.json.d.ts"; +export type * from "./utils.d.ts"; +export type * from "./matrix.d.ts"; +export type * from "./json.d.ts"; +export type * from "./electron.d.ts"; diff --git a/packages/shared-types/lib/index.js b/packages/shared-types/lib/index.js new file mode 100644 index 00000000000..1be6a73d8c5 --- /dev/null +++ b/packages/shared-types/lib/index.js @@ -0,0 +1 @@ +// Dummy file to make Node happy to import `shared-types` lib. diff --git a/apps/web/src/@types/json.ts b/packages/shared-types/lib/json.d.ts similarity index 57% rename from apps/web/src/@types/json.ts rename to packages/shared-types/lib/json.d.ts index 33af49a19ff..cf7255954ad 100644 --- a/apps/web/src/@types/json.ts +++ b/packages/shared-types/lib/json.d.ts @@ -1,13 +1,20 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2026 Element Creations Ltd. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ +/** Type representing a valid JSON value */ export type JsonValue = null | string | number | boolean; + +/** Type representing a valid JSON array */ export type JsonArray = Array; + +/** Type representing a valid JSON object */ export interface JsonObject { [key: string]: JsonObject | JsonArray | JsonValue; } -export type Json = JsonArray | JsonObject; + +/** Type representing a valid JSON document */ +export type JsonDocument = JsonArray | JsonObject; diff --git a/packages/shared-types/lib/matrix.d.ts b/packages/shared-types/lib/matrix.d.ts new file mode 100644 index 00000000000..609f62a0dd3 --- /dev/null +++ b/packages/shared-types/lib/matrix.d.ts @@ -0,0 +1,43 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { JsonDocument } from "./json"; + +/** + * As specified by https://spec.matrix.org/latest/client-server-api/#getwell-knownmatrixclient + */ +export type ClientWellKnown = { + /** + * Used by clients to discover homeserver information. + */ + "m.homeserver": { + /** + * The base URL for the homeserver for client-server connections. + */ + base_url: string; + /** + * This field is not part of the spec but supported by Element Web's config.json + * @deprecated - we should figure out whether we want to keep this or not. + */ + server_name?: string; + }; + /** + * Used by clients to discover identity server information. + */ + "m.identity_server"?: { + /** + * The base URL for the identity server for client-server connections. + */ + base_url: string; + }; +} & { + /** + * Other properties + * Application-dependent keys using Java package naming convention. + */ + [key: string]: JsonDocument; +}; diff --git a/packages/shared-types/lib/utils.d.ts b/packages/shared-types/lib/utils.d.ts new file mode 100644 index 00000000000..e68cf0bfba2 --- /dev/null +++ b/packages/shared-types/lib/utils.d.ts @@ -0,0 +1,83 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +/** + * Returns a union type of the keys of the Input type whose names start with the given string Str. + */ +export type KeysStartingWith = { + [P in keyof Input]: P extends `${Str}${infer _X}` ? P : never; // we don't use _X +}[keyof Input]; + +/** + * Makes fields of T and its object children optional if they are defined in D. + * Useful for generating the input type for a given function if it applies an object of defaults. + */ +export type Defaultize = P extends any + ? string extends keyof P + ? P + : Pick> & + Partial>> & + Partial>> + : never; + +/** + * Makes fields of T and its object children non-optional if they are defined in D. + * Useful for generating a type which allows you to know which fields will be defined once you apply default values. + */ +export type ResolveDefaults = { + [K in keyof T as K extends keyof D ? K : never]-?: SafeIndex extends object + ? NonNullable extends any[] + ? NonNullable + : NonNullable extends object + ? ResolveDefaults, SafeIndex> + : NonNullable + : NonNullable; +} & { + [K in keyof T as K extends keyof D ? never : K]: T[K]; +} & {}; + +type SafeIndex = K extends keyof D ? D[K] : never; + +/** + * Applies the `readonly` modifier to all fields of T and its object children. + */ +export type DeepReadonly = T extends (infer R)[] + ? DeepReadonlyArray + : T extends Function + ? T + : T extends object + ? DeepReadonlyObject + : T; + +interface DeepReadonlyArray extends ReadonlyArray> {} + +type DeepReadonlyObject = { + readonly [P in keyof T]: DeepReadonly; +}; + +/** + * Like `Partial` but requires at least one property to be present. + */ +export type AtLeastOne }> = Partial & U[keyof U]; + +/** + * Returns a union type of the keys of the input Object type whose values are assignable to the given Item type. + * Based on https://stackoverflow.com/a/57862073 + */ +export type Assignable = { + [Key in keyof Object]: Object[Key] extends Item ? Key : never; +}[keyof Object]; + +/** + * Like `Partial` but for applied to all nested objects. + * Based on https://dev.to/perennialautodidact/adventures-in-typescript-deeppartial-2f2a + */ +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json new file mode 100644 index 00000000000..6fd62d3c309 --- /dev/null +++ b/packages/shared-types/package.json @@ -0,0 +1,20 @@ +{ + "name": "shared-types", + "type": "module", + "version": "0.0.0", + "private": true, + "description": "Shared types for Element Web & Desktop", + "author": "element-hq", + "license": "SEE LICENSE IN README.md", + "main": "./lib/index.js", + "types": "./lib/index.d.ts", + "files": [ + "lib" + ], + "scripts": { + "lint:types": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "catalog:" + } +} diff --git a/packages/shared-types/tsconfig.json b/packages/shared-types/tsconfig.json new file mode 100644 index 00000000000..b58ee71b24b --- /dev/null +++ b/packages/shared-types/tsconfig.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "compilerOptions": { + "rootDir": "./lib", + "target": "esnext", + "lib": ["es2024"], + "strict": true, + "types": [], + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["lib"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f6fbc1bf4c..f0fa71a38a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -241,9 +241,6 @@ catalogs: react-dom: specifier: ^19.0.0 version: 19.2.7 - typescript: - specifier: 6.0.3 - version: 6.0.3 unplugin-dts: specifier: 1.0.1 version: 1.0.1 @@ -284,6 +281,7 @@ overrides: protobufjs@7 <7.5.8: 7.6.1 '@protobufjs/utf8@1 <1.1.1': 1.1.1 yauzl: ^3.3.1 + typescript: 6.0.3 packageExtensionsChecksum: sha256-EMEi1vcyzQthk7O/0AcntvnHgJaKCoFBlzp6iX/qNYk= @@ -351,7 +349,7 @@ importers: specifier: 0.56.0 version: 0.56.0 typescript: - specifier: 'catalog:' + specifier: 6.0.3 version: 6.0.3 vitepress: specifier: ^1.6.4 @@ -492,11 +490,14 @@ importers: rimraf: specifier: ^6.0.0 version: 6.1.3 + shared-types: + specifier: workspace:* + version: link:../../packages/shared-types tar: specifier: ^7.5.8 version: 7.5.19 typescript: - specifier: 'catalog:' + specifier: 6.0.3 version: 6.0.3 vitest: specifier: 'catalog:' @@ -1033,6 +1034,9 @@ importers: semver: specifier: ^7.5.2 version: 7.8.5 + shared-types: + specifier: workspace:* + version: link:../../packages/shared-types source-map-loader: specifier: ^5.0.0 version: 5.0.0(webpack@5.108.3) @@ -1055,7 +1059,7 @@ importers: specifier: ^12.0.0 version: 12.0.4 typescript: - specifier: 'catalog:' + specifier: 6.0.3 version: 6.0.3 util: specifier: ^0.12.5 @@ -1164,7 +1168,7 @@ importers: specifier: 'catalog:' version: 19.2.7 typescript: - specifier: 'catalog:' + specifier: 6.0.3 version: 6.0.3 vite: specifier: 'catalog:' @@ -1213,7 +1217,7 @@ importers: specifier: ^12.0.1 version: 12.0.4 typescript: - specifier: 'catalog:' + specifier: 6.0.3 version: 6.0.3 vite: specifier: 'catalog:' @@ -1235,7 +1239,7 @@ importers: specifier: 25.9.3 version: 25.9.3 typescript: - specifier: 'catalog:' + specifier: 6.0.3 version: 6.0.3 vite: specifier: 'catalog:' @@ -1293,7 +1297,7 @@ importers: specifier: 'catalog:' version: 19.2.7 typescript: - specifier: 'catalog:' + specifier: 6.0.3 version: 6.0.3 vite: specifier: 'catalog:' @@ -1344,8 +1348,11 @@ importers: semver: specifier: ^7.6.3 version: 7.8.5 + shared-types: + specifier: workspace:* + version: link:../shared-types typescript: - specifier: 'catalog:' + specifier: 6.0.3 version: 6.0.3 unplugin-dts: specifier: 'catalog:' @@ -1400,7 +1407,7 @@ importers: specifier: ^4.17.12 version: 4.17.12 typescript: - specifier: 'catalog:' + specifier: 6.0.3 version: 6.0.3 packages/shared-components: @@ -1602,7 +1609,7 @@ importers: specifier: ^4.1.2 version: 4.1.3(typedoc@0.28.19(typescript@6.0.3)) typescript: - specifier: 'catalog:' + specifier: 6.0.3 version: 6.0.3 unplugin-dts: specifier: 'catalog:' @@ -1617,6 +1624,12 @@ importers: specifier: 'catalog:' version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.10.6)(jsdom@26.1.0(patch_hash=040623e87b1c8b676c2a705513c0276c0704dd1b23fc3a1bb77cde8128b64b5f))(vite@8.1.3(@types/node@25.9.3)(esbuild@0.27.4)(jiti@2.7.0)(sugarss@5.0.1(postcss@8.5.16))(terser@5.48.0)(yaml@2.8.4)) + packages/shared-types: + devDependencies: + typescript: + specifier: 6.0.3 + version: 6.0.3 + packages: '@action-validator/cli@0.6.0': @@ -3383,7 +3396,7 @@ packages: '@joshwooding/vite-plugin-react-docgen-typescript@0.7.0': resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==} peerDependencies: - typescript: '>= 4.3.x' + typescript: 6.0.3 vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: typescript: @@ -5573,7 +5586,7 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 storybook: ^10.4.6 - typescript: '>= 4.9.x' + typescript: 6.0.3 peerDependenciesMeta: '@types/react': optional: true @@ -6148,32 +6161,32 @@ packages: peerDependencies: '@typescript-eslint/parser': ^8.61.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/parser@8.62.1': resolution: {integrity: sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/project-service@8.61.0': resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/project-service@8.61.1': resolution: {integrity: sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/project-service@8.62.1': resolution: {integrity: sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/scope-manager@8.61.0': resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} @@ -6191,26 +6204,26 @@ packages: resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/tsconfig-utils@8.61.1': resolution: {integrity: sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/tsconfig-utils@8.62.1': resolution: {integrity: sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/type-utils@8.61.0': resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/types@8.61.0': resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} @@ -6228,33 +6241,33 @@ packages: resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/typescript-estree@8.61.1': resolution: {integrity: sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/typescript-estree@8.62.1': resolution: {integrity: sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/utils@8.61.0': resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/utils@8.61.1': resolution: {integrity: sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + typescript: 6.0.3 '@typescript-eslint/visitor-keys@8.61.0': resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} @@ -7670,7 +7683,7 @@ packages: resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} engines: {node: '>=14'} peerDependencies: - typescript: '>=4.9.5' + typescript: 6.0.3 peerDependenciesMeta: typescript: optional: true @@ -7679,7 +7692,7 @@ packages: resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} engines: {node: '>=14'} peerDependencies: - typescript: '>=4.9.5' + typescript: 6.0.3 peerDependenciesMeta: typescript: optional: true @@ -8620,7 +8633,7 @@ packages: '@typescript-eslint/eslint-plugin': ^8.0.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 jest: '*' - typescript: '>=4.8.4 <7.0.0' + typescript: 6.0.3 peerDependenciesMeta: '@typescript-eslint/eslint-plugin': optional: true @@ -8655,7 +8668,7 @@ packages: eslint-plugin-react-hooks: '*' eslint-plugin-unicorn: <57 prettier: '*' - typescript: '*' + typescript: 6.0.3 eslint-plugin-n@17.24.0: resolution: {integrity: sha512-/gC7/KAYmfNnPNOb3eu8vw+TdVnV0zhdQwexsw6FLXbhzroVj20vRn2qL8lDWDGnAQ2J8DhdfvXxX9EoxvERvw==} @@ -11966,7 +11979,7 @@ packages: react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: - typescript: '>= 4.3.x' + typescript: 6.0.3 react-docgen@8.0.3: resolution: {integrity: sha512-aEZ9qP+/M+58x2qgfSFEWH1BxLyHe5+qkLNJOZQb5iGS017jpbRnoKhNRrXPeA6RfBrZO5wZrT9DMC1UqE1f1w==} @@ -13186,12 +13199,12 @@ packages: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: - typescript: '>=4.8.4' + typescript: 6.0.3 ts-declaration-location@1.0.7: resolution: {integrity: sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA==} peerDependencies: - typescript: '>=4.0.0' + typescript: 6.0.3 ts-dedent@2.3.0: resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} @@ -13271,7 +13284,7 @@ packages: type-plus@8.0.0-beta.8: resolution: {integrity: sha512-egrpXQq2tV0abCf99+n4SCD/stT76qEwPBI1q7BqiVUe5pHWc+bm4vsOiNR84SmZTv4SoEl9UOZUdkEbS3POdw==} peerDependencies: - typescript: '>= 5.6.0' + typescript: 6.0.3 typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} @@ -13305,12 +13318,7 @@ packages: engines: {node: '>= 18', pnpm: '>= 10'} hasBin: true peerDependencies: - typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true + typescript: 6.0.3 typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} @@ -13428,7 +13436,7 @@ packages: esbuild: 0.27.4 rolldown: '*' rollup: '>=3' - typescript: '>=4' + typescript: 6.0.3 vite: '>=3' webpack: ^4 || ^5 peerDependenciesMeta: @@ -13730,7 +13738,7 @@ packages: vue@3.5.31: resolution: {integrity: sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==} peerDependencies: - typescript: '*' + typescript: 6.0.3 peerDependenciesMeta: typescript: optional: true @@ -14282,7 +14290,7 @@ snapshots: '@arcmantle/vite-plugin-import-css-sheet@1.0.14(patch_hash=8019aa9feca17db6bab3483612b4150c911d70b58fddc52bf2d7258a1484a747)': dependencies: lightningcss: 1.32.0 - typescript: 5.9.3 + typescript: 6.0.3 '@asamuzakjp/css-color@3.2.0': dependencies: @@ -16545,7 +16553,7 @@ snapshots: resolve: 1.22.12 semver: 7.7.4 source-map: 0.6.1 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - '@types/node' @@ -27603,8 +27611,6 @@ snapshots: typescript: 6.0.3 yaml: 2.8.4 - typescript@5.9.3: {} - typescript@6.0.3: {} ua-parser-js@1.0.40: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d0919bc43a5..2c6c4c227f1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -135,6 +135,8 @@ overrides: "@protobufjs/utf8@1 <1.1.1": 1.1.1 # Workaround for https://github.com/electron/electron/issues/51619 yauzl: "^3.3.1" + # Convince api-extractor to use an up to date Typescript version + typescript: "catalog:" minimumReleaseAgeExclude: - "matrix-js-sdk"