diff --git a/apps/desktop/src/electron-main.ts b/apps/desktop/src/electron-main.ts index bd1832ecc82..05d14af6731 100644 --- a/apps/desktop/src/electron-main.ts +++ b/apps/desktop/src/electron-main.ts @@ -32,7 +32,6 @@ import minimist from "minimist"; import "./ipc.js"; import "./seshat.js"; -import "./settings.js"; import "./badge.js"; import * as tray from "./tray.js"; import Store from "./store.js"; @@ -46,6 +45,8 @@ import { setupMacosTitleBar } from "./macos-titlebar.js"; import { type Json, loadJsonFile } from "./utils.js"; import { setupMediaAuth } from "./media-auth.js"; import { readBuildConfig } from "./build-config.js"; +import { getLastAppliedConfig } from "./proxy.js"; +import { initProxy } from "./settings.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -170,7 +171,7 @@ function loadLocalConfigFile(): Json { let loadConfigPromise: Promise | undefined; // Loads the config from asar, and applies a config.json from userData atop if one exists -// Writes config to `global.vectorConfig`. Idempotent, returns the same promise on subsequent calls. +// Writes config to `globalThis.vectorConfig`. Idempotent, returns the same promise on subsequent calls. function loadConfig(): Promise { if (loadConfigPromise) return loadConfigPromise; @@ -179,13 +180,13 @@ function loadConfig(): Promise { try { console.log(`Loading app config: ${path.join(asarPath, LocalConfigFilename)}`); - global.vectorConfig = loadJsonFile(asarPath, LocalConfigFilename); + globalThis.vectorConfig = loadJsonFile(asarPath, LocalConfigFilename); } catch { // it would be nice to check the error code here and bail if the config // is unparsable, but we get MODULE_NOT_FOUND in the case of a missing // file or invalid json, so node is just very unhelpful. // Continue with the defaults (ie. an empty config) - global.vectorConfig = {}; + globalThis.vectorConfig = {}; } try { @@ -197,27 +198,27 @@ function loadConfig(): Promise { // defined, and panics as a result. if (Object.keys(localConfig).find((k) => homeserverProps.includes(k))) { // Rip out all the homeserver options from the vector config - global.vectorConfig = Object.keys(global.vectorConfig) + globalThis.vectorConfig = Object.keys(globalThis.vectorConfig) .filter((k) => !homeserverProps.includes(k)) .reduce( (obj, key) => { - obj[key] = global.vectorConfig[key]; + obj[key] = globalThis.vectorConfig[key]; return obj; }, - {} as Omit, keyof typeof homeserverProps>, + {} as Omit, keyof typeof homeserverProps>, ); } - global.vectorConfig = Object.assign(global.vectorConfig, localConfig); + globalThis.vectorConfig = Object.assign(globalThis.vectorConfig, localConfig); } catch (e) { if (e instanceof SyntaxError) { await app.whenReady(); void dialog.showMessageBox({ type: "error", - title: `Your ${global.vectorConfig.brand || "Element"} is misconfigured`, + title: `Your ${globalThis.vectorConfig.brand || "Element"} is misconfigured`, message: - `Your custom ${global.vectorConfig.brand || "Element"} configuration contains invalid JSON. ` + - `Please correct the problem and reopen ${global.vectorConfig.brand || "Element"}.`, + `Your custom ${globalThis.vectorConfig.brand || "Element"} configuration contains invalid JSON. ` + + `Please correct the problem and reopen ${globalThis.vectorConfig.brand || "Element"}.`, detail: e.message || "", }); } @@ -226,8 +227,8 @@ function loadConfig(): Promise { } // Tweak modules paths as they assume the root is at the same level as webapp, but for `vector://vector/webapp` it is not. - if (Array.isArray(global.vectorConfig.modules)) { - global.vectorConfig.modules = global.vectorConfig.modules.map((m) => { + if (Array.isArray(globalThis.vectorConfig.modules)) { + globalThis.vectorConfig.modules = globalThis.vectorConfig.modules.map((m) => { if (m.startsWith("/")) { return "/webapp" + m; } @@ -242,7 +243,7 @@ function loadConfig(): Promise { // Configure Electron Sentry and crashReporter using sentry.dsn in config.json if one is present. async function configureSentry(): Promise { await loadConfig(); - const { dsn, environment } = global.vectorConfig.sentry || {}; + const { dsn, environment } = globalThis.vectorConfig.sentry || {}; if (dsn) { console.log(`Enabling Sentry with dsn=${dsn} environment=${environment}`); Sentry.init({ @@ -261,13 +262,13 @@ async function setupGlobals(): Promise { // Figure out the tray icon path & brand name const iconFile = `icon.${process.platform === "win32" ? "ico" : "png"}`; - global.trayConfig = { + globalThis.trayConfig = { icon_path: path.join(path.dirname(asarPath), "build", iconFile), - brand: global.vectorConfig.brand || "Element", + brand: globalThis.vectorConfig.brand || "Element", }; } -global.appQuitting = false; +globalThis.appQuitting = false; const exitShortcuts: Array<(input: Input, platform: string) => boolean> = [ (input, platform): boolean => platform !== "darwin" && input.alt && input.key.toUpperCase() === "F4", @@ -334,6 +335,19 @@ app.commandLine.appendSwitch("disable-features", "HardwareMediaKeyHandling,Media const store = Store.initialize(argv["storage-mode"]); // must be called before any async actions +app.on("login", (event, _webContents, _request, authInfo, callback) => { + if (authInfo.isProxy) { + const proxyConfig = getLastAppliedConfig(); + if (proxyConfig?.mode === "custom" && proxyConfig.username && proxyConfig.password) { + event.preventDefault(); + callback(proxyConfig.username, proxyConfig.password); + console.log(`[proxy] Authenticating to ${authInfo.host}:${authInfo.port}`); + } + } +}); + +void initProxy(); + // Disable hardware acceleration if the setting has been set. if (store.get("disableHardwareAcceleration")) { console.log("Disabling hardware acceleration."); @@ -424,14 +438,14 @@ app.on("ready", async () => { // Minimist parses `--no-`-prefixed arguments as booleans with value `false` rather than verbatim. if (argv["update"] === false) { console.log("Auto update disabled via command line flag"); - } else if (global.vectorConfig["update_base_url"]) { - void updater.start(global.vectorConfig["update_base_url"]); + } else if (globalThis.vectorConfig["update_base_url"]) { + void updater.start(globalThis.vectorConfig["update_base_url"]); } else { console.log("No update_base_url is defined: auto update is disabled"); } // Set up i18n before loading storage as we need translations for dialogs - global.appLocalization = new AppLocalization({ + globalThis.appLocalization = new AppLocalization({ components: [(): void => tray.initApplicationMenu(), (): void => Menu.setApplicationMenu(buildMenuTemplate())], store, }); @@ -444,14 +458,14 @@ app.on("ready", async () => { console.debug("Opening main window"); const preloadScript = path.normalize(`${__dirname}/preload.cjs`); - global.mainWindow = new BrowserWindow({ + globalThis.mainWindow = new BrowserWindow({ // https://www.electronjs.org/docs/faq#the-font-looks-blurry-what-is-this-and-what-can-i-do backgroundColor: "#fff", titleBarStyle: process.platform === "darwin" ? "hidden" : "default", trafficLightPosition: { x: 9, y: 8 }, - icon: global.trayConfig.icon_path, + icon: globalThis.trayConfig.icon_path, show: false, autoHideMenuBar: store.get("autoHideMenuBar"), @@ -468,47 +482,47 @@ app.on("ready", async () => { }, }); - global.mainWindow.setContentProtection(store.get("enableContentProtection")); + globalThis.mainWindow.setContentProtection(store.get("enableContentProtection")); try { console.debug("Ensuring storage is ready"); - if (!(await store.prepareSafeStorage(global.mainWindow.webContents.session))) return; + if (!(await store.prepareSafeStorage(globalThis.mainWindow.webContents.session))) return; } catch (e) { console.error(e); app.exit(1); } - void global.mainWindow.loadURL("vector://vector/webapp/"); + globalThis.mainWindow.loadURL("vector://vector/webapp/"); if (process.platform === "darwin") { - setupMacosTitleBar(global.mainWindow); + setupMacosTitleBar(globalThis.mainWindow); } // Handle spellchecker // For some reason spellCheckerEnabled isn't persisted, so we have to use the store here - global.mainWindow.webContents.session.setSpellCheckerEnabled(store.get("spellCheckerEnabled", true)); + globalThis.mainWindow.webContents.session.setSpellCheckerEnabled(store.get("spellCheckerEnabled", true)); // Create trayIcon icon - if (store.get("minimizeToTray")) tray.create(global.trayConfig); + if (store.get("minimizeToTray")) tray.create(globalThis.trayConfig); - global.mainWindow.once("ready-to-show", () => { - if (!global.mainWindow) return; - mainWindowState.manage(global.mainWindow); + globalThis.mainWindow.once("ready-to-show", () => { + if (!globalThis.mainWindow) return; + mainWindowState.manage(globalThis.mainWindow); if (!argv["hidden"]) { - global.mainWindow.show(); + globalThis.mainWindow.show(); } else { // hide here explicitly because window manage above sometimes shows it - global.mainWindow.hide(); + globalThis.mainWindow.hide(); } }); - global.mainWindow.webContents.on("before-input-event", (event: Event, input: Input): void => { + globalThis.mainWindow.webContents.on("before-input-event", (event: Event, input: Input): void => { const exitShortcutPressed = input.type === "keyDown" && exitShortcuts.some((shortcutFn) => shortcutFn(input, process.platform)); // We only care about the exit shortcuts here - if (!exitShortcutPressed || !global.mainWindow) return; + if (!exitShortcutPressed || !globalThis.mainWindow) return; // Prevent the default behaviour event.preventDefault(); @@ -517,12 +531,12 @@ app.on("ready", async () => { const shouldWarnBeforeExit = store.get("warnBeforeExit", true); if (shouldWarnBeforeExit) { const shouldCancelCloseRequest = - dialog.showMessageBoxSync(global.mainWindow, { + dialog.showMessageBoxSync(globalThis.mainWindow, { type: "question", buttons: [ _t("action|cancel"), _t("action|close_brand", { - brand: global.vectorConfig.brand || "Element", + brand: globalThis.vectorConfig.brand || "Element", }), ], message: _t("confirm_quit"), @@ -536,23 +550,23 @@ app.on("ready", async () => { app.exit(); }); - global.mainWindow.on("closed", () => { - global.mainWindow = null; + globalThis.mainWindow.on("closed", () => { + globalThis.mainWindow = null; }); - global.mainWindow.on("close", async (e) => { + globalThis.mainWindow.on("close", async (e) => { // If we are not quitting and have a tray icon then minimize to tray - if (!global.appQuitting && (tray.hasTray() || process.platform === "darwin")) { + if (!globalThis.appQuitting && (tray.hasTray() || process.platform === "darwin")) { // On Mac, closing the window just hides it // (this is generally how single-window Mac apps // behave, eg. Mail.app) e.preventDefault(); - if (global.mainWindow?.isFullScreen()) { - global.mainWindow.once("leave-full-screen", () => global.mainWindow?.hide()); + if (globalThis.mainWindow?.isFullScreen()) { + globalThis.mainWindow.once("leave-full-screen", () => globalThis.mainWindow?.hide()); - global.mainWindow.setFullScreen(false); + globalThis.mainWindow.setFullScreen(false); } else { - global.mainWindow?.hide(); + globalThis.mainWindow?.hide(); } return false; @@ -561,16 +575,16 @@ app.on("ready", async () => { if (process.platform === "win32") { // Handle forward/backward mouse buttons in Windows - global.mainWindow.on("app-command", (e, cmd) => { - if (cmd === "browser-backward" && global.mainWindow?.webContents.canGoBack()) { - global.mainWindow.webContents.goBack(); - } else if (cmd === "browser-forward" && global.mainWindow?.webContents.canGoForward()) { - global.mainWindow.webContents.goForward(); + globalThis.mainWindow.on("app-command", (e, cmd) => { + if (cmd === "browser-backward" && globalThis.mainWindow?.webContents.canGoBack()) { + globalThis.mainWindow.webContents.goBack(); + } else if (cmd === "browser-forward" && globalThis.mainWindow?.webContents.canGoForward()) { + globalThis.mainWindow.webContents.goForward(); } }); } - webContentsHandler(global.mainWindow.webContents); + webContentsHandler(globalThis.mainWindow.webContents); session.defaultSession.setDisplayMediaRequestHandler( (_, callback) => { @@ -588,14 +602,14 @@ app.on("ready", async () => { callback({ video: { id: "", name: "" } }); // The promise does not return if no dummy is passed here as source }); } else { - global.mainWindow?.webContents.send("openDesktopCapturerSourcePicker"); + globalThis.mainWindow?.webContents.send("openProxySettings"); } setDisplayMediaCallback(callback); }, { useSystemPicker: true }, ); // Use Mac OS 15+ native picker - setupMediaAuth(global.mainWindow); + setupMediaAuth(globalThis.mainWindow); }); app.on("window-all-closed", () => { @@ -603,12 +617,12 @@ app.on("window-all-closed", () => { }); app.on("activate", () => { - global.mainWindow?.show(); + globalThis.mainWindow?.show(); }); function beforeQuit(): void { - global.appQuitting = true; - global.mainWindow?.webContents.send("before-quit"); + globalThis.appQuitting = true; + globalThis.mainWindow?.webContents.send("before-quit"); } app.on("before-quit", beforeQuit); @@ -619,10 +633,10 @@ app.on("second-instance", (ev, commandLine, workingDirectory) => { if (commandLine.includes("--hidden")) return; // Someone tried to run a second instance, we should focus our window. - if (global.mainWindow) { - if (!global.mainWindow.isVisible()) global.mainWindow.show(); - if (global.mainWindow.isMinimized()) global.mainWindow.restore(); - global.mainWindow.focus(); + if (globalThis.mainWindow) { + if (!globalThis.mainWindow.isVisible()) globalThis.mainWindow.show(); + if (globalThis.mainWindow.isMinimized()) globalThis.mainWindow.restore(); + globalThis.mainWindow.focus(); } }); diff --git a/apps/desktop/src/proxy.ts b/apps/desktop/src/proxy.ts new file mode 100644 index 00000000000..4383bfa35e0 --- /dev/null +++ b/apps/desktop/src/proxy.ts @@ -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): Promise { + 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 { + // 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 { + 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; +} diff --git a/apps/desktop/src/settings.ts b/apps/desktop/src/settings.ts index 84f671f6694..19de98f7291 100644 --- a/apps/desktop/src/settings.ts +++ b/apps/desktop/src/settings.ts @@ -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; @@ -88,8 +89,45 @@ const Settings: Record = { Store.instance?.set("enableContentProtection", value); }, }, + "desktopProxyConfig": { + async read(): Promise { + 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 { + 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 { + 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 = {}; for (const [key, setting] of Object.entries(Settings)) { diff --git a/apps/desktop/src/store.ts b/apps/desktop/src/store.ts index dee629be92e..89757911295 100644 --- a/apps/desktop/src/store.ts +++ b/apps/desktop/src/store.ts @@ -56,7 +56,19 @@ function relaunchApp(): void { * Clear all data and relaunch the app. */ export async function clearDataAndRelaunch(electronSession: Session): Promise { + 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(); diff --git a/apps/desktop/src/vectormenu.ts b/apps/desktop/src/vectormenu.ts index d5bab05ed50..7cc9fc7f75c 100644 --- a/apps/desktop/src/vectormenu.ts +++ b/apps/desktop/src/vectormenu.ts @@ -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"); + }, + }, ] : []), { @@ -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", diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 9435e53b6f8..67c826d2333 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -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"; diff --git a/apps/web/res/css/views/settings/_NetworkProxyModal.pcss b/apps/web/res/css/views/settings/_NetworkProxyModal.pcss new file mode 100644 index 00000000000..e8f315f4037 --- /dev/null +++ b/apps/web/res/css/views/settings/_NetworkProxyModal.pcss @@ -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; +} diff --git a/apps/web/src/@types/global.d.ts b/apps/web/src/@types/global.d.ts index f6b9ff81f9a..cdf24065485 100644 --- a/apps/web/src/@types/global.d.ts +++ b/apps/web/src/@types/global.d.ts @@ -65,6 +65,7 @@ type ElectronChannel = | "userDownloadCompleted" | "userDownloadAction" | "openDesktopCapturerSourcePicker" + | "openProxySettings" | "userAccessToken" | "homeserverUrl" | "serverSupportedVersions" diff --git a/apps/web/src/BasePlatform.ts b/apps/web/src/BasePlatform.ts index 8f2ee414446..2f600d5c6b9 100644 --- a/apps/web/src/BasePlatform.ts +++ b/apps/web/src/BasePlatform.ts @@ -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 | null { diff --git a/apps/web/src/components/views/auth/AuthFooter.tsx b/apps/web/src/components/views/auth/AuthFooter.tsx index 1942bf04318..fc00e987284 100644 --- a/apps/web/src/components/views/auth/AuthFooter.tsx +++ b/apps/web/src/components/views/auth/AuthFooter.tsx @@ -11,6 +11,9 @@ import React, { type JSX, type ReactElement } from "react"; import SdkConfig from "../../../SdkConfig"; import { _t } from "../../../languageHandler"; +import PlatformPeg from "../../../PlatformPeg"; +import Modal from "../../../Modal"; +import { NetworkProxyModal } from "../settings/NetworkProxyModal"; const AuthFooter = (): ReactElement => { const brandingConfig = SdkConfig.getObject("branding"); @@ -29,6 +32,21 @@ const AuthFooter = (): ReactElement => { ); } + if (PlatformPeg.get().supportsProxyConfiguration()) { + authFooterLinks.push( + { + e.preventDefault(); + Modal.createDialog(NetworkProxyModal, {}); + }} + > + {_t("settings|network_proxy|title")} + , + ); + } + return (