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:
@@ -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 &
|
||||
// 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, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user