Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 74 additions & 60 deletions apps/desktop/src/electron-main.ts

Large diffs are not rendered by default.

213 changes: 213 additions & 0 deletions apps/desktop/src/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/*
Copyright 2026 tim2zg

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.
*/

/**
* Proxy configuration utilities for Element Desktop.
*/

import { session } from "electron";

export interface DesktopProxyConfig {
mode: "system" | "direct" | "custom";
scheme?: "http" | "https" | "socks5" | "socks5h";
host?: string;
port?: number;
username?: string;
password?: string;
bypass?: string;
}

interface ElectronFixedConfig {
mode: "system" | "direct" | "fixed_servers" | "auto_detect";
proxyRules?: string;
proxyBypassRules?: string;
}

let lastApplied: DesktopProxyConfig | null = null;

/**
* Gets the last successfully applied proxy configuration.
*/
export function getLastAppliedConfig(): DesktopProxyConfig | null {
return lastApplied;
}

/**
* Apply the given proxy configuration.
* - If Electron app not ready yet => errors might be caught.
* - Errors are caught & logged; they do not throw.
*/
export async function applyProxyConfig(config?: Partial<DesktopProxyConfig>): Promise<void> {
try {
const normalized = normalizeConfig(config ?? { mode: "system" });
let electronCfg = toElectronProxyConfig(normalized);

// For system mode, we perform a manual resolution to avoid issues with Electron's default 'system' mode
// which sometimes bypasses HTTP traffic incorrectly.
if (normalized.mode === "system") {
electronCfg = await resolveSystemProxy(session.defaultSession);
}

// Avoid re-applying identical config (cheap equality check).
if (lastApplied && shallowEqual(normalized, lastApplied)) {
console.log("[proxy] Config unchanged, skipping re-apply:", normalized);
return;
}

console.log("[proxy] Applying new proxy config to session:", JSON.stringify(electronCfg));
await session.defaultSession.setProxy(electronCfg);
lastApplied = normalized;
console.log("[proxy] Successfully applied config.");

// Verification check for different protocols
const [resHttps, resHttp, resMatrix] = await Promise.all([
session.defaultSession.resolveProxy("https://google.com"),
session.defaultSession.resolveProxy("http://example.com"),
session.defaultSession.resolveProxy("https://matrix.org"),
]);
console.log("[proxy] Verification Google (HTTPS):", resHttps);
console.log("[proxy] Verification Example (HTTP):", resHttp);
console.log("[proxy] Verification Matrix (HTTPS):", resMatrix);

// Log certificate errors which often happen with intercepting proxies like ZAP
if (!session.defaultSession.listenerCount("certificate-error")) {
(session.defaultSession as any).on("certificate-error", (event: any, webContents: any, url: any, error: any, certificate: any, callback: any) => {
console.warn(`[proxy] Certificate error for ${url}: ${error} (Issuer: ${certificate.issuerName})`);
// We keep security strict by default, but this log confirms why traffic is failing.
});
}
} catch (err) {
console.error("Failed to apply proxy config:", err);
}
}

/**
* Resolves the system proxy settings by performing a manual resolution.
* This is used to work around Electron's built-in system mode limitations.
*/
async function resolveSystemProxy(sess: Electron.Session): Promise<ElectronFixedConfig> {
// We must set it to 'system' first, otherwise resolveProxy might just return 'DIRECT'
// because it's using the previous session state.
await sess.setProxy({ mode: "system" });

const [resHttp, resHttps] = await Promise.all([
sess.resolveProxy("http://example.com"),
sess.resolveProxy("https://google.com"),
]);

console.log("[proxy] System resolution results - HTTP:", resHttp, "HTTPS:", resHttps);

const httpProxy = parseProxyResult(resHttp);
const httpsProxy = parseProxyResult(resHttps);

if (httpProxy || httpsProxy) {
const rules: string[] = [];
// Chromium proxy rules can be: "http=proxy1:8080;https=proxy2:8080"
// or just "proxy1:8080" for all protocols.
if (httpProxy) rules.push(`http=${httpProxy}`);
if (httpsProxy) rules.push(`https=${httpsProxy}`);

return {
mode: "fixed_servers",
proxyRules: rules.join(";"),
};
}

return { mode: "direct" };
}

