Add tray controls and clean up child processes
Build Windows Portable Package / portable (release) Successful in 12m13s

This commit is contained in:
2026-09-02 02:25:18 +08:00
parent a8d803168f
commit 2bbd370e02
6 changed files with 299 additions and 21 deletions
+7 -1
View File
@@ -9,11 +9,17 @@ edition = "2021"
name = "super_n2n_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "super-n2n"
path = "src/main.rs"
test = false
bench = false
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+3 -1
View File
@@ -1,3 +1,5 @@
fn main() {
tauri_build::build()
let windows = tauri_build::WindowsAttributes::new().app_manifest(include_str!("manifest.xml"));
let attributes = tauri_build::Attributes::new().windows_attributes(windows);
tauri_build::try_build(attributes).expect("failed to run build script");
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
+216 -16
View File
@@ -3,18 +3,55 @@ use serde_json::Value;
use std::{
collections::HashMap,
fs,
net::{SocketAddr, TcpStream, UdpSocket},
net::{Ipv4Addr, SocketAddr, TcpStream, UdpSocket},
path::PathBuf,
process::{Child, Command, Stdio},
sync::Mutex,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use tauri::State;
use tauri::{
menu::{Menu, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Emitter, Manager, State,
};
struct AppState {
processes: Mutex<HashMap<String, Child>>,
}
fn stop_all_processes(state: &AppState) {
let Ok(mut processes) = state.processes.lock() else {
return;
};
for (_, mut child) in processes.drain() {
let _ = child.kill();
let _ = child.wait();
}
}
fn tray_icon_image() -> tauri::image::Image<'static> {
let mut rgba = vec![0_u8; 16 * 16 * 4];
for y in 0..16 {
for x in 0..16 {
let inside = x > 1 && x < 14 && y > 1 && y < 14;
if !inside {
continue;
}
let index = (y * 16 + x) * 4;
rgba[index..index + 4].copy_from_slice(&[8, 137, 123, 255]);
}
}
for (x, y) in [(5, 8), (11, 5), (11, 11)] {
let index = (y * 16 + x) * 4;
rgba[index..index + 4].copy_from_slice(&[255, 255, 255, 255]);
}
for (x, y) in [(6, 8), (10, 6), (10, 10)] {
let index = (y * 16 + x) * 4;
rgba[index..index + 4].copy_from_slice(&[255, 255, 255, 255]);
}
tauri::image::Image::new_owned(rgba, 16, 16)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct StartRequest {
@@ -87,6 +124,7 @@ struct TapAdapter {
name: String,
description: String,
guid: String,
if_index: u32,
}
const CONFIG_FILE_NAME: &str = "super-n2n.config.json";
@@ -102,6 +140,17 @@ struct StartupStatus {
tap_installer_path: Option<String>,
}
fn is_tap_windows_description(description: &str) -> bool {
let normalized = description.trim().to_ascii_lowercase();
if normalized == "tap-windows adapter v9" {
return true;
}
let Some(suffix) = normalized.strip_prefix("tap-windows adapter v9 #") else {
return false;
};
!suffix.is_empty() && suffix.chars().all(|character| character.is_ascii_digit())
}
fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -223,7 +272,7 @@ fn windows_tap_traffic(tap_device: &str) -> Result<TapTraffic, String> {
|| description.eq_ignore_ascii_case(tap_device)
|| guid.eq_ignore_ascii_case(tap_device))
.then_some(TapTraffic {
interface_name: alias,
interface_name: description,
received_bytes: row.InOctets,
sent_bytes: row.OutOctets,
})
@@ -278,14 +327,12 @@ fn windows_tap_adapters() -> Result<Vec<TapAdapter>, String> {
rows.iter()
.filter_map(|row| {
let description = wide_string(&row.Description);
description
.to_ascii_lowercase()
.contains("tap-windows")
.then_some(TapAdapter {
name: wide_string(&row.Alias),
description,
guid: guid_string(&row.InterfaceGuid),
})
is_tap_windows_description(&description).then_some(TapAdapter {
name: wide_string(&row.Alias),
description,
guid: guid_string(&row.InterfaceGuid),
if_index: row.InterfaceIndex,
})
})
.collect()
};
@@ -328,11 +375,19 @@ fn start_service(
));
}
let mut child = Command::new(relative_binary)
let mut command = Command::new(relative_binary);
command
.current_dir(&working_directory)
.args(&request.args)
.stdout(Stdio::null())
.stderr(Stdio::null())
.stderr(Stdio::null());
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
// Keep the console-subsystem edge process out of the user's desktop.
command.creation_flags(0x08000000);
}
let mut child = command
.spawn()
.map_err(|e| format!("unable to start {}: {}", binary.display(), e))?;
let pid = child.id();
@@ -354,6 +409,77 @@ fn start_service(
})
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TapAddressRequest {
tap_device: String,
dhcp: bool,
ip: String,
}
#[tauri::command]
fn configure_tap_adapter(request: TapAddressRequest) -> Result<(), String> {
let requested = request.tap_device.trim();
if requested.is_empty()
|| requested.contains(['/', '\\'])
|| requested == "."
|| requested == ".."
{
return Err("invalid TAP device name".to_string());
}
#[cfg(target_os = "windows")]
{
let adapter = windows_tap_adapters()?
.into_iter()
.find(|adapter| {
adapter.name.eq_ignore_ascii_case(requested)
|| adapter.description.eq_ignore_ascii_case(requested)
|| adapter.guid.eq_ignore_ascii_case(requested)
})
.ok_or_else(|| format!("找不到指定的 TAP 设备:{requested}"))?;
if adapter.if_index == 0 {
return Err("TAP 网卡没有有效的接口索引".to_string());
}
let interface = format!("name={}", adapter.if_index);
let mut command = Command::new("netsh");
command.args(["interface", "ipv4", "set", "address", &interface]);
if request.dhcp {
command.arg("source=dhcp");
} else {
let address = request
.ip
.trim()
.parse::<Ipv4Addr>()
.map_err(|_| format!("无效的本机 IP{}", request.ip.trim()))?;
command.args([
"source=static",
&format!("address={address}"),
"mask=255.255.255.0",
]);
}
let output = command
.output()
.map_err(|error| format!("无法配置 TAP 网卡:{error}"))?;
if !output.status.success() {
let detail = if output.stderr.is_empty() {
String::from_utf8_lossy(&output.stdout)
} else {
String::from_utf8_lossy(&output.stderr)
};
return Err(format!("TAP 网卡地址配置失败:{}", detail.trim()));
}
return Ok(());
}
#[cfg(not(target_os = "windows"))]
{
let _ = request;
Err("TAP 网卡地址配置仅支持 Windows".to_string())
}
}
#[tauri::command]
fn stop_service(service: String, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
let mut processes = state
@@ -578,6 +704,7 @@ fn resolve_tap_adapter(request: TapAdapterRequest) -> Result<TapAdapter, String>
name: requested.to_string(),
description: requested.to_string(),
guid: String::new(),
if_index: 0,
})
}
}
@@ -711,7 +838,19 @@ fn export_log(contents: String) -> Result<String, String> {
#[cfg(test)]
mod tests {
use super::{relative_binary_path, StartRequest};
use super::{is_tap_windows_description, relative_binary_path, StartRequest};
#[test]
fn tap_description_filter_excludes_network_filter_interfaces() {
assert!(is_tap_windows_description("TAP-Windows Adapter V9"));
assert!(is_tap_windows_description("TAP-Windows Adapter V9 #2"));
assert!(!is_tap_windows_description(
"TAP-Windows Adapter V9 #2-WFP Native MAC Layer LightWeight Filter-0000"
));
assert!(!is_tap_windows_description(
"TAP-Windows Adapter V9-Npcap Packet Driver (NPCAP)-0000"
));
}
#[cfg(target_os = "windows")]
#[test]
@@ -748,10 +887,62 @@ mod tests {
}
pub fn run() {
tauri::Builder::default()
let app = tauri::Builder::default()
.manage(AppState {
processes: Mutex::new(HashMap::new()),
})
.setup(|app| {
let connect = MenuItem::with_id(app, "tray-connect", "连接", true, None::<&str>)?;
let disconnect = MenuItem::with_id(app, "tray-disconnect", "断开", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "tray-quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&connect, &disconnect, &quit])?;
TrayIconBuilder::with_id("main")
.icon(tray_icon_image())
.menu(&menu)
.tooltip("Super N2N")
.show_menu_on_left_click(false)
.build(app)?;
Ok(())
})
.on_menu_event(|app, event| match event.id().as_ref() {
"tray-connect" => {
let _ = app.emit("tray-connect", ());
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
"tray-disconnect" => {
stop_all_processes(app.state::<AppState>().inner());
let _ = app.emit("tray-service-stopped", ());
}
"tray-quit" => {
stop_all_processes(app.state::<AppState>().inner());
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|app, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
stop_all_processes(window.state::<AppState>().inner());
let _ = window.emit("tray-service-stopped", ());
let _ = window.hide();
}
})
.invoke_handler(tauri::generate_handler![
start_service,
stop_service,
@@ -761,6 +952,7 @@ pub fn run() {
run_tcping,
tap_traffic,
resolve_tap_adapter,
configure_tap_adapter,
startup_status,
read_config,
write_config,
@@ -768,6 +960,14 @@ pub fn run() {
firewall_status,
export_log
])
.run(tauri::generate_context!())
.build(tauri::generate_context!())
.expect("error while running Super N2N");
app.run(|app, event| {
if matches!(
event,
tauri::RunEvent::ExitRequested { .. } | tauri::RunEvent::Exit
) {
stop_all_processes(app.state::<AppState>().inner());
}
});
}
+3
View File
@@ -39,6 +39,9 @@ export const tapTraffic = (tapDevice) => nativeCall('tap_traffic', { request: {
export const resolveTapAdapter = (tapDevice) => nativeCall('resolve_tap_adapter', { request: { tapDevice } }, () => {
throw new Error('浏览器预览无法识别 TAP 网卡')
})
export const configureTapAdapter = (request) => nativeCall('configure_tap_adapter', { request }, () => {
throw new Error('浏览器预览无法配置 TAP 网卡')
})
export const startupStatus = () => nativeCall('startup_status', {}, () => ({
configExists: false,
firstLaunch: false,
+48 -3
View File
@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { listen } from '@tauri-apps/api/event'
import {
ArrowDown, ArrowUp, CheckCircle2, ChevronDown, CircleAlert, Gauge, Globe2, Network, Play,
Download, Router, ScrollText, Server, Settings2, Square, Wifi,
} from 'lucide-react'
import {
exportLog, isTauri, openTapInstaller, readConfig, resolveTapAdapter, serviceStatus, startService,
configureTapAdapter, exportLog, isTauri, openTapInstaller, readConfig, resolveTapAdapter, serviceStatus, startService,
startupStatus, stopService, tapTraffic, writeConfig,
} from './lib/tauri'
import './styles.css'
@@ -22,6 +24,7 @@ const initialEdge = {
}
const edgeConfigStorageKey = 'super-n2n.edge-config.v1'
const isWindowsRuntime = typeof navigator !== 'undefined' && /Windows/i.test(navigator.userAgent)
function parseEdgeConfig(value) {
const fallback = { ...initialEdge }
@@ -119,6 +122,38 @@ function App() {
return () => { active = false }
}, [])
useEffect(() => {
if (!isTauri) return undefined
let active = true
let unlistenConnect
let unlistenDisconnect
let unlistenStopped
const bindTrayEvents = async () => {
unlistenConnect = await listen('tray-connect', async () => {
if (!active) return
await getCurrentWindow().show()
await getCurrentWindow().setFocus()
if (!edgeRunning) await toggleEdge()
})
unlistenDisconnect = await listen('tray-disconnect', async () => {
if (!active || !edgeRunning) return
await toggleEdge()
})
unlistenStopped = await listen('tray-service-stopped', () => {
if (!active) return
setEdgeRunning(false)
setActiveTapDevice('')
})
}
bindTrayEvents()
return () => {
active = false
unlistenConnect?.()
unlistenDisconnect?.()
unlistenStopped?.()
}
}, [edgeRunning, edge])
useEffect(() => {
let active = true
serviceStatus('edge')
@@ -203,10 +238,18 @@ function App() {
try {
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,
})
appendLog('已配置 TAP 网卡地址', 'success')
}
const status = await startService({
service: 'edge',
binaryPath: './n2n/edge.exe',
args: buildEdgeArgs(edge, tapAdapter.name),
args: buildEdgeArgs(edge, tapAdapter.guid || tapAdapter.name),
})
setEdgeRunning(Boolean(status.running))
setActiveTapDevice(tapAdapter.name)
@@ -444,7 +487,9 @@ function buildEdgeArgs(config, tapDevice) {
addOption(args, '-d', tapDevice)
addOption(args, '-t', config.managementPort)
if (config.encryptionKey) addOption(args, '-k', config.encryptionKey)
args.push('-f', '-v', '-v')
// Windows edge does not implement the POSIX-only -f daemon flag.
if (!isWindowsRuntime) args.push('-f')
args.push('-v', '-v')
return args
}