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
+2 -2
View File
@@ -26,6 +26,6 @@ jobs:
with: with:
name: super-n2n-${{ github.sha }} name: super-n2n-${{ github.sha }}
path: | path: |
release/super-n2n-portable.zip release/super-n2n-portable-*.zip
release/super-n2n-portable.zip.sha256 release/super-n2n-portable-*.zip.sha256
if-no-files-found: error if-no-files-found: error
+16 -1
View File
@@ -45,7 +45,8 @@ Install dependencies, then start the Tauri development window:
npm install npm install
npm run tauri dev npm run tauri dev
The portable build creates `release/super-n2n-portable.zip`. It contains The portable build creates a versioned archive such as
`release/super-n2n-portable-1.2.zip`. It contains
`n2n/edge.exe`, `n2n/supernode.exe`, and the official TAP-Windows installer in `n2n/edge.exe`, `n2n/supernode.exe`, and the official TAP-Windows installer in
`drivers/tap-windows-9.24.7-I601-Win10.exe`; on Windows Super N2N starts the n2n `drivers/tap-windows-9.24.7-I601-Win10.exe`; on Windows Super N2N starts the n2n
programs with the relative paths `./n2n/edge.exe` and `./n2n/supernode.exe`. programs with the relative paths `./n2n/edge.exe` and `./n2n/supernode.exe`.
@@ -60,6 +61,20 @@ select a specific existing adapter. Connection settings, including advanced
settings, are stored beside `super-n2n.exe` in `super-n2n.config.json` and are settings, are stored beside `super-n2n.exe` in `super-n2n.config.json` and are
restored on the next application launch. restored on the next application launch.
### Supernode and special server
The `创建服务器` home starts Supernode on the configured UDP port and can
optionally restrict communities and the Supernode address-assignment pool.
The optional special server listens on its own TCP port. A special client only
needs that server address and port; it receives the community, static TAP IP,
gateway, DNS, and Supernode port automatically. The client username is sent as
the n2n edge description (`-I`) and defaults to the Windows computer name.
The special address pool must be a subnet inside the normal Supernode pool, and
its community must be present in the allowed-community list when that list is
restricted. Supernode's compact client window polls the management API and
marks addresses issued by the special server.
## Gitea Actions ## Gitea Actions
`.gitea/workflows/build-windows.yml` runs when a Release is published and uploads `.gitea/workflows/build-windows.yml` runs when a Release is published and uploads
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "super-n2n", "name": "super-n2n",
"version": "0.1.0", "version": "1.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "super-n2n", "name": "super-n2n",
"version": "0.1.0", "version": "1.2.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.8.0", "@tauri-apps/api": "^2.8.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "super-n2n", "name": "super-n2n",
"private": true, "private": true,
"version": "0.1.0", "version": "1.2.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 127.0.0.1 --port 1420", "dev": "vite --host 127.0.0.1 --port 1420",
+3 -1
View File
@@ -12,7 +12,9 @@ $n2nRoot = Join-Path $projectRoot 'third_party\n2n'
$cargoManifest = Join-Path $projectRoot 'src-tauri\Cargo.toml' $cargoManifest = Join-Path $projectRoot 'src-tauri\Cargo.toml'
$packageScript = Join-Path $PSScriptRoot 'package-portable.ps1' $packageScript = Join-Path $PSScriptRoot 'package-portable.ps1'
$n2nBuildScript = Join-Path $PSScriptRoot 'build-n2n.sh' $n2nBuildScript = Join-Path $PSScriptRoot 'build-n2n.sh'
$archivePath = Join-Path $projectRoot 'release\super-n2n-portable.zip' $packageJson = Get-Content (Join-Path $projectRoot 'package.json') -Raw | ConvertFrom-Json
$artifactVersion = (($packageJson.version -split '\.')[0..1] -join '.')
$archivePath = Join-Path $projectRoot "release\super-n2n-portable-$artifactVersion.zip"
$checksumPath = $archivePath + '.sha256' $checksumPath = $archivePath + '.sha256'
function Get-RequiredCommand { function Get-RequiredCommand {
+4 -2
View File
@@ -9,7 +9,9 @@ $n2nRoot = Join-Path $projectRoot 'third_party\n2n'
$releaseRoot = Join-Path $projectRoot 'release' $releaseRoot = Join-Path $projectRoot 'release'
$portableRoot = Join-Path $projectRoot 'release\super-n2n-portable' $portableRoot = Join-Path $projectRoot 'release\super-n2n-portable'
$stagingRoot = Join-Path $releaseRoot 'super-n2n-portable-staging' $stagingRoot = Join-Path $releaseRoot 'super-n2n-portable-staging'
$archivePath = Join-Path $releaseRoot 'super-n2n-portable.zip' $packageJson = Get-Content (Join-Path $projectRoot 'package.json') -Raw | ConvertFrom-Json
$artifactVersion = (($packageJson.version -split '\.')[0..1] -join '.')
$archivePath = Join-Path $releaseRoot "super-n2n-portable-$artifactVersion.zip"
$releaseRootFull = [System.IO.Path]::GetFullPath($releaseRoot) $releaseRootFull = [System.IO.Path]::GetFullPath($releaseRoot)
$portableRootFull = [System.IO.Path]::GetFullPath($portableRoot) $portableRootFull = [System.IO.Path]::GetFullPath($portableRoot)
$stagingRootFull = [System.IO.Path]::GetFullPath($stagingRoot) $stagingRootFull = [System.IO.Path]::GetFullPath($stagingRoot)
@@ -21,7 +23,7 @@ if ((Split-Path -Parent $portableRootFull) -ne $releaseRootFull -or (Split-Path
if ((Split-Path -Parent $stagingRootFull) -ne $releaseRootFull -or (Split-Path -Leaf $stagingRootFull) -ne 'super-n2n-portable-staging') { if ((Split-Path -Parent $stagingRootFull) -ne $releaseRootFull -or (Split-Path -Leaf $stagingRootFull) -ne 'super-n2n-portable-staging') {
throw "Refusing to replace an unexpected staging directory: $stagingRootFull" throw "Refusing to replace an unexpected staging directory: $stagingRootFull"
} }
if ((Split-Path -Parent $archivePathFull) -ne $releaseRootFull -or (Split-Path -Leaf $archivePathFull) -ne 'super-n2n-portable.zip') { if ((Split-Path -Parent $archivePathFull) -ne $releaseRootFull -or (Split-Path -Leaf $archivePathFull) -ne "super-n2n-portable-$artifactVersion.zip") {
throw "Refusing to replace an unexpected portable archive: $archivePathFull" throw "Refusing to replace an unexpected portable archive: $archivePathFull"
} }
+1 -1
View File
@@ -2919,7 +2919,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "super-n2n" name = "super-n2n"
version = "0.1.0" version = "1.2.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "super-n2n" name = "super-n2n"
version = "0.1.0" version = "1.2.0"
description = "N2N desktop control plane" description = "N2N desktop control plane"
authors = ["Super N2N"] authors = ["Super N2N"]
edition = "2021" edition = "2021"
+11 -2
View File
@@ -2,6 +2,15 @@
"$schema": "../gen/schemas/desktop-schema.json", "$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default", "identifier": "default",
"description": "Default permissions for the Super N2N desktop window", "description": "Default permissions for the Super N2N desktop window",
"windows": ["main"], "windows": ["main", "supernode-users"],
"permissions": ["core:default"] "permissions": [
"core:default",
"core:window:allow-create",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:window:allow-close",
"core:window:allow-destroy",
"core:webview:allow-create-webview-window"
]
} }
+511 -22
View File
@@ -3,10 +3,14 @@ use serde_json::Value;
use std::{ use std::{
collections::HashMap, collections::HashMap,
fs, fs,
net::{Ipv4Addr, SocketAddr, TcpStream, UdpSocket}, io::{BufRead, BufReader, Write},
net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream, ToSocketAddrs, UdpSocket},
path::PathBuf, path::PathBuf,
process::{Child, Command, Stdio}, process::{Child, Command, Stdio},
sync::Mutex, sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::{Duration, Instant, SystemTime, UNIX_EPOCH}, time::{Duration, Instant, SystemTime, UNIX_EPOCH},
}; };
use tauri::{ use tauri::{
@@ -17,9 +21,70 @@ use tauri::{
struct AppState { struct AppState {
processes: Mutex<HashMap<String, Child>>, processes: Mutex<HashMap<String, Child>>,
special_server: Mutex<Option<SpecialServerHandle>>,
}
struct SpecialServerHandle {
stop: Arc<AtomicBool>,
join: Option<std::thread::JoinHandle<()>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SupernodeRequest {
port: u16,
dhcp: bool,
dhcp_pool: String,
allowed_communities: String,
special_enabled: bool,
special_port: u16,
special_community: String,
special_pool: String,
special_gateway: String,
special_dns: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SpecialClientRequest {
host: String,
port: u16,
username: String,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct SpecialConfig {
community: String,
ip: String,
gateway: String,
dns: String,
supernode_port: u16,
username: String,
special: bool,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct SpecialUser {
username: String,
ip: String,
special: bool,
last_seen: u64,
}
struct SpecialServerData {
community: String,
pool_start: u32,
pool_end: u32,
gateway: String,
dns: String,
supernode_port: u16,
leases: Mutex<HashMap<String, SpecialUser>>,
} }
fn stop_all_processes(state: &AppState) { fn stop_all_processes(state: &AppState) {
stop_special_server(state);
let Ok(mut processes) = state.processes.lock() else { let Ok(mut processes) = state.processes.lock() else {
return; return;
}; };
@@ -52,6 +117,223 @@ fn tray_icon_image() -> tauri::image::Image<'static> {
tauri::image::Image::new_owned(rgba, 16, 16) tauri::image::Image::new_owned(rgba, 16, 16)
} }
fn parse_cidr(value: &str) -> Result<(u32, u32, u8), String> {
let (address, prefix) = value
.trim()
.split_once('/')
.ok_or_else(|| format!("地址池必须使用 CIDR 格式:{value}"))?;
let ip = address
.parse::<Ipv4Addr>()
.map_err(|_| format!("无效的地址池:{value}"))?;
let prefix = prefix
.parse::<u8>()
.map_err(|_| format!("无效的 CIDR 前缀:{value}"))?;
if prefix > 30 {
return Err("地址池前缀必须在 0 到 30 之间".to_string());
}
let bits = u32::from(ip);
let mask = if prefix == 0 {
0
} else {
u32::MAX << (32 - prefix)
};
let network = bits & mask;
Ok((network, network | !mask, prefix))
}
fn dotted_ip(value: u32) -> String {
Ipv4Addr::from(value).to_string()
}
fn cidr_range(value: &str) -> Result<String, String> {
let (start, _end, prefix) = parse_cidr(value)?;
Ok(format!(
"{}-{}/{}",
dotted_ip(start),
dotted_ip(start),
prefix
))
}
fn validate_supernode_request(request: &SupernodeRequest) -> Result<(), String> {
if !(1..=65535).contains(&request.port) {
return Err("Supernode UDP 端口必须在 1 到 65535 之间".to_string());
}
let base_pool = if request.dhcp {
Some(parse_cidr(&request.dhcp_pool)?)
} else {
None
};
if request.special_enabled {
if !(1..=65535).contains(&request.special_port) {
return Err("特殊服务端 TCP 端口必须在 1 到 65535 之间".to_string());
}
if request.special_port == request.port {
return Err("特殊服务端 TCP 端口不能与 Supernode UDP 端口相同".to_string());
}
if request.special_community.trim().is_empty() {
return Err("请填写特殊服务端下发的社区名".to_string());
}
let special_pool = parse_cidr(&request.special_pool)?;
let Some(base_pool) = base_pool else {
return Err("开启特殊服务端时必须开启 DHCP,并配置普通 DHCP 地址池".to_string());
};
if special_pool.2 < base_pool.2
|| special_pool.0 < base_pool.0
|| special_pool.1 > base_pool.1
{
return Err("特殊服务端地址池必须包含在普通 DHCP 地址池内".to_string());
}
let allowed_lines = request
.allowed_communities
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
if !allowed_lines.is_empty()
&& !allowed_lines
.iter()
.any(|line| *line == request.special_community.trim())
{
return Err("特殊服务端社区名必须出现在允许的社区名列表中".to_string());
}
}
Ok(())
}
fn stop_special_server(state: &AppState) {
let handle = state
.special_server
.lock()
.ok()
.and_then(|mut value| value.take());
if let Some(mut handle) = handle {
handle.stop.store(true, Ordering::Relaxed);
if let Some(join) = handle.join.take() {
let _ = join.join();
}
}
}
fn allocate_special_ip(data: &SpecialServerData) -> Option<String> {
let leases = data.leases.lock().ok()?;
let used = leases
.values()
.map(|lease| lease.ip.clone())
.collect::<Vec<_>>();
(data.pool_start..=data.pool_end)
.filter(|ip| *ip != data.pool_start && *ip != data.pool_end)
.map(dotted_ip)
.find(|ip| !used.iter().any(|used_ip| used_ip == ip))
}
fn handle_special_connection(mut stream: TcpStream, data: &SpecialServerData) {
let mut command = String::new();
if BufReader::new(&mut stream).read_line(&mut command).is_err() {
return;
}
let command = command.trim();
if let Some(username) = command.strip_prefix("HELLO ") {
let username = if username.trim().is_empty() {
"未命名客户端".to_string()
} else {
username.trim().chars().take(64).collect()
};
let ip = {
let existing = data
.leases
.lock()
.ok()
.and_then(|leases| leases.get(&username).map(|lease| lease.ip.clone()));
existing.or_else(|| allocate_special_ip(data))
};
let Some(ip) = ip else {
let _ = writeln!(stream, "{{\"error\":\"地址池已耗尽\"}}");
return;
};
if let Ok(mut leases) = data.leases.lock() {
leases.insert(
username.clone(),
SpecialUser {
username: username.clone(),
ip: ip.clone(),
special: true,
last_seen: now_epoch(),
},
);
}
let response = SpecialConfig {
community: data.community.clone(),
ip,
gateway: data.gateway.clone(),
dns: data.dns.clone(),
supernode_port: data.supernode_port,
username,
special: true,
};
if let Ok(body) = serde_json::to_string(&response) {
let _ = writeln!(stream, "{body}");
}
} else if command == "LIST" {
let now = now_epoch();
let users = data
.leases
.lock()
.map(|leases| {
leases
.values()
.filter(|lease| now.saturating_sub(lease.last_seen) <= 90)
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
if let Ok(body) = serde_json::to_string(&users) {
let _ = writeln!(stream, "{body}");
}
}
}
fn start_special_server(request: &SupernodeRequest, state: &AppState) -> Result<(), String> {
stop_special_server(state);
let (pool_start, pool_end, _) = parse_cidr(&request.special_pool)?;
let listener = TcpListener::bind(("0.0.0.0", request.special_port))
.map_err(|error| format!("特殊服务端无法监听 TCP {}{error}", request.special_port))?;
listener
.set_nonblocking(true)
.map_err(|error| format!("特殊服务端设置监听模式失败:{error}"))?;
let data = Arc::new(SpecialServerData {
community: request.special_community.trim().to_string(),
pool_start,
pool_end,
gateway: request.special_gateway.trim().to_string(),
dns: request.special_dns.trim().to_string(),
supernode_port: request.port,
leases: Mutex::new(HashMap::new()),
});
let stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&stop);
let join = std::thread::spawn(move || {
while !thread_stop.load(Ordering::Relaxed) {
match listener.accept() {
Ok((stream, _)) => handle_special_connection(stream, &data),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(100));
}
Err(_) => break,
}
}
});
state
.special_server
.lock()
.map_err(|_| "特殊服务端状态不可用".to_string())?
.replace(SpecialServerHandle {
stop,
join: Some(join),
});
Ok(())
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct StartRequest { struct StartRequest {
@@ -340,11 +622,7 @@ fn windows_tap_adapters() -> Result<Vec<TapAdapter>, String> {
Ok(adapters) Ok(adapters)
} }
#[tauri::command] fn start_service_inner(request: StartRequest, state: &AppState) -> Result<ServiceStatus, String> {
fn start_service(
request: StartRequest,
state: State<'_, AppState>,
) -> Result<ServiceStatus, String> {
let mut processes = state let mut processes = state
.processes .processes
.lock() .lock()
@@ -409,12 +687,147 @@ fn start_service(
}) })
} }
#[tauri::command]
fn start_service(
request: StartRequest,
state: State<'_, AppState>,
) -> Result<ServiceStatus, String> {
start_service_inner(request, state.inner())
}
fn stop_service_process(service: &str, state: &AppState) -> ServiceStatus {
let Ok(mut processes) = state.processes.lock() else {
return empty_status(service, "process state is unavailable");
};
if let Some(mut child) = processes.remove(service) {
let _ = child.kill();
let _ = child.wait();
return empty_status(service, "stopped");
}
empty_status(service, "not running")
}
#[tauri::command]
fn start_supernode(
request: SupernodeRequest,
state: State<'_, AppState>,
) -> Result<ServiceStatus, String> {
validate_supernode_request(&request)?;
let directory = app_directory()?;
let allowed_path = directory.join("supernode-communities.list");
let allowed = request
.allowed_communities
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
if allowed.is_empty() {
let _ = fs::remove_file(&allowed_path);
} else {
fs::write(&allowed_path, format!("{}\n", allowed.join("\n")))
.map_err(|error| format!("无法写入社区列表:{error}"))?;
}
let mut config = format!("-p {}\n", request.port);
if request.dhcp {
config.push_str(&format!("-a {}\n", cidr_range(&request.dhcp_pool)?));
}
if !allowed.is_empty() {
config.push_str("-c supernode-communities.list\n");
}
config.push_str("-v\n");
fs::write(directory.join("supernode.conf"), config)
.map_err(|error| format!("无法写入 Supernode 配置:{error}"))?;
let status = start_service_inner(
StartRequest {
service: "supernode".to_string(),
binary_path: "./n2n/supernode.exe".to_string(),
args: vec!["supernode.conf".to_string()],
},
state.inner(),
)?;
if request.special_enabled {
if let Err(error) = start_special_server(&request, state.inner()) {
stop_service_process("supernode", state.inner());
return Err(error);
}
}
Ok(status)
}
#[tauri::command]
fn stop_special_service(state: State<'_, AppState>) -> Result<(), String> {
stop_special_server(state.inner());
Ok(())
}
fn special_request(request: &SpecialClientRequest, command: &str) -> Result<String, String> {
if request.host.trim().is_empty() || !(1..=65535).contains(&request.port) {
return Err("特殊服务端地址或端口无效".to_string());
}
let address = format!("{}:{}", request.host.trim(), request.port);
let socket = address
.to_socket_addrs()
.map_err(|error| format!("无法解析特殊服务端地址:{error}"))?
.next()
.ok_or_else(|| format!("无效的特殊服务端地址:{address}"))?;
let mut stream = TcpStream::connect_timeout(&socket, Duration::from_secs(3))
.map_err(|error| format!("无法连接特殊服务端:{error}"))?;
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.map_err(|error| error.to_string())?;
writeln!(stream, "{command}").map_err(|error| format!("发送特殊服务端请求失败:{error}"))?;
let mut response = String::new();
BufReader::new(stream)
.read_line(&mut response)
.map_err(|error| format!("读取特殊服务端响应失败:{error}"))?;
Ok(response)
}
fn default_username() -> String {
std::env::var("COMPUTERNAME")
.or_else(|_| std::env::var("HOSTNAME"))
.unwrap_or_else(|_| "未命名客户端".to_string())
}
#[tauri::command]
fn fetch_special_config(request: SpecialClientRequest) -> Result<SpecialConfig, String> {
let username = if request.username.trim().is_empty() {
default_username()
} else {
request.username.trim().to_string()
};
let command = format!("HELLO {}", username.chars().take(64).collect::<String>());
let response = special_request(&request, &command)?;
if let Ok(error) = serde_json::from_str::<HashMap<String, String>>(&response) {
if let Some(message) = error.get("error") {
return Err(message.clone());
}
}
serde_json::from_str(response.trim())
.map_err(|error| format!("特殊服务端返回无效配置:{error}"))
}
#[tauri::command]
fn fetch_special_users(request: SpecialClientRequest) -> Result<Vec<SpecialUser>, String> {
if !request.username.trim().is_empty() {
let username = request.username.trim().chars().take(64).collect::<String>();
let _ = special_request(&request, &format!("HELLO {username}"))?;
}
let response = special_request(&request, "LIST")?;
serde_json::from_str(response.trim()).map_err(|error| format!("在线用户列表无效:{error}"))
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct TapAddressRequest { struct TapAddressRequest {
tap_device: String, tap_device: String,
dhcp: bool, dhcp: bool,
ip: String, ip: String,
#[serde(default)]
gateway: String,
#[serde(default)]
dns: String,
} }
#[tauri::command] #[tauri::command]
@@ -458,6 +871,15 @@ fn configure_tap_adapter(request: TapAddressRequest) -> Result<(), String> {
&format!("address={address}"), &format!("address={address}"),
"mask=255.255.255.0", "mask=255.255.255.0",
]); ]);
let gateway = request.gateway.trim();
if gateway.is_empty() {
command.arg("gateway=none");
} else {
let gateway = gateway
.parse::<Ipv4Addr>()
.map_err(|_| format!("无效的网关:{gateway}"))?;
command.arg(format!("gateway={gateway}"));
}
} }
let output = command let output = command
.output() .output()
@@ -470,6 +892,35 @@ fn configure_tap_adapter(request: TapAddressRequest) -> Result<(), String> {
}; };
return Err(format!("TAP 网卡地址配置失败:{}", detail.trim())); return Err(format!("TAP 网卡地址配置失败:{}", detail.trim()));
} }
if !request.dhcp && !request.dns.trim().is_empty() {
let dns = request
.dns
.trim()
.parse::<Ipv4Addr>()
.map_err(|_| format!("无效的 DNS{}", request.dns.trim()))?;
let dns_output = Command::new("netsh")
.args([
"interface",
"ipv4",
"set",
"dnsservers",
&interface,
"source=static",
&format!("address={dns}"),
"register=primary",
"validate=no",
])
.output()
.map_err(|error| format!("无法配置 TAP DNS{error}"))?;
if !dns_output.status.success() {
let detail = if dns_output.stderr.is_empty() {
String::from_utf8_lossy(&dns_output.stdout)
} else {
String::from_utf8_lossy(&dns_output.stderr)
};
return Err(format!("TAP DNS 配置失败:{}", detail.trim()));
}
}
return Ok(()); return Ok(());
} }
@@ -482,18 +933,10 @@ fn configure_tap_adapter(request: TapAddressRequest) -> Result<(), String> {
#[tauri::command] #[tauri::command]
fn stop_service(service: String, state: State<'_, AppState>) -> Result<ServiceStatus, String> { fn stop_service(service: String, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
let mut processes = state if service == "supernode" {
.processes stop_special_server(state.inner());
.lock()
.map_err(|_| "process state is unavailable".to_string())?;
if let Some(mut child) = processes.remove(&service) {
child
.kill()
.map_err(|e| format!("unable to stop {}: {}", service, e))?;
let _ = child.wait();
return Ok(empty_status(&service, "stopped"));
} }
Ok(empty_status(&service, "not running")) Ok(stop_service_process(&service, state.inner()))
} }
#[tauri::command] #[tauri::command]
@@ -838,7 +1281,10 @@ fn export_log(contents: String) -> Result<String, String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{is_tap_windows_description, relative_binary_path, StartRequest}; use super::{
cidr_range, is_tap_windows_description, relative_binary_path, validate_supernode_request,
StartRequest, SupernodeRequest,
};
#[test] #[test]
fn tap_description_filter_excludes_network_filter_interfaces() { fn tap_description_filter_excludes_network_filter_interfaces() {
@@ -884,12 +1330,42 @@ mod tests {
"./n2n/supernode.exe" "./n2n/supernode.exe"
); );
} }
#[test]
fn supernode_cidr_range_keeps_network_and_broadcast() {
assert_eq!(
cidr_range("10.10.10.128/25").unwrap(),
"10.10.10.128-10.10.10.128/25"
);
}
#[test]
fn special_pool_must_be_inside_base_pool_and_allowed_community() {
let valid = SupernodeRequest {
port: 7777,
dhcp: true,
dhcp_pool: "10.10.10.0/24".to_string(),
allowed_communities: "team\nops".to_string(),
special_enabled: true,
special_port: 7788,
special_community: "team".to_string(),
special_pool: "10.10.10.128/25".to_string(),
special_gateway: "10.10.10.1".to_string(),
special_dns: "10.10.10.1".to_string(),
};
assert!(validate_supernode_request(&valid).is_ok());
let mut invalid = valid;
invalid.special_community = "guest".to_string();
assert!(validate_supernode_request(&invalid).is_err());
}
} }
pub fn run() { pub fn run() {
let app = tauri::Builder::default() let app = tauri::Builder::default()
.manage(AppState { .manage(AppState {
processes: Mutex::new(HashMap::new()), processes: Mutex::new(HashMap::new()),
special_server: Mutex::new(None),
}) })
.setup(|app| { .setup(|app| {
let connect = MenuItem::with_id(app, "tray-connect", "连接", true, None::<&str>)?; let connect = MenuItem::with_id(app, "tray-connect", "连接", true, None::<&str>)?;
@@ -938,14 +1414,27 @@ pub fn run() {
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close(); api.prevent_close();
stop_all_processes(window.state::<AppState>().inner()); if window.label() == "main" {
let _ = window.emit("tray-service-stopped", ()); stop_all_processes(window.state::<AppState>().inner());
let _ = window.hide(); if let Some(users_window) =
window.app_handle().get_webview_window("supernode-users")
{
let _ = users_window.destroy();
}
let _ = window.emit("tray-service-stopped", ());
let _ = window.hide();
} else if window.label() == "supernode-users" {
let _ = window.hide();
}
} }
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
start_service, start_service,
start_supernode,
stop_service, stop_service,
stop_special_service,
fetch_special_config,
fetch_special_users,
service_status, service_status,
management_query, management_query,
run_ping, run_ping,
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Super N2N", "productName": "Super N2N",
"version": "0.1.0", "version": "1.2.0",
"identifier": "com.supern2n.control", "identifier": "com.supern2n.control",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
+8
View File
@@ -14,10 +14,18 @@ export async function nativeCall(command, args, fallback) {
export const startService = (request) => nativeCall('start_service', { request }, () => { export const startService = (request) => nativeCall('start_service', { request }, () => {
throw new Error('浏览器预览无法启动原生服务') throw new Error('浏览器预览无法启动原生服务')
}) })
export const startSupernode = (request) => nativeCall('start_supernode', { request }, () => {
throw new Error('浏览器预览无法启动 Supernode')
})
export const stopService = (service) => nativeCall('stop_service', { service }, () => { export const stopService = (service) => nativeCall('stop_service', { service }, () => {
throw new Error('浏览器预览没有受管进程') 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 }, () => ({ export const serviceStatus = (service) => nativeCall('service_status', { service }, () => ({
service, service,
+332 -42
View File
@@ -1,14 +1,16 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { getCurrentWindow } from '@tauri-apps/api/window' import { getCurrentWindow } from '@tauri-apps/api/window'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import { import {
ArrowDown, ArrowUp, CheckCircle2, ChevronDown, CircleAlert, Gauge, Globe2, Network, Play, 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' } from 'lucide-react'
import { import {
configureTapAdapter, exportLog, isTauri, openTapInstaller, readConfig, resolveTapAdapter, serviceStatus, startService, configureTapAdapter, exportLog, fetchSpecialConfig, fetchSpecialUsers, isTauri, openTapInstaller,
startupStatus, stopService, tapTraffic, writeConfig, queryManagement, readConfig, resolveTapAdapter, runPing, serviceStatus, startService, startSupernode, startupStatus, stopService,
stopSpecialService, tapTraffic, writeConfig,
} from './lib/tauri' } from './lib/tauri'
import './styles.css' import './styles.css'
@@ -21,6 +23,22 @@ const initialEdge = {
encryptionKey: '', encryptionKey: '',
managementPort: '5644', managementPort: '5644',
tapDevice: '', 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' const edgeConfigStorageKey = 'super-n2n.edge-config.v1'
@@ -37,6 +55,17 @@ function parseEdgeConfig(value) {
return fallback 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() { function loadBrowserEdgeConfig() {
if (typeof window === 'undefined') return { ...initialEdge } if (typeof window === 'undefined') return { ...initialEdge }
try { try {
@@ -47,9 +76,18 @@ function loadBrowserEdgeConfig() {
} }
} }
function saveBrowserEdgeConfig(config) { function loadBrowserSupernodeConfig() {
if (typeof window === 'undefined') return { ...initialSupernode }
try { 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 { } catch {
// The form stays usable when the platform denies local storage access. // 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) return JSON.stringify({ version: 1, edge: config }, null, 2)
} }
function serializeAppConfig(edge, supernode) {
return JSON.stringify({ version: 2, edge, supernode }, null, 2)
}
function App() { function App() {
const [home, setHome] = useState('edge') const [home, setHome] = useState('edge')
const [edge, setEdge] = useState(loadBrowserEdgeConfig) const [edge, setEdge] = useState(loadBrowserEdgeConfig)
const [supernode, setSupernode] = useState(loadBrowserSupernodeConfig)
const [edgeRunning, setEdgeRunning] = useState(false) const [edgeRunning, setEdgeRunning] = useState(false)
const [supernodeRunning, setSupernodeRunning] = useState(false)
const [activeTapDevice, setActiveTapDevice] = useState('') const [activeTapDevice, setActiveTapDevice] = useState('')
const [advancedOpen, setAdvancedOpen] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false)
const [logOpen, setLogOpen] = useState(false) const [logOpen, setLogOpen] = useState(false)
const [logs, setLogs] = useState([]) const [logs, setLogs] = useState([])
const [traffic, setTraffic] = useState(null) const [traffic, setTraffic] = useState(null)
const [specialUsers, setSpecialUsers] = useState([])
const [specialLatencies, setSpecialLatencies] = useState({})
const [message, setMessage] = useState(null) const [message, setMessage] = useState(null)
const [startupInfo, setStartupInfo] = useState(null) const [startupInfo, setStartupInfo] = useState(null)
const [configReady, setConfigReady] = useState(!isTauri) const [configReady, setConfigReady] = useState(!isTauri)
@@ -82,15 +128,15 @@ function App() {
useEffect(() => { useEffect(() => {
if (!configReady) return if (!configReady) return
if (!isTauri) { if (!isTauri) {
saveBrowserEdgeConfig(edge) saveBrowserAppConfig(edge, supernode)
return return
} }
writeConfig(serializeEdgeConfig(edge)).catch((error) => { writeConfig(serializeAppConfig(edge, supernode)).catch((error) => {
if (configWriteFailed.current) return if (configWriteFailed.current) return
configWriteFailed.current = true configWriteFailed.current = true
appendLog('配置保存失败:' + error.message, 'warning') appendLog('配置保存失败:' + error.message, 'warning')
}) })
}, [edge, configReady]) }, [edge, supernode, configReady])
useEffect(() => { useEffect(() => {
let active = true let active = true
@@ -101,7 +147,11 @@ function App() {
setStartupInfo(status) setStartupInfo(status)
if (isTauri && status.configExists) { if (isTauri && status.configExists) {
const contents = await readConfig() 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) { if (active) {
setConfigReady(true) setConfigReady(true)
@@ -142,7 +192,9 @@ function App() {
unlistenStopped = await listen('tray-service-stopped', () => { unlistenStopped = await listen('tray-service-stopped', () => {
if (!active) return if (!active) return
setEdgeRunning(false) setEdgeRunning(false)
setSupernodeRunning(false)
setActiveTapDevice('') setActiveTapDevice('')
setSpecialUsers([])
}) })
} }
bindTrayEvents() bindTrayEvents()
@@ -156,16 +208,66 @@ function App() {
useEffect(() => { useEffect(() => {
let active = true let active = true
serviceStatus('edge') Promise.all([serviceStatus('edge'), serviceStatus('supernode')])
.then((status) => { .then(([edgeStatus, supernodeStatus]) => {
if (!active) return if (!active) return
setEdgeRunning(Boolean(status.running)) setEdgeRunning(Boolean(edgeStatus.running))
appendLog(status.running ? '检测到 Edge 已连接' : 'Edge 已就绪,等待连接', status.running ? 'success' : 'info') setSupernodeRunning(Boolean(supernodeStatus.running))
appendLog(edgeStatus.running ? '检测到 Edge 已连接' : 'Edge 已就绪,等待连接', edgeStatus.running ? 'success' : 'info')
}) })
.catch(() => {}) .catch(() => {})
return () => { active = false } 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(() => { useEffect(() => {
if (!edgeRunning || !activeTapDevice) { if (!edgeRunning || !activeTapDevice) {
lastTapSample.current = null lastTapSample.current = null
@@ -199,6 +301,7 @@ function App() {
}, [activeTapDevice, edgeRunning]) }, [activeTapDevice, edgeRunning])
const updateEdge = (field, value) => setEdge((current) => ({ ...current, [field]: value })) const updateEdge = (field, value) => setEdge((current) => ({ ...current, [field]: value }))
const updateSupernode = (field, value) => setSupernode((current) => ({ ...current, [field]: value }))
const notify = (text, tone = 'error') => { const notify = (text, tone = 'error') => {
setMessage({ text, tone }) setMessage({ text, tone })
window.setTimeout(() => setMessage(null), 3200) window.setTimeout(() => setMessage(null), 3200)
@@ -224,43 +327,96 @@ function App() {
notify('请填写服务器 IP 或域名') notify('请填写服务器 IP 或域名')
return return
} }
if (!edge.community.trim()) { if (!edge.specialClient && !edge.community.trim()) {
appendLog('连接校验:未填写群组名称', 'warning') appendLog('连接校验:未填写群组名称', 'warning')
notify('请填写群组名称') notify('请填写群组名称')
return return
} }
if (!edge.dhcp && !edge.ip.trim()) { if (!edge.specialClient && !edge.dhcp && !edge.ip.trim()) {
appendLog('连接校验:静态 IP 未填写', 'warning') appendLog('连接校验:静态 IP 未填写', 'warning')
notify('关闭 DHCP 后,请填写本机 IP') notify('关闭 DHCP 后,请填写本机 IP')
return return
} }
try { 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) const tapAdapter = await resolveTapAdapter(edge.tapDevice)
appendLog('已选择 TAP 设备:' + tapAdapter.name) appendLog('已选择 TAP 设备:' + tapAdapter.name)
if (isWindowsRuntime) { if (isWindowsRuntime) {
await configureTapAdapter({ await configureTapAdapter({
tapDevice: tapAdapter.guid || tapAdapter.name, tapDevice: tapAdapter.guid || tapAdapter.name,
dhcp: edge.dhcp, dhcp: runtimeEdge.dhcp,
ip: edge.ip, ip: runtimeEdge.ip,
gateway: runtimeEdge.gateway || '',
dns: runtimeEdge.dns || '',
}) })
appendLog('已配置 TAP 网卡地址', 'success') appendLog('已配置 TAP 网卡地址', 'success')
} }
const status = await startService({ const status = await startService({
service: 'edge', service: 'edge',
binaryPath: './n2n/edge.exe', binaryPath: './n2n/edge.exe',
args: buildEdgeArgs(edge, tapAdapter.guid || tapAdapter.name), args: buildEdgeArgs(runtimeEdge, tapAdapter.guid || tapAdapter.name),
}) })
setEdgeRunning(Boolean(status.running)) setEdgeRunning(Boolean(status.running))
setActiveTapDevice(tapAdapter.name) setActiveTapDevice(tapAdapter.name)
appendLog('已连接到群组 ' + edge.community, 'success') appendLog('已连接到群组 ' + runtimeEdge.community, 'success')
notify('已连接到 ' + edge.community, 'success') notify('已连接到 ' + runtimeEdge.community, 'success')
} catch (error) { } catch (error) {
appendLog('连接失败:' + error.message, 'error') appendLog('连接失败:' + error.message, 'error')
notify('无法建立连接:' + error.message) 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 () => { const installTapDriver = async () => {
try { try {
const path = await openTapInstaller() const path = await openTapInstaller()
@@ -275,7 +431,7 @@ function App() {
return <main className="app-shell"> return <main className="app-shell">
<header className="app-header"> <header className="app-header">
<div className="brand"><span className="brand-icon"><Network size={19} /></span><span>Super N2N</span></div> <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> </header>
<section className="workspace" aria-label="N2N 连接设置"> <section className="workspace" aria-label="N2N 连接设置">
@@ -289,8 +445,8 @@ function App() {
</div> </div>
{home === 'edge' {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)} /> ? <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 />} : <SupernodeHome config={supernode} update={updateSupernode} running={supernodeRunning} onToggle={toggleSupernode} />}
</section> </section>
<footer className="app-footer"><span>{isTauri ? '本机服务控制已就绪' : '浏览器预览模式'}</span><span>n2n 3.1.1</span></footer> <footer className="app-footer"><span>{isTauri ? '本机服务控制已就绪' : '浏览器预览模式'}</span><span>n2n 3.1.1</span></footer>
@@ -298,7 +454,108 @@ function App() {
</main> </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 = () => { const toggleAdvanced = () => {
setLogOpen(false) setLogOpen(false)
setAdvancedOpen((current) => !current) setAdvancedOpen((current) => !current)
@@ -325,20 +582,24 @@ function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, startup
<span className="server-inputs"> <span className="server-inputs">
<input value={edge.serverHost} onChange={(event) => updateEdge('serverHost', event.target.value)} placeholder="IP 地址或域名" autoComplete="off" disabled={running} aria-label="服务器 IP 或域名" /> <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> <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> </span>
</label> </label>
<label className="input-group"> {edge.specialClient
<span className="field-title"><Network size={18} />群组名称</span> ? <div className="special-client-note"><Users size={16} /><span>特殊客户端模式社区IP网关和 DNS 由服务端下发</span></div>
<input value={edge.community} onChange={(event) => updateEdge('community', event.target.value)} placeholder="例如:my-network" autoComplete="off" disabled={running} /> : <>
</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>
<div className="input-group"> <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> <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" /> <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> <small id="ip-help">{edge.dhcp ? 'DHCP 已开启,连接后自动获取 IP。' : '使用静态 IP 时,请确保它没有被其他设备占用。'}</small>
</div> </div>
</>}
</div> </div>
<TapSetupPrompt status={startupInfo} onInstall={onInstallTap} /> <TapSetupPrompt status={startupInfo} onInstall={onInstallTap} />
@@ -349,6 +610,7 @@ function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, startup
</button> </button>
{running && <TrafficStrip traffic={traffic} tapDevice={activeTapDevice} />} {running && <TrafficStrip traffic={traffic} tapDevice={activeTapDevice} />}
{running && edge.specialClient && <SpecialUsersPanel users={specialUsers} latencies={specialLatencies} />}
</section> </section>
<div className="secondary-actions"> <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 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>管理端口</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><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>} </div>}
</section> </section>
@@ -400,23 +664,48 @@ function TrafficMetric({ direction, label, rate, total }) {
</output> </output>
} }
function DhcpToggle({ checked, disabled, onChange }) { function DhcpToggle({ checked, disabled, onChange, label = 'DHCP' }) {
return <label className="dhcp-toggle"> return <label className="dhcp-toggle">
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} /> <input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange(event.target.checked)} />
<span className="toggle-track" aria-hidden="true"><i /></span> <span className="toggle-track" aria-hidden="true"><i /></span>
<span>DHCP</span> <span>{label}</span>
</label> </label>
} }
function SupernodeHome() { function SupernodeHome({ config, update, running, onToggle }) {
return <div className="coming-soon"> return <div className="supernode-flow">
<div className="coming-icon"><Server size={29} /></div> <section className="supernode-card">
<p className="eyebrow">SUPERNODE</p> <div className="supernode-heading"><span className="intro-icon"><Server size={23} /></span><span><p className="eyebrow">SUPERNODE</p><h1>创建服务器</h1></span></div>
<h1>创建服务器</h1> <div className="supernode-fields">
<p>服务器配置即将加入此页面</p> <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> </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 }) { function LogPanel({ open, onToggle, logs, onExport }) {
return <section className="log-section"> return <section className="log-section">
<button className="log-toggle" onClick={onToggle} aria-expanded={open}> <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, '-a', config.dhcp ? 'dhcp:0.0.0.0' : config.ip)
addOption(args, '-d', tapDevice) addOption(args, '-d', tapDevice)
addOption(args, '-t', config.managementPort) addOption(args, '-t', config.managementPort)
addOption(args, '-I', config.username)
if (config.encryptionKey) addOption(args, '-k', config.encryptionKey) if (config.encryptionKey) addOption(args, '-k', config.encryptionKey)
// Windows edge does not implement the POSIX-only -f daemon flag. // Windows edge does not implement the POSIX-only -f daemon flag.
if (!isWindowsRuntime) args.push('-f') if (!isWindowsRuntime) args.push('-f')
+89 -1
View File
@@ -21,7 +21,7 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; } html, body, #root { min-width: 320px; min-height: 100%; margin: 0; }
body { min-height: 100vh; background: var(--canvas); } body { min-height: 100vh; background: var(--canvas); }
button, input { font: inherit; } button, input, textarea { font: inherit; }
button { border: 0; cursor: pointer; } button { border: 0; cursor: pointer; }
button:focus-visible, input:focus-visible { outline: 3px solid rgba(43, 108, 176, .35); outline-offset: 2px; } button:focus-visible, input:focus-visible { outline: 3px solid rgba(43, 108, 176, .35); outline-offset: 2px; }
button:disabled, input:disabled { cursor: not-allowed; } 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; } .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); } .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; } .field-stack { display: grid; padding: 18px 22px 6px; }
.input-group { display: grid; gap: 7px; padding: 14px 0; border-bottom: 1px solid #e7eeee; } .input-group { display: grid; gap: 7px; padding: 14px 0; border-bottom: 1px solid #e7eeee; }
.input-group:last-child { border-bottom: 0; } .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 h1 { margin-bottom: 10px; }
.coming-soon p:last-child { margin: 0; color: var(--muted); font-size: 14px; } .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; } .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 { 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; } .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 label { gap: 4px; font-size: 11px; }
.advanced-fields input { height: 30px; padding: 0 7px; border-radius: 4px; font-size: 12px; } .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-content { padding: 8px 0 0; }
.log-toolbar { min-height: 32px; padding: 0 4px; gap: 8px; } .log-toolbar { min-height: 32px; padding: 0 4px; gap: 8px; }
.log-toolbar > span { font-size: 11px; } .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) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; transition-duration: .01ms !important; } *, *::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 />)
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#173f46" />
<title>Supernode 在线用户</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/supernode-users.jsx"></script>
</body>
</html>
+8
View File
@@ -4,6 +4,14 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
clearScreen: false, clearScreen: false,
build: {
rollupOptions: {
input: {
main: 'index.html',
supernodeUsers: 'supernode-users.html',
},
},
},
server: { server: {
strictPort: true, strictPort: true,
}, },