function normalizeConfig(cfg: Partial<DesktopProxyConfig>): DesktopProxyConfig {
if (cfg.mode === "custom") {
return {
mode: "custom",
scheme: cfg.scheme ?? "http",
host: cfg.host ?? "",
port: cfg.port,
username: cfg.username,
password: cfg.password,
bypass: cfg.bypass,
};
}
if (cfg.mode === "direct") {
return { mode: "direct" };
}
return { mode: "system" };
}

function toElectronProxyConfig(cfg: DesktopProxyConfig): ElectronFixedConfig {
if (cfg.mode === "system") {
return { mode: "system" };
}
if (cfg.mode === "direct") {
return { mode: "direct" };
}
// custom
const parts: string[] = [];
if (cfg.host && cfg.port) {
let auth = "";
if (cfg.username) {
auth = encodeURIComponent(cfg.username);
if (cfg.password) {
auth += ":" + encodeURIComponent(cfg.password);
}
auth += "@";
}
// Build rule like: scheme://authhost:port
// If we don't prefix with "scheme=", Chromium applies it to all protocols.
const scheme = cfg.scheme ?? "http";
parts.push(`${scheme}://${auth}${cfg.host}:${cfg.port}`);
}

const proxyRules = parts.join(";");
const proxyBypassRules = (cfg.bypass ?? "")
.split(/[,;]/)
.map((s) => s.trim())
.filter(Boolean)
.join(",");

return {
mode: "fixed_servers",
proxyRules: proxyRules || undefined,
proxyBypassRules: proxyBypassRules || undefined,
};
}

/**
* Parses a proxy string from Electron's resolveProxy.
* E.g. "PROXY 127.0.0.1:8081; DIRECT" -> "http://127.0.0.1:8081"
*/
function parseProxyResult(res: string): string | undefined {
const parts = res.split(";");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("PROXY ")) {
const addr = trimmed.substring(6);
return `http://${addr}`;
}
if (trimmed.startsWith("SOCKS ")) {
const addr = trimmed.substring(6);
return `socks4://${addr}`;
}
if (trimmed.startsWith("SOCKS5 ")) {
const addr = trimmed.substring(7);
return `socks5://${addr}`;
}
if (trimmed.startsWith("HTTPS ")) {
const addr = trimmed.substring(6);
return `https://${addr}`;
}
}
return undefined;
}

function shallowEqual(a: DesktopProxyConfig, b: DesktopProxyConfig): boolean {
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const k of keys) {
if ((a as any)[k] !== (b as any)[k]) return false;
}
return true;
}
38 changes: 38 additions & 0 deletions apps/desktop/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ipcMain } from "electron";
import * as tray from "./tray.js";
import Store from "./store.js";
import { AutoLaunch, type AutoLaunchState } from "./auto-launch.js";
import { type DesktopProxyConfig, applyProxyConfig } from "./proxy.js";

interface Setting {
read(): Promise<any>;
Expand Down Expand Up @@ -88,8 +89,45 @@ const Settings: Record<string, Setting> = {
Store.instance?.set("enableContentProtection", value);
},
},
"desktopProxyConfig": {
async read(): Promise<DesktopProxyConfig> {
const config = (Store.instance?.get("desktopProxyConfig") as DesktopProxyConfig) || { mode: "system" };
if (config.mode === "custom") {
const password = await Store.instance?.getSecret("proxy_password");
if (password) {
config.password = password;
}
}
return config;
},
async write(value: any): Promise<void> {
if (!value || typeof value !== "object") value = { mode: "system" };
if (!value.mode) value.mode = "system";

const config = value as DesktopProxyConfig;
if (config.mode === "custom" && config.password) {
await Store.instance?.setSecret("proxy_password", config.password);
delete config.password;
} else {
await Store.instance?.deleteSecret("proxy_password");
}

Store.instance?.set("desktopProxyConfig", config);
await applyProxyConfig(config);
},
},
};

/**
* Initializes the proxy from settings.
*/
export async function initProxy(): Promise<void> {
console.log("[proxy] Initializing proxy from settings...");
const stored = await Settings["desktopProxyConfig"].read();
console.log("[proxy] Stored proxy config read:", JSON.stringify(stored));
await applyProxyConfig(stored);
}

