From e9b072fc929fc90025fc4e4fc0cfe451a09424e9 Mon Sep 17 00:00:00 2001 From: ZenEcho Date: Wed, 18 Feb 2026 14:31:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=B7=B7=E5=90=88=E4=BB=A3=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增本地 HTTP/SOCKS5 代理服务,支持固定端口和节点选择策略 - 添加系统 API 用于端口检测和 HTTP 代理连通性测试 - 在设置页面添加本地代理配置面板,支持启用/禁用和节点选择策略 - 在节点管理器中显示当前代理活动节点 --- dist-electron/main.js | 71 +++++++++++- dist-electron/preload.mjs | 4 +- electron/main.ts | 34 ++++++ electron/network/port.ts | 36 ++++++ electron/preload.ts | 3 + src/components/node/NodeManager.vue | 13 +++ src/locales/en-US.json | 14 ++- src/locales/zh-CN.json | 14 ++- src/main.ts | 17 ++- src/stores/games.ts | 27 ++++- src/stores/local-proxy.ts | 167 ++++++++++++++++++++++++++++ src/stores/settings.ts | 8 ++ src/utils/singbox-config.ts | 58 +++++++++- src/views/settings/index.vue | 68 ++++++++++- src/vite-env.d.ts | 7 ++ 15 files changed, 517 insertions(+), 24 deletions(-) create mode 100644 electron/network/port.ts create mode 100644 src/stores/local-proxy.ts diff --git a/dist-electron/main.js b/dist-electron/main.js index 4362dce..b17faec 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -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) { diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index 3cc513a..ab80890 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -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), diff --git a/electron/main.ts b/electron/main.ts index 270d4e6..1af69a4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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()) diff --git a/electron/network/port.ts b/electron/network/port.ts new file mode 100644 index 0000000..b5e3157 --- /dev/null +++ b/electron/network/port.ts @@ -0,0 +1,36 @@ + +import net from 'net' + +export async function findAvailablePort(startPort: number, count: number = 1): Promise { + 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 { + 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') + }) +} diff --git a/electron/preload.ts b/electron/preload.ts index 8523ef8..ad38dde 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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', { diff --git a/src/components/node/NodeManager.vue b/src/components/node/NodeManager.vue index 0c66de6..3b82477 100644 --- a/src/components/node/NodeManager.vue +++ b/src/components/node/NodeManager.vue @@ -19,6 +19,9 @@ {{ node.type }} {{ node.server }}:{{ node.server_port }} +
+ HTTP {{ settingsStore.localProxyPort }} / SOCKS5 {{ settingsStore.localProxyPort + 1 }} +
@@ -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 diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 92f56c9..b6cc0c8 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -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" } -} \ No newline at end of file +} diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index 922fad5..98c4947 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -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 发生错误" } -} \ No newline at end of file +} diff --git a/src/main.ts b/src/main.ts index 4ffb437..803d831 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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') diff --git a/src/stores/games.ts b/src/stores/games.ts index 695c8b6..5913f41 100644 --- a/src/stores/games.ts +++ b/src/stores/games.ts @@ -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 diff --git a/src/stores/local-proxy.ts b/src/stores/local-proxy.ts new file mode 100644 index 0000000..e759fae --- /dev/null +++ b/src/stores/local-proxy.ts @@ -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 { + 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 { + const port = await window.system.findAvailablePort(preferredPort, 2) + return port + } + + async function applyNodeConfig(node: NodeConfig): Promise { + 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 { + 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 { + const gameStore = useGameStore() + if (gameStore.getAcceleratingGame()) return + await window.proxyMonitor.stop() + await window.singbox.stop() + running.value = false + activeNodeKey.value = '' + } + + async function applySettingsChange(): Promise { + 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 + } +}) diff --git a/src/stores/settings.ts b/src/stores/settings.ts index cbe8578..afa9fcb 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -13,6 +13,10 @@ export const useSettingsStore = defineStore('settings', () => { const dnsMode = useLocalStorage('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 } }) diff --git a/src/utils/singbox-config.ts b/src/utils/singbox-config.ts index b2fc047..3550629 100644 --- a/src/utils/singbox-config.ts +++ b/src/utils/singbox-config.ts @@ -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) diff --git a/src/views/settings/index.vue b/src/views/settings/index.vue index 71cd0da..f8472f9 100644 --- a/src/views/settings/index.vue +++ b/src/views/settings/index.vue @@ -191,6 +191,54 @@
+ +
+
+

+
+ {{ $t('settings.local_proxy') }} +

+ + + + +
+ +
+
+ + +

{{ $t('settings.local_proxy_info', { + socks: + settingsStore.localProxyPort + 1 }) }}

+
+
+
+ + +
+

+ {{ $t('settings.local_proxy_node_recursive') }} +

+
+
+ + +
+
+
+ +
+

@@ -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"> {{ $t('settings.current_version') }} v{{ appVersion - }} + }}