diff --git a/README.md b/README.md index 947281619..a22ee3671 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,32 @@ When you launch ClawX for the first time, the **Setup Wizard** will guide you th 3. **Skill Bundles** – Select pre-configured skills for common use cases 4. **Verification** – Test your configuration before entering the main interface +### Proxy Settings + +ClawX includes built-in proxy settings for environments where Electron, the OpenClaw Gateway, or channels such as Telegram need to reach the internet through a local proxy client. + +Open **Settings → Gateway → Proxy** and configure: + +- **Proxy Server**: the default proxy for all requests +- **Bypass Rules**: hosts that should connect directly, separated by semicolons, commas, or new lines +- In **Developer Mode**, you can optionally override: + - **HTTP Proxy** + - **HTTPS Proxy** + - **ALL_PROXY / SOCKS** + +Recommended local examples: + +```text +Proxy Server: http://127.0.0.1:7890 +``` + +Notes: + +- A bare `host:port` value is treated as HTTP. +- If advanced proxy fields are left empty, ClawX falls back to `Proxy Server`. +- Saving proxy settings reapplies Electron networking immediately and restarts the Gateway automatically. +- ClawX also syncs the proxy to OpenClaw's Telegram channel config when Telegram is enabled. + --- ## Architecture diff --git a/README.zh-CN.md b/README.zh-CN.md index fba14bd8e..ccfc56b8f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -155,6 +155,32 @@ pnpm dev 3. **技能包** – 选择适用于常见场景的预配置技能 4. **验证** – 在进入主界面前测试你的配置 +### 代理设置 + +ClawX 内置了代理设置,适用于需要通过本地代理客户端访问外网的场景,包括 Electron 本身、OpenClaw Gateway,以及 Telegram 这类频道的联网请求。 + +打开 **设置 → 网关 → 代理**,配置以下内容: + +- **代理服务器**:所有请求默认使用的代理 +- **绕过规则**:需要直连的主机,使用分号、逗号或换行分隔 +- 在 **开发者模式** 下,还可以单独覆盖: + - **HTTP 代理** + - **HTTPS 代理** + - **ALL_PROXY / SOCKS** + +本地代理的常见填写示例: + +```text +代理服务器: http://127.0.0.1:7890 +``` + +说明: + +- 只填写 `host:port` 时,会按 HTTP 代理处理。 +- 高级代理项留空时,会自动回退到“代理服务器”。 +- 保存代理设置后,Electron 网络层会立即重新应用代理,并自动重启 Gateway。 +- 如果启用了 Telegram,ClawX 还会把代理同步到 OpenClaw 的 Telegram 频道配置中。 + --- ## 系统架构 diff --git a/electron/gateway/clawhub.ts b/electron/gateway/clawhub.ts index 044d1a2c6..150455761 100644 --- a/electron/gateway/clawhub.ts +++ b/electron/gateway/clawhub.ts @@ -88,8 +88,9 @@ export class ClawHubService { const isWin = process.platform === 'win32'; const useShell = isWin && !this.useNodeRunner; + const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env; const env = { - ...process.env, + ...baseEnv, CI: 'true', FORCE_COLOR: '0', }; diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 044da7840..c1a902815 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -17,7 +17,7 @@ import { appendNodeRequireToNodeOptions, quoteForCmd, } from '../utils/paths'; -import { getSetting } from '../utils/store'; +import { getAllSettings, getSetting } from '../utils/store'; import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage'; import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry'; import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol'; @@ -32,6 +32,8 @@ import { type DeviceIdentity, } from '../utils/device-identity'; import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth'; +import { buildProxyEnv, resolveProxySettings } from '../utils/proxy'; +import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy'; import { shouldAttemptConfigAutoRepair } from './startup-recovery'; /** @@ -871,7 +873,9 @@ export class GatewayManager extends EventEmitter { } // Get or generate gateway token - const gatewayToken = await getSetting('gatewayToken'); + const appSettings = await getAllSettings(); + const gatewayToken = appSettings.gatewayToken; + await syncProxyConfigToOpenClaw(appSettings); // Strip stale/invalid keys from openclaw.json that would cause the // Gateway's strict config validation to reject the file on startup @@ -989,17 +993,21 @@ export class GatewayManager extends EventEmitter { } const uvEnv = await getUvMirrorEnv(); + const proxyEnv = buildProxyEnv(appSettings); + const resolvedProxy = resolveProxySettings(appSettings); logger.info( - `Starting Gateway process (mode=${mode}, port=${this.status.port}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount})` + `Starting Gateway process (mode=${mode}, port=${this.status.port}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, proxy=${appSettings.proxyEnabled ? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}` : 'disabled'})` ); this.lastSpawnSummary = `mode=${mode}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}"`; return new Promise((resolve, reject) => { + const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env; const spawnEnv: Record = { - ...process.env, + ...baseEnv, PATH: finalPath, ...providerEnv, ...uvEnv, + ...proxyEnv, OPENCLAW_GATEWAY_TOKEN: gatewayToken, OPENCLAW_SKIP_CHANNELS: '', CLAWDBOT_SKIP_CHANNELS: '', diff --git a/electron/main/index.ts b/electron/main/index.ts index 83da79b4b..34a55dfa0 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -17,6 +17,8 @@ import { ClawHubService } from '../gateway/clawhub'; import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace'; import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli'; import { isQuitting, setQuitting } from './app-state'; +import { applyProxySettings } from './proxy'; +import { getSetting } from '../utils/store'; import { ensureBuiltinSkillsInstalled } from '../utils/skill-config'; // Disable GPU hardware acceleration globally for maximum stability across @@ -128,6 +130,9 @@ async function initialize(): Promise { // Warm up network optimization (non-blocking) void warmupNetworkOptimization(); + // Apply persisted proxy settings before creating windows or network requests. + await applyProxySettings(); + // Set application menu createMenu(); @@ -195,13 +200,18 @@ async function initialize(): Promise { }); // Start Gateway automatically (this seeds missing bootstrap files with full templates) - try { - logger.debug('Auto-starting Gateway...'); - await gatewayManager.start(); - logger.info('Gateway auto-start succeeded'); - } catch (error) { - logger.error('Gateway auto-start failed:', error); - mainWindow?.webContents.send('gateway:error', String(error)); + const gatewayAutoStart = await getSetting('gatewayAutoStart'); + if (gatewayAutoStart) { + try { + logger.debug('Auto-starting Gateway...'); + await gatewayManager.start(); + logger.info('Gateway auto-start succeeded'); + } catch (error) { + logger.error('Gateway auto-start failed:', error); + mainWindow?.webContents.send('gateway:error', String(error)); + } + } else { + logger.info('Gateway auto-start disabled in settings'); } // Merge ClawX context snippets into the workspace bootstrap files. diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 268bea77b..891c127e0 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -24,7 +24,7 @@ import { } from '../utils/secure-storage'; import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir, ensureDir } from '../utils/paths'; import { getOpenClawCliCommand } from '../utils/openclaw-cli'; -import { getSetting } from '../utils/store'; +import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../utils/store'; import { saveProviderKeyToOpenClaw, removeProviderFromOpenClaw, @@ -49,6 +49,8 @@ import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/ import { whatsAppLoginManager } from '../utils/whatsapp-login'; import { getProviderConfig } from '../utils/provider-registry'; import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth'; +import { applyProxySettings } from './proxy'; +import { proxyAwareFetch } from '../utils/proxy-fetch'; import { getRecentTokenUsageHistory } from '../utils/token-usage'; /** @@ -100,6 +102,9 @@ export function registerIpcHandlers( // App handlers registerAppHandlers(); + // Settings handlers + registerSettingsHandlers(gatewayManager); + // UV handlers registerUvHandlers(); @@ -1478,7 +1483,7 @@ async function performProviderValidationRequest( ): Promise<{ valid: boolean; error?: string }> { try { logValidationRequest(providerLabel, 'GET', url, headers); - const response = await fetch(url, { headers }); + const response = await proxyAwareFetch(url, { headers }); logValidationStatus(providerLabel, response.status); const data = await response.json().catch(() => ({})); return classifyAuthResponse(response.status, data); @@ -1553,7 +1558,7 @@ async function performChatCompletionsProbe( ): Promise<{ valid: boolean; error?: string }> { try { logValidationRequest(providerLabel, 'POST', url, headers); - const response = await fetch(url, { + const response = await proxyAwareFetch(url, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -1755,6 +1760,67 @@ function registerAppHandlers(): void { }); } +function registerSettingsHandlers(gatewayManager: GatewayManager): void { + const handleProxySettingsChange = async () => { + const settings = await getAllSettings(); + await applyProxySettings(settings); + if (gatewayManager.getStatus().state === 'running') { + await gatewayManager.restart(); + } + }; + + ipcMain.handle('settings:get', async (_, key: keyof AppSettings) => { + return await getSetting(key); + }); + + ipcMain.handle('settings:getAll', async () => { + return await getAllSettings(); + }); + + ipcMain.handle('settings:set', async (_, key: keyof AppSettings, value: AppSettings[keyof AppSettings]) => { + await setSetting(key, value as never); + + if ( + key === 'proxyEnabled' || + key === 'proxyServer' || + key === 'proxyHttpServer' || + key === 'proxyHttpsServer' || + key === 'proxyAllServer' || + key === 'proxyBypassRules' + ) { + await handleProxySettingsChange(); + } + + return { success: true }; + }); + + ipcMain.handle('settings:setMany', async (_, patch: Partial) => { + const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>; + for (const [key, value] of entries) { + await setSetting(key, value as never); + } + + if (entries.some(([key]) => + key === 'proxyEnabled' || + key === 'proxyServer' || + key === 'proxyHttpServer' || + key === 'proxyHttpsServer' || + key === 'proxyAllServer' || + key === 'proxyBypassRules' + )) { + await handleProxySettingsChange(); + } + + return { success: true }; + }); + + ipcMain.handle('settings:reset', async () => { + await resetSettings(); + const settings = await getAllSettings(); + await handleProxySettingsChange(); + return { success: true, settings }; + }); +} function registerUsageHandlers(): void { ipcMain.handle('usage:recentTokenHistory', async (_, limit?: number) => { const safeLimit = typeof limit === 'number' && Number.isFinite(limit) diff --git a/electron/main/proxy.ts b/electron/main/proxy.ts new file mode 100644 index 000000000..f8982b13d --- /dev/null +++ b/electron/main/proxy.ts @@ -0,0 +1,22 @@ +import { session } from 'electron'; +import { getAllSettings, type AppSettings } from '../utils/store'; +import { buildElectronProxyConfig } from '../utils/proxy'; +import { logger } from '../utils/logger'; + +export async function applyProxySettings( + partialSettings?: Pick +): Promise { + const settings = partialSettings ?? await getAllSettings(); + const config = buildElectronProxyConfig(settings); + + await session.defaultSession.setProxy(config); + try { + await session.defaultSession.closeAllConnections(); + } catch (error) { + logger.debug('Failed to close existing connections after proxy update:', error); + } + + logger.info( + `Applied Electron proxy (${config.mode}${config.proxyRules ? `, server=${config.proxyRules}` : ''}${config.proxyBypassRules ? `, bypass=${config.proxyBypassRules}` : ''})` + ); +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 7f9b7caa9..f445014c2 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -49,6 +49,7 @@ const electronAPI = { // Settings 'settings:get', 'settings:set', + 'settings:setMany', 'settings:getAll', 'settings:reset', 'usage:recentTokenHistory', diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 102d6aa63..eba0897fa 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -10,6 +10,7 @@ import { join } from 'path'; import { homedir } from 'os'; import { getOpenClawResolvedDir } from './paths'; import * as logger from './logger'; +import { proxyAwareFetch } from './proxy-fetch'; const OPENCLAW_DIR = join(homedir(), '.openclaw'); const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json'); @@ -497,7 +498,7 @@ async function validateTelegramCredentials( if (!allowedUsers) return { valid: false, errors: ['At least one allowed user ID is required'], warnings: [] }; try { - const response = await fetch(`https://api.telegram.org/bot${botToken}/getMe`); + const response = await proxyAwareFetch(`https://api.telegram.org/bot${botToken}/getMe`); const data = (await response.json()) as { ok?: boolean; description?: string; result?: { username?: string } }; if (data.ok) { return { valid: true, errors: [], warnings: [], details: { botUsername: data.result?.username || 'Unknown' } }; diff --git a/electron/utils/openclaw-proxy.ts b/electron/utils/openclaw-proxy.ts new file mode 100644 index 000000000..1ae6f9b92 --- /dev/null +++ b/electron/utils/openclaw-proxy.ts @@ -0,0 +1,43 @@ +import { readOpenClawConfig, writeOpenClawConfig } from './channel-config'; +import { resolveProxySettings, type ProxySettings } from './proxy'; +import { logger } from './logger'; + +/** + * Sync ClawX global proxy settings into OpenClaw channel config where the + * upstream runtime expects an explicit per-channel proxy knob. + */ +export async function syncProxyConfigToOpenClaw(settings: ProxySettings): Promise { + const config = await readOpenClawConfig(); + const telegramConfig = config.channels?.telegram; + + if (!telegramConfig) { + return; + } + + const resolved = resolveProxySettings(settings); + const nextProxy = settings.proxyEnabled + ? (resolved.allProxy || resolved.httpsProxy || resolved.httpProxy) + : ''; + const currentProxy = typeof telegramConfig.proxy === 'string' ? telegramConfig.proxy : ''; + + if (!nextProxy && !currentProxy) { + return; + } + + if (!config.channels) { + config.channels = {}; + } + + config.channels.telegram = { + ...telegramConfig, + }; + + if (nextProxy) { + config.channels.telegram.proxy = nextProxy; + } else { + delete config.channels.telegram.proxy; + } + + await writeOpenClawConfig(config); + logger.info(`Synced Telegram proxy to OpenClaw config (${nextProxy || 'disabled'})`); +} diff --git a/electron/utils/proxy-fetch.ts b/electron/utils/proxy-fetch.ts new file mode 100644 index 000000000..cd9c63e46 --- /dev/null +++ b/electron/utils/proxy-fetch.ts @@ -0,0 +1,21 @@ +/** + * Use Electron's network stack when available so requests honor + * session.defaultSession.setProxy(...). Fall back to the Node global fetch + * for non-Electron test environments. + */ + +export async function proxyAwareFetch( + input: string | URL, + init?: RequestInit +): Promise { + if (process.versions.electron) { + try { + const { net } = await import('electron'); + return await net.fetch(input, init); + } catch { + // Fall through to the global fetch. + } + } + + return await fetch(input, init); +} diff --git a/electron/utils/proxy.ts b/electron/utils/proxy.ts new file mode 100644 index 000000000..08ce76169 --- /dev/null +++ b/electron/utils/proxy.ts @@ -0,0 +1,122 @@ +/** + * Proxy helpers shared by the Electron main process and Gateway launcher. + */ + +export interface ProxySettings { + proxyEnabled: boolean; + proxyServer: string; + proxyHttpServer: string; + proxyHttpsServer: string; + proxyAllServer: string; + proxyBypassRules: string; +} + +export interface ResolvedProxySettings { + httpProxy: string; + httpsProxy: string; + allProxy: string; + bypassRules: string; +} + +export interface ElectronProxyConfig { + mode: 'direct' | 'fixed_servers'; + proxyRules?: string; + proxyBypassRules?: string; +} + +function trimValue(value: string | undefined | null): string { + return typeof value === 'string' ? value.trim() : ''; +} + +/** + * Accept bare host:port values from users and normalize them to a valid URL. + * Electron accepts scheme-less proxy rules in some cases, but child-process + * env vars are more reliable when they are full URLs. + */ +export function normalizeProxyServer(proxyServer: string): string { + const value = trimValue(proxyServer); + if (!value) return ''; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return value; + return `http://${value}`; +} + +export function resolveProxySettings(settings: ProxySettings): ResolvedProxySettings { + const legacyProxy = normalizeProxyServer(settings.proxyServer); + const allProxy = normalizeProxyServer(settings.proxyAllServer); + const httpProxy = normalizeProxyServer(settings.proxyHttpServer) || legacyProxy || allProxy; + const httpsProxy = normalizeProxyServer(settings.proxyHttpsServer) || legacyProxy || allProxy; + + return { + httpProxy, + httpsProxy, + allProxy: allProxy || legacyProxy, + bypassRules: trimValue(settings.proxyBypassRules), + }; +} + +export function buildElectronProxyConfig(settings: ProxySettings): ElectronProxyConfig { + if (!settings.proxyEnabled) { + return { mode: 'direct' }; + } + + const resolved = resolveProxySettings(settings); + const rules: string[] = []; + + if (resolved.httpProxy) { + rules.push(`http=${resolved.httpProxy}`); + } + if (resolved.httpsProxy) { + rules.push(`https=${resolved.httpsProxy}`); + } + + // Fallback rule for protocols like ws/wss or when users only configured ALL_PROXY. + const fallbackProxy = resolved.allProxy || resolved.httpsProxy || resolved.httpProxy; + if (fallbackProxy) { + rules.push(fallbackProxy); + } + + if (rules.length === 0) { + return { mode: 'direct' }; + } + + return { + mode: 'fixed_servers', + proxyRules: rules.join(';'), + ...(resolved.bypassRules ? { proxyBypassRules: resolved.bypassRules } : {}), + }; +} + +export function buildProxyEnv(settings: ProxySettings): Record { + const blank = { + HTTP_PROXY: '', + HTTPS_PROXY: '', + ALL_PROXY: '', + http_proxy: '', + https_proxy: '', + all_proxy: '', + NO_PROXY: '', + no_proxy: '', + }; + + if (!settings.proxyEnabled) { + return blank; + } + + const resolved = resolveProxySettings(settings); + const noProxy = resolved.bypassRules + .split(/[,\n;]/) + .map((rule) => rule.trim()) + .filter(Boolean) + .join(','); + + return { + HTTP_PROXY: resolved.httpProxy, + HTTPS_PROXY: resolved.httpsProxy, + ALL_PROXY: resolved.allProxy, + http_proxy: resolved.httpProxy, + https_proxy: resolved.httpsProxy, + all_proxy: resolved.allProxy, + NO_PROXY: noProxy, + no_proxy: noProxy, + }; +} diff --git a/electron/utils/store.ts b/electron/utils/store.ts index 1b86c9293..a28fb9af5 100644 --- a/electron/utils/store.ts +++ b/electron/utils/store.ts @@ -30,6 +30,12 @@ export interface AppSettings { gatewayAutoStart: boolean; gatewayPort: number; gatewayToken: string; + proxyEnabled: boolean; + proxyServer: string; + proxyHttpServer: string; + proxyHttpsServer: string; + proxyAllServer: string; + proxyBypassRules: string; // Update updateChannel: 'stable' | 'beta' | 'dev'; @@ -61,6 +67,12 @@ const defaults: AppSettings = { gatewayAutoStart: true, gatewayPort: 18789, gatewayToken: generateToken(), + proxyEnabled: false, + proxyServer: '', + proxyHttpServer: '', + proxyHttpsServer: '', + proxyAllServer: '', + proxyBypassRules: ';localhost;127.0.0.1;::1', // Update updateChannel: 'stable', diff --git a/src/App.tsx b/src/App.tsx index 825587b14..95a787bb9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -87,11 +87,16 @@ class ErrorBoundary extends Component< function App() { const navigate = useNavigate(); const location = useLocation(); + const initSettings = useSettingsStore((state) => state.init); const theme = useSettingsStore((state) => state.theme); const language = useSettingsStore((state) => state.language); const setupComplete = useSettingsStore((state) => state.setupComplete); const initGateway = useGatewayStore((state) => state.init); + useEffect(() => { + initSettings(); + }, [initSettings]); + // Sync i18n language with persisted settings on mount useEffect(() => { if (language && language !== i18n.language) { diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 50fc00b64..e9d5c7451 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -85,7 +85,22 @@ "appLogs": "Application Logs", "openFolder": "Open Folder", "autoStart": "Auto-start Gateway", - "autoStartDesc": "Start Gateway when ClawX launches" + "autoStartDesc": "Start Gateway when ClawX launches", + "proxyTitle": "Proxy", + "proxyDesc": "Route Electron and Gateway traffic through your local proxy client.", + "proxyServer": "Proxy Server", + "proxyServerHelp": "The default proxy for all requests. Bare host:port values default to HTTP.", + "proxyHttpServer": "HTTP Proxy", + "proxyHttpServerHelp": "Advanced override for HTTP requests. Leave blank to use Proxy Server.", + "proxyHttpsServer": "HTTPS Proxy", + "proxyHttpsServerHelp": "Advanced override for HTTPS requests. Leave blank to use Proxy Server.", + "proxyAllServer": "ALL_PROXY / SOCKS", + "proxyAllServerHelp": "Advanced fallback for SOCKS-capable clients and protocols such as Telegram. Leave blank to use Proxy Server.", + "proxyBypass": "Bypass Rules", + "proxyBypassHelp": "Semicolon, comma, or newline separated hosts that should connect directly.", + "proxyRestartNote": "Saving reapplies Electron networking and restarts the Gateway immediately.", + "proxySaved": "Proxy settings saved", + "proxySaveFailed": "Failed to save proxy settings" }, "updates": { "title": "Updates", @@ -155,4 +170,4 @@ "docs": "Website", "github": "GitHub" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 2d277763a..7810a48dc 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -83,7 +83,22 @@ "appLogs": "アプリケーションログ", "openFolder": "フォルダーを開く", "autoStart": "ゲートウェイ自動起動", - "autoStartDesc": "ClawX 起動時にゲートウェイを自動起動" + "autoStartDesc": "ClawX 起動時にゲートウェイを自動起動", + "proxyTitle": "プロキシ", + "proxyDesc": "Electron と Gateway の通信をローカルプロキシ経由にします。", + "proxyServer": "プロキシサーバー", + "proxyServerHelp": "すべてのリクエストで使う基本プロキシです。host:port のみの場合は HTTP 扱いです。", + "proxyHttpServer": "HTTP プロキシ", + "proxyHttpServerHelp": "HTTP リクエスト用の高度な上書き設定です。空欄の場合はプロキシサーバーを使用します。", + "proxyHttpsServer": "HTTPS プロキシ", + "proxyHttpsServerHelp": "HTTPS リクエスト用の高度な上書き設定です。空欄の場合はプロキシサーバーを使用します。", + "proxyAllServer": "ALL_PROXY / SOCKS", + "proxyAllServerHelp": "SOCKS 対応クライアントや Telegram など向けの高度なフォールバックです。空欄の場合はプロキシサーバーを使用します。", + "proxyBypass": "バイパスルール", + "proxyBypassHelp": "直接接続するホストをセミコロン、カンマ、または改行で区切って指定します。", + "proxyRestartNote": "保存すると Electron のネットワーク設定を再適用し、Gateway をすぐ再起動します。", + "proxySaved": "プロキシ設定を保存しました", + "proxySaveFailed": "プロキシ設定の保存に失敗しました" }, "updates": { "title": "アップデート", @@ -153,4 +168,4 @@ "docs": "公式サイト", "github": "GitHub" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 082815b9a..4f94d9a51 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -85,7 +85,22 @@ "appLogs": "应用日志", "openFolder": "打开文件夹", "autoStart": "自动启动网关", - "autoStartDesc": "ClawX 启动时自动启动网关" + "autoStartDesc": "ClawX 启动时自动启动网关", + "proxyTitle": "代理", + "proxyDesc": "让 Electron 和 Gateway 的网络请求都走本地代理客户端。", + "proxyServer": "代理服务器", + "proxyServerHelp": "所有请求默认使用的代理。只填 host:port 时默认按 HTTP 处理。", + "proxyHttpServer": "HTTP 代理", + "proxyHttpServerHelp": "HTTP 请求的高级覆盖项。留空时使用“代理服务器”。", + "proxyHttpsServer": "HTTPS 代理", + "proxyHttpsServerHelp": "HTTPS 请求的高级覆盖项。留空时使用“代理服务器”。", + "proxyAllServer": "ALL_PROXY / SOCKS", + "proxyAllServerHelp": "支持 SOCKS 的客户端和 Telegram 等协议的高级兜底代理。留空时使用“代理服务器”。", + "proxyBypass": "绕过规则", + "proxyBypassHelp": "使用分号、逗号或换行分隔需要直连的主机。", + "proxyRestartNote": "保存后会立即重新应用 Electron 网络代理,并自动重启 Gateway。", + "proxySaved": "代理设置已保存", + "proxySaveFailed": "保存代理设置失败" }, "updates": { "title": "更新", @@ -155,4 +170,4 @@ "docs": "官网", "github": "GitHub" } -} \ No newline at end of file +} diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index 1901744d3..dfc10d872 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -45,6 +45,18 @@ export function Settings() { setLanguage, gatewayAutoStart, setGatewayAutoStart, + proxyEnabled, + proxyServer, + proxyHttpServer, + proxyHttpsServer, + proxyAllServer, + proxyBypassRules, + setProxyEnabled, + setProxyServer, + setProxyHttpServer, + setProxyHttpsServer, + setProxyAllServer, + setProxyBypassRules, autoCheckUpdate, setAutoCheckUpdate, autoDownloadUpdate, @@ -59,6 +71,13 @@ export function Settings() { const [controlUiInfo, setControlUiInfo] = useState(null); const [openclawCliCommand, setOpenclawCliCommand] = useState(''); const [openclawCliError, setOpenclawCliError] = useState(null); + const [proxyServerDraft, setProxyServerDraft] = useState(''); + const [proxyHttpServerDraft, setProxyHttpServerDraft] = useState(''); + const [proxyHttpsServerDraft, setProxyHttpsServerDraft] = useState(''); + const [proxyAllServerDraft, setProxyAllServerDraft] = useState(''); + const [proxyBypassRulesDraft, setProxyBypassRulesDraft] = useState(''); + const [proxyEnabledDraft, setProxyEnabledDraft] = useState(false); + const [savingProxy, setSavingProxy] = useState(false); const isWindows = window.electron.platform === 'win32'; const showCliTools = true; @@ -184,6 +203,62 @@ export function Settings() { return () => { unsubscribe?.(); }; }, []); + useEffect(() => { + setProxyEnabledDraft(proxyEnabled); + }, [proxyEnabled]); + + useEffect(() => { + setProxyServerDraft(proxyServer); + }, [proxyServer]); + + useEffect(() => { + setProxyHttpServerDraft(proxyHttpServer); + }, [proxyHttpServer]); + + useEffect(() => { + setProxyHttpsServerDraft(proxyHttpsServer); + }, [proxyHttpsServer]); + + useEffect(() => { + setProxyAllServerDraft(proxyAllServer); + }, [proxyAllServer]); + + useEffect(() => { + setProxyBypassRulesDraft(proxyBypassRules); + }, [proxyBypassRules]); + + const handleSaveProxySettings = async () => { + setSavingProxy(true); + try { + const normalizedProxyServer = proxyServerDraft.trim(); + const normalizedHttpServer = proxyHttpServerDraft.trim(); + const normalizedHttpsServer = proxyHttpsServerDraft.trim(); + const normalizedAllServer = proxyAllServerDraft.trim(); + const normalizedBypassRules = proxyBypassRulesDraft.trim(); + await window.electron.ipcRenderer.invoke('settings:setMany', { + proxyEnabled: proxyEnabledDraft, + proxyServer: normalizedProxyServer, + proxyHttpServer: normalizedHttpServer, + proxyHttpsServer: normalizedHttpsServer, + proxyAllServer: normalizedAllServer, + proxyBypassRules: normalizedBypassRules, + }); + + setProxyServer(normalizedProxyServer); + setProxyHttpServer(normalizedHttpServer); + setProxyHttpsServer(normalizedHttpsServer); + setProxyAllServer(normalizedAllServer); + setProxyBypassRules(normalizedBypassRules); + setProxyEnabled(proxyEnabledDraft); + + toast.success(t('gateway.proxySaved')); + } catch (error) { + toast.error(`${t('gateway.proxySaveFailed')}: ${String(error)}`); + } finally { + setSavingProxy(false); + } + }; + return (
@@ -332,6 +407,106 @@ export function Settings() { onCheckedChange={setGatewayAutoStart} />
+ + + +
+
+
+ +

+ {t('gateway.proxyDesc')} +

+
+ +
+ +
+ + setProxyServerDraft(event.target.value)} + placeholder="http://127.0.0.1:7890" + /> +

