Add built-in proxy settings for Electron and Gateway (#239)
Co-authored-by: zuolingxuan <zuolingxuan@bytedance.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
c09b45832b
commit
e40f4b2163
26
README.md
26
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
|
3. **Skill Bundles** – Select pre-configured skills for common use cases
|
||||||
4. **Verification** – Test your configuration before entering the main interface
|
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
|
## Architecture
|
||||||
|
|||||||
@@ -155,6 +155,32 @@ pnpm dev
|
|||||||
3. **技能包** – 选择适用于常见场景的预配置技能
|
3. **技能包** – 选择适用于常见场景的预配置技能
|
||||||
4. **验证** – 在进入主界面前测试你的配置
|
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 频道配置中。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 系统架构
|
## 系统架构
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ export class ClawHubService {
|
|||||||
|
|
||||||
const isWin = process.platform === 'win32';
|
const isWin = process.platform === 'win32';
|
||||||
const useShell = isWin && !this.useNodeRunner;
|
const useShell = isWin && !this.useNodeRunner;
|
||||||
|
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...baseEnv,
|
||||||
CI: 'true',
|
CI: 'true',
|
||||||
FORCE_COLOR: '0',
|
FORCE_COLOR: '0',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
appendNodeRequireToNodeOptions,
|
appendNodeRequireToNodeOptions,
|
||||||
quoteForCmd,
|
quoteForCmd,
|
||||||
} from '../utils/paths';
|
} from '../utils/paths';
|
||||||
import { getSetting } from '../utils/store';
|
import { getAllSettings, getSetting } from '../utils/store';
|
||||||
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
|
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
|
||||||
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
|
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
|
||||||
import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol';
|
import { GatewayEventType, JsonRpcNotification, isNotification, isResponse } from './protocol';
|
||||||
@@ -32,6 +32,8 @@ import {
|
|||||||
type DeviceIdentity,
|
type DeviceIdentity,
|
||||||
} from '../utils/device-identity';
|
} from '../utils/device-identity';
|
||||||
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
|
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';
|
import { shouldAttemptConfigAutoRepair } from './startup-recovery';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -871,7 +873,9 @@ export class GatewayManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get or generate gateway token
|
// 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
|
// Strip stale/invalid keys from openclaw.json that would cause the
|
||||||
// Gateway's strict config validation to reject the file on startup
|
// 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 uvEnv = await getUvMirrorEnv();
|
||||||
|
const proxyEnv = buildProxyEnv(appSettings);
|
||||||
|
const resolvedProxy = resolveProxySettings(appSettings);
|
||||||
logger.info(
|
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}"`;
|
this.lastSpawnSummary = `mode=${mode}, command="${command}", args="${this.sanitizeSpawnArgs(args).join(' ')}", cwd="${openclawDir}"`;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
|
||||||
const spawnEnv: Record<string, string | undefined> = {
|
const spawnEnv: Record<string, string | undefined> = {
|
||||||
...process.env,
|
...baseEnv,
|
||||||
PATH: finalPath,
|
PATH: finalPath,
|
||||||
...providerEnv,
|
...providerEnv,
|
||||||
...uvEnv,
|
...uvEnv,
|
||||||
|
...proxyEnv,
|
||||||
OPENCLAW_GATEWAY_TOKEN: gatewayToken,
|
OPENCLAW_GATEWAY_TOKEN: gatewayToken,
|
||||||
OPENCLAW_SKIP_CHANNELS: '',
|
OPENCLAW_SKIP_CHANNELS: '',
|
||||||
CLAWDBOT_SKIP_CHANNELS: '',
|
CLAWDBOT_SKIP_CHANNELS: '',
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { ClawHubService } from '../gateway/clawhub';
|
|||||||
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
|
import { ensureClawXContext, repairClawXOnlyBootstrapFiles } from '../utils/openclaw-workspace';
|
||||||
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
|
import { autoInstallCliIfNeeded, generateCompletionCache, installCompletionToProfile } from '../utils/openclaw-cli';
|
||||||
import { isQuitting, setQuitting } from './app-state';
|
import { isQuitting, setQuitting } from './app-state';
|
||||||
|
import { applyProxySettings } from './proxy';
|
||||||
|
import { getSetting } from '../utils/store';
|
||||||
import { ensureBuiltinSkillsInstalled } from '../utils/skill-config';
|
import { ensureBuiltinSkillsInstalled } from '../utils/skill-config';
|
||||||
|
|
||||||
// Disable GPU hardware acceleration globally for maximum stability across
|
// Disable GPU hardware acceleration globally for maximum stability across
|
||||||
@@ -128,6 +130,9 @@ async function initialize(): Promise<void> {
|
|||||||
// Warm up network optimization (non-blocking)
|
// Warm up network optimization (non-blocking)
|
||||||
void warmupNetworkOptimization();
|
void warmupNetworkOptimization();
|
||||||
|
|
||||||
|
// Apply persisted proxy settings before creating windows or network requests.
|
||||||
|
await applyProxySettings();
|
||||||
|
|
||||||
// Set application menu
|
// Set application menu
|
||||||
createMenu();
|
createMenu();
|
||||||
|
|
||||||
@@ -195,13 +200,18 @@ async function initialize(): Promise<void> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
|
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
|
||||||
try {
|
const gatewayAutoStart = await getSetting('gatewayAutoStart');
|
||||||
logger.debug('Auto-starting Gateway...');
|
if (gatewayAutoStart) {
|
||||||
await gatewayManager.start();
|
try {
|
||||||
logger.info('Gateway auto-start succeeded');
|
logger.debug('Auto-starting Gateway...');
|
||||||
} catch (error) {
|
await gatewayManager.start();
|
||||||
logger.error('Gateway auto-start failed:', error);
|
logger.info('Gateway auto-start succeeded');
|
||||||
mainWindow?.webContents.send('gateway:error', String(error));
|
} 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.
|
// Merge ClawX context snippets into the workspace bootstrap files.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from '../utils/secure-storage';
|
} from '../utils/secure-storage';
|
||||||
import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir, ensureDir } from '../utils/paths';
|
import { getOpenClawStatus, getOpenClawDir, getOpenClawConfigDir, getOpenClawSkillsDir, ensureDir } from '../utils/paths';
|
||||||
import { getOpenClawCliCommand } from '../utils/openclaw-cli';
|
import { getOpenClawCliCommand } from '../utils/openclaw-cli';
|
||||||
import { getSetting } from '../utils/store';
|
import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../utils/store';
|
||||||
import {
|
import {
|
||||||
saveProviderKeyToOpenClaw,
|
saveProviderKeyToOpenClaw,
|
||||||
removeProviderFromOpenClaw,
|
removeProviderFromOpenClaw,
|
||||||
@@ -49,6 +49,8 @@ import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/
|
|||||||
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
||||||
import { getProviderConfig } from '../utils/provider-registry';
|
import { getProviderConfig } from '../utils/provider-registry';
|
||||||
import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth';
|
import { deviceOAuthManager, OAuthProviderType } from '../utils/device-oauth';
|
||||||
|
import { applyProxySettings } from './proxy';
|
||||||
|
import { proxyAwareFetch } from '../utils/proxy-fetch';
|
||||||
import { getRecentTokenUsageHistory } from '../utils/token-usage';
|
import { getRecentTokenUsageHistory } from '../utils/token-usage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,6 +102,9 @@ export function registerIpcHandlers(
|
|||||||
// App handlers
|
// App handlers
|
||||||
registerAppHandlers();
|
registerAppHandlers();
|
||||||
|
|
||||||
|
// Settings handlers
|
||||||
|
registerSettingsHandlers(gatewayManager);
|
||||||
|
|
||||||
// UV handlers
|
// UV handlers
|
||||||
registerUvHandlers();
|
registerUvHandlers();
|
||||||
|
|
||||||
@@ -1478,7 +1483,7 @@ async function performProviderValidationRequest(
|
|||||||
): Promise<{ valid: boolean; error?: string }> {
|
): Promise<{ valid: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
logValidationRequest(providerLabel, 'GET', url, headers);
|
logValidationRequest(providerLabel, 'GET', url, headers);
|
||||||
const response = await fetch(url, { headers });
|
const response = await proxyAwareFetch(url, { headers });
|
||||||
logValidationStatus(providerLabel, response.status);
|
logValidationStatus(providerLabel, response.status);
|
||||||
const data = await response.json().catch(() => ({}));
|
const data = await response.json().catch(() => ({}));
|
||||||
return classifyAuthResponse(response.status, data);
|
return classifyAuthResponse(response.status, data);
|
||||||
@@ -1553,7 +1558,7 @@ async function performChatCompletionsProbe(
|
|||||||
): Promise<{ valid: boolean; error?: string }> {
|
): Promise<{ valid: boolean; error?: string }> {
|
||||||
try {
|
try {
|
||||||
logValidationRequest(providerLabel, 'POST', url, headers);
|
logValidationRequest(providerLabel, 'POST', url, headers);
|
||||||
const response = await fetch(url, {
|
const response = await proxyAwareFetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
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<AppSettings>) => {
|
||||||
|
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 {
|
function registerUsageHandlers(): void {
|
||||||
ipcMain.handle('usage:recentTokenHistory', async (_, limit?: number) => {
|
ipcMain.handle('usage:recentTokenHistory', async (_, limit?: number) => {
|
||||||
const safeLimit = typeof limit === 'number' && Number.isFinite(limit)
|
const safeLimit = typeof limit === 'number' && Number.isFinite(limit)
|
||||||
|
|||||||
22
electron/main/proxy.ts
Normal file
22
electron/main/proxy.ts
Normal file
@@ -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<AppSettings, 'proxyEnabled' | 'proxyServer' | 'proxyBypassRules'>
|
||||||
|
): Promise<void> {
|
||||||
|
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}` : ''})`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ const electronAPI = {
|
|||||||
// Settings
|
// Settings
|
||||||
'settings:get',
|
'settings:get',
|
||||||
'settings:set',
|
'settings:set',
|
||||||
|
'settings:setMany',
|
||||||
'settings:getAll',
|
'settings:getAll',
|
||||||
'settings:reset',
|
'settings:reset',
|
||||||
'usage:recentTokenHistory',
|
'usage:recentTokenHistory',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { join } from 'path';
|
|||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { getOpenClawResolvedDir } from './paths';
|
import { getOpenClawResolvedDir } from './paths';
|
||||||
import * as logger from './logger';
|
import * as logger from './logger';
|
||||||
|
import { proxyAwareFetch } from './proxy-fetch';
|
||||||
|
|
||||||
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
||||||
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
|
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: [] };
|
if (!allowedUsers) return { valid: false, errors: ['At least one allowed user ID is required'], warnings: [] };
|
||||||
|
|
||||||
try {
|
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 } };
|
const data = (await response.json()) as { ok?: boolean; description?: string; result?: { username?: string } };
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
return { valid: true, errors: [], warnings: [], details: { botUsername: data.result?.username || 'Unknown' } };
|
return { valid: true, errors: [], warnings: [], details: { botUsername: data.result?.username || 'Unknown' } };
|
||||||
|
|||||||
43
electron/utils/openclaw-proxy.ts
Normal file
43
electron/utils/openclaw-proxy.ts
Normal file
@@ -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<void> {
|
||||||
|
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'})`);
|
||||||
|
}
|
||||||
21
electron/utils/proxy-fetch.ts
Normal file
21
electron/utils/proxy-fetch.ts
Normal file
@@ -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<Response> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
122
electron/utils/proxy.ts
Normal file
122
electron/utils/proxy.ts
Normal file
@@ -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<string, string> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -30,6 +30,12 @@ export interface AppSettings {
|
|||||||
gatewayAutoStart: boolean;
|
gatewayAutoStart: boolean;
|
||||||
gatewayPort: number;
|
gatewayPort: number;
|
||||||
gatewayToken: string;
|
gatewayToken: string;
|
||||||
|
proxyEnabled: boolean;
|
||||||
|
proxyServer: string;
|
||||||
|
proxyHttpServer: string;
|
||||||
|
proxyHttpsServer: string;
|
||||||
|
proxyAllServer: string;
|
||||||
|
proxyBypassRules: string;
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
updateChannel: 'stable' | 'beta' | 'dev';
|
updateChannel: 'stable' | 'beta' | 'dev';
|
||||||
@@ -61,6 +67,12 @@ const defaults: AppSettings = {
|
|||||||
gatewayAutoStart: true,
|
gatewayAutoStart: true,
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
gatewayToken: generateToken(),
|
gatewayToken: generateToken(),
|
||||||
|
proxyEnabled: false,
|
||||||
|
proxyServer: '',
|
||||||
|
proxyHttpServer: '',
|
||||||
|
proxyHttpsServer: '',
|
||||||
|
proxyAllServer: '',
|
||||||
|
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
updateChannel: 'stable',
|
updateChannel: 'stable',
|
||||||
|
|||||||
@@ -87,11 +87,16 @@ class ErrorBoundary extends Component<
|
|||||||
function App() {
|
function App() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const initSettings = useSettingsStore((state) => state.init);
|
||||||
const theme = useSettingsStore((state) => state.theme);
|
const theme = useSettingsStore((state) => state.theme);
|
||||||
const language = useSettingsStore((state) => state.language);
|
const language = useSettingsStore((state) => state.language);
|
||||||
const setupComplete = useSettingsStore((state) => state.setupComplete);
|
const setupComplete = useSettingsStore((state) => state.setupComplete);
|
||||||
const initGateway = useGatewayStore((state) => state.init);
|
const initGateway = useGatewayStore((state) => state.init);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initSettings();
|
||||||
|
}, [initSettings]);
|
||||||
|
|
||||||
// Sync i18n language with persisted settings on mount
|
// Sync i18n language with persisted settings on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (language && language !== i18n.language) {
|
if (language && language !== i18n.language) {
|
||||||
|
|||||||
@@ -85,7 +85,22 @@
|
|||||||
"appLogs": "Application Logs",
|
"appLogs": "Application Logs",
|
||||||
"openFolder": "Open Folder",
|
"openFolder": "Open Folder",
|
||||||
"autoStart": "Auto-start Gateway",
|
"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": {
|
"updates": {
|
||||||
"title": "Updates",
|
"title": "Updates",
|
||||||
|
|||||||
@@ -83,7 +83,22 @@
|
|||||||
"appLogs": "アプリケーションログ",
|
"appLogs": "アプリケーションログ",
|
||||||
"openFolder": "フォルダーを開く",
|
"openFolder": "フォルダーを開く",
|
||||||
"autoStart": "ゲートウェイ自動起動",
|
"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": {
|
"updates": {
|
||||||
"title": "アップデート",
|
"title": "アップデート",
|
||||||
|
|||||||
@@ -85,7 +85,22 @@
|
|||||||
"appLogs": "应用日志",
|
"appLogs": "应用日志",
|
||||||
"openFolder": "打开文件夹",
|
"openFolder": "打开文件夹",
|
||||||
"autoStart": "自动启动网关",
|
"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": {
|
"updates": {
|
||||||
"title": "更新",
|
"title": "更新",
|
||||||
|
|||||||
@@ -45,6 +45,18 @@ export function Settings() {
|
|||||||
setLanguage,
|
setLanguage,
|
||||||
gatewayAutoStart,
|
gatewayAutoStart,
|
||||||
setGatewayAutoStart,
|
setGatewayAutoStart,
|
||||||
|
proxyEnabled,
|
||||||
|
proxyServer,
|
||||||
|
proxyHttpServer,
|
||||||
|
proxyHttpsServer,
|
||||||
|
proxyAllServer,
|
||||||
|
proxyBypassRules,
|
||||||
|
setProxyEnabled,
|
||||||
|
setProxyServer,
|
||||||
|
setProxyHttpServer,
|
||||||
|
setProxyHttpsServer,
|
||||||
|
setProxyAllServer,
|
||||||
|
setProxyBypassRules,
|
||||||
autoCheckUpdate,
|
autoCheckUpdate,
|
||||||
setAutoCheckUpdate,
|
setAutoCheckUpdate,
|
||||||
autoDownloadUpdate,
|
autoDownloadUpdate,
|
||||||
@@ -59,6 +71,13 @@ export function Settings() {
|
|||||||
const [controlUiInfo, setControlUiInfo] = useState<ControlUiInfo | null>(null);
|
const [controlUiInfo, setControlUiInfo] = useState<ControlUiInfo | null>(null);
|
||||||
const [openclawCliCommand, setOpenclawCliCommand] = useState('');
|
const [openclawCliCommand, setOpenclawCliCommand] = useState('');
|
||||||
const [openclawCliError, setOpenclawCliError] = useState<string | null>(null);
|
const [openclawCliError, setOpenclawCliError] = useState<string | null>(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 isWindows = window.electron.platform === 'win32';
|
||||||
const showCliTools = true;
|
const showCliTools = true;
|
||||||
@@ -184,6 +203,62 @@ export function Settings() {
|
|||||||
return () => { unsubscribe?.(); };
|
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 (
|
return (
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -332,6 +407,106 @@ export function Settings() {
|
|||||||
onCheckedChange={setGatewayAutoStart}
|
onCheckedChange={setGatewayAutoStart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label>{t('gateway.proxyTitle')}</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('gateway.proxyDesc')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={proxyEnabledDraft}
|
||||||
|
onCheckedChange={setProxyEnabledDraft}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="proxy-server">{t('gateway.proxyServer')}</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy-server"
|
||||||
|
value={proxyServerDraft}
|
||||||
|
onChange={(event) => setProxyServerDraft(event.target.value)}
|
||||||
|
placeholder="http://127.0.0.1:7890"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('gateway.proxyServerHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{devModeUnlocked && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="proxy-http-server">{t('gateway.proxyHttpServer')}</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy-http-server"
|
||||||
|
value={proxyHttpServerDraft}
|
||||||
|
onChange={(event) => setProxyHttpServerDraft(event.target.value)}
|
||||||
|
placeholder={proxyServerDraft || 'http://127.0.0.1:7890'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('gateway.proxyHttpServerHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="proxy-https-server">{t('gateway.proxyHttpsServer')}</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy-https-server"
|
||||||
|
value={proxyHttpsServerDraft}
|
||||||
|
onChange={(event) => setProxyHttpsServerDraft(event.target.value)}
|
||||||
|
placeholder={proxyServerDraft || 'http://127.0.0.1:7890'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('gateway.proxyHttpsServerHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="proxy-all-server">{t('gateway.proxyAllServer')}</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy-all-server"
|
||||||
|
value={proxyAllServerDraft}
|
||||||
|
onChange={(event) => setProxyAllServerDraft(event.target.value)}
|
||||||
|
placeholder={proxyServerDraft || 'socks5://127.0.0.1:7891'}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('gateway.proxyAllServerHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="proxy-bypass">{t('gateway.proxyBypass')}</Label>
|
||||||
|
<Input
|
||||||
|
id="proxy-bypass"
|
||||||
|
value={proxyBypassRulesDraft}
|
||||||
|
onChange={(event) => setProxyBypassRulesDraft(event.target.value)}
|
||||||
|
placeholder="<local>;localhost;127.0.0.1;::1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('gateway.proxyBypassHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-background/40 p-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('gateway.proxyRestartNote')}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSaveProxySettings}
|
||||||
|
disabled={savingProxy}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-2${savingProxy ? ' animate-spin' : ''}`} />
|
||||||
|
{savingProxy ? t('common:status.saving') : t('common:actions.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ interface SettingsState {
|
|||||||
// Gateway
|
// Gateway
|
||||||
gatewayAutoStart: boolean;
|
gatewayAutoStart: boolean;
|
||||||
gatewayPort: number;
|
gatewayPort: number;
|
||||||
|
proxyEnabled: boolean;
|
||||||
|
proxyServer: string;
|
||||||
|
proxyHttpServer: string;
|
||||||
|
proxyHttpsServer: string;
|
||||||
|
proxyAllServer: string;
|
||||||
|
proxyBypassRules: string;
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
updateChannel: UpdateChannel;
|
updateChannel: UpdateChannel;
|
||||||
@@ -33,12 +39,19 @@ interface SettingsState {
|
|||||||
setupComplete: boolean;
|
setupComplete: boolean;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
init: () => Promise<void>;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
setLanguage: (language: string) => void;
|
setLanguage: (language: string) => void;
|
||||||
setStartMinimized: (value: boolean) => void;
|
setStartMinimized: (value: boolean) => void;
|
||||||
setLaunchAtStartup: (value: boolean) => void;
|
setLaunchAtStartup: (value: boolean) => void;
|
||||||
setGatewayAutoStart: (value: boolean) => void;
|
setGatewayAutoStart: (value: boolean) => void;
|
||||||
setGatewayPort: (port: number) => 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;
|
setUpdateChannel: (channel: UpdateChannel) => void;
|
||||||
setAutoCheckUpdate: (value: boolean) => void;
|
setAutoCheckUpdate: (value: boolean) => void;
|
||||||
setAutoDownloadUpdate: (value: boolean) => void;
|
setAutoDownloadUpdate: (value: boolean) => void;
|
||||||
@@ -60,6 +73,12 @@ const defaultSettings = {
|
|||||||
launchAtStartup: false,
|
launchAtStartup: false,
|
||||||
gatewayAutoStart: true,
|
gatewayAutoStart: true,
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
|
proxyEnabled: false,
|
||||||
|
proxyServer: '',
|
||||||
|
proxyHttpServer: '',
|
||||||
|
proxyHttpsServer: '',
|
||||||
|
proxyAllServer: '',
|
||||||
|
proxyBypassRules: '<local>;localhost;127.0.0.1;::1',
|
||||||
updateChannel: 'stable' as UpdateChannel,
|
updateChannel: 'stable' as UpdateChannel,
|
||||||
autoCheckUpdate: true,
|
autoCheckUpdate: true,
|
||||||
autoDownloadUpdate: false,
|
autoDownloadUpdate: false,
|
||||||
@@ -73,12 +92,31 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
|
|
||||||
|
init: async () => {
|
||||||
|
try {
|
||||||
|
const settings = await window.electron.ipcRenderer.invoke('settings:getAll') as Partial<typeof defaultSettings>;
|
||||||
|
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 }),
|
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 }),
|
setStartMinimized: (startMinimized) => set({ startMinimized }),
|
||||||
setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }),
|
setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }),
|
||||||
setGatewayAutoStart: (gatewayAutoStart) => set({ gatewayAutoStart }),
|
setGatewayAutoStart: (gatewayAutoStart) => { set({ gatewayAutoStart }); void window.electron.ipcRenderer.invoke('settings:set', 'gatewayAutoStart', gatewayAutoStart).catch(() => {}); },
|
||||||
setGatewayPort: (gatewayPort) => set({ gatewayPort }),
|
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 }),
|
setUpdateChannel: (updateChannel) => set({ updateChannel }),
|
||||||
setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }),
|
setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }),
|
||||||
setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }),
|
setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }),
|
||||||
|
|||||||
111
tests/unit/proxy.test.ts
Normal file
111
tests/unit/proxy.test.ts
Normal file
@@ -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: '<local>',
|
||||||
|
})).toEqual({
|
||||||
|
httpProxy: 'http://127.0.0.1:7890',
|
||||||
|
httpsProxy: 'http://127.0.0.1:7890',
|
||||||
|
allProxy: 'http://127.0.0.1:7890',
|
||||||
|
bypassRules: '<local>',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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: '<local>',
|
||||||
|
})).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: '<local>;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: '<local>;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: '<local>;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: '<local>,localhost,127.0.0.1',
|
||||||
|
no_proxy: '<local>,localhost,127.0.0.1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user