Add tray controls and clean up child processes
Build Windows Portable Package / portable (release) Successful in 12m13s
Build Windows Portable Package / portable (release) Successful in 12m13s
This commit is contained in:
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user