diff --git a/README.md b/README.md index 065376a..1dd96ad 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,15 @@ The portable build creates `release/super-n2n-portable.zip`. It contains `n2n/edge.exe` and `n2n/supernode.exe`; on Windows Super N2N starts them with the relative paths `./n2n/edge.exe` and `./n2n/supernode.exe`. +### Windows TAP requirement + +N2N uses an installed TAP-Windows virtual adapter; it does not create a new +adapter itself. Super N2N automatically selects an available TAP-Windows +adapter before starting Edge and reports a clear error when none is installed. +The optional TAP device field is only needed to select a specific existing +adapter. Connection settings, including advanced settings, are restored on the +next application launch. + ## Gitea Actions `.gitea/workflows/build-windows.yml` runs when a Release is published and uploads diff --git a/scripts/PORTABLE-README.txt b/scripts/PORTABLE-README.txt index 2bec63f..68a75b0 100644 --- a/scripts/PORTABLE-README.txt +++ b/scripts/PORTABLE-README.txt @@ -8,4 +8,9 @@ them as .\n2n\edge.exe and .\n2n\supernode.exe, so no PATH configuration is requ Windows WebView2 is required. It is included by default on current Windows 10 and Windows 11 installations. +N2N requires a TAP-Windows virtual network adapter installed on the computer. +Super N2N automatically selects an available adapter when connecting. It does +not install or create a TAP adapter itself; install the TAP-Windows driver once +if the application reports that no TAP adapter is available. + The bundled n2n programs are distributed under n2n\LICENSE-n2n.txt. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f775308..483ffe9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -75,6 +75,20 @@ struct TapTraffic { sent_bytes: u64, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TapAdapterRequest { + tap_device: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct TapAdapter { + name: String, + description: String, + guid: String, +} + fn now_epoch() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -183,6 +197,65 @@ fn windows_tap_traffic(tap_device: &str) -> Result { traffic } +#[cfg(target_os = "windows")] +fn windows_tap_adapters() -> Result, String> { + use std::{ptr, slice}; + use windows_sys::Win32::{ + Foundation::NO_ERROR, + NetworkManagement::IpHelper::{FreeMibTable, GetIfTable2, MIB_IF_TABLE2}, + }; + + fn wide_string(value: &[u16]) -> String { + let end = value + .iter() + .position(|unit| *unit == 0) + .unwrap_or(value.len()); + String::from_utf16_lossy(&value[..end]) + } + + fn guid_string(guid: &windows_sys::core::GUID) -> String { + format!( + "{{{:08x}-{:04x}-{:04x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}}}", + guid.data1, + guid.data2, + guid.data3, + guid.data4[0], + guid.data4[1], + guid.data4[2], + guid.data4[3], + guid.data4[4], + guid.data4[5], + guid.data4[6], + guid.data4[7] + ) + } + + let mut table: *mut MIB_IF_TABLE2 = ptr::null_mut(); + let result = unsafe { GetIfTable2(&mut table) }; + if result != NO_ERROR || table.is_null() { + return Err(format!("unable to read network adapter table: {result}")); + } + + let adapters = unsafe { + let rows = slice::from_raw_parts((*table).Table.as_ptr(), (*table).NumEntries as usize); + 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), + }) + }) + .collect() + }; + unsafe { FreeMibTable(table.cast()) }; + Ok(adapters) +} + #[tauri::command] fn start_service(request: StartRequest, state: State<'_, AppState>) -> Result { let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?; @@ -209,7 +282,7 @@ fn start_service(request: StartRequest, state: State<'_, AppState>) -> Result) -> Result Result { Err("TAP traffic monitoring is not supported on this operating system".to_string()) } +#[tauri::command] +fn resolve_tap_adapter(request: TapAdapterRequest) -> Result { + let requested = request.tap_device.trim(); + if requested.contains(['/', '\\']) || requested == "." || requested == ".." { + return Err("invalid TAP device name".to_string()); + } + + #[cfg(target_os = "windows")] + { + let adapters = windows_tap_adapters()?; + if adapters.is_empty() { + return Err( + "未找到 TAP-Windows 网卡。请先安装 TAP-Windows 驱动,然后重新连接。".to_string(), + ); + } + if requested.is_empty() { + return Ok(adapters.into_iter().next().expect("non-empty adapter list")); + } + return 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}")); + } + + #[cfg(not(target_os = "windows"))] + { + if requested.is_empty() { + return Err("请输入 TAP 设备名称".to_string()); + } + Ok(TapAdapter { + name: requested.to_string(), + description: requested.to_string(), + guid: String::new(), + }) + } +} + #[tauri::command] fn firewall_status() -> Result { #[cfg(target_os = "windows")] @@ -497,6 +618,7 @@ pub fn run() { run_ping, run_tcping, tap_traffic, + resolve_tap_adapter, firewall_status, export_log ]) diff --git a/src/lib/tauri.js b/src/lib/tauri.js index 7f460ec..d97c29b 100644 --- a/src/lib/tauri.js +++ b/src/lib/tauri.js @@ -36,4 +36,7 @@ export const checkFirewall = (fallback) => nativeCall('firewall_status', {}, fal export const tapTraffic = (tapDevice) => nativeCall('tap_traffic', { request: { tapDevice } }, () => { throw new Error('浏览器预览无法读取 TAP 流量') }) +export const resolveTapAdapter = (tapDevice) => nativeCall('resolve_tap_adapter', { request: { tapDevice } }, () => { + throw new Error('浏览器预览无法识别 TAP 网卡') +}) export const exportLog = (contents) => nativeCall('export_log', { contents }, () => null) diff --git a/src/main.jsx b/src/main.jsx index 62e4d88..2b8f565 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -4,7 +4,7 @@ import { ArrowDown, ArrowUp, CheckCircle2, ChevronDown, CircleAlert, Gauge, Globe2, Network, Play, Download, Router, ScrollText, Server, Settings2, Square, Wifi, } from 'lucide-react' -import { exportLog, isTauri, serviceStatus, startService, stopService, tapTraffic } from './lib/tauri' +import { exportLog, isTauri, resolveTapAdapter, serviceStatus, startService, stopService, tapTraffic } from './lib/tauri' import './styles.css' const initialEdge = { @@ -15,13 +15,39 @@ const initialEdge = { ip: '', encryptionKey: '', managementPort: '5644', - tapDevice: 'n2n0', + tapDevice: '', +} + +const edgeConfigStorageKey = 'super-n2n.edge-config.v1' + +function loadEdgeConfig() { + const fallback = { ...initialEdge } + if (typeof window === 'undefined') return fallback + try { + const saved = JSON.parse(window.localStorage.getItem(edgeConfigStorageKey) || 'null') + if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return fallback + for (const [field, defaultValue] of Object.entries(initialEdge)) { + if (typeof saved[field] === typeof defaultValue) fallback[field] = saved[field] + } + } catch { + // A damaged or unavailable browser store must not block the connection form. + } + return fallback +} + +function saveEdgeConfig(config) { + try { + window.localStorage.setItem(edgeConfigStorageKey, JSON.stringify(config)) + } catch { + // The form stays usable when the platform denies local storage access. + } } function App() { const [home, setHome] = useState('edge') - const [edge, setEdge] = useState(initialEdge) + const [edge, setEdge] = useState(loadEdgeConfig) const [edgeRunning, setEdgeRunning] = useState(false) + const [activeTapDevice, setActiveTapDevice] = useState('') const [advancedOpen, setAdvancedOpen] = useState(false) const [logOpen, setLogOpen] = useState(false) const [logs, setLogs] = useState([]) @@ -34,6 +60,10 @@ function App() { setLogs((current) => [{ time, level, text }, ...current].slice(0, 100)) } + useEffect(() => { + saveEdgeConfig(edge) + }, [edge]) + useEffect(() => { let active = true serviceStatus('edge') @@ -47,7 +77,7 @@ function App() { }, []) useEffect(() => { - if (!edgeRunning) { + if (!edgeRunning || !activeTapDevice) { lastTapSample.current = null setTraffic(null) return undefined @@ -57,7 +87,7 @@ function App() { let timer const poll = async () => { try { - const sample = await tapTraffic(edge.tapDevice) + const sample = await tapTraffic(activeTapDevice) const sampledAt = Date.now() const previous = lastTapSample.current const elapsedSeconds = previous ? Math.max((sampledAt - previous.sampledAt) / 1000, .25) : 1 @@ -76,7 +106,7 @@ function App() { active = false window.clearTimeout(timer) } - }, [edgeRunning, edge.tapDevice]) + }, [activeTapDevice, edgeRunning]) const updateEdge = (field, value) => setEdge((current) => ({ ...current, [field]: value })) const notify = (text, tone = 'error') => { @@ -89,6 +119,7 @@ function App() { try { const status = await stopService('edge') setEdgeRunning(Boolean(status.running)) + setActiveTapDevice('') appendLog('已断开 N2N 网络') notify('已断开 N2N 网络', 'neutral') } catch (error) { @@ -115,12 +146,15 @@ function App() { } try { + const tapAdapter = await resolveTapAdapter(edge.tapDevice) + appendLog('已选择 TAP 设备:' + tapAdapter.name) const status = await startService({ service: 'edge', binaryPath: './n2n/edge.exe', - args: buildEdgeArgs(edge), + args: buildEdgeArgs(edge, tapAdapter.name), }) setEdgeRunning(Boolean(status.running)) + setActiveTapDevice(tapAdapter.name) appendLog('已连接到群组 ' + edge.community, 'success') notify('已连接到 ' + edge.community, 'success') } catch (error) { @@ -146,7 +180,7 @@ function App() { {home === 'edge' - ? exportLogs(logs, notify)} /> + ? exportLogs(logs, notify)} /> : } @@ -155,7 +189,7 @@ function App() { } -function EdgeHome({ edge, updateEdge, running, traffic, advancedOpen, setAdvancedOpen, logOpen, setLogOpen, logs, onToggle, onExportLog }) { +function EdgeHome({ edge, updateEdge, running, traffic, activeTapDevice, advancedOpen, setAdvancedOpen, logOpen, setLogOpen, logs, onToggle, onExportLog }) { const toggleAdvanced = () => { setLogOpen(false) setAdvancedOpen((current) => !current) @@ -203,7 +237,7 @@ function EdgeHome({ edge, updateEdge, running, traffic, advancedOpen, setAdvance {running ? '断开连接' : '连接网络'} - {running && } + {running && }
@@ -214,7 +248,7 @@ function EdgeHome({ edge, updateEdge, running, traffic, advancedOpen, setAdvance {advancedOpen &&
- +
} @@ -323,12 +357,12 @@ function addOption(args, flag, value) { if (String(value ?? '').trim()) args.push(flag, String(value).trim()) } -function buildEdgeArgs(config) { +function buildEdgeArgs(config, tapDevice) { const args = [] addOption(args, '-c', config.community) addOption(args, '-l', config.serverHost + ':' + (config.serverPort || '7777')) addOption(args, '-a', config.dhcp ? 'dhcp:0.0.0.0' : config.ip) - addOption(args, '-d', config.tapDevice) + addOption(args, '-d', tapDevice) addOption(args, '-t', config.managementPort) if (config.encryptionKey) addOption(args, '-k', config.encryptionKey) args.push('-f', '-v', '-v')