Initial Super N2N desktop application
Build Windows Portable Package / portable (push) Has been cancelled
@@ -0,0 +1,7 @@
|
||||
# Ignore generated files that would otherwise restart `tauri dev`.
|
||||
/target/
|
||||
/gen/
|
||||
/.tauri/
|
||||
|
||||
# Local native build diagnostics.
|
||||
*.log
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "super-n2n"
|
||||
version = "0.1.0"
|
||||
description = "N2N desktop control plane"
|
||||
authors = ["Super N2N"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "super_n2n_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_NetworkManagement_IpHelper", "Win32_NetworkManagement_Ndis"] }
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="88" fill="#101716"/>
|
||||
<path d="M128 178h118l48 78h90" fill="none" stroke="#56e390" stroke-linecap="round" stroke-linejoin="round" stroke-width="34"/>
|
||||
<path d="M128 334h118l48-78h90" fill="none" stroke="#56e390" stroke-linecap="round" stroke-linejoin="round" stroke-width="34"/>
|
||||
<circle cx="128" cy="256" r="45" fill="#f2fbf5"/>
|
||||
<circle cx="384" cy="256" r="45" fill="#f2fbf5"/>
|
||||
<circle cx="256" cy="256" r="31" fill="#f7c75d"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 544 B |
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default permissions for the Super N2N desktop window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 633 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 529 B |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 817 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 957 B |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 373 B |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 644 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 762 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,505 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
net::{SocketAddr, TcpStream, UdpSocket},
|
||||
path::PathBuf,
|
||||
process::{Child, Command, Stdio},
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tauri::State;
|
||||
|
||||
struct AppState {
|
||||
processes: Mutex<HashMap<String, Child>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StartRequest {
|
||||
service: String,
|
||||
binary_path: String,
|
||||
args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ServiceStatus {
|
||||
service: String,
|
||||
running: bool,
|
||||
pid: Option<u32>,
|
||||
started_at: u64,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ManagementRequest {
|
||||
host: String,
|
||||
port: u16,
|
||||
command: String,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ToolResult {
|
||||
ok: bool,
|
||||
summary: String,
|
||||
output: String,
|
||||
latency_ms: Option<u128>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PingRequest {
|
||||
host: String,
|
||||
count: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TcpingRequest {
|
||||
host: String,
|
||||
port: u16,
|
||||
timeout_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TapTrafficRequest {
|
||||
tap_device: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct TapTraffic {
|
||||
interface_name: String,
|
||||
received_bytes: u64,
|
||||
sent_bytes: u64,
|
||||
}
|
||||
|
||||
fn now_epoch() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
fn empty_status(service: &str, message: &str) -> ServiceStatus {
|
||||
ServiceStatus {
|
||||
service: service.to_string(),
|
||||
running: false,
|
||||
pid: None,
|
||||
started_at: 0,
|
||||
message: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_binary_path(service: &str) -> Result<&'static str, String> {
|
||||
match service {
|
||||
"edge" => Ok("./n2n/edge.exe"),
|
||||
"supernode" => Ok("./n2n/supernode.exe"),
|
||||
_ => Err(format!("unsupported service: {service}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn app_directory() -> Result<std::path::PathBuf, String> {
|
||||
std::env::current_exe()
|
||||
.map_err(|error| format!("unable to locate application executable: {error}"))?
|
||||
.parent()
|
||||
.map(|path| path.to_path_buf())
|
||||
.ok_or_else(|| "application executable has no parent directory".to_string())
|
||||
}
|
||||
|
||||
fn downloads_directory() -> PathBuf {
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Some(profile) = std::env::var_os("USERPROFILE") {
|
||||
return PathBuf::from(profile).join("Downloads");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home).join("Downloads");
|
||||
}
|
||||
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_tap_traffic(tap_device: &str) -> Result<TapTraffic, 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 traffic = unsafe {
|
||||
let rows = slice::from_raw_parts((*table).Table.as_ptr(), (*table).NumEntries as usize);
|
||||
rows.iter()
|
||||
.find_map(|row| {
|
||||
let alias = wide_string(&row.Alias);
|
||||
let description = wide_string(&row.Description);
|
||||
let guid = guid_string(&row.InterfaceGuid);
|
||||
(alias.eq_ignore_ascii_case(tap_device)
|
||||
|| description.eq_ignore_ascii_case(tap_device)
|
||||
|| guid.eq_ignore_ascii_case(tap_device))
|
||||
.then_some(TapTraffic {
|
||||
interface_name: alias,
|
||||
received_bytes: row.InOctets,
|
||||
sent_bytes: row.OutOctets,
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| format!("TAP adapter not found: {tap_device}"))
|
||||
};
|
||||
unsafe { FreeMibTable(table.cast()) };
|
||||
traffic
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_service(request: StartRequest, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
|
||||
let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?;
|
||||
if let Some(existing) = processes.get_mut(&request.service) {
|
||||
if existing.try_wait().map_err(|e| e.to_string())?.is_none() {
|
||||
return Ok(ServiceStatus {
|
||||
service: request.service,
|
||||
running: true,
|
||||
pid: Some(existing.id()),
|
||||
started_at: now_epoch(),
|
||||
message: "already running".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let relative_binary = relative_binary_path(&request.service)?;
|
||||
if request.binary_path != relative_binary {
|
||||
return Err(format!("{} must use {}", request.service, relative_binary));
|
||||
}
|
||||
|
||||
let working_directory = app_directory()?;
|
||||
let binary = working_directory.join(relative_binary);
|
||||
if !binary.is_file() {
|
||||
return Err(format!("required n2n binary is missing: {}", binary.display()));
|
||||
}
|
||||
|
||||
let child = Command::new(relative_binary)
|
||||
.current_dir(&working_directory)
|
||||
.args(&request.args)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("unable to start {}: {}", binary.display(), e))?;
|
||||
let pid = child.id();
|
||||
processes.insert(request.service.clone(), child);
|
||||
|
||||
Ok(ServiceStatus {
|
||||
service: request.service,
|
||||
running: true,
|
||||
pid: Some(pid),
|
||||
started_at: now_epoch(),
|
||||
message: format!("started {relative_binary}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_service(service: String, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
|
||||
let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?;
|
||||
if let Some(mut child) = processes.remove(&service) {
|
||||
child.kill().map_err(|e| format!("unable to stop {}: {}", service, e))?;
|
||||
let _ = child.wait();
|
||||
return Ok(empty_status(&service, "stopped"));
|
||||
}
|
||||
Ok(empty_status(&service, "not running"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn service_status(service: String, state: State<'_, AppState>) -> Result<ServiceStatus, String> {
|
||||
let mut processes = state.processes.lock().map_err(|_| "process state is unavailable".to_string())?;
|
||||
if let Some(child) = processes.get_mut(&service) {
|
||||
if child.try_wait().map_err(|e| e.to_string())?.is_none() {
|
||||
return Ok(ServiceStatus {
|
||||
service,
|
||||
running: true,
|
||||
pid: Some(child.id()),
|
||||
started_at: now_epoch(),
|
||||
message: "running".to_string(),
|
||||
});
|
||||
}
|
||||
processes.remove(&service);
|
||||
return Ok(empty_status(&service, "exited"));
|
||||
}
|
||||
Ok(empty_status(&service, "not running"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn management_query(request: ManagementRequest) -> Result<Vec<Value>, String> {
|
||||
let address: SocketAddr = format!("{}:{}", request.host, request.port)
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid management address: {}", e))?;
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").map_err(|e| e.to_string())?;
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(650)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
let tag = now_epoch() % 1000;
|
||||
let auth = request
|
||||
.password
|
||||
.filter(|password| !password.is_empty())
|
||||
.map(|password| format!(":1:{}", password))
|
||||
.unwrap_or_default();
|
||||
let payload = format!("r {}{} {}\n", tag, auth, request.command);
|
||||
socket
|
||||
.send_to(payload.as_bytes(), address)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut packets = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
loop {
|
||||
match socket.recv_from(&mut buffer) {
|
||||
Ok((size, _)) => {
|
||||
if let Ok(text) = std::str::from_utf8(&buffer[..size]) {
|
||||
for line in text.lines() {
|
||||
if let Ok(value) = serde_json::from_str::<Value>(line) {
|
||||
let finished = value.get("_type").and_then(Value::as_str) == Some("end");
|
||||
packets.push(value);
|
||||
if finished {
|
||||
return Ok(packets);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock || error.kind() == std::io::ErrorKind::TimedOut => {
|
||||
break;
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
if packets.is_empty() {
|
||||
Err(format!("management API timed out: {address}"))
|
||||
} else {
|
||||
Ok(packets)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn run_ping(request: PingRequest) -> Result<ToolResult, String> {
|
||||
let count = request.count.clamp(1, 8).to_string();
|
||||
let started = Instant::now();
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = Command::new("ping")
|
||||
.args(["-n", &count, &request.host])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let output = Command::new("ping")
|
||||
.args(["-c", &count, &request.host])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
Ok(ToolResult {
|
||||
ok: output.status.success(),
|
||||
summary: if output.status.success() { "目标可达".to_string() } else { "目标不可达".to_string() },
|
||||
output: if stdout.is_empty() { stderr } else { stdout },
|
||||
latency_ms: Some(started.elapsed().as_millis()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn run_tcping(request: TcpingRequest) -> Result<ToolResult, String> {
|
||||
let address: SocketAddr = format!("{}:{}", request.host, request.port)
|
||||
.parse()
|
||||
.map_err(|e| format!("invalid TCP address: {}", e))?;
|
||||
let started = Instant::now();
|
||||
match TcpStream::connect_timeout(&address, Duration::from_millis(request.timeout_ms.max(100))) {
|
||||
Ok(_) => Ok(ToolResult {
|
||||
ok: true,
|
||||
summary: "TCP 端口可连接".to_string(),
|
||||
output: format!("connected to {} in {} ms", address, started.elapsed().as_millis()),
|
||||
latency_ms: Some(started.elapsed().as_millis()),
|
||||
}),
|
||||
Err(error) => Ok(ToolResult {
|
||||
ok: false,
|
||||
summary: "TCP 端口不可连接".to_string(),
|
||||
output: error.to_string(),
|
||||
latency_ms: Some(started.elapsed().as_millis()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn tap_traffic(request: TapTrafficRequest) -> Result<TapTraffic, String> {
|
||||
let tap_device = request.tap_device.trim();
|
||||
if tap_device.is_empty() {
|
||||
return Err("TAP device name is empty".to_string());
|
||||
}
|
||||
if tap_device.contains(['/', '\\']) || tap_device == "." || tap_device == ".." {
|
||||
return Err("invalid TAP device name".to_string());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return windows_tap_traffic(tap_device);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let path = std::path::Path::new("/sys/class/net").join(tap_device).join("statistics");
|
||||
let received_bytes = fs::read_to_string(path.join("rx_bytes"))
|
||||
.map_err(|error| format!("unable to read TAP adapter {tap_device}: {error}"))?
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|error| format!("invalid TAP receive counter: {error}"))?;
|
||||
let sent_bytes = fs::read_to_string(path.join("tx_bytes"))
|
||||
.map_err(|error| format!("unable to read TAP adapter {tap_device}: {error}"))?
|
||||
.trim()
|
||||
.parse::<u64>()
|
||||
.map_err(|error| format!("invalid TAP transmit counter: {error}"))?;
|
||||
return Ok(TapTraffic {
|
||||
interface_name: tap_device.to_string(),
|
||||
received_bytes,
|
||||
sent_bytes,
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
Err("TAP traffic monitoring is not supported on this operating system".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn firewall_status() -> Result<ToolResult, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let output = Command::new("netsh")
|
||||
.args(["advfirewall", "show", "allprofiles", "state"])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
#[cfg(target_os = "linux")]
|
||||
let output = Command::new("sh")
|
||||
.args(["-c", "ufw status verbose 2>/dev/null || firewall-cmd --state 2>/dev/null || true"])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
#[cfg(target_os = "macos")]
|
||||
let output = Command::new("/usr/libexec/ApplicationFirewall/socketfilterfw")
|
||||
.args(["--getglobalstate"])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
|
||||
let output = Command::new("true").output().map_err(|e| e.to_string())?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let normalized = stdout
|
||||
.to_ascii_lowercase()
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let enabled = normalized.contains("state on")
|
||||
|| normalized.contains("status: active")
|
||||
|| normalized.contains("firewall is enabled")
|
||||
|| normalized.contains("state = 1")
|
||||
|| normalized.split_whitespace().any(|token| token == "running");
|
||||
Ok(ToolResult {
|
||||
ok: output.status.success(),
|
||||
summary: if enabled { "本地防火墙已开启".to_string() } else { "未检测到防火墙开启".to_string() },
|
||||
output: stdout,
|
||||
latency_ms: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_log(contents: String) -> Result<String, String> {
|
||||
let directory = downloads_directory();
|
||||
fs::create_dir_all(&directory)
|
||||
.map_err(|error| format!("unable to create downloads directory: {error}"))?;
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let file = directory.join(format!("super-n2n-{millis}.log"));
|
||||
fs::write(&file, contents).map_err(|error| format!("unable to export log: {error}"))?;
|
||||
Ok(file.display().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{relative_binary_path, StartRequest};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_tap_traffic_uses_the_adapter_table() {
|
||||
assert!(super::windows_tap_traffic("__super_n2n_missing_tap__").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_request_accepts_an_argument_vector_from_tauri() {
|
||||
let request: StartRequest = serde_json::from_str(
|
||||
r#"{
|
||||
"service": "edge",
|
||||
"binaryPath": "./n2n/edge.exe",
|
||||
"args": ["-c", "test-community", "-l", "127.0.0.1:7777"]
|
||||
}"#,
|
||||
)
|
||||
.expect("a Tauri start request with an argument array should deserialize");
|
||||
|
||||
assert_eq!(request.binary_path, "./n2n/edge.exe");
|
||||
assert_eq!(
|
||||
request.args,
|
||||
["-c", "test-community", "-l", "127.0.0.1:7777"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn services_have_fixed_relative_binary_paths() {
|
||||
assert_eq!(relative_binary_path("edge").unwrap(), "./n2n/edge.exe");
|
||||
assert_eq!(
|
||||
relative_binary_path("supernode").unwrap(),
|
||||
"./n2n/supernode.exe"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(AppState {
|
||||
processes: Mutex::new(HashMap::new()),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
start_service,
|
||||
stop_service,
|
||||
service_status,
|
||||
management_query,
|
||||
run_ping,
|
||||
run_tcping,
|
||||
tap_traffic,
|
||||
firewall_status,
|
||||
export_log
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Super N2N");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
super_n2n_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Super N2N",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.supern2n.control",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Super N2N Control",
|
||||
"width": 350,
|
||||
"height": 380,
|
||||
"minWidth": 350,
|
||||
"minHeight": 380,
|
||||
"maxWidth": 350,
|
||||
"maxHeight": 380,
|
||||
"resizable": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": false,
|
||||
"targets": "all"
|
||||
}
|
||||
}
|
||||