diff --git a/scripts/copy-res.ts b/scripts/copy-res.ts index fd5aa60f7c..3f09da5ad4 100755 --- a/scripts/copy-res.ts +++ b/scripts/copy-res.ts @@ -79,3 +79,5 @@ INCLUDE_LANGS.forEach((file): void => { if (watch) { INCLUDE_LANGS.forEach((file) => watchLanguage(I18N_BASE_PATH + file, I18N_DEST)); } + +// IPC resources diff --git a/src/electron-main.ts b/src/electron-main.ts index 9b4b9f9089..34b19ab416 100644 --- a/src/electron-main.ts +++ b/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)); @@ -53,6 +54,17 @@ const argv = minimist(process.argv, { alias: { help: "h" }, }); +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}`); + } + } +}); + if (argv["help"]) { console.log("Options:"); console.log(" --profile-dir {path}: Path to where to store the profile."); @@ -170,7 +182,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 +191,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 +209,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>, ); } - 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 +238,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 +254,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 +273,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", @@ -342,6 +354,7 @@ if (store.get("disableHardwareAcceleration")) { app.on("ready", async () => { console.debug("Reached Electron ready state"); + await initProxy(); let asarPath: string; @@ -424,14 +437,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 +457,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 +481,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 +530,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 +549,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 +574,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 +601,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("openDesktopCapturerSourcePicker"); } setDisplayMediaCallback(callback); }, { useSystemPicker: true }, ); // Use Mac OS 15+ native picker - setupMediaAuth(global.mainWindow); + setupMediaAuth(globalThis.mainWindow); }); app.on("window-all-closed", () => { @@ -603,12 +616,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 +632,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/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 88c584a00c..09ad526029 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -63,6 +63,23 @@ "save_image_as_error_description": "The image failed to save", "save_image_as_error_title": "Failed to save image" }, + "proxy": { + "bypass": "No proxy for (comma separated)", + "cancel": "Cancel", + "close": "Close", + "host": "Proxy Host", + "mode_custom": "Manual configuration", + "mode_direct": "No proxy (direct connection)", + "mode_system": "Use system proxy settings", + "password": "Password", + "password_help": "Configuration is encrypted using the system's secure storage.", + "port": "Port", + "protocol": "Protocol", + "save": "Save", + "title": "Network Proxy", + "updates_warning": "Note: These settings may not apply to application updates.", + "username": "Username" + }, "store": { "error": { "backend_changed": "Clear data and reload?", diff --git a/src/proxy-window.ts b/src/proxy-window.ts new file mode 100644 index 0000000000..bd296ea787 --- /dev/null +++ b/src/proxy-window.ts @@ -0,0 +1,118 @@ +/* +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. +*/ + +import { BrowserWindow, ipcMain, app } from "electron"; +import path from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import fs from "node:fs"; + +import { _t } from "./language-helper.js"; + +let proxyWindow: BrowserWindow | null = null; + +const __dirnameResolved = path.dirname(fileURLToPath(import.meta.url)); + +function pickExisting(paths: string[]): string | null { + for (const p of paths) { + if (fs.existsSync(p)) return p; + } + return null; +} + +/** + * Opens the native proxy settings window. + * + * If the window is already open, it will be brought to focus. + * Attempts to locate the necessary preload script and HTML asset from + * both source-relative paths (dev) and bundled paths (packaged app). + */ +export function createProxyWindow(): void { + if (proxyWindow && !proxyWindow.isDestroyed()) { + proxyWindow.focus(); + return; + } + + // Prefer a CommonJS preload if present + const preloadPath = + pickExisting([ + path.join(__dirnameResolved, "proxy-preload.cjs"), + path.join(app.getAppPath(), "lib", "proxy-preload.cjs"), + ]) ?? path.join(__dirnameResolved, "proxy-preload.cjs"); + + const htmlPath = + pickExisting([ + path.join(__dirnameResolved, "proxy-window.html"), + path.join(app.getAppPath(), "lib", "proxy-window.html"), + ]) ?? path.join(__dirnameResolved, "proxy-window.html"); + + proxyWindow = new BrowserWindow({ + width: 540, + height: 560, + title: "Network Proxy", + resizable: false, + minimizable: false, + maximizable: false, + autoHideMenuBar: true, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + preload: preloadPath, + }, + }); + + proxyWindow.on("closed", () => { + proxyWindow = null; + }); + + proxyWindow.loadURL(pathToFileURL(htmlPath).toString()).catch((e: unknown) => { + console.error("Failed to load proxy window:", e); + }); +} + +if (!ipcMain.listenerCount("proxyWindowClose")) { + ipcMain.on("proxyWindowClose", () => { + if (proxyWindow && !proxyWindow.isDestroyed()) { + proxyWindow.close(); + } + }); +} + +// Handler for fetching localized strings +ipcMain.handle("getProxyStrings", () => { + return { + title: _t("proxy|title"), + mode_system: _t("proxy|mode_system"), + mode_direct: _t("proxy|mode_direct"), + mode_custom: _t("proxy|mode_custom"), + protocol: _t("proxy|protocol"), + host: _t("proxy|host"), + port: _t("proxy|port"), + bypass: _t("proxy|bypass"), + username: _t("proxy|username"), + password: _t("proxy|password"), + password_help: _t("proxy|password_help"), + updates_warning: _t("proxy|updates_warning"), + save: _t("proxy|save"), + cancel: _t("proxy|cancel"), + close: _t("proxy|close"), + }; +}); + +if (!ipcMain.listenerCount("proxyWindowResize")) { + ipcMain.on("proxyWindowResize", (_event: unknown, width: number, height: number) => { + if (proxyWindow && !proxyWindow.isDestroyed()) { + try { + const w = Math.max(width, 400); + const h = Math.max(height, 200); + proxyWindow.setContentSize(w, h); + } catch (e) { + console.error("Failed to resize proxy window", e); + } + } + }); +} diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000000..29d781abfd --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,239 @@ +/* +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. + * + * This module is written defensively so that importing it (or calling applyProxyConfig) + * in a pure Node.js context (e.g. build / maintenance scripts run with ts-node/tsx) + * will silently no-op instead of throwing Electron lifecycle errors. + */ + +/** + * Describes the proxy configuration for the Electron session. + */ +export interface DesktopProxyConfig { + /** The rough mode of operation: system default, direct/none, or custom settings. */ + mode: "system" | "direct" | "custom"; + /** The protocol scheme for the proxy server (e.g. socks5, https). */ + scheme?: "socks5" | "socks5h" | "http" | "https"; + /** The hostname or IP of the proxy server. */ + host?: string; + /** The port number of the proxy server. */ + port?: number; + /** Optional username for authentication. */ + username?: string; + /** Optional password for authentication. */ + password?: string; + /** Comma or semicolon separated list of hosts to bypass proxy for. */ + bypass?: string; // comma or semicolon separated list +} + +type ElectronFixedConfig = { + mode: "system" | "direct" | "fixed_servers"; + proxyRules?: string; + proxyBypassRules?: string; +}; + +let lastApplied: DesktopProxyConfig | undefined; + +/** + * Retrieve the currently active proxy configuration. + */ +export function getLastAppliedConfig(): DesktopProxyConfig | undefined { + return lastApplied; +} + +/** + * Apply the given proxy configuration. + * - If not running under Electron (process.versions.electron undefined) => no-op. + * - If Electron app not ready yet => waits for app.whenReady(). + * - Errors are caught & logged; they do not throw. + */ +export async function applyProxyConfig(config?: Partial): Promise { + try { + // Not an Electron runtime (e.g. node/tsx script) -> ignore silently. + if (!process.versions.electron) { + return; + } + + // Dynamically import only after confirming Electron environment. + const { app, session } = await import("electron"); + + if (!app.isReady()) { + // Wait until ready; this covers early invocations from main process bootstrap. + await app.whenReady(); + } + + 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: import("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=scheme://authhost:port + // Electron accepts a single URL or comma-separated protocol=... pairs. + const scheme = cfg.scheme ?? "http"; + parts.push(`${scheme}=${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/src/settings.ts b/src/settings.ts index 84f671f669..404ef2906c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. import { ipcMain } from "electron"; +import { applyProxyConfig, type DesktopProxyConfig } from "./proxy.js"; import * as tray from "./tray.js"; import Store from "./store.js"; import { AutoLaunch, type AutoLaunchState } from "./auto-launch.js"; @@ -88,6 +89,57 @@ const Settings: Record = { Store.instance?.set("enableContentProtection", value); }, }, + "desktopProxyConfig": { + async read(): Promise { + const config = (Store.instance?.get("desktopProxyConfig") || { mode: "system" }) as DesktopProxyConfig; + if (config.mode === "custom") { + try { + const password = await Store.instance?.getSecret("proxy_password"); + if (password) { + config.password = password; + } + } catch (e) { + console.error("Failed to read proxy password from secure storage:", e); + } + } + return config; + }, + async write(value: any): Promise { + if (!value || typeof value !== "object") value = { mode: "system" }; + if (!value.mode) value.mode = "system"; + + if (!["system", "direct", "custom"].includes(value.mode)) { + console.warn(`Invalid proxy mode ${value.mode}, falling back to system`); + value.mode = "system"; + } + + const configToSave = { ...value }; + + const password = configToSave.password; + + delete configToSave.password; + + Store.instance?.set("desktopProxyConfig", configToSave); + + if (value.mode === "custom") { + try { + if (password) { + await Store.instance?.setSecret("proxy_password", password); + value.password = password; + } else { + await Store.instance?.deleteSecret("proxy_password"); + } + } catch (e) { + console.error("Failed to write proxy password to secure storage:", e); + } + } + + await applyProxyConfig(value as DesktopProxyConfig); + }, + supported(): boolean { + return true; + }, + }, }; ipcMain.handle("getSupportedSettings", async () => { @@ -102,7 +154,6 @@ ipcMain.handle("setSettingValue", async (_ev, settingName: string, value: any) = if (!setting) { throw new Error(`Unknown setting: ${settingName}`); } - console.debug(`Writing setting value for: ${settingName} = ${value}`); await setting.write(value); }); ipcMain.handle("getSettingValue", async (_ev, settingName: string) => { @@ -111,6 +162,17 @@ ipcMain.handle("getSettingValue", async (_ev, settingName: string) => { throw new Error(`Unknown setting: ${settingName}`); } const value = await setting.read(); - console.debug(`Reading setting value for: ${settingName} = ${value}`); return value; }); + +export async function initProxy(): Promise { + if (!process.versions.electron) return; + console.log("[proxy] Initializing proxy from settings..."); + const { app } = await import("electron"); + await app.whenReady(); + const storeAny = Store.instance as any; + if (storeAny.readyPromise) await storeAny.readyPromise; + const stored = await Settings["desktopProxyConfig"].read(); + console.log("[proxy] Stored proxy config read:", JSON.stringify(stored)); + await applyProxyConfig(stored); +} diff --git a/src/vectormenu.ts b/src/vectormenu.ts index d5bab05ed5..6cfb569077 100644 --- a/src/vectormenu.ts +++ b/src/vectormenu.ts @@ -89,14 +89,21 @@ export function buildMenuTemplate(): Menu { { type: "separator" }, // in macOS the Preferences menu item goes in the first menu ...(!isMac - ? [ + ? ([ { label: _t("common|preferences"), click(): void { - global.mainWindow?.webContents.send("preferences"); + globalThis.mainWindow?.webContents.send("preferences"); }, }, - ] + { type: "separator" as const }, + { + label: _t("proxy|title") + "…", + click(): void { + globalThis.mainWindow?.webContents.send("open_proxy_settings"); + }, + }, + ] as MenuItemConstructorOptions[]) : []), { role: "togglefullscreen", @@ -130,9 +137,9 @@ export function buildMenuTemplate(): Menu { submenu: [ { // XXX: vectorConfig won't have defaults applied to it so we need to duplicate them here - label: _t("common|brand_help", { brand: global.vectorConfig?.brand || "Element" }), + label: _t("common|brand_help", { brand: globalThis.vectorConfig?.brand || "Element" }), click(): void { - void shell.openExternal(global.vectorConfig?.help_url || "https://element.io/help"); + shell.openExternal(globalThis.vectorConfig?.help_url || "https://element.io/help"); }, }, ], @@ -151,14 +158,23 @@ export function buildMenuTemplate(): Menu { label: _t("common|about") + " " + app.name, }, { type: "separator" }, + { type: "separator" }, { label: _t("common|preferences") + "…", - accelerator: "Command+,", // Mac-only accelerator + accelerator: "Command+,", click(): void { - global.mainWindow?.webContents.send("preferences"); + globalThis.mainWindow?.webContents.send("preferences"); }, }, { type: "separator" }, + { + label: _t("proxy|title") + "…", + click(): void { + globalThis.mainWindow?.webContents.send("open_proxy_settings"); + }, + }, + { type: "separator" }, + { type: "separator" }, { role: "services", label: _t("menu|services"),