diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 9d8d057..2eb1a20 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -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"
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
index d860e1e..1767b91 100644
--- a/src-tauri/build.rs
+++ b/src-tauri/build.rs
@@ -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");
}
diff --git a/src-tauri/manifest.xml b/src-tauri/manifest.xml
new file mode 100644
index 0000000..3e50be5
--- /dev/null
+++ b/src-tauri/manifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 7706f4b..b216fef 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -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>,
}
+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,
}
+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 {
|| 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, 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::()
+ .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 {
let mut processes = state
@@ -578,6 +704,7 @@ fn resolve_tap_adapter(request: TapAdapterRequest) -> Result
name: requested.to_string(),
description: requested.to_string(),
guid: String::new(),
+ if_index: 0,
})
}
}
@@ -711,7 +838,19 @@ fn export_log(contents: String) -> Result {
#[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::().inner());
+ let _ = app.emit("tray-service-stopped", ());
+ }
+ "tray-quit" => {
+ stop_all_processes(app.state::().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::().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::().inner());
+ }
+ });
}
diff --git a/src/lib/tauri.js b/src/lib/tauri.js
index b1c8b76..7eceb59 100644
--- a/src/lib/tauri.js
+++ b/src/lib/tauri.js
@@ -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,
diff --git a/src/main.jsx b/src/main.jsx
index f58700b..9ccad95 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -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
}