ipcMain.handle("getSupportedSettings", async () => {
const supportedSettings: Record<string, boolean> = {};
for (const [key, setting] of Object.entries(Settings)) {
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,19 @@ function relaunchApp(): void {
* Clear all data and relaunch the app.
*/
export async function clearDataAndRelaunch(electronSession: Session): Promise<void> {
const proxyConfig = Store.instance?.get("desktopProxyConfig");
const proxyPassword = await Store.instance?.getSecret("proxy_password");

Store.instance?.clear();

// Restore proxy settings after clear so they survive logout
if (proxyConfig) {
Store.instance?.set("desktopProxyConfig", proxyConfig);
}
if (proxyPassword) {
await Store.instance?.setSecret("proxy_password", proxyPassword);
}

electronSession.flushStorageData();
await electronSession.clearStorageData();
relaunchApp();
Expand Down
12 changes: 12 additions & 0 deletions apps/desktop/src/vectormenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export function buildMenuTemplate(): Menu {
global.mainWindow?.webContents.send("preferences");
},
},
{
label: _t("settings|network_proxy|title") + "…",
click(): void {
global.mainWindow?.webContents.send("openProxySettings");
},
},
]
: []),
{
Expand Down Expand Up @@ -158,6 +164,12 @@ export function buildMenuTemplate(): Menu {
global.mainWindow?.webContents.send("preferences");
},
},
{
label: _t("settings|network_proxy|title") + "…",
click(): void {
global.mainWindow?.webContents.send("openProxySettings");
},
},
{ type: "separator" },
{
role: "services",
Expand Down
1 change: 1 addition & 0 deletions apps/web/res/css/_components.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@
@import "./views/settings/_JoinRuleSettings.pcss";
@import "./views/settings/_KeyboardShortcut.pcss";
@import "./views/settings/_LayoutSwitcher.pcss";
@import "./views/settings/_NetworkProxyModal.pcss";
@import "./views/settings/_NotificationPusherSettings.pcss";
@import "./views/settings/_NotificationSettings2.pcss";
@import "./views/settings/_Notifications.pcss";
Expand Down
38 changes: 38 additions & 0 deletions apps/web/res/css/views/settings/_NetworkProxyModal.pcss
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
Copyright 2026 tim2zg

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/

.mx_NetworkProxyModal {
/* Override fixed dimensions from BaseDialog if needed, but usually we want it flexible */
min-width: 400px;
max-width: 600px;
padding: var(--cpd-space-2x);
}

/*
* Force standard inputs to show focus rings.
* Target any Compound container that has focus within it and apply the outline to its visual UI element.
* We target the container directly for focus-within and apply to its internal UI part.
*/
.mx_NetworkProxyModal div[class*="_container_"]:focus-within,
.mx_NetworkProxyModal div[class*="_container_"]:focus-within div[class*="_ui_"],
.mx_NetworkProxyModal div[class*="_container_"]:focus-within input {
outline: 2px solid var(--cpd-color-border-focused) !important;
outline-offset: 1px !important;
}

/* Specific fix for PasswordInput where the container itself often needs the outline */
.mx_NetworkProxyModal .mx_NetworkProxyModal_passwordInput:focus-within {
outline: 2px solid var(--cpd-color-border-focused) !important;
outline-offset: 1px !important;
border-radius: 8px !important;
}

.mx_NetworkProxyModal select:focus {
outline: 2px solid var(--cpd-color-border-focused) !important;
outline-offset: 2px !important;
border-radius: 4px !important;
}
1 change: 1 addition & 0 deletions apps/web/src/@types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ type ElectronChannel =
| "userDownloadCompleted"
| "userDownloadAction"
| "openDesktopCapturerSourcePicker"
| "openProxySettings"
| "userAccessToken"
| "homeserverUrl"
| "serverSupportedVersions"
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/BasePlatform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,14 @@ export default abstract class BasePlatform {
return false;
}

/**
* Returns true if the platform supports network proxy configuration.
* @returns {boolean} whether the platform supports proxy configuration
*/
public supportsProxyConfiguration(): boolean {
return false;
}

public navigateForwardBack(back: boolean): void {}

public getAvailableSpellCheckLanguages(): Promise<string[]> | null {
Expand Down
Loading
Loading