From 2bbd370e029d39e8fa8351dd7eed39c81a3e73cf Mon Sep 17 00:00:00 2001 From: chun_qiu Date: Wed, 2 Sep 2026 02:25:18 +0800 Subject: [PATCH] Add tray controls and clean up child processes --- src-tauri/Cargo.toml | 8 +- src-tauri/build.rs | 4 +- src-tauri/manifest.xml | 22 ++++ src-tauri/src/lib.rs | 232 ++++++++++++++++++++++++++++++++++++++--- src/lib/tauri.js | 3 + src/main.jsx | 51 ++++++++- 6 files changed, 299 insertions(+), 21 deletions(-) create mode 100644 src-tauri/manifest.xml 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 }