feat: 添加本地混合代理功能

- 新增本地 HTTP/SOCKS5 代理服务,支持固定端口和节点选择策略
- 添加系统 API 用于端口检测和 HTTP 代理连通性测试
- 在设置页面添加本地代理配置面板,支持启用/禁用和节点选择策略
- 在节点管理器中显示当前代理活动节点
This commit is contained in:
ZenEcho committed 2026-02-18 14:31:22 +08:00
1 parent 66d9c1de08
commit e9b072fc92
15 files changed
+517 -24

No files matched your search

+68 -3
View File
@@ -1,6 +1,7 @@
import { app, ipcMain, BrowserWindow, dialog, net as net$1, shell, Tray, Menu, nativeImage } from "electron";
import { app, ipcMain, BrowserWindow, dialog, net as net$2, shell, Tray, Menu, nativeImage } from "electron";
import { fileURLToPath, pathToFileURL } from "node:url";
import path$1, { posix, basename, win32, join } from "node:path";
import net from "node:net";
import { exec as exec$1, spawn as spawn$1 } from "node:child_process";
import { inspect } from "node:util";
import { randomBytes, randomUUID, randomFillSync } from "node:crypto";
@@ -26,8 +27,8 @@ import { pipeline } from "stream/promises";
import https from "node:https";
import DatabaseConstructor from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";
import net from "node:net";
import os from "node:os";
import net$1 from "net";
var commonjsGlobal = typeof globalThis !== "undefined" ? globalThis : typeof window !== "undefined" ? window : typeof global !== "undefined" ? global : typeof self !== "undefined" ? self : {};
function getDefaultExportFromCjs(x) {
return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, "default") ? x["default"] : x;
@@ -11862,6 +11863,36 @@ function tcpPing(host, port, timeout = 2e3) {
}
});
}
async function findAvailablePort(startPort, count = 1) {
let port = startPort;
while (true) {
let allAvailable = true;
for (let i = 0; i < count; i++) {
const isAvailable = await isPortAvailable(port + i);
if (!isAvailable) {
allAvailable = false;
break;
}
}
if (allAvailable) {
return port;
}
port++;
if (port > 65535) {
throw new Error("No available ports found");
}
}
}
function isPortAvailable(port) {
return new Promise((resolve) => {
const server = net$1.createServer();
server.once("error", () => resolve(false));
server.once("listening", () => {
server.close(() => resolve(true));
});
server.listen(port, "127.0.0.1");
});
}
const name = "lagzero";
const productName = "LagZero";
const pkg = {
@@ -12695,12 +12726,46 @@ ipcMain.handle("system:flush-dns-cache", () => flushDnsCache());
ipcMain.handle("system:tun-reinstall", async (_, interfaceName) => {
return reinstallTunAdapter(interfaceName || "LagZero");
});
ipcMain.handle("system:find-available-port", async (_, port, count) => {
return findAvailablePort(port, count);
});
ipcMain.handle("system:test-http-proxy-connect", async (_, proxyPort, targetHost, targetPort = 443, timeoutMs = 5e3) => {
return new Promise((resolve) => {
const socket = net.createConnection({ host: "127.0.0.1", port: proxyPort });
let settled = false;
let buf = "";
const done = (payload) => {
if (settled) return;
settled = true;
socket.destroy();
resolve(payload);
};
socket.setTimeout(Math.max(1e3, timeoutMs));
socket.on("timeout", () => done({ ok: false, statusLine: "", error: "timeout" }));
socket.on("error", (err) => done({ ok: false, statusLine: "", error: String(err?.message || err) }));
socket.on("connect", () => {
const req = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r
Host: ${targetHost}:${targetPort}\r
Proxy-Connection: Keep-Alive\r
\r
`;
socket.write(req);
});
socket.on("data", (chunk) => {
buf += chunk.toString("utf8");
const lineEnd = buf.indexOf("\r\n");
if (lineEnd < 0) return;
const statusLine = buf.slice(0, lineEnd).trim();
done({ ok: /^HTTP\/1\.[01]\s+200\b/i.test(statusLine), statusLine });
});
});
});
ipcMain.handle("app:get-version", () => app.getVersion());
ipcMain.handle("app:check-update", async () => {
try {
const fetchGithub = async (url) => {
return new Promise((resolve, reject) => {
const request = net$1.request(url);
const request = net$2.request(url);
request.setHeader("User-Agent", "LagZero-Client");
request.on("response", (response) => {
if (response.statusCode !== 200) {
+3 -1
View File
@@ -36,7 +36,9 @@ electron.contextBridge.exposeInMainWorld("system", {
ping: (host) => electron.ipcRenderer.invoke("system:ping", host),
tcpPing: (host, port) => electron.ipcRenderer.invoke("system:tcp-ping", host, port),
flushDnsCache: () => electron.ipcRenderer.invoke("system:flush-dns-cache"),
reinstallTunAdapter: (interfaceName) => electron.ipcRenderer.invoke("system:tun-reinstall", interfaceName)
reinstallTunAdapter: (interfaceName) => electron.ipcRenderer.invoke("system:tun-reinstall", interfaceName),
findAvailablePort: (port, count) => electron.ipcRenderer.invoke("system:find-available-port", port, count),
testHttpProxyConnect: (proxyPort, targetHost, targetPort, timeoutMs) => electron.ipcRenderer.invoke("system:test-http-proxy-connect", proxyPort, targetHost, targetPort, timeoutMs)
});
electron.contextBridge.exposeInMainWorld("proxyMonitor", {
start: (gameId, processNames) => electron.ipcRenderer.invoke("proxy-monitor:start", gameId, processNames),
+34
View File
@@ -2,6 +2,7 @@ import { app, BrowserWindow, ipcMain, dialog, Tray, Menu, nativeImage, NativeIma
// import { autoUpdater } from 'electron-updater'
import { fileURLToPath, pathToFileURL } from 'node:url'
import path from 'node:path'
import nodeNet from 'node:net'
import { spawn } from 'node:child_process'
import { inspect } from 'node:util'
import { randomUUID } from 'node:crypto'
@@ -24,6 +25,7 @@ import { ProxyMonitor } from './proxy/monitor'
import { NodeManager } from './nodes/manager'
import { DatabaseManager } from './db'
import { ping, tcpPing } from './network/ping'
import { findAvailablePort } from './network/port'
import pkg from '../package.json'
process.env.APP_ROOT = path.join(__dirname, '..')
@@ -1030,6 +1032,38 @@ ipcMain.handle('system:flush-dns-cache', () => flushDnsCache())
ipcMain.handle('system:tun-reinstall', async (_, interfaceName?: string) => {
return reinstallTunAdapter(interfaceName || 'LagZero')
})
ipcMain.handle('system:find-available-port', async (_, port: number, count?: number) => {
return findAvailablePort(port, count)
})
ipcMain.handle('system:test-http-proxy-connect', async (_, proxyPort: number, targetHost: string, targetPort: number = 443, timeoutMs: number = 5000) => {
return new Promise<{ ok: boolean, statusLine: string, error?: string }>((resolve) => {
const socket = nodeNet.createConnection({ host: '127.0.0.1', port: proxyPort })
let settled = false
let buf = ''
const done = (payload: { ok: boolean, statusLine: string, error?: string }) => {
if (settled) return
settled = true
socket.destroy()
resolve(payload)
}
socket.setTimeout(Math.max(1000, timeoutMs))
socket.on('timeout', () => done({ ok: false, statusLine: '', error: 'timeout' }))
socket.on('error', (err: any) => done({ ok: false, statusLine: '', error: String(err?.message || err) }))
socket.on('connect', () => {
const req = `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\nHost: ${targetHost}:${targetPort}\r\nProxy-Connection: Keep-Alive\r\n\r\n`
socket.write(req)
})
socket.on('data', (chunk: Buffer) => {
buf += chunk.toString('utf8')
const lineEnd = buf.indexOf('\r\n')
if (lineEnd < 0) return
const statusLine = buf.slice(0, lineEnd).trim()
done({ ok: /^HTTP\/1\.[01]\s+200\b/i.test(statusLine), statusLine })
})
})
})
// Update & Info handlers
ipcMain.handle('app:get-version', () => app.getVersion())
+36
View File
@@ -0,0 +1,36 @@
import net from 'net'
export async function findAvailablePort(startPort: number, count: number = 1): Promise<number> {
let port = startPort
while (true) {
let allAvailable = true
for (let i = 0; i < count; i++) {
const isAvailable = await isPortAvailable(port + i)
if (!isAvailable) {
allAvailable = false
break
}
}
if (allAvailable) {
return port
}
port++
if (port > 65535) {
throw new Error('No available ports found')
}
}
}
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer()
server.once('error', () => resolve(false))
server.once('listening', () => {
server.close(() => resolve(true))
})
server.listen(port, '127.0.0.1')
})
}
+3
View File
@@ -40,6 +40,9 @@ contextBridge.exposeInMainWorld('system', {
tcpPing: (host: string, port: number) => ipcRenderer.invoke('system:tcp-ping', host, port),
flushDnsCache: () => ipcRenderer.invoke('system:flush-dns-cache'),
reinstallTunAdapter: (interfaceName?: string) => ipcRenderer.invoke('system:tun-reinstall', interfaceName),
findAvailablePort: (port: number, count?: number) => ipcRenderer.invoke('system:find-available-port', port, count),
testHttpProxyConnect: (proxyPort: number, targetHost: string, targetPort?: number, timeoutMs?: number) =>
ipcRenderer.invoke('system:test-http-proxy-connect', proxyPort, targetHost, targetPort, timeoutMs),
})
contextBridge.exposeInMainWorld('proxyMonitor', {
+13
View File
@@ -19,6 +19,9 @@
<span class="uppercase bg-surface-overlay my-1 px-1 rounded text-[10px]">{{ node.type }}</span>
<span class="truncate">{{ node.server }}:{{ node.server_port }}</span>
</div>
<div v-if="isLocalProxyActiveNode(node)" class="text-[10px] text-primary font-mono">
HTTP {{ settingsStore.localProxyPort }} / SOCKS5 {{ settingsStore.localProxyPort + 1 }}
</div>
</div>
</div>
<div class="flex items-center gap-4">
@@ -71,6 +74,8 @@
import { ref, reactive } from 'vue'
import { useMessage, useDialog } from 'naive-ui'
import { useNodeStore } from '@/stores/nodes'
import { useSettingsStore } from '@/stores/settings'
import { useLocalProxyStore } from '@/stores/local-proxy'
import { generateShareLink } from '@/utils/protocol'
import type { NodeConfig } from '@/utils/protocol'
import { useClipboard } from '@vueuse/core'
@@ -79,6 +84,8 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const nodeStore = useNodeStore()
const settingsStore = useSettingsStore()
const localProxyStore = useLocalProxyStore()
const message = useMessage()
const dialog = useDialog()
const { copy } = useClipboard()
@@ -114,6 +121,12 @@ function getLatencyColor(ms: number) {
return 'text-error'
}
function isLocalProxyActiveNode(node: NodeConfig) {
if (!settingsStore.localProxyEnabled || !localProxyStore.running) return false
const key = String(node.id || node.tag || '')
return !!key && key === localProxyStore.activeNodeKey
}
function editNode(node: NodeConfig) {
editingNode.value = node
showEditModal.value = true
+11 -3
View File
@@ -43,7 +43,9 @@
"more": "More",
"fill_required": "Please complete required fields.",
"none": "None",
"auto": "Auto"
"auto": "Auto",
"enabled": "Enabled",
"disabled": "Disabled"
},
"nodes": {
"add_node": "Add Node",
@@ -185,7 +187,13 @@
"logs_category_frontend": "Frontend",
"logs_category_backend": "Backend",
"logs_category_core": "Core",
"logs_all_levels": "All Levels"
"logs_all_levels": "All Levels",
"local_proxy": "Local Proxy",
"local_proxy_port": "HTTP Port",
"local_proxy_info": "SOCKS5 port will be automatically set to: {socks}",
"local_proxy_node_strategy": "Node Selection",
"local_proxy_node_recursive": "Auto test nodes in order (Google + Apple), then use the first available one.",
"local_proxy_fixed_node_index": "Fixed Node Index (1-based)"
},
"categories": {
"new_category_placeholder": "Enter new category name..."
@@ -202,4 +210,4 @@
"start_failed": "Failed to start acceleration",
"singbox_error": "sing-box error occurred"
}
}
}
+11 -3
View File
@@ -43,7 +43,9 @@
"more": "更多",
"fill_required": "请填写必填项",
"none": "无",
"auto": "自动"
"auto": "自动",
"enabled": "已开启",
"disabled": "已关闭"
},
"nodes": {
"add_node": "添加节点",
@@ -186,7 +188,13 @@
"logs_category_frontend": "前端",
"logs_category_backend": "后端",
"logs_category_core": "核心",
"logs_all_levels": "全部级别"
"logs_all_levels": "全部级别",
"local_proxy": "本地混合代理",
"local_proxy_port": "HTTP 端口",
"local_proxy_info": "SOCKS5 端口将自动设置为: {socks}",
"local_proxy_node_strategy": "节点选择",
"local_proxy_node_recursive": "按顺序测试节点(Google + Apple),自动使用第一个可用节点。",
"local_proxy_fixed_node_index": "固定节点序号(从 1 开始)"
},
"categories": {
"new_category_placeholder": "输入新分类名称..."
@@ -205,4 +213,4 @@
"start_failed": "启动加速失败",
"singbox_error": "sing-box 发生错误"
}
}
}
+12 -5
View File
@@ -9,6 +9,7 @@ import pinia from './stores'
import i18n from './i18n'
import { initLatencySessionStore } from './utils/latency-session'
import { setupRuntimeLogging } from './utils/runtime-logger'
import { useLocalProxyStore } from './stores/local-proxy'
setupRuntimeLogging()
@@ -16,8 +17,14 @@ void initLatencySessionStore().catch((e) => {
console.error('Failed to init latency session store:', e)
})
createApp(App)
.use(router)
.use(pinia)
.use(i18n)
.mount('#app')
const app = createApp(App)
app.use(router)
app.use(pinia)
app.use(i18n)
const localProxyStore = useLocalProxyStore()
void localProxyStore.startLocalProxy('startup').catch((e) => {
console.error('Failed to start local proxy on startup:', e)
})
app.mount('#app')
+23 -4
View File
@@ -5,6 +5,7 @@ import type { Game } from '@/types'
import { useNodeStore } from './nodes'
import { generateSingboxConfig } from '@/utils/singbox-config'
import { useSettingsStore } from './settings'
import { useLocalProxyStore } from './local-proxy'
export type { Game }
@@ -193,6 +194,7 @@ export const useGameStore = defineStore('games', () => {
const nodeStore = useNodeStore()
const settingsStore = useSettingsStore()
const localProxyStore = useLocalProxyStore()
const selectedNodeId = String(game.nodeId || '')
const node = nodeStore.nodes.find(n => n.id === selectedNodeId || n.tag === selectedNodeId)
@@ -202,6 +204,15 @@ export const useGameStore = defineStore('games', () => {
throw new Error(msg)
}
if (settingsStore.localProxyEnabled) {
try {
const port = await window.system.findAvailablePort(settingsStore.localProxyPort, 2)
settingsStore.localProxyPort = port
} catch (e) {
console.error('Failed to find available port for local proxy:', e)
}
}
try {
const rawGame = toRaw(game) as Game
const rawNode = toRaw(node) as any
@@ -209,10 +220,14 @@ export const useGameStore = defineStore('games', () => {
mode: settingsStore.dnsMode,
primary: settingsStore.dnsPrimary,
secondary: settingsStore.dnsSecondary,
tunInterfaceName: settingsStore.tunInterfaceName
tunInterfaceName: settingsStore.tunInterfaceName,
localProxy: {
enabled: settingsStore.localProxyEnabled,
port: settingsStore.localProxyPort
}
}))
// @ts-ignore
await window.singbox.start(config)
await window.singbox.restart(config)
const procs = Array.isArray(rawGame.processName) ? rawGame.processName.map(p => String(p)) : [String(rawGame.processName)]
const shouldEnableChainProxy = rawGame.proxyMode === 'process' && rawGame.chainProxy !== false
@@ -220,6 +235,10 @@ export const useGameStore = defineStore('games', () => {
if (shouldEnableChainProxy) await window.proxyMonitor.start(String(rawGame.id), procs)
else await window.proxyMonitor.stop()
if (settingsStore.localProxyEnabled) {
localProxyStore.setActiveNode(String(rawNode.id || rawNode.tag || ''))
}
setGameStatus(id, 'accelerating')
} catch (e) {
console.error('Failed to start game acceleration:', e)
@@ -228,13 +247,13 @@ export const useGameStore = defineStore('games', () => {
}
async function stopGame(_id: string) {
const localProxyStore = useLocalProxyStore()
try {
// @ts-ignore
await window.singbox.stop()
// @ts-ignore
await window.proxyMonitor.stop()
resetAllAccelerationStatus()
await localProxyStore.startLocalProxy('game-stop')
} catch (e) {
console.error('Failed to stop game acceleration:', e)
throw e
+167
View File
@@ -0,0 +1,167 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Game } from '@/types'
import type { NodeConfig } from '@/utils/protocol'
import { generateSingboxConfig } from '@/utils/singbox-config'
import { useNodeStore } from './nodes'
import { useSettingsStore } from './settings'
import { useGameStore } from './games'
const LOCAL_PROXY_HOSTS = ['www.google.com', 'www.apple.com']
function nodeKeyOf(node: NodeConfig): string {
return String(node.id || node.tag || '')
}
function buildLocalProxyGame(): Game {
return {
id: '__local_proxy__',
name: 'Local Proxy',
processName: [],
category: 'system',
proxyMode: 'routing',
routingRules: [],
chainProxy: false
}
}
export const useLocalProxyStore = defineStore('local-proxy', () => {
const running = ref(false)
const starting = ref(false)
const activeNodeKey = ref('')
async function verifyHostsViaLocalHttpProxy(httpPort: number): Promise<boolean> {
for (const host of LOCAL_PROXY_HOSTS) {
const res = await window.system.testHttpProxyConnect(httpPort, host, 443, 5000)
if (!res?.ok) return false
}
return true
}
async function ensurePortAvailable(preferredPort: number): Promise<number> {
const port = await window.system.findAvailablePort(preferredPort, 2)
return port
}
async function applyNodeConfig(node: NodeConfig): Promise<void> {
const settings = useSettingsStore()
const localGame = buildLocalProxyGame()
const config = generateSingboxConfig(localGame, node, {
mode: settings.dnsMode,
primary: settings.dnsPrimary,
secondary: settings.dnsSecondary,
tunInterfaceName: settings.tunInterfaceName,
disableTun: true,
localProxy: {
enabled: true,
port: settings.localProxyPort
}
})
await window.proxyMonitor.stop()
await window.singbox.restart(config)
}
function resolveFixedNode(nodes: NodeConfig[], fixedIndex: number): NodeConfig | null {
if (nodes.length === 0) return null
const idx = Math.max(1, Math.floor(fixedIndex || 1)) - 1
return nodes[idx] || nodes[0] || null
}
async function startLocalProxy(reason: 'startup' | 'settings' | 'game-stop' = 'startup'): Promise<boolean> {
if (starting.value) return false
const settings = useSettingsStore()
const nodeStore = useNodeStore()
const gameStore = useGameStore()
if (!settings.localProxyEnabled) {
running.value = false
activeNodeKey.value = ''
return false
}
// Do not interrupt active acceleration unless we're recovering from stop.
if (reason !== 'game-stop' && gameStore.getAcceleratingGame()) {
return false
}
starting.value = true
try {
if (nodeStore.nodes.length === 0) {
await nodeStore.loadNodes()
}
const nodes = nodeStore.nodes.slice()
if (nodes.length === 0) {
running.value = false
activeNodeKey.value = ''
return false
}
const availablePort = await ensurePortAvailable(settings.localProxyPort)
settings.localProxyPort = availablePort
if (!settings.localProxyNodeRecursiveTest) {
const fixed = resolveFixedNode(nodes, settings.localProxyFixedNodeIndex)
if (!fixed) return false
await applyNodeConfig(fixed)
running.value = true
activeNodeKey.value = nodeKeyOf(fixed)
return true
}
for (const node of nodes) {
try {
await applyNodeConfig(node)
const ok = await verifyHostsViaLocalHttpProxy(settings.localProxyPort)
if (!ok) continue
running.value = true
activeNodeKey.value = nodeKeyOf(node)
return true
} catch {
// Try next node.
}
}
await window.proxyMonitor.stop()
await window.singbox.stop()
running.value = false
activeNodeKey.value = ''
return false
} finally {
starting.value = false
}
}
async function stopLocalProxy(): Promise<void> {
const gameStore = useGameStore()
if (gameStore.getAcceleratingGame()) return
await window.proxyMonitor.stop()
await window.singbox.stop()
running.value = false
activeNodeKey.value = ''
}
async function applySettingsChange(): Promise<void> {
const settings = useSettingsStore()
if (!settings.localProxyEnabled) {
await stopLocalProxy()
return
}
await startLocalProxy('settings')
}
function setActiveNode(nodeKey: string) {
activeNodeKey.value = nodeKey
running.value = true
}
return {
running,
starting,
activeNodeKey,
startLocalProxy,
stopLocalProxy,
applySettingsChange,
setActiveNode
}
})
+8
View File
@@ -13,6 +13,10 @@ export const useSettingsStore = defineStore('settings', () => {
const dnsMode = useLocalStorage<DnsMode>('settings-dns-mode', 'secure')
const dnsPrimary = useLocalStorage('settings-dns-primary', 'https://dns.google/dns-query')
const dnsSecondary = useLocalStorage('settings-dns-secondary', 'https://1.1.1.1/dns-query')
const localProxyEnabled = useLocalStorage('settings-local-proxy-enabled', true)
const localProxyPort = useLocalStorage('settings-local-proxy-port', 10860)
const localProxyNodeRecursiveTest = useLocalStorage('settings-local-proxy-node-recursive-test', true)
const localProxyFixedNodeIndex = useLocalStorage('settings-local-proxy-fixed-node-index', 1)
const tunInterfaceName = useLocalStorage('settings-tun-interface-name', 'LagZero')
if (tunInterfaceName.value === 'singbox-tun') {
tunInterfaceName.value = 'LagZero'
@@ -25,6 +29,10 @@ export const useSettingsStore = defineStore('settings', () => {
dnsMode,
dnsPrimary,
dnsSecondary,
localProxyEnabled,
localProxyPort,
localProxyNodeRecursiveTest,
localProxyFixedNodeIndex,
tunInterfaceName
}
})
+56 -2
View File
@@ -41,6 +41,12 @@ export interface DnsConfigOptions {
mode?: 'secure' | 'system' // DNS 模式:安全模式 (DoH/DoT) 或 系统解析
primary?: string // 主 DNS 服务器地址
secondary?: string // 备用 DNS 服务器地址
tunInterfaceName?: string
disableTun?: boolean
localProxy?: {
enabled: boolean
port: number
}
}
/**
@@ -51,7 +57,7 @@ export interface DnsConfigOptions {
* @param dnsOptions DNS 配置选项
* @returns 格式化后的 JSON 字符串
*/
export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?: DnsConfigOptions & { tunInterfaceName?: string }): string {
export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?: DnsConfigOptions): string {
const isRoutingMode = game.proxyMode === 'routing'
const processes = normalizeProcessNames(Array.isArray(game.processName) ? game.processName : [game.processName])
const dnsMode = dnsOptions?.mode || 'secure'
@@ -59,6 +65,7 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
const dnsSecondary = String(dnsOptions?.secondary || 'https://1.1.1.1/dns-query').trim()
const tunInterfaceName = String(dnsOptions?.tunInterfaceName || pkg.productName).trim() || pkg.productName
const useSecureDns = dnsMode === 'secure'
const disableTun = !!dnsOptions?.disableTun
// 基础配置结构
const config: SingboxConfig = {
@@ -81,7 +88,8 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
],
final: useSecureDns ? 'remote-primary' : 'local'
},
inbounds: [
inbounds: [],
/*
{
type: 'tun', // 虚拟网卡类型
tag: 'tun-in', // 虚拟网卡标签
@@ -94,6 +102,7 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
sniff: true
}
],
*/
outbounds: [
{
type: 'selector',
@@ -123,6 +132,51 @@ export function generateSingboxConfig(game: Game, node: NodeConfig, dnsOptions?:
}
}
if (!disableTun) {
config.inbounds.push({
type: 'tun',
tag: 'tun-in',
interface_name: tunInterfaceName,
inet4_address: '172.19.0.1/30',
mtu: 1280,
auto_route: true,
strict_route: false,
stack: 'system',
sniff: true
})
}
if (dnsOptions?.localProxy?.enabled) {
const httpPort = dnsOptions.localProxy.port
const socksPort = httpPort + 1
// HTTP Proxy
config.inbounds.push({
type: 'http',
tag: 'http-in',
listen: '127.0.0.1',
listen_port: httpPort,
sniff: true,
set_system_proxy: false
})
// SOCKS5 Proxy
config.inbounds.push({
type: 'socks',
tag: 'socks-in',
listen: '127.0.0.1',
listen_port: socksPort,
sniff: true
})
// Local mixed proxy inbounds should always tunnel through selected node.
// Otherwise process mode falls back to route.final=direct, which may timeout.
config.route.rules.push({
inbound: ['http-in', 'socks-in'],
outbound: 'proxy'
})
}
// 1. 将节点配置转换为 Outbound 格式并添加到配置中
const nodeOutbound = convertNodeToOutbound(node)
config.outbounds.push(nodeOutbound)
+65 -3
View File
@@ -191,6 +191,54 @@
<div class="w-full h-px bg-border/30"></div>
<!-- Local Proxy -->
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold flex items-center gap-2">
<div class="i-material-symbols-lan text-primary"></div>
{{ $t('settings.local_proxy') }}
</h2>
<n-switch v-model:value="settingsStore.localProxyEnabled">
<template #checked>{{ $t('common.enabled') }}</template>
<template #unchecked>{{ $t('common.disabled') }}</template>
</n-switch>
</div>
<div
class="bg-surface-panel/50 border border-border/50 rounded-2xl p-6 backdrop-blur-sm transition-opacity duration-300"
:class="{ 'opacity-50 pointer-events-none': !settingsStore.localProxyEnabled }">
<div class="space-y-2">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">{{
$t('settings.local_proxy_port') }} (HTTP)</label>
<n-input-number v-model:value="settingsStore.localProxyPort" :min="1024" :max="65535"
class="glass-select" button-placement="both" />
<p class="text-xs text-on-surface-muted mt-1">{{ $t('settings.local_proxy_info', {
socks:
settingsStore.localProxyPort + 1 }) }}</p>
</div>
<div class="space-y-2 pt-2">
<div class="flex items-center justify-between">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.local_proxy_node_strategy') }}
</label>
<n-switch v-model:value="settingsStore.localProxyNodeRecursiveTest" size="small" />
</div>
<p class="text-xs text-on-surface-muted">
{{ $t('settings.local_proxy_node_recursive') }}
</p>
</div>
<div class="space-y-2" :class="{ 'opacity-50 pointer-events-none': settingsStore.localProxyNodeRecursiveTest }">
<label class="text-xs font-bold uppercase tracking-widest text-on-surface-muted">
{{ $t('settings.local_proxy_fixed_node_index') }}
</label>
<n-input-number v-model:value="settingsStore.localProxyFixedNodeIndex" :min="1" :max="9999"
class="glass-select" button-placement="both" />
</div>
</div>
</section>
<div class="w-full h-px bg-border/30"></div>
<!-- Advanced Network -->
<section>
<h2 class="text-xl font-bold mb-4 flex items-center gap-2">
@@ -329,7 +377,7 @@
class="bg-surface-overlay/50 border border-border/50 rounded-xl p-3 flex justify-between items-center mb-4 backdrop-blur-sm">
<span class="text-sm font-medium text-on-surface-muted">{{ $t('settings.current_version') }}</span>
<span class="font-mono font-bold text-primary bg-primary/10 px-2 py-1 rounded">v{{ appVersion
}}</span>
}}</span>
</div>
<n-button type="primary" block secondary size="large" @click="checkUpdate" :loading="checkingUpdate"
@@ -396,16 +444,18 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { computed, ref, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTheme, type ThemeColor } from '@/composables/useTheme'
import { useSettingsStore } from '@/stores/settings'
import { NSelect, NInput, NButton } from 'naive-ui'
import { useLocalProxyStore } from '@/stores/local-proxy'
import { NSelect, NInput, NButton, NSwitch, NInputNumber } from 'naive-ui'
import pkg from '../../../package.json'
const { locale, t } = useI18n()
const { themeColor, setThemeColor } = useTheme()
const settingsStore = useSettingsStore()
const localProxyStore = useLocalProxyStore()
const activeTab = ref('general')
const tabs = computed(() => [
@@ -616,6 +666,18 @@ onMounted(async () => {
window.logs?.onNew(onLogNew)
})
watch(
() => [
settingsStore.localProxyEnabled,
settingsStore.localProxyPort,
settingsStore.localProxyNodeRecursiveTest,
settingsStore.localProxyFixedNodeIndex
],
() => {
void localProxyStore.applySettingsChange()
}
)
onUnmounted(() => {
window.logs?.offNew(onLogNew)
})
+7
View File
@@ -72,6 +72,13 @@ interface Window {
tcpPing: (host: string, port: number) => Promise<number>
flushDnsCache: () => Promise<{ ok: boolean, code: number, output: string, message: string }>
reinstallTunAdapter: (interfaceName?: string) => Promise<{ ok: boolean, code: number, output: string, message: string }>
findAvailablePort: (port: number, count?: number) => Promise<number>
testHttpProxyConnect: (
proxyPort: number,
targetHost: string,
targetPort?: number,
timeoutMs?: number
) => Promise<{ ok: boolean; statusLine: string; error?: string }>
}
proxyMonitor: {
start: (gameId: string, processNames: string[]) => Promise<void>