mv element.io @types __mocks__/ debian docker module_system/ playwright res src test webapp Dockerfile .dockerignore .eslintignore .stylelintrc.cjs babel.config.cjs recorder-worklet-loader.cjs .modernizr.json components.json config.json config.sample.json package.json project.json tsconfig.json tsconfig.module_system.json jest.config.ts playwright.config.ts webpack.config.ts build_config.sample.yaml apps/web/

mkdir apps/web/scripts
mv scripts/{cleanup.sh,ci_package.sh,copy-res.ts,deploy.py,package.sh} apps/web/scripts

And a couple of gitignore tweaks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2026-02-24 15:43:58 +00:00
parent e7509c92a1
commit 91a3cb03c1
3408 changed files with 28 additions and 32 deletions
@@ -0,0 +1,571 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
Copyright 2018-2021 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2016 Aviral Dasgupta
Copyright 2016 OpenMarket 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 {
type MatrixClient,
type Room,
type MatrixEvent,
type OidcRegistrationClientMetadata,
} from "matrix-js-sdk/src/matrix";
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { uniqueId } from "lodash";
import BasePlatform, { UpdateCheckStatus, type UpdateStatus } from "../../BasePlatform";
import type BaseEventIndexManager from "../../indexing/BaseEventIndexManager";
import dis from "../../dispatcher/dispatcher";
import SdkConfig from "../../SdkConfig";
import { type IConfigOptions } from "../../IConfigOptions";
import * as rageshake from "../../rageshake/rageshake";
import Modal from "../../Modal";
import InfoDialog from "../../components/views/dialogs/InfoDialog";
import Spinner from "../../components/views/elements/Spinner";
import { Action } from "../../dispatcher/actions";
import { type ActionPayload } from "../../dispatcher/payloads";
import { showToast as showUpdateToast } from "../../toasts/UpdateToast";
import { type CheckUpdatesPayload } from "../../dispatcher/payloads/CheckUpdatesPayload";
import ToastStore from "../../stores/ToastStore";
import GenericExpiringToast from "../../components/views/toasts/GenericExpiringToast";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
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 {
// used to use window.process but the same info is available here
if (navigator.userAgent.includes("Macintosh")) {
return "macOS";
} else if (navigator.userAgent.includes("FreeBSD")) {
return "FreeBSD";
} else if (navigator.userAgent.includes("OpenBSD")) {
return "OpenBSD";
} else if (navigator.userAgent.includes("SunOS")) {
return "SunOS";
} else if (navigator.userAgent.includes("Windows")) {
return "Windows";
} else if (navigator.userAgent.includes("Linux")) {
return "Linux";
} else {
return "Unknown";
}
}
function getUpdateCheckStatus(status: boolean | string): UpdateStatus {
if (status === true) {
return { status: UpdateCheckStatus.Downloading };
} else if (status === false) {
return { status: UpdateCheckStatus.NotAvailable };
} else {
return {
status: UpdateCheckStatus.Error,
detail: status,
};
}
}
export default class ElectronPlatform extends BasePlatform {
private readonly ipc = new IPCManager("ipcCall", "ipcReply");
private readonly eventIndexManager: BaseEventIndexManager = new SeshatIndexManager();
public readonly initialised: Promise<void>;
private readonly electron: Electron;
private protocol!: string;
private sessionId!: string;
private badgeOverlayRenderer?: BadgeOverlayRenderer;
private config!: IConfigOptions;
private supportedSettings?: Record<string, boolean>;
private clientStartedPromiseWithResolvers = Promise.withResolvers<void>();
public constructor() {
super();
if (!window.electron) {
throw new Error("Cannot instantiate ElectronPlatform, window.electron is not set");
}
this.electron = window.electron;
/*
IPC Call `check_updates` returns:
true if there is an update available
false if there is not
or the error if one is encountered
*/
this.electron.on("check_updates", (event, status) => {
dis.dispatch<CheckUpdatesPayload>({
action: Action.CheckUpdates,
...getUpdateCheckStatus(status),
});
});
// `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());
});
// `homeserverUrl` (IPC) is requested by the main process. A reply is sent over the same channel.
this.electron.on("homeserverUrl", () => {
this.electron.send("homeserverUrl", MatrixClientPeg.get()?.getHomeserverUrl());
});
// `serverSupportedVersions` is requested by the main process when it needs to know if the
// server supports a particular version. This is primarily used to detect authenticated media
// support. A reply is sent over the same channel.
this.electron.on("serverSupportedVersions", async () => {
this.electron.send("serverSupportedVersions", await MatrixClientPeg.get()?.getVersions());
});
// try to flush the rageshake logs to indexeddb before quit.
this.electron.on("before-quit", function () {
logger.log("element-desktop closing");
rageshake.flush();
});
this.electron.on("update-downloaded", this.onUpdateDownloaded);
this.electron.on("preferences", () => {
dis.fire(Action.ViewUserSettings);
});
this.electron.on("userDownloadCompleted", (ev, { id, name }) => {
const key = `DOWNLOAD_TOAST_${id}`;
const onAccept = (): void => {
this.electron.send("userDownloadAction", { id, open: true });
ToastStore.sharedInstance().dismissToast(key);
};
const onDismiss = (): void => {
this.electron.send("userDownloadAction", { id });
};
ToastStore.sharedInstance().addOrReplaceToast({
key,
title: _t("download_completed"),
props: {
description: name,
primaryLabel: _t("action|open"),
onPrimaryClick: onAccept,
dismissLabel: _t("action|dismiss"),
onDismiss,
numSeconds: 10,
},
component: GenericExpiringToast,
priority: 99,
});
});
this.electron.on("openDesktopCapturerSourcePicker", async () => {
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: "" });
});
this.electron.on("showToast", async (ev, { title, description, priority = 40 }) => {
await this.clientStartedPromiseWithResolvers.promise;
const key = uniqueId("electron_showToast_");
const onPrimaryClick = (): void => {
ToastStore.sharedInstance().dismissToast(key);
};
ToastStore.sharedInstance().addOrReplaceToast({
key,
title,
props: {
description,
primaryLabel: _t("action|dismiss"),
onPrimaryClick,
},
component: GenericToast,
priority,
});
});
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
this.initialised = this.initialise();
}
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);
}
if (payload.action === Action.ClientStarted) {
this.clientStartedPromiseWithResolvers.resolve();
}
}
private async initialise(): Promise<void> {
const { protocol, sessionId, config, supportedSettings, supportsBadgeOverlay } =
await this.electron.initialise();
this.protocol = protocol;
this.sessionId = sessionId;
this.config = config;
this.supportedSettings = supportedSettings;
if (supportsBadgeOverlay) {
this.badgeOverlayRenderer = new BadgeOverlayRenderer();
}
}
public async getConfig(): Promise<IConfigOptions | undefined> {
await this.initialised;
return this.config;
}
private onBreadcrumbsUpdate = (): void => {
const rooms = BreadcrumbsStore.instance.rooms.slice(0, 7).map((r) => ({
roomId: r.roomId,
avatarUrl: avatarUrlForRoom(
r,
Math.floor(60 * window.devicePixelRatio),
Math.floor(60 * window.devicePixelRatio),
"crop",
),
initial: getInitialLetter(r.name),
}));
void this.ipc.call("breadcrumbs", rooms);
};
private onUpdateDownloaded = async (ev: Event, { releaseNotes, releaseName }: SquirrelUpdate): Promise<void> => {
dis.dispatch<CheckUpdatesPayload>({
action: Action.CheckUpdates,
status: UpdateCheckStatus.Ready,
});
if (this.shouldShowUpdate(releaseName)) {
showUpdateToast(await this.getAppVersion(), releaseName, releaseNotes);
}
};
public getHumanReadableName(): string {
return "Electron Platform"; // no translation required: only used for analytics
}
/**
* Return true if platform supports multi-language
* spell-checking, otherwise false.
*/
public supportsSpellCheckSettings(): boolean {
return true;
}
public allowOverridingNativeContextMenus(): boolean {
return true;
}
public setNotificationCount(count: number): void {
if (this.notificationCount === count) return;
super.setNotificationCount(count);
if (this.badgeOverlayRenderer) {
this.badgeOverlayRenderer
.render(count)
.then((buffer) => {
this.electron.send("setBadgeCount", count, buffer);
})
.catch((ex) => {
logger.warn("Unable to generate badge overlay", ex);
});
} else {
this.electron.send("setBadgeCount", count);
}
}
public setErrorStatus(errorDidOccur: boolean): void {
if (!this.badgeOverlayRenderer) {
super.setErrorStatus(errorDidOccur);
return;
}
// Check before calling super so we don't override the previous state.
if (this.errorDidOccur !== errorDidOccur) {
super.setErrorStatus(errorDidOccur);
let promise: Promise<ArrayBuffer | null>;
if (errorDidOccur) {
promise = this.badgeOverlayRenderer.render(this.notificationCount || "×", "#f00");
} else {
promise = this.badgeOverlayRenderer.render(this.notificationCount);
}
promise
.then((buffer) => {
this.electron.send("setBadgeCount", this.notificationCount, buffer, errorDidOccur);
})
.catch((ex) => {
logger.warn("Unable to generate badge overlay", ex);
});
}
}
public supportsNotifications(): boolean {
return true;
}
public maySendNotifications(): boolean {
return true;
}
public displayNotification(
title: string,
msg: string,
avatarUrl: string,
room: Room,
ev?: MatrixEvent,
): Notification {
// GNOME notification spec parses HTML tags for styling...
// Electron Docs state all supported linux notification systems follow this markup spec
// https://github.com/electron/electron/blob/master/docs/tutorial/desktop-environment-integration.md#linux
// maybe we should pass basic styling (italics, bold, underline) through from MD
// we only have to strip out < and > as the spec doesn't include anything about things like &amp;
// so we shouldn't assume that all implementations will treat those properly. Very basic tag parsing is done.
if (navigator.userAgent.includes("Linux")) {
msg = msg.replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
const notification = super.displayNotification(title, msg, avatarUrl, room, ev);
const handler = notification.onclick as () => void;
notification.onclick = (): void => {
handler?.();
void this.ipc.call("focusWindow");
};
return notification;
}
public loudNotification(ev: MatrixEvent, room: Room): void {
this.electron.send("loudNotification");
}
public needsUrlTooltips(): boolean {
return true;
}
public async getAppVersion(): Promise<string> {
return this.ipc.call("getAppVersion");
}
public supportsSetting(settingName?: string): boolean {
if (settingName === undefined) return true;
return this.supportedSettings?.[settingName] === true;
}
public async getSettingValue(settingName: string): Promise<any> {
await this.initialised;
return this.electron.getSettingValue(settingName);
}
public async setSettingValue(settingName: string, value: any): Promise<void> {
await this.initialised;
return this.electron.setSettingValue(settingName, value);
}
public async canSelfUpdate(): Promise<boolean> {
const feedUrl = await this.ipc.call("getUpdateFeedUrl");
return Boolean(feedUrl);
}
public startUpdateCheck(): void {
super.startUpdateCheck();
this.electron.send("check_updates");
}
public installUpdate(): void {
// IPC to the main process to install the update, since quitAndInstall
// doesn't fire the before-quit event so the main process needs to know
// it should exit.
this.electron.send("install_update");
}
public getDefaultDeviceDisplayName(): string {
const brand = SdkConfig.get().brand;
return _t("desktop_default_device_name", {
brand,
platformName: platformFriendlyName(),
});
}
public requestNotificationPermission(): Promise<string> {
return Promise.resolve("granted");
}
public reload(): void {
window.location.reload();
}
public getEventIndexingManager(): BaseEventIndexManager | null {
return this.eventIndexManager;
}
public async setLanguage(preferredLangs: string[]): Promise<any> {
return this.ipc.call("setLanguage", preferredLangs);
}
public setSpellCheckEnabled(enabled: boolean): void {
this.ipc.call("setSpellCheckEnabled", enabled).catch((error) => {
logger.log("Failed to send setSpellCheckEnabled IPC to Electron");
logger.error(error);
});
}
public async getSpellCheckEnabled(): Promise<boolean> {
return this.ipc.call("getSpellCheckEnabled");
}
public setSpellCheckLanguages(preferredLangs: string[]): void {
this.ipc.call("setSpellCheckLanguages", preferredLangs).catch((error) => {
logger.log("Failed to send setSpellCheckLanguages IPC to Electron");
logger.error(error);
});
}
public async getSpellCheckLanguages(): Promise<string[]> {
return this.ipc.call("getSpellCheckLanguages");
}
public async getDesktopCapturerSources(options: GetSourcesOptions): Promise<Array<DesktopCapturerSource>> {
return this.ipc.call("getDesktopCapturerSources", options);
}
public supportsDesktopCapturer(): boolean {
return true;
}
public supportsJitsiScreensharing(): boolean {
// See https://github.com/element-hq/element-web/issues/4880
return false;
}
public async getAvailableSpellCheckLanguages(): Promise<string[]> {
return this.ipc.call("getAvailableSpellCheckLanguages");
}
public getSSOCallbackUrl(fragmentAfterLogin?: string): URL {
const url = super.getSSOCallbackUrl(fragmentAfterLogin);
url.protocol = "element";
url.searchParams.set(SSO_ID_KEY, this.sessionId);
return url;
}
public startSingleSignOn(
mxClient: MatrixClient,
loginType: "sso" | "cas",
fragmentAfterLogin: string,
idpId?: string,
): void {
// this will get intercepted by electron-main will-navigate
super.startSingleSignOn(mxClient, loginType, fragmentAfterLogin, idpId);
Modal.createDialog(InfoDialog, {
title: _t("auth|sso_complete_in_browser_dialog_title"),
description: <Spinner />,
});
}
public navigateForwardBack(back: boolean): void {
void this.ipc.call(back ? "navigateBack" : "navigateForward");
}
public overrideBrowserShortcuts(): boolean {
return true;
}
public async getPickleKey(userId: string, deviceId: string): Promise<string | null> {
try {
return await this.ipc.call("getPickleKey", userId, deviceId);
} catch {
// if we can't connect to the password storage, assume there's no
// pickle key
return null;
}
}
public async createPickleKey(userId: string, deviceId: string): Promise<string | null> {
try {
return await this.ipc.call("createPickleKey", userId, deviceId);
} catch {
// if we can't connect to the password storage, assume there's no
// pickle key
return null;
}
}
public async destroyPickleKey(userId: string, deviceId: string): Promise<void> {
try {
await this.ipc.call("destroyPickleKey", userId, deviceId);
} catch {}
}
public async clearStorage(): Promise<void> {
try {
await super.clearStorage();
await this.ipc.call("clearStorage");
} catch {}
}
public get baseUrl(): string {
// This configuration is element-desktop specific so the types here do not know about it
return (SdkConfig.get() as unknown as Record<string, string>)["web_base_url"] ?? "https://app.element.io";
}
public get defaultOidcClientUri(): string {
// Default to element.io as our scheme `io.element.desktop` is within its scope on default MAS policies
return "https://element.io";
}
public async getOidcClientMetadata(): Promise<OidcRegistrationClientMetadata> {
const baseMetadata = await super.getOidcClientMetadata();
return {
...baseMetadata,
applicationType: "native",
};
}
public getOidcClientState(): string {
return `:${SSO_ID_KEY}:${this.sessionId}`;
}
/**
* The URL to return to after a successful OIDC authentication
*/
public getOidcCallbackUrl(): URL {
const url = super.getOidcCallbackUrl();
url.protocol = this.protocol;
// Trim the double slash into a single slash to comply with https://datatracker.ietf.org/doc/html/rfc8252#section-7.1
if (url.href.startsWith(`${url.protocol}//`)) {
url.href = url.href.replace("://", ":/");
}
return url;
}
public checkSessionLockFree(): boolean {
return true;
}
public async getSessionLock(_onNewInstance: () => Promise<void>): Promise<boolean> {
return true;
}
}
@@ -0,0 +1,66 @@
/*
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<any> } = {};
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<any> {
// TODO this should be moved into the preload.js file.
const ipcCallId = ++this.nextIpcCallId;
const deferred = Promise.withResolvers<any>();
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);
}
};
}
@@ -0,0 +1,22 @@
/*
Copyright 2020-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 WebPlatform from "./WebPlatform";
export default class PWAPlatform extends WebPlatform {
public setNotificationCount(count: number): void {
if (!navigator.setAppBadge) return super.setNotificationCount(count);
if (this.notificationCount === count) return;
this.notificationCount = count;
navigator.setAppBadge(count).catch((e) => {
logger.error("Failed to update PWA app badge", e);
});
}
}
@@ -0,0 +1,102 @@
/*
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.
*/
// eslint-disable-next-line no-restricted-imports
import {
type IMatrixProfile,
type IEventWithRoomId as IMatrixEvent,
type IResultRoomEvents,
} from "matrix-js-sdk/src/@types/search";
import BaseEventIndexManager, {
type ICrawlerCheckpoint,
type IEventAndProfile,
type IIndexStats,
type ISearchArgs,
type ILoadArgs,
} from "../../indexing/BaseEventIndexManager";
import { IPCManager } from "./IPCManager";
export class SeshatIndexManager extends BaseEventIndexManager {
private readonly ipc = new IPCManager("seshat", "seshatReply");
public async supportsEventIndexing(): Promise<boolean> {
return this.ipc.call("supportsEventIndexing");
}
public async initEventIndex(userId: string, deviceId: string): Promise<void> {
return this.ipc.call("initEventIndex", userId, deviceId);
}
public async addEventToIndex(ev: IMatrixEvent, profile: IMatrixProfile): Promise<void> {
return this.ipc.call("addEventToIndex", ev, profile);
}
public async deleteEvent(eventId: string): Promise<boolean> {
return this.ipc.call("deleteEvent", eventId);
}
public async isEventIndexEmpty(): Promise<boolean> {
return this.ipc.call("isEventIndexEmpty");
}
public async isRoomIndexed(roomId: string): Promise<boolean> {
return this.ipc.call("isRoomIndexed", roomId);
}
public async commitLiveEvents(): Promise<void> {
return this.ipc.call("commitLiveEvents");
}
public async searchEventIndex(searchConfig: ISearchArgs): Promise<IResultRoomEvents> {
return this.ipc.call("searchEventIndex", searchConfig);
}
public async addHistoricEvents(
events: IEventAndProfile[],
checkpoint: ICrawlerCheckpoint | null,
oldCheckpoint: ICrawlerCheckpoint | null,
): Promise<boolean> {
return this.ipc.call("addHistoricEvents", events, checkpoint, oldCheckpoint);
}
public async addCrawlerCheckpoint(checkpoint: ICrawlerCheckpoint): Promise<void> {
return this.ipc.call("addCrawlerCheckpoint", checkpoint);
}
public async removeCrawlerCheckpoint(checkpoint: ICrawlerCheckpoint): Promise<void> {
return this.ipc.call("removeCrawlerCheckpoint", checkpoint);
}
public async loadFileEvents(args: ILoadArgs): Promise<IEventAndProfile[]> {
return this.ipc.call("loadFileEvents", args);
}
public async loadCheckpoints(): Promise<ICrawlerCheckpoint[]> {
return this.ipc.call("loadCheckpoints");
}
public async closeEventIndex(): Promise<void> {
return this.ipc.call("closeEventIndex");
}
public async getStats(): Promise<IIndexStats> {
return this.ipc.call("getStats");
}
public async getUserVersion(): Promise<number> {
return this.ipc.call("getUserVersion");
}
public async setUserVersion(version: number): Promise<void> {
return this.ipc.call("setUserVersion", version);
}
public async deleteEventIndex(): Promise<void> {
return this.ipc.call("deleteEventIndex");
}
}
+280
View File
@@ -0,0 +1,280 @@
/*
Copyright 2017-2024 New Vector Ltd.
Copyright 2016 Aviral Dasgupta
Copyright 2016 OpenMarket 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 UAParser from "ua-parser-js";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import BasePlatform, { UpdateCheckStatus, type UpdateStatus } from "../../BasePlatform";
import dis from "../../dispatcher/dispatcher";
import { hideToast as hideUpdateToast, showToast as showUpdateToast } from "../../toasts/UpdateToast";
import { Action } from "../../dispatcher/actions";
import { type CheckUpdatesPayload } from "../../dispatcher/payloads/CheckUpdatesPayload";
import { parseQs } from "../url_utils";
import { _t } from "../../languageHandler";
import ToastStore from "../../stores/ToastStore.ts";
import GenericToast from "../../components/views/toasts/GenericToast.tsx";
import SdkConfig from "../../SdkConfig.ts";
import type { ActionPayload } from "../../dispatcher/payloads.ts";
import * as SessionLock from "../../utils/SessionLock.ts";
const POKE_RATE_MS = 10 * 60 * 1000; // 10 min
function getNormalizedAppVersion(version: string): string {
// if version looks like semver with leading v, strip it (matches scripts/normalize-version.sh)
const semVerRegex = /^v\d+.\d+.\d+(-.+)?$/;
if (semVerRegex.test(version)) {
return version.substring(1);
}
return version;
}
export default class WebPlatform extends BasePlatform {
private static readonly VERSION = process.env.VERSION!; // baked in by Webpack
private readonly registerServiceWorkerPromise: Promise<void>;
public constructor() {
super();
// Register the service worker in the background
this.registerServiceWorkerPromise = this.registerServiceWorker();
this.registerServiceWorkerPromise.catch((e) => {
console.error("Error registering/updating service worker:", e);
});
}
protected onAction(payload: ActionPayload): void {
super.onAction(payload);
switch (payload.action) {
case Action.ClientStarted:
// Defer drawing the toast until the client is started as the lifecycle methods reset the ToastStore right before
this.registerServiceWorkerPromise.catch(this.handleServiceWorkerRegistrationError);
break;
}
}
private async registerServiceWorker(): Promise<void> {
// sw.js is exported by webpack, sourced from `/src/serviceworker/index.ts`
const registration = await navigator.serviceWorker.register("sw.js");
if (!registration) {
throw new Error("Service worker registration failed");
}
navigator.serviceWorker.addEventListener("message", this.onServiceWorkerPostMessage);
await registration.update();
}
private handleServiceWorkerRegistrationError = (): void => {
const key = "service_worker_error";
const brand = SdkConfig.get().brand;
ToastStore.sharedInstance().addOrReplaceToast({
key,
title: _t("service_worker_error|title"),
props: {
description: _t("service_worker_error|description", { brand }),
primaryLabel: _t("action|ok"),
onPrimaryClick: () => {
ToastStore.sharedInstance().dismissToast(key);
},
},
component: GenericToast,
priority: 95,
});
};
private onServiceWorkerPostMessage = (event: MessageEvent): void => {
try {
if (event.data?.["type"] === "userinfo" && event.data?.["responseKey"]) {
const userId = localStorage.getItem("mx_user_id");
const deviceId = localStorage.getItem("mx_device_id");
const homeserver = MatrixClientPeg.get()?.getHomeserverUrl();
event.source!.postMessage({
responseKey: event.data["responseKey"],
userId,
deviceId,
homeserver,
});
}
} catch (e) {
console.error("Error responding to service worker: ", e);
}
};
public getHumanReadableName(): string {
return "Web Platform"; // no translation required: only used for analytics
}
/**
* Returns true if the platform supports displaying
* notifications, otherwise false.
*/
public supportsNotifications(): boolean {
return Boolean(window.Notification);
}
/**
* Returns true if the application currently has permission
* to display notifications. Otherwise false.
*/
public maySendNotifications(): boolean {
return window.Notification.permission === "granted";
}
/**
* Requests permission to send notifications. Returns
* a promise that is resolved when the user has responded
* to the request. The promise has a single string argument
* that is 'granted' if the user allowed the request or
* 'denied' otherwise.
*/
public requestNotificationPermission(): Promise<string> {
// annoyingly, the latest spec says this returns a
// promise, but this is only supported in Chrome 46
// and Firefox 47, so adapt the callback API.
return new Promise(function (resolve, reject) {
window.Notification.requestPermission((result) => {
resolve(result);
}).catch(reject);
});
}
private async getMostRecentVersion(): Promise<string> {
const res = await fetch("version", {
method: "GET",
cache: "no-cache",
});
if (res.ok) {
const text = await res.text();
return getNormalizedAppVersion(text.trim());
}
return Promise.reject({ status: res.status });
}
public getAppVersion(): Promise<string> {
return Promise.resolve(getNormalizedAppVersion(WebPlatform.VERSION));
}
public startUpdater(): void {
// Poll for an update immediately, and reload the page now if we're out of date
// already as we've just initialised an old version of the app somehow.
//
// Forcibly reloading the page aims to avoid users interacting at all with the old
// and potentially broken version of the app.
//
// Ideally, loading an old copy would be impossible with the
// cache-control: nocache HTTP header set, but Firefox doesn't always obey it :/
console.log("startUpdater, current version is " + getNormalizedAppVersion(WebPlatform.VERSION));
void this.pollForUpdate((version: string, newVersion: string) => {
const query = parseQs(location);
if (query.updated) {
console.log("Update reloaded but still on an old version, stopping");
// We just reloaded already and are still on the old version!
// Show the toast rather than reload in a loop.
showUpdateToast(version, newVersion);
return;
}
// Set updated as a cachebusting query param and reload the page.
const url = new URL(window.location.href);
url.searchParams.set("updated", newVersion);
console.log("Update reloading to " + url.toString());
window.location.href = url.toString();
});
setInterval(() => this.pollForUpdate(showUpdateToast, hideUpdateToast), POKE_RATE_MS);
}
public async canSelfUpdate(): Promise<boolean> {
return true;
}
// Exported for tests
public pollForUpdate = (
showUpdate: (currentVersion: string, mostRecentVersion: string) => void,
showNoUpdate?: () => void,
): Promise<UpdateStatus> => {
return this.getMostRecentVersion().then(
(mostRecentVersion) => {
const currentVersion = getNormalizedAppVersion(WebPlatform.VERSION);
if (currentVersion !== mostRecentVersion) {
if (this.shouldShowUpdate(mostRecentVersion)) {
console.log("Update available to " + mostRecentVersion + ", will notify user");
showUpdate(currentVersion, mostRecentVersion);
} else {
console.log("Update available to " + mostRecentVersion + " but won't be shown");
}
return { status: UpdateCheckStatus.Ready };
} else {
console.log("No update available, already on " + mostRecentVersion);
showNoUpdate?.();
}
return { status: UpdateCheckStatus.NotAvailable };
},
(err) => {
logger.error("Failed to poll for update", err);
return {
status: UpdateCheckStatus.Error,
detail: err.message || (err.status ? err.status.toString() : "Unknown Error"),
};
},
);
};
public startUpdateCheck(): void {
super.startUpdateCheck();
void this.pollForUpdate(showUpdateToast, hideUpdateToast).then((updateState) => {
dis.dispatch<CheckUpdatesPayload>({
action: Action.CheckUpdates,
...updateState,
});
});
}
public installUpdate(): void {
window.location.reload();
}
public getDefaultDeviceDisplayName(): string {
// strip query-string and fragment from uri
const url = new URL(window.location.href);
// `appName` in the format `develop.element.io/abc/xyz`
const appName = [
url.host,
url.pathname.replace(/\/$/, ""), // Remove trailing slash if present
].join("");
const ua = new UAParser();
const browserName = ua.getBrowser().name || "unknown browser";
let osName = ua.getOS().name || "unknown OS";
// Stylise the value from the parser to match Apple's current branding.
if (osName === "Mac OS") osName = "macOS";
return _t("web_default_device_name", {
appName,
browserName,
osName,
});
}
public reload(): void {
window.location.reload();
}
public checkSessionLockFree(): boolean {
return SessionLock.checkSessionLockFree();
}
public async getSessionLock(onNewInstance: () => Promise<void>): Promise<boolean> {
return SessionLock.getSessionLock(onNewInstance);
}
}