Release version 1.2 with Supernode user window
Build Windows Portable Package / portable (release) Successful in 9m52s

This commit is contained in:
2026-09-02 04:45:39 +08:00
parent 2bbd370e02
commit ba914125f2
17 changed files with 1074 additions and 79 deletions
+8
View File
@@ -14,10 +14,18 @@ export async function nativeCall(command, args, fallback) {
export const startService = (request) => nativeCall('start_service', { request }, () => {
throw new Error('浏览器预览无法启动原生服务')
})
export const startSupernode = (request) => nativeCall('start_supernode', { request }, () => {
throw new Error('浏览器预览无法启动 Supernode')
})
export const stopService = (service) => nativeCall('stop_service', { service }, () => {
throw new Error('浏览器预览没有受管进程')
})
export const stopSpecialService = () => nativeCall('stop_special_service', {}, () => null)
export const fetchSpecialConfig = (request) => nativeCall('fetch_special_config', { request }, () => {
throw new Error('浏览器预览无法连接特殊服务端')
})
export const fetchSpecialUsers = (request) => nativeCall('fetch_special_users', { request }, () => [])
export const serviceStatus = (service) => nativeCall('service_status', { service }, () => ({
service,
+332 -42
View File
@@ -1,14 +1,16 @@
import { useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { listen } from '@tauri-apps/api/event'
import {
ArrowDown, ArrowUp, CheckCircle2, ChevronDown, CircleAlert, Gauge, Globe2, Network, Play,
Download, Router, ScrollText, Server, Settings2, Square, Wifi,
Download, Router, ScrollText, Server, Settings2, Square, Users, Wifi,
} from 'lucide-react'
import {
configureTapAdapter, exportLog, isTauri, openTapInstaller, readConfig, resolveTapAdapter, serviceStatus, startService,
startupStatus, stopService, tapTraffic, writeConfig,
configureTapAdapter, exportLog, fetchSpecialConfig, fetchSpecialUsers, isTauri, openTapInstaller,
queryManagement, readConfig, resolveTapAdapter, runPing, serviceStatus, startService, startSupernode, startupStatus, stopService,
stopSpecialService, tapTraffic, writeConfig,
} from './lib/tauri'
import './styles.css'
@@ -21,6 +23,22 @@ const initialEdge = {
encryptionKey: '',
managementPort: '5644',
tapDevice: '',
specialClient: false,
specialServerPort: '7788',
username: '',
}
const initialSupernode = {
port: '7777',
dhcp: true,
dhcpPool: '10.10.10.0/24',
allowedCommunities: '',
specialEnabled: false,
specialPort: '7788',
specialCommunity: '',
specialPool: '10.10.10.128/25',
specialGateway: '10.10.10.1',
specialDns: '10.10.10.1',
}
const edgeConfigStorageKey = 'super-n2n.edge-config.v1'
@@ -37,6 +55,17 @@ function parseEdgeConfig(value) {
return fallback
}
function parseSupernodeConfig(value) {
const fallback = { ...initialSupernode }
const saved = value && typeof value === 'object' && value.supernode && typeof value.supernode === 'object'
? value.supernode : null
if (!saved) return fallback
for (const [field, defaultValue] of Object.entries(initialSupernode)) {
if (typeof saved[field] === typeof defaultValue) fallback[field] = saved[field]
}
return fallback
}
function loadBrowserEdgeConfig() {
if (typeof window === 'undefined') return { ...initialEdge }
try {
@@ -47,9 +76,18 @@ function loadBrowserEdgeConfig() {
}
}
function saveBrowserEdgeConfig(config) {
function loadBrowserSupernodeConfig() {
if (typeof window === 'undefined') return { ...initialSupernode }
try {
window.localStorage.setItem(edgeConfigStorageKey, JSON.stringify(config))
return parseSupernodeConfig(JSON.parse(window.localStorage.getItem(edgeConfigStorageKey) || 'null'))
} catch {
return { ...initialSupernode }
}
}
function saveBrowserAppConfig(edge, supernode) {
try {
window.localStorage.setItem(edgeConfigStorageKey, JSON.stringify({ version: 2, edge, supernode }))
} catch {
// The form stays usable when the platform denies local storage access.
}
@@ -59,15 +97,23 @@ function serializeEdgeConfig(config) {
return JSON.stringify({ version: 1, edge: config }, null, 2)
}
function serializeAppConfig(edge, supernode) {
return JSON.stringify({ version: 2, edge, supernode }, null, 2)
}
function App() {
const [home, setHome] = useState('edge')
const [edge, setEdge] = useState(loadBrowserEdgeConfig)
const [supernode, setSupernode] = useState(loadBrowserSupernodeConfig)
const [edgeRunning, setEdgeRunning] = useState(false)
const [supernodeRunning, setSupernodeRunning] = useState(false)
const [activeTapDevice, setActiveTapDevice] = useState('')
const [advancedOpen, setAdvancedOpen] = useState(false)
const [logOpen, setLogOpen] = useState(false)
const [logs, setLogs] = useState([])
const [traffic, setTraffic] = useState(null)
const [specialUsers, setSpecialUsers] = useState([])
const [specialLatencies, setSpecialLatencies] = useState({})
const [message, setMessage] = useState(null)
const [startupInfo, setStartupInfo] = useState(null)
const [configReady, setConfigReady] = useState(!isTauri)
@@ -82,15 +128,15 @@ function App() {
useEffect(() => {
if (!configReady) return
if (!isTauri) {
saveBrowserEdgeConfig(edge)
saveBrowserAppConfig(edge, supernode)
return
}
writeConfig(serializeEdgeConfig(edge)).catch((error) => {
writeConfig(serializeAppConfig(edge, supernode)).catch((error) => {
if (configWriteFailed.current) return
configWriteFailed.current = true
appendLog('配置保存失败:' + error.message, 'warning')
})
}, [edge, configReady])
}, [edge, supernode, configReady])
useEffect(() => {
let active = true
@@ -101,7 +147,11 @@ function App() {
setStartupInfo(status)
if (isTauri && status.configExists) {
const contents = await readConfig()
if (active && contents) setEdge(parseEdgeConfig(JSON.parse(contents)))
if (active && contents) {
const parsed = JSON.parse(contents)
setEdge(parseEdgeConfig(parsed))
setSupernode(parseSupernodeConfig(parsed))
}
}
if (active) {
setConfigReady(true)
@@ -142,7 +192,9 @@ function App() {
unlistenStopped = await listen('tray-service-stopped', () => {
if (!active) return
setEdgeRunning(false)
setSupernodeRunning(false)
setActiveTapDevice('')
setSpecialUsers([])
})
}
bindTrayEvents()
@@ -156,16 +208,66 @@ function App() {
useEffect(() => {
let active = true
serviceStatus('edge')
.then((status) => {
Promise.all([serviceStatus('edge'), serviceStatus('supernode')])
.then(([edgeStatus, supernodeStatus]) => {
if (!active) return
setEdgeRunning(Boolean(status.running))
appendLog(status.running ? '检测到 Edge 已连接' : 'Edge 已就绪,等待连接', status.running ? 'success' : 'info')
setEdgeRunning(Boolean(edgeStatus.running))
setSupernodeRunning(Boolean(supernodeStatus.running))
appendLog(edgeStatus.running ? '检测到 Edge 已连接' : 'Edge 已就绪,等待连接', edgeStatus.running ? 'success' : 'info')
})
.catch(() => {})
return () => { active = false }
}, [])
useEffect(() => {
if (!edgeRunning || !edge.specialClient) {
setSpecialUsers([])
return undefined
}
let active = true
let timer
const poll = async () => {
try {
const users = await fetchSpecialUsers({
host: edge.serverHost,
port: Number(edge.specialServerPort),
username: edge.username,
})
if (active) setSpecialUsers(Array.isArray(users) ? users : [])
} catch {
if (active) setSpecialUsers([])
} finally {
if (active) timer = window.setTimeout(poll, 2000)
}
}
poll()
return () => { active = false; window.clearTimeout(timer) }
}, [edgeRunning, edge.specialClient, edge.serverHost, edge.specialServerPort, edge.username])
useEffect(() => {
if (!edgeRunning || !edge.specialClient || specialUsers.length === 0) {
setSpecialLatencies({})
return undefined
}
let active = true
let timer
const poll = async () => {
const entries = await Promise.all(specialUsers.map(async (user) => {
try {
const result = await runPing({ host: user.ip, count: 1 }, () => ({ ok: false, output: '' }))
const match = String(result?.output || '').match(/(?:time|时间)[=<]\s*(\d+)/i)
return [user.ip, match ? Number(match[1]) : null]
} catch {
return [user.ip, null]
}
}))
if (active) setSpecialLatencies(Object.fromEntries(entries))
if (active) timer = window.setTimeout(poll, 3000)
}
poll()
return () => { active = false; window.clearTimeout(timer) }
}, [edgeRunning, edge.specialClient, specialUsers])
useEffect(() => {
if (!edgeRunning || !activeTapDevice) {
lastTapSample.current = null
@@ -199,6 +301,7 @@ function App() {
}, [activeTapDevice, edgeRunning])
const updateEdge = (field, value) => setEdge((current) => ({ ...current, [field]: value }))
const updateSupernode = (field, value) => setSupernode((current) => ({ ...current, [field]: value }))
const notify = (text, tone = 'error') => {
setMessage({ text, tone })
window.setTimeout(() => setMessage(null), 3200)
@@ -224,43 +327,96 @@ function App() {
notify('请填写服务器 IP 或域名')
return
}
if (!edge.community.trim()) {
if (!edge.specialClient && !edge.community.trim()) {
appendLog('连接校验:未填写群组名称', 'warning')
notify('请填写群组名称')
return
}
if (!edge.dhcp && !edge.ip.trim()) {
if (!edge.specialClient && !edge.dhcp && !edge.ip.trim()) {
appendLog('连接校验:静态 IP 未填写', 'warning')
notify('关闭 DHCP 后,请填写本机 IP')
return
}
try {
let runtimeEdge = edge
if (edge.specialClient) {
const special = await fetchSpecialConfig({
host: edge.serverHost,
port: Number(edge.specialServerPort),
username: edge.username || '',
})
runtimeEdge = {
...edge,
community: special.community,
serverPort: String(special.supernodePort),
dhcp: false,
ip: special.ip,
gateway: special.gateway,
dns: special.dns,
}
if (!edge.username.trim() && special.username) {
setEdge((current) => ({ ...current, username: special.username }))
}
appendLog('已从特殊服务端获取网络配置', 'success')
}
const tapAdapter = await resolveTapAdapter(edge.tapDevice)
appendLog('已选择 TAP 设备:' + tapAdapter.name)
if (isWindowsRuntime) {
await configureTapAdapter({
tapDevice: tapAdapter.guid || tapAdapter.name,
dhcp: edge.dhcp,
ip: edge.ip,
dhcp: runtimeEdge.dhcp,
ip: runtimeEdge.ip,
gateway: runtimeEdge.gateway || '',
dns: runtimeEdge.dns || '',
})
appendLog('已配置 TAP 网卡地址', 'success')
}
const status = await startService({
service: 'edge',
binaryPath: './n2n/edge.exe',
args: buildEdgeArgs(edge, tapAdapter.guid || tapAdapter.name),
args: buildEdgeArgs(runtimeEdge, tapAdapter.guid || tapAdapter.name),
})
setEdgeRunning(Boolean(status.running))
setActiveTapDevice(tapAdapter.name)
appendLog('已连接到群组 ' + edge.community, 'success')
notify('已连接到 ' + edge.community, 'success')
appendLog('已连接到群组 ' + runtimeEdge.community, 'success')
notify('已连接到 ' + runtimeEdge.community, 'success')
} catch (error) {
appendLog('连接失败:' + error.message, 'error')
notify('无法建立连接:' + error.message)
}
}
const toggleSupernode = async () => {
if (supernodeRunning) {
try {
await stopService('supernode')
await stopSpecialService()
await hideSupernodeUsersWindow()
setSupernodeRunning(false)
appendLog('Supernode 已停止')
notify('Supernode 已停止', 'neutral')
} catch (error) {
notify('停止 Supernode 失败:' + error.message)
}
return
}
try {
const status = await startSupernode({
...supernode,
port: Number(supernode.port),
specialPort: Number(supernode.specialPort),
})
setSupernodeRunning(Boolean(status.running))
await openSupernodeUsersWindow()
appendLog('Supernode 已启动', 'success')
notify('Supernode 已启动', 'success')
} catch (error) {
appendLog('启动 Supernode 失败:' + error.message, 'error')
notify('无法启动 Supernode' + error.message)
}
}
const installTapDriver = async () => {
try {
const path = await openTapInstaller()
@@ -275,7 +431,7 @@ function App() {
return <main className="app-shell">
<header className="app-header">
<div className="brand"><span className="brand-icon"><Network size={19} /></span><span>Super N2N</span></div>
<div className="runtime"><span className={edgeRunning ? 'status-light live' : 'status-light'} />{edgeRunning ? '已连接' : '未连接'}</div>
<div className="runtime"><span className={edgeRunning || supernodeRunning ? 'status-light live' : 'status-light'} />{edgeRunning ? 'Edge 已连接' : supernodeRunning ? 'Supernode 运行中' : '未连接'}</div>
</header>
<section className="workspace" aria-label="N2N 连接设置">
@@ -289,8 +445,8 @@ function App() {
</div>
{home === 'edge'
? <EdgeHome edge={edge} updateEdge={updateEdge} running={edgeRunning} traffic={traffic} activeTapDevice={activeTapDevice} startupInfo={startupInfo} advancedOpen={advancedOpen} setAdvancedOpen={setAdvancedOpen} logOpen={logOpen} setLogOpen={setLogOpen} logs={logs} onToggle={toggleEdge} onInstallTap={installTapDriver} onExportLog={() => exportLogs(logs, notify)} />
: <SupernodeHome />}
? <EdgeHome edge={edge} updateEdge={updateEdge} running={edgeRunning} traffic={traffic} activeTapDevice={activeTapDevice} specialUsers={specialUsers} specialLatencies={specialLatencies} startupInfo={startupInfo} advancedOpen={advancedOpen} setAdvancedOpen={setAdvancedOpen} logOpen={logOpen} setLogOpen={setLogOpen} logs={logs} onToggle={toggleEdge} onInstallTap={installTapDriver} onExportLog={() => exportLogs(logs, notify)} />
: <SupernodeHome config={supernode} update={updateSupernode} running={supernodeRunning} onToggle={toggleSupernode} />}
</section>
<footer className="app-footer"><span>{isTauri ? '本机服务控制已就绪' : '浏览器预览模式'}</span><span>n2n 3.1.1</span></footer>
@@ -298,7 +454,108 @@ function App() {
</main>
}
function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, startupInfo, advancedOpen, setAdvancedOpen, setLogOpen, logOpen, logs, onToggle, onInstallTap, onExportLog }) {
async function openSupernodeUsersWindow() {
if (!isTauri) return
const existing = await WebviewWindow.getByLabel('supernode-users')
if (existing) {
await existing.destroy()
}
const window = new WebviewWindow('supernode-users', {
url: 'supernode-users.html',
title: 'Supernode 在线用户',
width: 320,
height: 460,
minWidth: 320,
minHeight: 360,
maxWidth: 420,
maxHeight: 720,
resizable: true,
visible: true,
})
window.once('tauri://error', (event) => console.error('无法打开 Supernode 在线用户窗口', event))
}
async function hideSupernodeUsersWindow() {
if (!isTauri) return
const existing = await WebviewWindow.getByLabel('supernode-users')
if (existing) await existing.hide()
}
function SupernodeUsersWindow() {
const [users, setUsers] = useState([])
const [running, setRunning] = useState(false)
const [specialPort, setSpecialPort] = useState(7788)
const [specialEnabled, setSpecialEnabled] = useState(false)
useEffect(() => {
let active = true
let timer
const poll = async () => {
try {
const status = await serviceStatus('supernode')
if (!active) return
setRunning(Boolean(status.running))
const configText = await readConfig()
const config = configText ? JSON.parse(configText).supernode || {} : {}
const enabled = Boolean(config.specialEnabled)
const port = Number(config.specialPort) || 7788
setSpecialEnabled(enabled)
setSpecialPort(port)
if (!status.running) {
setUsers([])
return
}
const rows = await queryManagement({ host: '127.0.0.1', port: 5645, command: 'edges' })
const merged = (Array.isArray(rows) ? rows : [])
.filter((row) => row?._type === 'row' && row.ip4addr)
.map((row) => ({ username: row.desc || '未知主机', ip: row.ip4addr, special: false }))
if (enabled) {
const leased = await fetchSpecialUsers({ host: '127.0.0.1', port, username: '' })
const knownIps = new Set(merged.map((user) => user.ip))
;(Array.isArray(leased) ? leased : []).forEach((user) => {
const current = merged.find((entry) => entry.ip === user.ip)
if (current) current.special = true
else if (!knownIps.has(user.ip)) merged.push({ username: user.username, ip: user.ip, special: true })
})
}
if (active) setUsers(merged)
} catch {
if (active) setUsers([])
} finally {
if (active) timer = window.setTimeout(poll, 2000)
}
}
poll()
return () => { active = false; window.clearTimeout(timer) }
}, [])
return <main className="friends-window">
<header className="friends-header">
<div><span className={'status-light ' + (running ? 'live' : '')} /><strong>Supernode 在线用户</strong></div>
<span className="friends-count">{users.length}</span>
</header>
<div className="friends-subtitle">{running ? (specialEnabled ? `特殊服务端 :${specialPort}` : '当前连接的客户端') : 'Supernode 未运行'}</div>
{users.length === 0
? <div className="friends-empty"><Users size={30} /><span>{running ? '暂无在线用户' : '启动 Supernode 后显示在线用户'}</span></div>
: <ul className="friends-list">{users.map((user) => <li key={user.username + user.ip}>
<span className="friend-avatar">{(user.username || '?').slice(0, 1).toUpperCase()}</span>
<span className="friend-main"><strong>{user.username}</strong><small>{user.ip}</small></span>
{user.special && <span className="friend-badge">特殊</span>}
</li>)}</ul>}
</main>
}
function Root() {
const queryView = typeof window !== 'undefined'
? new URLSearchParams(window.location.search).get('view')
: null
const label = isTauri ? getCurrentWindow().label : 'main'
return queryView === 'supernode-users' || label === 'supernode-users'
? <SupernodeUsersWindow />
: <App />
}
function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, specialUsers, specialLatencies, startupInfo, advancedOpen, setAdvancedOpen, setLogOpen, logOpen, logs, onToggle, onInstallTap, onExportLog }) {
const toggleAdvanced = () => {
setLogOpen(false)
setAdvancedOpen((current) => !current)
@@ -325,20 +582,24 @@ function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, startup
<span className="server-inputs">
<input value={edge.serverHost} onChange={(event) => updateEdge('serverHost', event.target.value)} placeholder="IP 地址或域名" autoComplete="off" disabled={running} aria-label="服务器 IP 或域名" />
<span className="port-separator">:</span>
<input className="port-input" inputMode="numeric" value={edge.serverPort} onChange={(event) => updateEdge('serverPort', event.target.value)} placeholder="7777" disabled={running} aria-label="服务器端口" />
<input className="port-input" inputMode="numeric" value={edge.specialClient ? edge.specialServerPort : edge.serverPort} onChange={(event) => updateEdge(edge.specialClient ? 'specialServerPort' : 'serverPort', event.target.value)} placeholder={edge.specialClient ? '7788' : '7777'} disabled={running} aria-label={edge.specialClient ? '特殊服务端端口' : '服务器端口'} />
</span>
</label>
<label className="input-group">
<span className="field-title"><Network size={18} />群组名称</span>
<input value={edge.community} onChange={(event) => updateEdge('community', event.target.value)} placeholder="例如:my-network" autoComplete="off" disabled={running} />
</label>
{edge.specialClient
? <div className="special-client-note"><Users size={16} /><span>特殊客户端模式社区IP网关和 DNS 由服务端下发</span></div>
: <>
<label className="input-group">
<span className="field-title"><Network size={18} />群组名称</span>
<input value={edge.community} onChange={(event) => updateEdge('community', event.target.value)} placeholder="例如:my-network" autoComplete="off" disabled={running} />
</label>
<div className="input-group">
<span className="ip-heading"><span className="field-title"><Router size={18} />本机 IP</span><DhcpToggle checked={edge.dhcp} disabled={running} onChange={(checked) => updateEdge('dhcp', checked)} /></span>
<input value={edge.dhcp ? '' : edge.ip} onChange={(event) => updateEdge('ip', event.target.value)} placeholder={edge.dhcp ? '开启 DHCP 后由服务器分配' : '例如:10.0.0.2'} autoComplete="off" disabled={edge.dhcp || running} aria-describedby="ip-help" />
<small id="ip-help">{edge.dhcp ? 'DHCP 已开启,连接后自动获取 IP。' : '使用静态 IP 时,请确保它没有被其他设备占用。'}</small>
</div>
<div className="input-group">
<span className="ip-heading"><span className="field-title"><Router size={18} />本机 IP</span><DhcpToggle checked={edge.dhcp} disabled={running} onChange={(checked) => updateEdge('dhcp', checked)} /></span>
<input value={edge.dhcp ? '' : edge.ip} onChange={(event) => updateEdge('ip', event.target.value)} placeholder={edge.dhcp ? '开启 DHCP 后由服务器分配' : '例如:10.0.0.2'} autoComplete="off" disabled={edge.dhcp || running} aria-describedby="ip-help" />
<small id="ip-help">{edge.dhcp ? 'DHCP 已开启,连接后自动获取 IP。' : '使用静态 IP 时,请确保它没有被其他设备占用。'}</small>
</div>
</>}
</div>
<TapSetupPrompt status={startupInfo} onInstall={onInstallTap} />
@@ -349,6 +610,7 @@ function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, startup
</button>
{running && <TrafficStrip traffic={traffic} tapDevice={activeTapDevice} />}
{running && edge.specialClient && <SpecialUsersPanel users={specialUsers} latencies={specialLatencies} />}
</section>
<div className="secondary-actions">
@@ -360,6 +622,8 @@ function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, startup
<label><span>加密密钥</span><input type="password" value={edge.encryptionKey} onChange={(event) => updateEdge('encryptionKey', event.target.value)} placeholder="可选" disabled={running} /></label>
<label><span>管理端口</span><input inputMode="numeric" value={edge.managementPort} onChange={(event) => updateEdge('managementPort', event.target.value)} disabled={running} /></label>
<label><span>TAP 设备可选</span><input value={edge.tapDevice} onChange={(event) => updateEdge('tapDevice', event.target.value)} placeholder="留空自动选择" disabled={running} /></label>
<label className="switch-field"><span>特殊客户端</span><DhcpToggle label="启用" checked={edge.specialClient} disabled={running} onChange={(checked) => updateEdge('specialClient', checked)} /></label>
{edge.specialClient && <label><span>用户名</span><input value={edge.username} onChange={(event) => updateEdge('username', event.target.value)} placeholder="留空使用主机名" disabled={running} /></label>}
</div>}
</section>
@@ -400,23 +664,48 @@ function TrafficMetric({ direction, label, rate, total }) {
</output>
}
function DhcpToggle({ checked, disabled, onChange }) {
function DhcpToggle({ checked, disabled, onChange, label = 'DHCP' }) {
return <label className="dhcp-toggle">
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
<span className="toggle-track" aria-hidden="true"><i /></span>
<span>DHCP</span>
<span>{label}</span>
</label>
}
function SupernodeHome() {
return <div className="coming-soon">
<div className="coming-icon"><Server size={29} /></div>
<p className="eyebrow">SUPERNODE</p>
<h1>创建服务器</h1>
<p>服务器配置即将加入此页面</p>
function SupernodeHome({ config, update, running, onToggle }) {
return <div className="supernode-flow">
<section className="supernode-card">
<div className="supernode-heading"><span className="intro-icon"><Server size={23} /></span><span><p className="eyebrow">SUPERNODE</p><h1>创建服务器</h1></span></div>
<div className="supernode-fields">
<label><span>UDP 监听端口</span><input inputMode="numeric" value={config.port} onChange={(event) => update('port', event.target.value)} disabled={running} /></label>
<label className="supernode-dhcp"><span>DHCP 服务</span><DhcpToggle checked={config.dhcp} disabled={running} onChange={(checked) => update('dhcp', checked)} /></label>
<label className="wide"><span>DHCP 地址池</span><input value={config.dhcp ? config.dhcpPool : ''} onChange={(event) => update('dhcpPool', event.target.value)} placeholder="10.10.10.0/24" disabled={!config.dhcp || running} /></label>
<label className="wide"><span>允许的社区名</span><textarea value={config.allowedCommunities} onChange={(event) => update('allowedCommunities', event.target.value)} placeholder="每行一个;留空时不限制" disabled={running} /></label>
</div>
<section className="special-server">
<div className="special-server-toggle"><span><Users size={16} />特殊服务端</span><DhcpToggle label="启用" checked={config.specialEnabled} disabled={running} onChange={(checked) => update('specialEnabled', checked)} /></div>
{config.specialEnabled && <div className="special-server-fields">
<label><span>TCP 端口</span><input inputMode="numeric" value={config.specialPort} onChange={(event) => update('specialPort', event.target.value)} disabled={running} /></label>
<label><span>下发社区</span><input value={config.specialCommunity} onChange={(event) => update('specialCommunity', event.target.value)} disabled={running} /></label>
<label><span>下发地址池</span><input value={config.specialPool} onChange={(event) => update('specialPool', event.target.value)} placeholder="10.10.10.128/25" disabled={running} /></label>
<label><span>网关</span><input value={config.specialGateway} onChange={(event) => update('specialGateway', event.target.value)} placeholder="可选" disabled={running} /></label>
<label><span>DNS</span><input value={config.specialDns} onChange={(event) => update('specialDns', event.target.value)} placeholder="可选" disabled={running} /></label>
</div>}
</section>
<button className={running ? 'connect-button stop' : 'connect-button'} onClick={onToggle}>{running ? <Square size={18} fill="currentColor" /> : <Play size={19} fill="currentColor" />}{running ? '停止 Supernode' : '启动 Supernode'}</button>
</section>
</div>
}
function SpecialUsersPanel({ users, latencies = {} }) {
return <section className="special-users" aria-label="社区在线用户">
<header><span><Users size={16} />社区在线用户</span><small>{users.length} </small></header>
{users.length === 0
? <p>暂无客户端向特殊服务端请求配置</p>
: <ol>{users.map((user) => <li key={user.username + user.ip}><strong>{user.username}</strong><span>{user.ip}</span><small>{latencies[user.ip] == null ? '延迟 --' : `延迟 ${latencies[user.ip]} ms`}</small></li>)}</ol>}
</section>
}
function LogPanel({ open, onToggle, logs, onExport }) {
return <section className="log-section">
<button className="log-toggle" onClick={onToggle} aria-expanded={open}>
@@ -486,6 +775,7 @@ function buildEdgeArgs(config, tapDevice) {
addOption(args, '-a', config.dhcp ? 'dhcp:0.0.0.0' : config.ip)
addOption(args, '-d', tapDevice)
addOption(args, '-t', config.managementPort)
addOption(args, '-I', config.username)
if (config.encryptionKey) addOption(args, '-k', config.encryptionKey)
// Windows edge does not implement the POSIX-only -f daemon flag.
if (!isWindowsRuntime) args.push('-f')
+89 -1
View File
@@ -21,7 +21,7 @@
* { box-sizing: border-box; }
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
body { min-height: 100vh; background: var(--canvas); }
button, input { font: inherit; }
button, input, textarea { font: inherit; }
button { border: 0; cursor: pointer; }
button:focus-visible, input:focus-visible { outline: 3px solid rgba(43, 108, 176, .35); outline-offset: 2px; }
button:disabled, input:disabled { cursor: not-allowed; }
@@ -49,6 +49,8 @@ h1 { margin: 0; color: var(--ink); font-size: 24px; line-height: 1.2; letter-spa
.intro-copy { margin: 4px 0 0; color: var(--muted); font-size: 13px; line-height: 1.45; }
.connection-card { overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: 7px; box-shadow: 0 7px 20px rgba(21, 53, 57, .07); }
.special-client-note { display: flex; align-items: center; gap: 8px; margin: 8px 0 2px; padding: 9px 10px; color: #245c78; background: #edf7fb; border: 1px solid #c9e1ed; border-radius: 5px; font-size: 12px; line-height: 1.4; }
.special-client-note svg { flex: 0 0 auto; color: var(--blue); }
.field-stack { display: grid; padding: 18px 22px 6px; }
.input-group { display: grid; gap: 7px; padding: 14px 0; border-bottom: 1px solid #e7eeee; }
.input-group:last-child { border-bottom: 0; }
@@ -141,6 +143,38 @@ h1 { margin: 0; color: var(--ink); font-size: 24px; line-height: 1.2; letter-spa
.coming-soon h1 { margin-bottom: 10px; }
.coming-soon p:last-child { margin: 0; color: var(--muted); font-size: 14px; }
.supernode-flow { display: grid; gap: 8px; animation: reveal 220ms ease-out; }
.supernode-card { overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: 7px; box-shadow: 0 7px 20px rgba(21, 53, 57, .07); }
.supernode-heading { display: flex; align-items: center; gap: 10px; padding: 14px 16px 10px; border-bottom: 1px solid #e7eeee; }
.supernode-heading .intro-icon { width: 36px; height: 36px; border-radius: 7px; }
.supernode-heading h1 { font-size: 18px; }
.supernode-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 9px 10px; padding: 12px 16px; }
.supernode-fields label, .special-server-fields label { display: grid; gap: 5px; color: var(--muted); font-size: 12px; font-weight: 600; }
.supernode-fields .wide { grid-column: 1 / -1; }
.supernode-fields input, .supernode-fields textarea, .special-server-fields input { width: 100%; height: 34px; padding: 0 9px; color: var(--ink); background: #fbfdfd; border: 1px solid var(--line-strong); border-radius: 5px; font-size: 13px; }
.supernode-fields textarea { height: 58px; padding: 8px 9px; resize: vertical; }
.supernode-fields input:disabled, .supernode-fields textarea:disabled, .special-server-fields input:disabled { color: #83969b; background: #f0f5f5; border-color: #dce6e7; }
.supernode-dhcp { align-content: space-between; }
.special-server { margin: 0 16px; border: 1px solid #cbdede; border-radius: 6px; background: #f7fbfb; }
.special-server-toggle { min-height: 38px; display: flex; align-items: center; justify-content: space-between; padding: 0 10px; color: var(--ink); font-size: 12px; font-weight: 700; }
.special-server-toggle > span { display: inline-flex; align-items: center; gap: 6px; }
.special-server-toggle svg { color: var(--blue); }
.special-server-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0 10px 10px; border-top: 1px solid #dbe8e8; }
.special-server-fields label { padding-top: 8px; }
.special-server-fields label:nth-child(n + 3) { grid-column: 1 / -1; }
.supernode-card .connect-button { margin-top: 11px; margin-bottom: 13px; }
.special-users { overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: 7px; }
.special-users header { min-height: 38px; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; color: var(--ink); background: #f5fbfa; border-bottom: 1px solid var(--line); font-size: 12px; font-weight: 700; }
.special-users header span { display: inline-flex; align-items: center; gap: 6px; color: var(--teal-dark); }
.special-users header small { color: var(--faint); font-size: 11px; }
.special-users > p { margin: 0; padding: 13px; color: var(--muted); font-size: 12px; }
.special-users ol { max-height: 130px; margin: 0; padding: 0; overflow-y: auto; list-style: none; }
.special-users li { display: grid; grid-template-columns: minmax(0, 1fr) 94px 76px; gap: 8px; align-items: center; min-height: 34px; padding: 7px 12px; border-bottom: 1px solid #e7eeee; font-size: 12px; }
.special-users li:last-child { border-bottom: 0; }
.special-users li strong { overflow: hidden; color: var(--ink); text-overflow: ellipsis; white-space: nowrap; }
.special-users li span { color: var(--blue); font-family: ui-monospace, "Cascadia Code", Consolas, monospace; font-size: 11px; }
.special-users li small { color: var(--faint); font-size: 10px; text-align: right; }
.app-footer { color: var(--faint); border-top: 1px solid var(--line); font-size: 12px; }
.notice { position: fixed; right: 24px; bottom: 24px; max-width: min(420px, calc(100vw - 48px)); display: flex; align-items: center; gap: 10px; padding: 14px 16px; color: #803035; background: #fff7f7; border: 1px solid #e6b9bb; border-radius: 7px; box-shadow: 0 8px 22px rgba(44, 44, 44, .13); font-size: 14px; animation: reveal 200ms ease-out; }
.notice.success { color: #0d6a5e; background: #f0fbf8; border-color: #a8d9cd; }
@@ -254,6 +288,27 @@ h1 { margin: 0; color: var(--ink); font-size: 24px; line-height: 1.2; letter-spa
.advanced-fields label { gap: 4px; font-size: 11px; }
.advanced-fields input { height: 30px; padding: 0 7px; border-radius: 4px; font-size: 12px; }
.supernode-flow { height: calc(100% - 41px); grid-template-rows: minmax(0, 1fr) auto; overflow: hidden; }
.supernode-card { min-height: 0; overflow-y: auto; box-shadow: none; border-radius: 6px; }
.supernode-heading { display: none; }
.supernode-fields { grid-template-columns: 1fr; gap: 5px; padding: 7px 8px; }
.supernode-fields label, .special-server-fields label { gap: 3px; font-size: 10px; }
.supernode-fields input, .supernode-fields textarea, .special-server-fields input { height: 28px; padding: 0 7px; border-radius: 4px; font-size: 11px; }
.supernode-fields textarea { height: 42px; padding: 5px 7px; }
.supernode-fields .wide { grid-column: auto; }
.supernode-dhcp { display: flex !important; align-items: center; justify-content: space-between; }
.special-server { margin: 0 8px; border-radius: 4px; }
.special-server-toggle { min-height: 31px; padding: 0 7px; font-size: 11px; }
.special-server-fields { grid-template-columns: 1fr; gap: 5px; padding: 0 7px 7px; }
.special-server-fields label { grid-column: auto !important; padding-top: 5px; }
.supernode-card .connect-button { width: calc(100% - 16px); min-height: 32px; margin: 6px 8px; font-size: 12px; }
.special-users { border-radius: 5px; }
.special-users header { min-height: 30px; padding: 0 8px; font-size: 11px; }
.special-users > p { padding: 8px; font-size: 10px; }
.special-users ol { max-height: 64px; }
.special-users li { grid-template-columns: minmax(0, 1fr) 78px 60px; min-height: 26px; gap: 4px; padding: 4px 8px; font-size: 10px; }
.special-users li span, .special-users li small { font-size: 9px; }
.log-content { padding: 8px 0 0; }
.log-toolbar { min-height: 32px; padding: 0 4px; gap: 8px; }
.log-toolbar > span { font-size: 11px; }
@@ -276,3 +331,36 @@ h1 { margin: 0; color: var(--ink); font-size: 24px; line-height: 1.2; letter-spa
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
.friends-window {
min-height: 100vh;
box-sizing: border-box;
padding: 0 0 10px;
overflow: hidden;
background: #eef5f5;
color: #163238;
}
.friends-header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 58px;
padding: 0 16px;
color: #f6ffff;
background: #173f46;
border-bottom: 3px solid #16a394;
}
.friends-header > div { display: flex; align-items: center; gap: 9px; min-width: 0; }
.friends-header strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; letter-spacing: .01em; }
.friends-count { display: grid; place-items: center; min-width: 25px; height: 25px; border-radius: 50%; color: #173f46; background: #a7eee2; font-size: 12px; font-weight: 800; }
.friends-subtitle { padding: 11px 16px 7px; color: #698084; font-size: 11px; font-weight: 600; }
.friends-list { display: grid; gap: 2px; margin: 0; padding: 0 8px; list-style: none; }
.friends-list li { display: flex; align-items: center; gap: 10px; min-height: 53px; padding: 0 9px; border: 1px solid transparent; border-radius: 7px; background: rgba(255,255,255,.72); transition: background .15s ease, border-color .15s ease; }
.friends-list li:hover { border-color: #a6d9d3; background: #fff; }
.friend-avatar { display: grid; place-items: center; width: 30px; height: 30px; flex: 0 0 30px; border-radius: 8px; color: #fff; background: #0b8d82; font-size: 14px; font-weight: 800; }
.friend-main { display: grid; min-width: 0; gap: 3px; }
.friend-main strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.friend-main small { color: #6c8588; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; }
.friend-badge { margin-left: auto; padding: 3px 6px; border-radius: 4px; color: #08786f; background: #d1f3ee; font-size: 9px; font-weight: 800; }
.friends-empty { display: grid; justify-items: center; gap: 10px; padding: 60px 20px; color: #7c9295; text-align: center; font-size: 12px; }
.friends-empty svg { color: #8fb2b2; }
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { Users } from 'lucide-react'
import { fetchSpecialUsers, isTauri, queryManagement, readConfig, serviceStatus } from './lib/tauri'
import './styles.css'
function SupernodeUsersWindow() {
const [users, setUsers] = useState([])
const [running, setRunning] = useState(false)
const [specialPort, setSpecialPort] = useState(7788)
const [specialEnabled, setSpecialEnabled] = useState(false)
useEffect(() => {
let active = true
let timer
const poll = async () => {
try {
const status = await serviceStatus('supernode')
if (!active) return
setRunning(Boolean(status.running))
const configText = await readConfig()
const config = configText ? JSON.parse(configText).supernode || {} : {}
const enabled = Boolean(config.specialEnabled)
const port = Number(config.specialPort) || 7788
setSpecialEnabled(enabled)
setSpecialPort(port)
if (!status.running) {
setUsers([])
return
}
const rows = await queryManagement({ host: '127.0.0.1', port: 5645, command: 'edges' })
const merged = (Array.isArray(rows) ? rows : [])
.filter((row) => row?._type === 'row' && row.ip4addr)
.map((row) => ({ username: row.desc || '未知主机', ip: row.ip4addr, special: false }))
if (enabled) {
const leased = await fetchSpecialUsers({ host: '127.0.0.1', port, username: '' })
const knownIps = new Set(merged.map((user) => user.ip))
;(Array.isArray(leased) ? leased : []).forEach((user) => {
const current = merged.find((entry) => entry.ip === user.ip)
if (current) current.special = true
else if (!knownIps.has(user.ip)) merged.push({ username: user.username, ip: user.ip, special: true })
})
}
if (active) setUsers(merged)
} catch {
if (active) setUsers([])
} finally {
if (active) timer = window.setTimeout(poll, 2000)
}
}
poll()
return () => { active = false; window.clearTimeout(timer) }
}, [])
return <main className="friends-window">
<header className="friends-header">
<div><span className={'status-light ' + (running ? 'live' : '')} /><strong>Supernode 在线用户</strong></div>
<span className="friends-count">{users.length}</span>
</header>
<div className="friends-subtitle">{running ? (specialEnabled ? `特殊服务端 :${specialPort}` : '当前连接的客户端') : 'Supernode 未运行'}</div>
{users.length === 0
? <div className="friends-empty"><Users size={30} /><span>{running ? '暂无在线用户' : '启动 Supernode 后显示在线用户'}</span></div>
: <ul className="friends-list">{users.map((user) => <li key={user.username + user.ip}>
<span className="friend-avatar">{(user.username || '?').slice(0, 1).toUpperCase()}</span>
<span className="friend-main"><strong>{user.username}</strong><small>{user.ip}</small></span>
{user.special && <span className="friend-badge">特殊</span>}
</li>)}</ul>}
</main>
}
createRoot(document.getElementById('root')).render(<SupernodeUsersWindow />)