Use the code signing Subject Name as basis for Tray GUID on Windows (#32939)
* Use the code signing Subject Name as basis for Tray GUID on Windows Fixes https://github.com/element-hq/element-web/issues/32907 * Delint * Iterate * Add missing imports
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { type Configuration as BaseConfiguration, type Protocol } from "electron-builder";
|
import { type Configuration as BaseConfiguration } from "electron-builder";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This script has different outputs depending on your os platform.
|
* This script has different outputs depending on your os platform.
|
||||||
@@ -38,6 +38,7 @@ interface Metadata {
|
|||||||
interface ExtraMetadata extends Metadata {
|
interface ExtraMetadata extends Metadata {
|
||||||
electron_appId: string;
|
electron_appId: string;
|
||||||
electron_protocol: string;
|
electron_protocol: string;
|
||||||
|
electron_windows_cert_sn?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,6 +209,7 @@ if (variant["linux.deb.name"]) {
|
|||||||
if (process.env.ED_SIGNTOOL_SUBJECT_NAME && process.env.ED_SIGNTOOL_THUMBPRINT) {
|
if (process.env.ED_SIGNTOOL_SUBJECT_NAME && process.env.ED_SIGNTOOL_THUMBPRINT) {
|
||||||
config.win.signtoolOptions!.certificateSubjectName = process.env.ED_SIGNTOOL_SUBJECT_NAME;
|
config.win.signtoolOptions!.certificateSubjectName = process.env.ED_SIGNTOOL_SUBJECT_NAME;
|
||||||
config.win.signtoolOptions!.certificateSha1 = process.env.ED_SIGNTOOL_THUMBPRINT;
|
config.win.signtoolOptions!.certificateSha1 = process.env.ED_SIGNTOOL_THUMBPRINT;
|
||||||
|
config.extraMetadata.electron_windows_cert_sn = config.win.signtoolOptions!.certificateSubjectName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (os.platform() === "linux") {
|
if (os.platform() === "linux") {
|
||||||
|
|||||||
Vendored
-5
@@ -18,10 +18,5 @@ declare global {
|
|||||||
var appQuitting: boolean;
|
var appQuitting: boolean;
|
||||||
var appLocalization: AppLocalization;
|
var appLocalization: AppLocalization;
|
||||||
var vectorConfig: IConfigOptions;
|
var vectorConfig: IConfigOptions;
|
||||||
var trayConfig: {
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
icon_path: string;
|
|
||||||
brand: string;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
/* eslint-enable no-var */
|
/* eslint-enable no-var */
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname } from "node:path";
|
||||||
|
|
||||||
|
import { tryPaths } from "./utils.js";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
let asarPathPromise: Promise<string> | undefined;
|
||||||
|
// Get the webapp resource file path, memoizes result
|
||||||
|
export function getAsarPath(): Promise<string> {
|
||||||
|
if (!asarPathPromise) {
|
||||||
|
asarPathPromise = tryPaths("webapp", __dirname, [
|
||||||
|
// If run from the source checkout, this will be in the directory above
|
||||||
|
"../webapp.asar",
|
||||||
|
// but if run from a packaged application, electron-main.js will be in
|
||||||
|
// a different asar file, so it will be two levels above
|
||||||
|
"../../webapp.asar",
|
||||||
|
// also try without the 'asar' suffix to allow symlinking in a directory
|
||||||
|
"../webapp",
|
||||||
|
// from a packaged application
|
||||||
|
"../../webapp",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return asarPathPromise;
|
||||||
|
}
|
||||||
@@ -12,15 +12,27 @@ import { type JsonObject, loadJsonFile } from "./utils.js";
|
|||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
let buildConfig: BuildConfig;
|
||||||
|
|
||||||
interface BuildConfig {
|
interface BuildConfig {
|
||||||
|
// Application User Model ID
|
||||||
appId: string;
|
appId: string;
|
||||||
|
// Protocol string used for OIDC callbacks
|
||||||
protocol: string;
|
protocol: string;
|
||||||
|
// Subject name of the code signing cert used for Windows packages, if signed
|
||||||
|
// used as a basis for the Tray GUID which must be rolled if the certificate changes.
|
||||||
|
windowsCertSubjectName: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readBuildConfig(): BuildConfig {
|
export function getBuildConfig(): BuildConfig {
|
||||||
const packageJson = loadJsonFile(path.join(__dirname, "..", "package.json")) as JsonObject;
|
if (!buildConfig) {
|
||||||
return {
|
const packageJson = loadJsonFile(path.join(__dirname, "..", "package.json")) as JsonObject;
|
||||||
appId: (packageJson["electron_appId"] as string) || "im.riot.app",
|
buildConfig = {
|
||||||
protocol: (packageJson["electron_protocol"] as string) || "io.element.desktop",
|
appId: (packageJson["electron_appId"] as string) || "im.riot.app",
|
||||||
};
|
protocol: (packageJson["electron_protocol"] as string) || "io.element.desktop",
|
||||||
|
windowsCertSubjectName: packageJson["electron_windows_cert_sn"] as string,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function getBrand(): string {
|
||||||
|
return global.vectorConfig.brand || "Element";
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
import * as Sentry from "@sentry/electron/main";
|
import * as Sentry from "@sentry/electron/main";
|
||||||
import path, { dirname } from "node:path";
|
import path, { dirname } from "node:path";
|
||||||
import windowStateKeeper from "electron-window-state";
|
import windowStateKeeper from "electron-window-state";
|
||||||
import fs, { promises as afs } from "node:fs";
|
import fs from "node:fs";
|
||||||
import { URL, fileURLToPath } from "node:url";
|
import { URL, fileURLToPath } from "node:url";
|
||||||
import minimist from "minimist";
|
import minimist from "minimist";
|
||||||
|
|
||||||
@@ -45,7 +45,9 @@ import { setDisplayMediaCallback } from "./displayMediaCallback.js";
|
|||||||
import { setupMacosTitleBar } from "./macos-titlebar.js";
|
import { setupMacosTitleBar } from "./macos-titlebar.js";
|
||||||
import { type Json, loadJsonFile } from "./utils.js";
|
import { type Json, loadJsonFile } from "./utils.js";
|
||||||
import { setupMediaAuth } from "./media-auth.js";
|
import { setupMediaAuth } from "./media-auth.js";
|
||||||
import { readBuildConfig } from "./build-config.js";
|
import { getBuildConfig } from "./build-config.js";
|
||||||
|
import { getAsarPath } from "./asar.js";
|
||||||
|
import { getIconPath } from "./icon.js";
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -84,7 +86,7 @@ function isRealUserDataDir(d: string): boolean {
|
|||||||
return fs.existsSync(path.join(d, "IndexedDB"));
|
return fs.existsSync(path.join(d, "IndexedDB"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildConfig = readBuildConfig();
|
const buildConfig = getBuildConfig();
|
||||||
const protocolHandler = new ProtocolHandler(buildConfig.protocol);
|
const protocolHandler = new ProtocolHandler(buildConfig.protocol);
|
||||||
|
|
||||||
// check if we are passed a profile in the SSO callback url
|
// check if we are passed a profile in the SSO callback url
|
||||||
@@ -118,45 +120,8 @@ if (userDataPathInProtocol) {
|
|||||||
}
|
}
|
||||||
app.setPath("userData", userDataPath);
|
app.setPath("userData", userDataPath);
|
||||||
|
|
||||||
async function tryPaths(name: string, root: string, rawPaths: string[]): Promise<string> {
|
|
||||||
// Make everything relative to root
|
|
||||||
const paths = rawPaths.map((p) => path.join(root, p));
|
|
||||||
|
|
||||||
for (const p of paths) {
|
|
||||||
try {
|
|
||||||
await afs.stat(p);
|
|
||||||
return p + "/";
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
console.log(`Couldn't find ${name} files in any of: `);
|
|
||||||
for (const p of paths) {
|
|
||||||
console.log("\t" + path.resolve(p));
|
|
||||||
}
|
|
||||||
throw new Error(`Failed to find ${name} files`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const homeserverProps = ["default_is_url", "default_hs_url", "default_server_name", "default_server_config"] as const;
|
const homeserverProps = ["default_is_url", "default_hs_url", "default_server_name", "default_server_config"] as const;
|
||||||
|
|
||||||
let asarPathPromise: Promise<string> | undefined;
|
|
||||||
// Get the webapp resource file path, memoizes result
|
|
||||||
function getAsarPath(): Promise<string> {
|
|
||||||
if (!asarPathPromise) {
|
|
||||||
asarPathPromise = tryPaths("webapp", __dirname, [
|
|
||||||
// If run from the source checkout, this will be in the directory above
|
|
||||||
"../webapp.asar",
|
|
||||||
// but if run from a packaged application, electron-main.js will be in
|
|
||||||
// a different asar file, so it will be two levels above
|
|
||||||
"../../webapp.asar",
|
|
||||||
// also try without the 'asar' suffix to allow symlinking in a directory
|
|
||||||
"../webapp",
|
|
||||||
// from a packaged application
|
|
||||||
"../../webapp",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return asarPathPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadLocalConfigFile(): Json {
|
function loadLocalConfigFile(): Json {
|
||||||
if (LocalConfigLocation) {
|
if (LocalConfigLocation) {
|
||||||
console.log("Loading local config: " + LocalConfigLocation);
|
console.log("Loading local config: " + LocalConfigLocation);
|
||||||
@@ -254,19 +219,6 @@ async function configureSentry(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up globals for Tray
|
|
||||||
async function setupGlobals(): Promise<void> {
|
|
||||||
const asarPath = await getAsarPath();
|
|
||||||
await loadConfig();
|
|
||||||
|
|
||||||
// Figure out the tray icon path & brand name
|
|
||||||
const iconFile = `icon.${process.platform === "win32" ? "ico" : "png"}`;
|
|
||||||
global.trayConfig = {
|
|
||||||
icon_path: path.join(path.dirname(asarPath), "build", iconFile),
|
|
||||||
brand: global.vectorConfig.brand || "Element",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
global.appQuitting = false;
|
global.appQuitting = false;
|
||||||
|
|
||||||
const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [
|
const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [
|
||||||
@@ -347,7 +299,7 @@ app.on("ready", async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
asarPath = await getAsarPath();
|
asarPath = await getAsarPath();
|
||||||
await setupGlobals();
|
await loadConfig();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("App setup failed: exiting", e);
|
console.log("App setup failed: exiting", e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -451,7 +403,7 @@ app.on("ready", async () => {
|
|||||||
titleBarStyle: process.platform === "darwin" ? "hidden" : "default",
|
titleBarStyle: process.platform === "darwin" ? "hidden" : "default",
|
||||||
trafficLightPosition: { x: 9, y: 8 },
|
trafficLightPosition: { x: 9, y: 8 },
|
||||||
|
|
||||||
icon: global.trayConfig.icon_path,
|
icon: await getIconPath(),
|
||||||
show: false,
|
show: false,
|
||||||
autoHideMenuBar: store.get("autoHideMenuBar"),
|
autoHideMenuBar: store.get("autoHideMenuBar"),
|
||||||
|
|
||||||
@@ -489,7 +441,7 @@ app.on("ready", async () => {
|
|||||||
global.mainWindow.webContents.session.setSpellCheckerEnabled(store.get("spellCheckerEnabled", true));
|
global.mainWindow.webContents.session.setSpellCheckerEnabled(store.get("spellCheckerEnabled", true));
|
||||||
|
|
||||||
// Create trayIcon icon
|
// Create trayIcon icon
|
||||||
if (store.get("minimizeToTray")) tray.create(global.trayConfig);
|
if (store.get("minimizeToTray")) await tray.create();
|
||||||
|
|
||||||
global.mainWindow.once("ready-to-show", () => {
|
global.mainWindow.once("ready-to-show", () => {
|
||||||
if (!global.mainWindow) return;
|
if (!global.mainWindow) return;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { getAsarPath } from "./asar.js";
|
||||||
|
|
||||||
|
export async function getIconPath(): Promise<string> {
|
||||||
|
const asarPath = await getAsarPath();
|
||||||
|
|
||||||
|
const iconFile = `icon.${process.platform === "win32" ? "ico" : "png"}`;
|
||||||
|
return path.join(path.dirname(asarPath), "build", iconFile);
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ const Settings: Record<string, Setting> = {
|
|||||||
async write(value: any): Promise<void> {
|
async write(value: any): Promise<void> {
|
||||||
if (value) {
|
if (value) {
|
||||||
// Create trayIcon icon
|
// Create trayIcon icon
|
||||||
tray.create(global.trayConfig);
|
await tray.create();
|
||||||
} else {
|
} else {
|
||||||
tray.destroy();
|
tray.destroy();
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-18
@@ -14,6 +14,12 @@ import pngToIco from "png-to-ico";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { _t } from "./language-helper.js";
|
import { _t } from "./language-helper.js";
|
||||||
|
import { getBuildConfig } from "./build-config.js";
|
||||||
|
import { getBrand } from "./config.js";
|
||||||
|
import { getIconPath } from "./icon.js";
|
||||||
|
|
||||||
|
// This hardcoded uuid is an arbitrary v4 uuid generated on https://www.uuidgenerator.net/version4
|
||||||
|
const UUID_NAMESPACE = "9fc9c6a0-9ffe-45c9-9cd7-5639ae38b232";
|
||||||
|
|
||||||
let trayIcon: Tray | null = null;
|
let trayIcon: Tray | null = null;
|
||||||
|
|
||||||
@@ -38,31 +44,25 @@ function toggleWin(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUuid(): string {
|
export async function create(): Promise<void> {
|
||||||
// The uuid field is optional and only needed on unsigned Windows packages where the executable path changes
|
|
||||||
// The hardcoded uuid is an arbitrary v4 uuid generated on https://www.uuidgenerator.net/version4
|
|
||||||
return global.vectorConfig["uuid"] || "eba84003-e499-4563-8e9d-166e34b5cc25";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function create(config: (typeof global)["trayConfig"]): void {
|
|
||||||
// no trays on darwin
|
// no trays on darwin
|
||||||
if (process.platform === "darwin" || trayIcon) return;
|
if (process.platform === "darwin" || trayIcon) return;
|
||||||
const defaultIcon = nativeImage.createFromPath(config.icon_path);
|
const iconPath = await getIconPath();
|
||||||
|
const defaultIcon = nativeImage.createFromPath(iconPath);
|
||||||
|
|
||||||
let guid: string | undefined;
|
const buildConfig = getBuildConfig();
|
||||||
if (process.platform === "win32" && app.isPackaged) {
|
if (process.platform === "win32" && app.isPackaged && buildConfig.windowsCertSubjectName) {
|
||||||
// Providing a GUID lets Windows be smarter about maintaining user's tray preferences
|
// Providing a GUID lets Windows be smarter about maintaining user's tray preferences
|
||||||
// https://github.com/electron/electron/pull/21891
|
// https://github.com/electron/electron/pull/21891
|
||||||
// Ideally we would only specify it for signed packages but determining whether the app is signed sufficiently
|
// We generate the GUID in a custom arbitrary namespace and use the subject name & userData path
|
||||||
// is non-trivial. So instead we have an escape hatch that unsigned packages can iterate the `uuid` in
|
// to differentiate different app builds on the same system.
|
||||||
// config.json to prevent Windows refusing GUID-reuse if their executable path changes.
|
const guid = uuidv5(`${buildConfig.windowsCertSubjectName}:${app.getPath("userData")}`, UUID_NAMESPACE);
|
||||||
guid = uuidv5(`${app.getName()}-${app.getPath("userData")}`, getUuid());
|
trayIcon = new Tray(defaultIcon, guid);
|
||||||
|
} else {
|
||||||
|
trayIcon = new Tray(defaultIcon);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passing guid=undefined on Windows will cause it to throw `Error: Invalid GUID format`
|
trayIcon.setToolTip(getBrand());
|
||||||
// The type here is wrong, the param must be omitted, never undefined.
|
|
||||||
trayIcon = guid ? new Tray(defaultIcon, guid) : new Tray(defaultIcon);
|
|
||||||
trayIcon.setToolTip(config.brand);
|
|
||||||
initApplicationMenu();
|
initApplicationMenu();
|
||||||
trayIcon.on("click", toggleWin);
|
trayIcon.on("click", toggleWin);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import os from "node:os";
|
|||||||
import { getSquirrelExecutable } from "./squirrelhooks.js";
|
import { getSquirrelExecutable } from "./squirrelhooks.js";
|
||||||
import { _t } from "./language-helper.js";
|
import { _t } from "./language-helper.js";
|
||||||
import { initialisePromise } from "./ipc.js";
|
import { initialisePromise } from "./ipc.js";
|
||||||
|
import { getBrand } from "./config.js";
|
||||||
|
|
||||||
const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000;
|
const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
const INITIAL_UPDATE_DELAY_MS = 30 * 1000;
|
const INITIAL_UPDATE_DELAY_MS = 30 * 1000;
|
||||||
@@ -149,7 +150,7 @@ async function available(): Promise<boolean> {
|
|||||||
initialisePromise.then(() => {
|
initialisePromise.then(() => {
|
||||||
ipcMain.emit("showToast", {
|
ipcMain.emit("showToast", {
|
||||||
title: _t("eol|title"),
|
title: _t("eol|title"),
|
||||||
description: _t("eol|no_more_updates", { brand: global.trayConfig.brand }),
|
description: _t("eol|no_more_updates", { brand: getBrand() }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
console.warn("Auto update not supported, macOS version too old");
|
console.warn("Auto update not supported, macOS version too old");
|
||||||
@@ -160,7 +161,7 @@ async function available(): Promise<boolean> {
|
|||||||
initialisePromise.then(() => {
|
initialisePromise.then(() => {
|
||||||
ipcMain.emit("showToast", {
|
ipcMain.emit("showToast", {
|
||||||
title: _t("eol|title"),
|
title: _t("eol|title"),
|
||||||
description: _t("eol|warning", { brand: global.trayConfig.brand }),
|
description: _t("eol|warning", { brand: getBrand() }),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details.
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import afs from "node:fs/promises";
|
||||||
|
|
||||||
export async function randomArray(size: number): Promise<string> {
|
export async function randomArray(size: number): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -44,3 +45,26 @@ export function loadJsonFile<T extends Json>(...paths: string[]): T {
|
|||||||
const file = fs.readFileSync(joinedPaths, { encoding: "utf-8" });
|
const file = fs.readFileSync(joinedPaths, { encoding: "utf-8" });
|
||||||
return JSON.parse(file);
|
return JSON.parse(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks for a given path relative to root
|
||||||
|
* @param name - dir name to use in logging
|
||||||
|
* @param root - the root to search from
|
||||||
|
* @param rawPaths - the paths to search, in order
|
||||||
|
*/
|
||||||
|
export async function tryPaths(name: string, root: string, rawPaths: string[]): Promise<string> {
|
||||||
|
// Make everything relative to root
|
||||||
|
const paths = rawPaths.map((p) => path.join(root, p));
|
||||||
|
|
||||||
|
for (const p of paths) {
|
||||||
|
try {
|
||||||
|
await afs.stat(p);
|
||||||
|
return p + "/";
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
console.log(`Couldn't find ${name} files in any of: `);
|
||||||
|
for (const p of paths) {
|
||||||
|
console.log("\t" + path.resolve(p));
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to find ${name} files`);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user