feat: 添加本地混合代理功能
- 新增本地 HTTP/SOCKS5 代理服务,支持固定端口和节点选择策略 - 添加系统 API 用于端口检测和 HTTP 代理连通性测试 - 在设置页面添加本地代理配置面板,支持启用/禁用和节点选择策略 - 在节点管理器中显示当前代理活动节点
This commit is contained in:
1 parent
66d9c1de08
commit
e9b072fc92
15 files changed
+517
-24
No files matched your search
+68
-3
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Vendored
+7
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user