feat: add unified Tauri desktop client and secure web access

This commit is contained in:
lbl
2026-08-22 04:09:44 +08:00
parent 0ccd045e78
commit cd5b4431dc
62 changed files with 5018 additions and 500 deletions
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "vnt-desktop"
version = "2.0.0"
description = "VNT virtual network desktop client"
edition = "2024"
[lib]
name = "vnt_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.6.3", features = [] }
[dependencies]
tauri = { version = "2.11.5", features = ["tray-icon"] }
tauri-plugin-single-instance = "2.4.3"
tauri-plugin-opener = "2"
vnt-web = { path = "../../vnt-web" }
vnt2 = { path = "../.." }
log.workspace = true
tokio = { workspace = true, features = ["full"] }
tokio-util.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
anyhow.workspace = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,16 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability",
"description": "VNT Desktop main window permissions",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-maximize",
"core:window:allow-unmaximize",
"core:window:allow-is-maximized",
"core:window:allow-start-dragging",
"opener:allow-open-url"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

+322
View File
@@ -0,0 +1,322 @@
use serde::{Deserialize, Serialize};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::menu::{Menu, MenuItem};
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Manager, WindowEvent};
use tauri_plugin_opener::OpenerExt;
use tokio_util::sync::CancellationToken;
use vnt_web::VntService;
#[cfg(windows)]
mod wintun;
static EXITING: AtomicBool = AtomicBool::new(false);
const WEB_ACCESS_CONFIG: &str = "web_access.toml";
#[derive(Clone, Deserialize, Serialize)]
#[serde(default)]
struct WebAccessConfig {
enabled: bool,
port: u16,
global: bool,
token: String,
}
impl Default for WebAccessConfig {
fn default() -> Self {
Self {
enabled: false,
port: 19099,
global: false,
token: vnt_web::generate_access_token(),
}
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WebAccessStatus {
enabled: bool,
running: bool,
port: u16,
global: bool,
token: String,
url: String,
listen_address: String,
}
struct WebRuntime {
config: WebAccessConfig,
cancellation: Option<CancellationToken>,
handle: Option<tokio::task::JoinHandle<anyhow::Result<()>>>,
}
struct DesktopState {
service: VntService,
web: tokio::sync::Mutex<WebRuntime>,
config_path: PathBuf,
}
fn load_web_config(path: &Path) -> WebAccessConfig {
let mut config: WebAccessConfig = std::fs::read_to_string(path)
.ok()
.and_then(|text| toml::from_str(&text).ok())
.unwrap_or_default();
if config.port == 0 {
config.port = 19099;
}
if config.token.len() < 16 {
config.token = vnt_web::generate_access_token();
}
config
}
fn save_web_config(path: &Path, config: &WebAccessConfig) -> anyhow::Result<()> {
std::fs::write(path, toml::to_string_pretty(config)?)?;
Ok(())
}
fn listen_addr(config: &WebAccessConfig) -> SocketAddr {
let ip = if config.global {
IpAddr::V4(Ipv4Addr::UNSPECIFIED)
} else {
IpAddr::V4(Ipv4Addr::LOCALHOST)
};
SocketAddr::new(ip, config.port)
}
fn lan_ip() -> Option<IpAddr> {
let socket = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).ok()?;
socket.connect((Ipv4Addr::new(8, 8, 8, 8), 80)).ok()?;
Some(socket.local_addr().ok()?.ip())
}
fn access_url(config: &WebAccessConfig) -> String {
let host = if config.global {
lan_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST))
} else {
IpAddr::V4(Ipv4Addr::LOCALHOST)
};
format!("http://{}:{}/?token={}", host, config.port, config.token)
}
async fn stop_web(runtime: &mut WebRuntime) {
if let Some(cancellation) = runtime.cancellation.take() {
cancellation.cancel();
}
if let Some(handle) = runtime.handle.take() {
match handle.await {
Ok(Ok(())) => {}
Ok(Err(error)) => log::warn!("Web service stopped with error: {error:#}"),
Err(error) if !error.is_cancelled() => log::warn!("Web service task failed: {error}"),
Err(_) => {}
}
}
}
async fn start_web(service: &VntService, runtime: &mut WebRuntime) -> anyhow::Result<()> {
if !runtime.config.enabled {
return Ok(());
}
let cancellation = CancellationToken::new();
let handle = service
.start_http(
listen_addr(&runtime.config),
runtime.config.token.clone(),
cancellation.clone(),
)
.await?;
runtime.cancellation = Some(cancellation);
runtime.handle = Some(handle);
Ok(())
}
#[tauri::command]
async fn api_request(
state: tauri::State<'_, DesktopState>,
method: String,
path: String,
body: Option<String>,
) -> Result<serde_json::Value, String> {
if !path.starts_with("/api/") {
return Err("只允许调用 VNT API".to_string());
}
state
.service
.request(&method, &path, body)
.await
.map_err(|error| format!("{error:#}"))
}
fn web_status(runtime: &WebRuntime) -> WebAccessStatus {
WebAccessStatus {
enabled: runtime.config.enabled,
running: runtime
.handle
.as_ref()
.is_some_and(|handle| !handle.is_finished()),
port: runtime.config.port,
global: runtime.config.global,
token: runtime.config.token.clone(),
url: access_url(&runtime.config),
listen_address: listen_addr(&runtime.config).to_string(),
}
}
#[tauri::command]
async fn web_access_status(
state: tauri::State<'_, DesktopState>,
) -> Result<WebAccessStatus, String> {
let runtime = state.web.lock().await;
Ok(web_status(&runtime))
}
#[tauri::command]
async fn update_web_access(
state: tauri::State<'_, DesktopState>,
config: WebAccessConfig,
) -> Result<WebAccessStatus, String> {
if config.port == 0 {
return Err("监听端口必须在 1-65535 之间".to_string());
}
if config.token.len() < 16 {
return Err("访问令牌至少需要 16 个字符".to_string());
}
let mut runtime = state.web.lock().await;
stop_web(&mut runtime).await;
let previous = runtime.config.clone();
runtime.config = config;
if let Err(error) = start_web(&state.service, &mut runtime).await {
runtime.config = previous;
if let Err(restore_error) = start_web(&state.service, &mut runtime).await {
log::error!("Failed to restore Web service: {restore_error:#}");
}
return Err(format!("无法启动 Web 服务:{error:#}"));
}
save_web_config(&state.config_path, &runtime.config)
.map_err(|error| format!("保存 Web 访问设置失败:{error:#}"))?;
Ok(web_status(&runtime))
}
#[tauri::command]
fn generate_web_token() -> String {
vnt_web::generate_access_token()
}
#[tauri::command]
fn open_web_url(app: AppHandle, url: String) -> Result<(), String> {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err("只允许打开 HTTP(S) 地址".to_string());
}
app.opener()
.open_url(url, None::<&str>)
.map_err(|error| error.to_string())
}
fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
fn toggle_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
match window.is_visible() {
Ok(true) => {
let _ = window.hide();
}
_ => show_main_window(app),
}
}
}
pub fn run() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
api_request,
web_access_status,
update_web_access,
generate_web_token,
open_web_url
])
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
show_main_window(app);
}))
.setup(|app| {
let data_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&data_dir)?;
std::env::set_current_dir(&data_dir)?;
vnt2::log::log_init("vnt-desktop");
#[cfg(windows)]
wintun::ensure_wintun(&data_dir)?;
let config_path = data_dir.join(WEB_ACCESS_CONFIG);
let config = load_web_config(&config_path);
save_web_config(&config_path, &config)?;
let service = tauri::async_runtime::block_on(VntService::new(None))?;
let mut web = WebRuntime {
config,
cancellation: None,
handle: None,
};
if let Err(error) = tauri::async_runtime::block_on(start_web(&service, &mut web)) {
log::error!("Failed to restore Web access service: {error:#}");
web.config.enabled = false;
save_web_config(&config_path, &web.config)?;
}
app.manage(DesktopState {
service,
web: tokio::sync::Mutex::new(web),
config_path,
});
let show = MenuItem::with_id(app, "show", "显示主窗口", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出 VNT", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?;
let mut tray = TrayIconBuilder::with_id("vnt-tray")
.tooltip("VNT Desktop")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => show_main_window(app),
"quit" => {
EXITING.store(true, Ordering::SeqCst);
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
toggle_main_window(tray.app_handle());
}
});
if let Some(icon) = app.default_window_icon() {
tray = tray.icon(icon.clone());
}
tray.build(app)?;
Ok(())
})
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event
&& !EXITING.load(Ordering::SeqCst)
{
api.prevent_close();
let _ = window.hide();
}
})
.run(tauri::generate_context!())
.expect("error while running VNT Desktop");
}
+3
View File
@@ -0,0 +1,3 @@
fn main() {
vnt_desktop_lib::run();
}
+20
View File
@@ -0,0 +1,20 @@
use std::io;
use std::path::Path;
#[cfg(target_arch = "x86_64")]
const WINTUN_DLL: &[u8] = include_bytes!("../../../dll/amd64/wintun.dll");
#[cfg(target_arch = "x86")]
const WINTUN_DLL: &[u8] = include_bytes!("../../../dll/x86/wintun.dll");
#[cfg(target_arch = "aarch64")]
const WINTUN_DLL: &[u8] = include_bytes!("../../../dll/arm64/wintun.dll");
#[cfg(target_arch = "arm")]
const WINTUN_DLL: &[u8] = include_bytes!("../../../dll/arm/wintun.dll");
pub fn ensure_wintun(data_dir: &Path) -> io::Result<()> {
let target = data_dir.join("wintun.dll");
let current = std::fs::read(&target).unwrap_or_default();
if current != WINTUN_DLL {
std::fs::write(target, WINTUN_DLL)?;
}
Ok(())
}
+49
View File
@@ -0,0 +1,49 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "VNT Desktop",
"version": "2.0.0",
"identifier": "com.vnt.desktop",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://127.0.0.1:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"label": "main",
"title": "VNT Desktop",
"width": 1180,
"height": 760,
"minWidth": 820,
"minHeight": 600,
"center": true,
"decorations": true,
"resizable": true,
"shadow": true
}
],
"security": {
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' asset: data:; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*"
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/[email protected]",
"icons/icon.icns",
"icons/icon.ico"
],
"category": "Utility",
"shortDescription": "轻量、高效的虚拟局域网桌面客户端",
"longDescription": "VNT Desktop 用于创建和管理安全、快速的虚拟局域网连接。",
"windows": {
"wix": { "language": "zh-CN" },
"nsis": { "displayLanguageSelector": false }
}
}
}