feat: add unified Tauri desktop client and secure web access
@@ -0,0 +1,59 @@
|
||||
# VNT Desktop
|
||||
|
||||
基于 Tauri 2 + Vue 3 的 VNT PC 客户端。桌面端直接内置 `vnt-web` 的服务能力,不需要另行启动 `vnt2_web`。
|
||||
|
||||
桌面端与 Web 端共用 `vnt-web/ui/src/` 下的同一套 Vue 应用、路由、状态、组件和样式。`vnt-desktop/src/main.js` 只负责注册 Tauri IPC 桥接,不维护第二套界面代码。
|
||||
|
||||
## 功能
|
||||
|
||||
- 桌面工作台:总览、实例、在线设备、路由和配置管理
|
||||
- 多实例启动、停止、重启及启动日志
|
||||
- 表单 / TOML 双模式配置编辑
|
||||
- 原生窗口、系统托盘、单实例运行
|
||||
- 关闭主窗口时隐藏到托盘,托盘菜单可彻底退出
|
||||
- 深浅主题与 `Ctrl/Cmd + 1~5` 页面快捷键
|
||||
- 响应式布局:桌面侧栏、移动端顶部栏和抽屉导航
|
||||
- 桌面工作台通过 Tauri IPC 直接调用进程内 `vnt-core`,不监听本地 API 端口
|
||||
- 可选 Web 访问:启停、端口、本机/局域网监听范围、访问令牌、打开浏览器
|
||||
- Web API 强制使用 Bearer 令牌鉴权,令牌可在桌面端重新生成
|
||||
|
||||
桌面数据存放在系统应用数据目录的 `com.vnt.desktop` 下,包括 `vnt_config`、自启动记录、`web_access.toml`、日志及 Windows 下的 `wintun.dll`。
|
||||
|
||||
## 开发
|
||||
|
||||
要求:Rust、Node.js、pnpm,以及 Tauri 2 对应的系统依赖。
|
||||
|
||||
依赖由仓库根目录的 pnpm workspace 统一管理:
|
||||
|
||||
```powershell
|
||||
pnpm install
|
||||
pnpm dev:desktop
|
||||
```
|
||||
|
||||
只验证前端:
|
||||
|
||||
```powershell
|
||||
pnpm build:desktop-ui
|
||||
```
|
||||
|
||||
验证 Rust 桌面模块:
|
||||
|
||||
```powershell
|
||||
cargo check -p vnt-desktop
|
||||
```
|
||||
|
||||
## 打包
|
||||
|
||||
```powershell
|
||||
pnpm build:desktop
|
||||
```
|
||||
|
||||
Windows 使用虚拟网卡模式时,可能需要以管理员身份运行。
|
||||
|
||||
## 应用图标
|
||||
|
||||
图标母版保存在 `vnt-desktop/assets/vnt-icon-master.png`。需要重新生成各平台图标时,在仓库根目录执行:
|
||||
|
||||
```powershell
|
||||
pnpm --filter vnt-desktop tauri icon assets/vnt-icon-master.png --ios-color '#4F46E5'
|
||||
```
|
||||
|
After Width: | Height: | Size: 988 KiB |
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#07111f" />
|
||||
<title>VNT Desktop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "vnt-desktop",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"pinia": "catalog:",
|
||||
"vue": "catalog:",
|
||||
"vue-router": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@tauri-apps/cli": "^2.8.4",
|
||||
"@vitejs/plugin-vue": "catalog:",
|
||||
"tailwindcss": "catalog:",
|
||||
"vite": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 192 KiB |
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
vnt_desktop_lib::run();
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
globalThis.__VNT_DESKTOP__ = true;
|
||||
globalThis.__VNT_IPC_REQUEST__ = ({ method, path, body }) =>
|
||||
invoke("api_request", { method, path, body });
|
||||
globalThis.__VNT_WEB_ACCESS__ = {
|
||||
status: () => invoke("web_access_status"),
|
||||
update: (config) => invoke("update_web_access", { config }),
|
||||
generateToken: () => invoke("generate_web_token"),
|
||||
openUrl: (url) => invoke("open_web_url", { url }),
|
||||
};
|
||||
|
||||
// Tauri 与 Web 端共用同一个 Vue 应用,这里只负责平台初始化。
|
||||
await import("@shared/main.js");
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const sharedUi = fileURLToPath(new URL("../vnt-web/ui/src", import.meta.url));
|
||||
const desktopRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@shared": sharedUi,
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: "127.0.0.1",
|
||||
fs: { allow: [desktopRoot, sharedUi] },
|
||||
watch: { ignored: ["**/src-tauri/**"] },
|
||||
},
|
||||
clearScreen: false,
|
||||
});
|
||||