+ {t('gateway.proxyServerHelp')} +

+
+ + {devModeUnlocked && ( + <> +
+ + setProxyHttpServerDraft(event.target.value)} + placeholder={proxyServerDraft || 'http://127.0.0.1:7890'} + /> +

+ {t('gateway.proxyHttpServerHelp')} +

+
+ +
+ + setProxyHttpsServerDraft(event.target.value)} + placeholder={proxyServerDraft || 'http://127.0.0.1:7890'} + /> +

+ {t('gateway.proxyHttpsServerHelp')} +

+
+ +
+ + setProxyAllServerDraft(event.target.value)} + placeholder={proxyServerDraft || 'socks5://127.0.0.1:7891'} + /> +

+ {t('gateway.proxyAllServerHelp')} +

+
+ + )} + +
+ + setProxyBypassRulesDraft(event.target.value)} + placeholder=";localhost;127.0.0.1;::1" + /> +

+ {t('gateway.proxyBypassHelp')} +

+
+ +
+

+ {t('gateway.proxyRestartNote')} +

+ +
+
diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 3815fd40b..2aefc081f 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -19,6 +19,12 @@ interface SettingsState { // Gateway gatewayAutoStart: boolean; gatewayPort: number; + proxyEnabled: boolean; + proxyServer: string; + proxyHttpServer: string; + proxyHttpsServer: string; + proxyAllServer: string; + proxyBypassRules: string; // Update updateChannel: UpdateChannel; @@ -33,12 +39,19 @@ interface SettingsState { setupComplete: boolean; // Actions + init: () => Promise; setTheme: (theme: Theme) => void; setLanguage: (language: string) => void; setStartMinimized: (value: boolean) => void; setLaunchAtStartup: (value: boolean) => void; setGatewayAutoStart: (value: boolean) => void; setGatewayPort: (port: number) => void; + setProxyEnabled: (value: boolean) => void; + setProxyServer: (value: string) => void; + setProxyHttpServer: (value: string) => void; + setProxyHttpsServer: (value: string) => void; + setProxyAllServer: (value: string) => void; + setProxyBypassRules: (value: string) => void; setUpdateChannel: (channel: UpdateChannel) => void; setAutoCheckUpdate: (value: boolean) => void; setAutoDownloadUpdate: (value: boolean) => void; @@ -60,6 +73,12 @@ const defaultSettings = { launchAtStartup: false, gatewayAutoStart: true, gatewayPort: 18789, + proxyEnabled: false, + proxyServer: '', + proxyHttpServer: '', + proxyHttpsServer: '', + proxyAllServer: '', + proxyBypassRules: ';localhost;127.0.0.1;::1', updateChannel: 'stable' as UpdateChannel, autoCheckUpdate: true, autoDownloadUpdate: false, @@ -73,12 +92,31 @@ export const useSettingsStore = create()( (set) => ({ ...defaultSettings, + init: async () => { + try { + const settings = await window.electron.ipcRenderer.invoke('settings:getAll') as Partial; + set((state) => ({ ...state, ...settings })); + if (settings.language) { + i18n.changeLanguage(settings.language); + } + } catch { + // Keep renderer-persisted settings as a fallback when the main + // process store is not reachable. + } + }, + setTheme: (theme) => set({ theme }), - setLanguage: (language) => { i18n.changeLanguage(language); set({ language }); }, + setLanguage: (language) => { i18n.changeLanguage(language); set({ language }); void window.electron.ipcRenderer.invoke('settings:set', 'language', language).catch(() => {}); }, setStartMinimized: (startMinimized) => set({ startMinimized }), setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }), - setGatewayAutoStart: (gatewayAutoStart) => set({ gatewayAutoStart }), - setGatewayPort: (gatewayPort) => set({ gatewayPort }), + setGatewayAutoStart: (gatewayAutoStart) => { set({ gatewayAutoStart }); void window.electron.ipcRenderer.invoke('settings:set', 'gatewayAutoStart', gatewayAutoStart).catch(() => {}); }, + setGatewayPort: (gatewayPort) => { set({ gatewayPort }); void window.electron.ipcRenderer.invoke('settings:set', 'gatewayPort', gatewayPort).catch(() => {}); }, + setProxyEnabled: (proxyEnabled) => set({ proxyEnabled }), + setProxyServer: (proxyServer) => set({ proxyServer }), + setProxyHttpServer: (proxyHttpServer) => set({ proxyHttpServer }), + setProxyHttpsServer: (proxyHttpsServer) => set({ proxyHttpsServer }), + setProxyAllServer: (proxyAllServer) => set({ proxyAllServer }), + setProxyBypassRules: (proxyBypassRules) => set({ proxyBypassRules }), setUpdateChannel: (updateChannel) => set({ updateChannel }), setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }), setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }), diff --git a/tests/unit/proxy.test.ts b/tests/unit/proxy.test.ts new file mode 100644 index 000000000..5cc2a66c8 --- /dev/null +++ b/tests/unit/proxy.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import { + buildElectronProxyConfig, + buildProxyEnv, + normalizeProxyServer, + resolveProxySettings, +} from '@electron/utils/proxy'; + +describe('proxy helpers', () => { + it('normalizes bare host:port values to http URLs', () => { + expect(normalizeProxyServer('127.0.0.1:7890')).toBe('http://127.0.0.1:7890'); + }); + + it('preserves explicit proxy schemes', () => { + expect(normalizeProxyServer('socks5://127.0.0.1:7891')).toBe('socks5://127.0.0.1:7891'); + }); + + it('falls back to the base proxy server when advanced fields are empty', () => { + expect(resolveProxySettings({ + proxyEnabled: true, + proxyServer: '127.0.0.1:7890', + proxyHttpServer: '', + proxyHttpsServer: '', + proxyAllServer: '', + proxyBypassRules: '', + })).toEqual({ + httpProxy: 'http://127.0.0.1:7890', + httpsProxy: 'http://127.0.0.1:7890', + allProxy: 'http://127.0.0.1:7890', + bypassRules: '', + }); + }); + + it('uses advanced overrides when provided', () => { + expect(resolveProxySettings({ + proxyEnabled: true, + proxyServer: 'http://127.0.0.1:7890', + proxyHttpServer: '', + proxyHttpsServer: 'http://127.0.0.1:7892', + proxyAllServer: 'socks5://127.0.0.1:7891', + proxyBypassRules: '', + })).toEqual({ + httpProxy: 'http://127.0.0.1:7890', + httpsProxy: 'http://127.0.0.1:7892', + allProxy: 'socks5://127.0.0.1:7891', + bypassRules: '', + }); + }); + + it('keeps blank advanced fields aligned with the base proxy server', () => { + expect(resolveProxySettings({ + proxyEnabled: true, + proxyServer: 'http://127.0.0.1:7890', + proxyHttpServer: '', + proxyHttpsServer: 'http://127.0.0.1:7892', + proxyAllServer: '', + proxyBypassRules: '', + })).toEqual({ + httpProxy: 'http://127.0.0.1:7890', + httpsProxy: 'http://127.0.0.1:7892', + allProxy: 'http://127.0.0.1:7890', + bypassRules: '', + }); + }); + + it('builds a direct Electron config when proxy is disabled', () => { + expect(buildElectronProxyConfig({ + proxyEnabled: false, + proxyServer: '127.0.0.1:7890', + proxyHttpServer: '', + proxyHttpsServer: '', + proxyAllServer: '', + proxyBypassRules: '', + })).toEqual({ mode: 'direct' }); + }); + + it('builds protocol-specific Electron rules when proxy is enabled', () => { + expect(buildElectronProxyConfig({ + proxyEnabled: true, + proxyServer: 'http://127.0.0.1:7890', + proxyHttpServer: '', + proxyHttpsServer: 'http://127.0.0.1:7892', + proxyAllServer: 'socks5://127.0.0.1:7891', + proxyBypassRules: ';localhost', + })).toEqual({ + mode: 'fixed_servers', + proxyRules: 'http=http://127.0.0.1:7890;https=http://127.0.0.1:7892;socks5://127.0.0.1:7891', + proxyBypassRules: ';localhost', + }); + }); + + it('builds upper and lower-case proxy env vars for the Gateway', () => { + expect(buildProxyEnv({ + proxyEnabled: true, + proxyServer: 'http://127.0.0.1:7890', + proxyHttpServer: '', + proxyHttpsServer: '', + proxyAllServer: 'socks5://127.0.0.1:7891', + proxyBypassRules: ';localhost\n127.0.0.1', + })).toEqual({ + HTTP_PROXY: 'http://127.0.0.1:7890', + HTTPS_PROXY: 'http://127.0.0.1:7890', + ALL_PROXY: 'socks5://127.0.0.1:7891', + http_proxy: 'http://127.0.0.1:7890', + https_proxy: 'http://127.0.0.1:7890', + all_proxy: 'socks5://127.0.0.1:7891', + NO_PROXY: ',localhost,127.0.0.1', + no_proxy: ',localhost,127.0.0.1', + }); + }); +});