feat(tls): manage ACME domain and add desktop client settings

This commit is contained in:
opsx committed 2026-10-21 10:25:55 +08:00
1 parent 003f755b37
commit 00f6c876cf
85 files changed
+7276 -1

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
src-tauri/target/
src-tauri/gen/
.env
.env.*
!.env.example
+19
View File
@@ -0,0 +1,19 @@
# OpsX VPN 桌面客户端(第一阶段)
仓库根目录的 `client/` 是独立的 Rust + Tauri v2 + React/TypeScript 桌面项目。形态参考同级 `opsxshell`、`opsxapi`:界面运行在 Tauri WebView,服务请求经 Rust 命令完成。与仓库的 `frontend/` 管理后台独立构建。
目前仅实现对现有 `GET /api/health` 的真实检测。在“设置”中输入并保存服务端 HTTP 或 HTTPS 根地址:HTTP 可用于健康检测,但明文传输今后不应承载登录口令或设备令牌,推荐使用可信 HTTPS 域名。地址仅保存在本机前端存储中,不含账号。界面采用蓝白灰布局,线路弹窗中的节点和绿黄延时均为**演示数据**,选择仅改变页面展示。健康检查显示的耗时是控制面请求往返时间,不能当作 VPN 节点延时。没有普通用户设备 API 和系统隧道适配前,不提供登录或一键拨号。
桌面版托盘显示“尚未连接”,提供打开窗口与退出。设置页通过 Rust 只读检查 `wg`、`wg-quick` 是否出现在本机 PATH;工具存在不代表驱动、权限、路由及实际隧道可用。浏览器界面预览不会调用本机检查。
```bash
cd client
npm install
npm run dev # Tauri 桌面窗口
npm run dev:ui # 仅预览界面;浏览器里不能执行 Tauri 的真实健康检查
npm run build:ui # 界面类型检查与构建
npm run build # Tauri 安装包(需目标 OS 的构建依赖)
cd src-tauri && cargo test
```
平台首期应使用**标准 WireGuard UDP**。现有 `/api/login` 是管理账户,Peer 创建/下载要求 operator 以上权限且下载的托管 Peer 私钥由服务端保存;不能直接把这些接口和管理令牌给桌面用户。需要先设计独立的客户端认证、设备公钥注册、分配 IP、撤销和增量更新 Peer。技术依据、验收条件及后续顺序见 [SDD-002](../docs/specs/SDD-002-desktop-wireguard-client.md)。`corplink-rs` 对接的是另一套飞连私有协议,不是 opsx-vpn 的协议实现。
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpsX VPN</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1486
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "opsx-vpn-client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tauri dev",
"dev:ui": "vite --host 127.0.0.1 --port 5176 --strictPort",
"build:ui": "tsc --noEmit && vite build",
"build": "tauri build",
"tauri": "tauri"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.11.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.8.2",
"vite": "^6.2.2"
}
}
+4669
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "opsx-vpn-client"
version = "0.1.0"
description = "OpsX VPN desktop client"
authors = ["OpsX"]
edition = "2021"
[lib]
name = "opsx_vpn_client_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
serde = { version = "1", features = ["derive"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "OpsX VPN desktop window",
"windows": ["main"],
"permissions": ["core:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+45
View File
@@ -0,0 +1,45 @@
mod server;
mod system;
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::TrayIconBuilder,
Manager,
};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let status = MenuItem::with_id(app, "status", "VPN:尚未连接", false, None::<&str>)?;
let open = MenuItem::with_id(app, "open", "打开 OpsX VPN", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(
app,
&[&status, &open, &PredefinedMenuItem::separator(app)?, &quit],
)?;
TrayIconBuilder::new()
.icon(app.default_window_icon().expect("desktop app icon").clone())
.tooltip("OpsX VPN · 尚未连接")
.menu(&menu)
.on_menu_event(|app, event| match event.id().as_ref() {
"open" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
"quit" => app.exit(0),
_ => {}
})
.build(app)?;
Ok(())
})
.invoke_handler(tauri::generate_handler![
server::check_server,
system::get_system_readiness
])
.run(tauri::generate_context!())
.expect("failed to start OpsX VPN client");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
opsx_vpn_client_lib::run();
}
+117
View File
@@ -0,0 +1,117 @@
use std::time::Duration;
use reqwest::{redirect::Policy, Url};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct HealthResponse {
ok: bool,
}
#[derive(Serialize)]
pub struct ServerHealth {
address: String,
reachable: bool,
}
fn health_url(input: &str) -> Result<Url, String> {
let mut url = Url::parse(input.trim())
.map_err(|_| "请输入完整的服务端地址(含 http:// 或 https://)".to_string())?;
if url.scheme() != "https" && url.scheme() != "http" {
return Err("服务端地址须以 http:// 或 https:// 开头".to_string());
}
if url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| url.path() != "/"
{
return Err("请填写服务端根地址,不包含账号、路径、查询参数或片段".to_string());
}
url.set_path("/api/health");
Ok(url)
}
#[tauri::command]
pub async fn check_server(base_url: String) -> Result<ServerHealth, String> {
let url = health_url(&base_url)?;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(5))
.redirect(Policy::none())
.build()
.map_err(|err| format!("无法创建网络客户端:{err}"))?;
let response = client
.get(url.clone())
.send()
.await
.map_err(|err| format!("连接服务端失败:{err}"))?;
if !response.status().is_success() {
return Err(format!("服务端健康检查返回 HTTP {}", response.status()));
}
let body: HealthResponse = response
.json()
.await
.map_err(|_| "服务端响应不是有效的 opsx-vpn 健康结果".to_string())?;
if !body.ok {
return Err("服务端报告未就绪".to_string());
}
let mut root = url;
root.set_path("");
Ok(ServerHealth {
address: root.as_str().trim_end_matches('/').to_string(),
reachable: true,
})
}
#[cfg(test)]
mod tests {
use super::{check_server, health_url};
use std::io::{Read, Write};
use std::net::TcpListener;
#[test]
fn accepts_http_and_https_server_addresses() {
assert_eq!(
health_url("https://vpn.example.com:443").unwrap().as_str(),
"https://vpn.example.com/api/health"
);
assert!(health_url("http://127.0.0.1:18080/").is_ok());
assert!(health_url("http://localhost:18080").is_ok());
assert_eq!(
health_url("http://121.4.111.208:18080").unwrap().as_str(),
"http://121.4.111.208:18080/api/health"
);
}
#[test]
fn rejects_unsupported_schemes_and_non_root_addresses() {
for input in [
"ftp://vpn.example.com",
"https://user:pass@vpn.example.com",
"https://vpn.example.com/admin",
"https://vpn.example.com/?token=abc",
"file:///etc/passwd",
] {
assert!(health_url(input).is_err(), "accepted {input}");
}
}
#[test]
fn checks_the_existing_health_contract_over_http() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test listener");
let address = listener.local_addr().unwrap();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0; 512];
let read = stream.read(&mut request).unwrap();
assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET /api/health "));
stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}").unwrap();
});
let result =
tauri::async_runtime::block_on(check_server(format!("http://{address}"))).unwrap();
assert!(result.reachable);
assert_eq!(result.address, format!("http://{address}"));
server.join().unwrap();
}
}
+32
View File
@@ -0,0 +1,32 @@
use serde::Serialize;
#[derive(Serialize)]
pub struct SystemReadiness {
platform: &'static str,
wg_found: bool,
wg_quick_found: bool,
}
fn on_path(name: &str) -> bool {
std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()).any(|directory| {
#[cfg(windows)]
{
[name.to_string(), format!("{name}.exe")]
.iter()
.any(|candidate| directory.join(candidate).is_file())
}
#[cfg(not(windows))]
{
directory.join(name).is_file()
}
})
}
#[tauri::command]
pub fn get_system_readiness() -> SystemReadiness {
SystemReadiness {
platform: std::env::consts::OS,
wg_found: on_path("wg"),
wg_quick_found: on_path("wg-quick"),
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpsX VPN",
"version": "0.1.0",
"identifier": "com.opsx.vpn.client",
"build": {
"beforeDevCommand": "npm run dev:ui",
"devUrl": "http://127.0.0.1:5176",
"beforeBuildCommand": "npm run build:ui",
"frontendDist": "../dist"
},
"app": {
"windows": [{ "label": "main", "title": "OpsX VPN", "width": 1080, "height": 740, "minWidth": 800, "minHeight": 590 }],
"security": { "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:5176 ws://127.0.0.1:5176; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'" }
},
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"],
"category": "Utility"
}
}
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useState, type FormEvent } from 'react'
import { checkServer, getSystemReadiness, type SystemReadiness } from './platform'
import './system.css'
type Page = 'overview' | 'routes' | 'settings'
type CheckState = 'idle' | 'checking' | 'reachable' | 'error'
const nodes = [
{ name: '自动选择', region: '推荐线路', ms: 74 },
{ name: '香港 · 中心节点', region: '亚太地区', ms: 75 },
{ name: '新加坡 · 备用节点', region: '亚太地区', ms: 91 },
{ name: '法兰克福 · 节点', region: '欧洲地区', ms: 313 },
{ name: '洛杉矶 · 节点', region: '美洲地区', ms: 314 },
]
function Icon({ name, size = 22 }: { name: 'home' | 'globe' | 'settings' | 'power' | 'shield' | 'server' | 'check' | 'close' | 'chevron'; size?: number }) {
const paths = {
home: <path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z" />,
globe: <><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3c-5 5-5 13 0 18M12 3c5 5 5 13 0 18" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10 2 2M19 5l-2 2M7 17l-2 2" /></>,
power: <><path d="M12 2v10" /><path d="M6.1 5.5a9 9 0 1 0 11.8 0" /></>,
shield: <><path d="m12 2 8 3v6c0 5.4-3.5 8.8-8 11-4.5-2.2-8-5.6-8-11V5z" /><path d="m9 12 2 2 4-4" /></>,
server: <><rect x="3" y="3" width="18" height="7" rx="2" /><rect x="3" y="14" width="18" height="7" rx="2" /><path d="M7 6.5h.01M7 17.5h.01" /></>,
check: <path d="m5 12 5 5 9-10" />,
close: <path d="M5 5 19 19M19 5 5 19" />,
chevron: <path d="m7 10 5 5 5-5" />,
}
return <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>
}
export default function App() {
const [page, setPage] = useState<Page>('overview')
const [dialog, setDialog] = useState(false)
const [selected, setSelected] = useState(0)
const [address, setAddress] = useState(() => localStorage.getItem('opsx_server_address') || '')
const [state, setState] = useState<CheckState>('idle')
const [error, setError] = useState('')
const [checkedAddress, setCheckedAddress] = useState('')
const [responseMs, setResponseMs] = useState<number | null>(null)
const [readiness, setReadiness] = useState<SystemReadiness | null>(null)
const [readinessError, setReadinessError] = useState('')
useEffect(() => {
if (page !== 'settings') return
let active = true
getSystemReadiness().then(result => {
if (active) { setReadiness(result); setReadinessError('') }
}).catch(() => {
if (active) setReadinessError('本机检测仅在 Tauri 桌面应用中可用。')
})
return () => { active = false }
}, [page])
async function handleCheck(event: FormEvent) {
event.preventDefault()
setState('checking')
setError('')
setResponseMs(null)
const start = performance.now()
try {
const health = await checkServer(address)
setCheckedAddress(health.address)
setResponseMs(Math.round(performance.now() - start))
setState(health.reachable ? 'reachable' : 'error')
} catch (cause) {
setState('error')
setError(String(cause))
}
}
function saveAddress() {
try {
const url = new URL(address.trim())
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
throw new Error('请输入 HTTP 或 HTTPS 服务端根地址,不包含路径、账号或参数。')
}
const root = url.href.replace(/\/$/, '')
localStorage.setItem('opsx_server_address', root)
setAddress(root)
setError('')
} catch (cause) {
setError(cause instanceof TypeError ? '请输入完整的 HTTP 或 HTTPS 服务端地址。' : String(cause))
setState('error')
}
}
return <div className="app-shell">
<aside className="sidebar" aria-label="主导航">
<div className="brand-symbol" aria-label="OpsX VPN"><span /><i /></div>
<nav className="navigation">
{([['overview', 'home', '概览'], ['routes', 'globe', '线路'], ['settings', 'settings', '设置']] as const).map(([id, icon, label]) =>
<button key={id} className={`nav-button ${page === id ? 'active' : ''}`} onClick={() => setPage(id)} aria-current={page === id ? 'page' : undefined}><Icon name={icon} /><span>{label}</span></button>)}
</nav>
<div className="sidebar-bottom">OX <small>/ VPN</small></div>
</aside>
<div className="workspace">
<header className="topbar"><div className="topbar-brand"><span className="brand-dot" />OpsX <strong>VPN</strong><span className="divider" />桌面客户端</div><span className="preview-pill">● &nbsp;界面预览 · 0.1</span></header>
{page === 'overview' && <main className="page">
<div className="section-intro"><div><span className="overline">WORKSPACE / OVERVIEW</span><h1>连接概览</h1><p>从这里查看接入状态,准备好后即可连接企业网络。</p></div><span className="status-tag">● &nbsp;尚未连接</span></div>
<div className="overview-grid">
<section className="connection-card" aria-label="VPN 连接状态">
<div className="card-top"><div><span className="kicker">安全连接</span><h2>企业网络</h2></div><span className="protocol"><Icon name="shield" size={15} /> WIREGUARD</span></div>
<div className="connect-visual"><div className="orbit outer" /><div className="orbit inner" /><div className="power-display" aria-label="VPN 拨号待接入"><Icon name="power" size={42} /></div></div>
<div className="connection-message"><strong>尚未连接</strong><span>登录与设备授权接口接入后,即可启用 VPN 拨号</span></div>
<button className="node-selector" onClick={() => setDialog(true)}><span className="node-icon"><Icon name="globe" size={19} /></span><span className="node-selector-copy"><small>当前预览线路</small><strong>{nodes[selected].name}</strong></span><span className="demo-chip">演示数据</span><Icon name="chevron" size={18} /></button>
</section>
<div className="right-column">
<section className="info-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端状态</h2><p>管理服务可达性</p></div></div><p className="server-address">{checkedAddress || address || '尚未设置服务端地址'}</p><span className={`service-state ${state}`}>{state === 'reachable' ? `服务可达 · 控制面往返约 ${responseMs} ms` : state === 'error' ? '检测失败' : state === 'checking' ? '检测中…' : '尚未检测'}</span><p className="hint">控制面检测不代表 VPN 已连接。</p><button className="secondary-action" onClick={() => setPage('settings')}>在设置中配置 →</button></section>
<section className="info-card progress-card"><div className="info-heading"><span className="icon-tile green"><Icon name="shield" size={20} /></span><div><h2>接入进度</h2><p>桌面端能力逐步开放</p></div></div>
{([['✓', '服务可达性检测', '已接入现有健康接口', '已就绪'], ['2', '用户登录与设备授权', '等待客户端专用接口', '待接入'], ['3', '原生 WireGuard 连接', '等待多平台隧道适配', '待接入']] as const).map(([num, title, detail, status], index) => <div className="milestone" key={num}><span className={`milestone-mark ${index === 0 ? 'done' : ''}`}>{num}</span><span><strong>{title}</strong><small>{detail}</small></span><em>{status}</em></div>)}
</section>
</div>
</div>
<p className="footnote">预览线路与延时仅展示交互样式,不代表实际节点;当前没有建立 VPN 隧道。</p>
</main>}
{page === 'routes' && <main className="page subpage"><span className="overline">WORKSPACE / ROUTES</span><h1>线路</h1><p className="subpage-intro">了解线路选择界面。真实节点列表和延时需要服务端开放客户端接口。</p><section className="info-card route-preview"><span className="icon-tile large"><Icon name="globe" size={29} /></span><div><span className="demo-chip">演示数据</span><h2>{nodes[selected].name}</h2><p>当前选择只用于界面预览,不会建立 VPN 连接。</p></div><button className="secondary-action" onClick={() => setDialog(true)}>查看预览线路 →</button></section></main>}
{page === 'settings' && <main className="page subpage"><span className="overline">WORKSPACE / SETTINGS</span><h1>设置</h1><p className="subpage-intro">管理服务端入口并查看本机环境。这里不会保存账号或修改本机网络。</p><section className="info-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>服务端地址</h2><p>保存后下次打开客户端仍可使用</p></div></div><form onSubmit={handleCheck}><label htmlFor="server-url">HTTP / HTTPS 根地址</label><div className="input-row"><input id="server-url" type="url" required placeholder="https://test-link.appx365.com 或 http://121.4.111.208:18080" value={address} onChange={event => { setAddress(event.target.value); setState('idle'); setError(''); setResponseMs(null) }} /><button type="submit" disabled={state === 'checking'}>{state === 'checking' ? '检测中…' : '检测服务'}</button><button className="save-button" type="button" onClick={saveAddress}>保存地址</button></div></form>{address.trim().toLowerCase().startsWith('http://') && <p className="http-warning">HTTP 是明文传输;健康检查可用,但今后登录和设备授权建议改用 HTTPS。</p>}<p className="hint">只填写服务端根地址;健康检查会请求 /api/health。</p>{state === 'reachable' && <div className="result success" role="status"><Icon name="check" size={17} /><span>服务可达 · 控制面往返约 {responseMs} ms<br /><small>{checkedAddress}。这不是 VPN 节点延时或已连接状态。</small></span></div>}{state === 'error' && <div className="result failure" role="alert"><Icon name="close" size={17} /><span>{error || '服务端没有返回有效的健康响应。'}</span></div>}</section><section className="info-card route-preview system-card"><span className="icon-tile large"><Icon name="shield" size={27} /></span><div><h2>安全接入</h2><p>用户认证、设备授权与系统隧道设置将在服务端接口具备后开放。</p></div><span className="status-tag">待接入</span></section><section className="info-card system-card"><div className="info-heading"><span className="icon-tile"><Icon name="server" size={20} /></span><div><h2>本机环境</h2><p>只读检查,不代表已经具备 VPN 拨号能力</p></div></div>{readiness ? <div className="system-grid"><span>操作系统</span><strong>{readiness.platform}</strong><span>wg 命令</span><strong>{readiness.wg_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong><span>wg-quick 命令</span><strong>{readiness.wg_quick_found ? 'PATH 中已发现' : 'PATH 中未发现'}</strong></div> : <p className="hint" role="status">{readinessError || '正在检测本机环境…'}</p>}<p className="hint">仅检查文件是否存在;没有检查执行权限、驱动、系统授权或 WireGuard 隧道状态。</p></section></main>}
</div>
{dialog && <div className="modal-backdrop" onMouseDown={event => { if (event.target === event.currentTarget) setDialog(false) }}><section className="node-dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title" onKeyDown={event => { if (event.key === 'Escape') setDialog(false) }}><div className="dialog-header"><div><span className="overline">ROUTE PREVIEW</span><h2 id="dialog-title">选择服务器节点</h2><p>线路名称与延时均为演示数据,尚未接入真实节点。</p></div><button className="icon-button" onClick={() => setDialog(false)} aria-label="关闭节点选择"><Icon name="close" size={20} /></button></div><div className="dialog-labels"><span>预览线路</span><span>示例延时</span></div><div className="node-list">{nodes.map((node, index) => <button className={`node-row ${selected === index ? 'selected' : ''}`} key={node.name} onClick={() => setSelected(index)}><span className="radio-mark">{selected === index ? '✓' : ''}</span><span className="node-icon"><Icon name={index === 0 ? 'globe' : 'server'} size={18} /></span><span className="node-name"><strong>{node.name}</strong><small>{node.region}</small></span><span className={`latency ${node.ms < 150 ? 'fast' : 'slow'}`}><span className="signal-bars"><i /><i /><i /></span>{node.ms} ms</span></button>)}</div><div className="dialog-footer"><span>仅预览界面,不会发起连接</span><button className="primary-action" onClick={() => setDialog(false)}>完成选择</button></div></section></div>}
</div>
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './style.css'
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+14
View File
@@ -0,0 +1,14 @@
import { invoke } from '@tauri-apps/api/core'
export type ServerHealth = { address: string; reachable: boolean }
export type SystemReadiness = { platform: string; wg_found: boolean; wg_quick_found: boolean }
// All desktop-to-native calls pass through this boundary. The WebView never
// handles a management token or a WireGuard private key.
export function checkServer(baseUrl: string): Promise<ServerHealth> {
return invoke<ServerHealth>('check_server', { baseUrl })
}
export function getSystemReadiness(): Promise<SystemReadiness> {
return invoke<SystemReadiness>('get_system_readiness')
}
+1
View File
@@ -0,0 +1 @@
:root{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#192b45;background:#f5f7fb;font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-width:680px}button,input{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible{outline:3px solid #a6c6ff;outline-offset:2px}.app-shell{display:flex;min-height:100vh}.sidebar{width:92px;flex:none;display:flex;flex-direction:column;align-items:center;background:linear-gradient(172deg,#3476f7,#245cdd 76%,#2859d0);color:#fff}.brand-symbol{width:46px;height:46px;margin:27px 0 50px;position:relative}.brand-symbol span{position:absolute;inset:5px;border:4px solid #fff;border-radius:12px;transform:rotate(-32deg)}.brand-symbol i{position:absolute;inset:15px;border-radius:50%;background:#fff}.navigation{width:100%;display:grid;gap:8px}.nav-button{width:100%;height:78px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:0;background:transparent;color:#d6e5ff;font-size:12px}.nav-button:hover,.nav-button.active{background:#174bbd;color:#fff}.nav-button.active{box-shadow:inset 3px 0 0 #fff}.sidebar-bottom{margin:auto 0 28px;font-weight:800;font-size:15px;letter-spacing:-.06em}.sidebar-bottom small{font-size:9px;font-weight:500;opacity:.7}.workspace{flex:1;min-width:0}.topbar{height:70px;background:#fff;border-bottom:1px solid #ecf0f6;display:flex;align-items:center;justify-content:space-between;padding:0 43px}.topbar-brand{display:flex;align-items:center;gap:5px;font-size:15px;font-weight:700;color:#516077;white-space:nowrap}.topbar-brand strong{color:#326ef3}.brand-dot{width:10px;height:10px;background:#3575f5;border-radius:3px;transform:rotate(-30deg);margin-right:7px}.divider{height:16px;width:1px;background:#d9e0ec;margin:0 10px}.preview-pill{border:1px solid #dae7ff;border-radius:100px;background:#f3f7ff;color:#4268ad;font-size:11px;padding:7px 11px}.page{max-width:1160px;margin:0 auto;padding:38px 44px 50px}.section-intro{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:29px}.overline{color:#6483bc;letter-spacing:.18em;font-size:10px;font-weight:800}h1{font-size:29px;letter-spacing:-.04em;margin:9px 0 8px}h2{margin:0;font-size:17px;letter-spacing:-.02em}.section-intro p,.subpage-intro{color:#8b98aa;font-size:13px;margin:0}.status-tag{padding:9px 14px;border:1px solid #e4e9f1;background:white;border-radius:100px;color:#6c7c94;font-size:12px;white-space:nowrap}.overview-grid{display:grid;grid-template-columns:minmax(350px,1.06fr) minmax(340px,.94fr);gap:20px}.connection-card,.info-card{background:white;border:1px solid #e8edf5;border-radius:18px;box-shadow:0 8px 32px rgba(29,72,137,.035)}.connection-card{padding:26px 27px 22px;min-height:494px;display:flex;flex-direction:column;overflow:hidden}.card-top{display:flex;justify-content:space-between;align-items:start;gap:12px}.kicker{font-size:11px;color:#7e90ac}.card-top h2{font-size:20px;margin-top:6px}.protocol{display:flex;align-items:center;gap:5px;font-size:10px;font-weight:750;color:#4a7de8;background:#f2f6ff;padding:8px;border-radius:7px;letter-spacing:.04em}.connect-visual{position:relative;width:218px;height:218px;margin:23px auto 0;display:grid;place-items:center}.orbit{position:absolute;border-radius:50%;border:1px solid #e8f0ff}.orbit.outer{inset:0;background:repeating-conic-gradient(from -2deg,#d5e4ff 0 1deg,transparent 1deg 5deg);mask:radial-gradient(transparent 66%,black 67%,black 69%,transparent 70%);-webkit-mask:radial-gradient(transparent 66%,black 67%,black 69%,transparent 70%)}.orbit.inner{inset:19px;border:1px solid #e2ecff}.power-display{display:grid;place-items:center;width:136px;height:136px;border-radius:50%;background:linear-gradient(145deg,#5090ff,#2864ea);color:white;box-shadow:0 16px 30px #397af149,inset 0 1px 2px #ffffff66}.connection-message{text-align:center;display:grid;gap:7px;margin:0 0 25px}.connection-message strong{font-size:16px}.connection-message span{color:#8898ae;font-size:12px}.node-selector{display:flex;align-items:center;width:100%;gap:11px;text-align:left;border:1px solid #e6edf8;background:#fafcff;padding:11px 12px;border-radius:11px;color:#536785;margin-top:auto}.node-selector:hover{border-color:#92b5fc;background:#f5f9ff}.node-icon{width:35px;height:35px;border-radius:10px;background:#ecf3ff;color:#3577f4;display:grid;place-items:center;flex:none}.node-selector-copy{display:grid;gap:3px;flex:1}.node-selector-copy small{color:#93a1b4;font-size:10px}.node-selector-copy strong{font-size:12px;color:#304461}.demo-chip{font-size:10px;color:#6383b7;background:#eaf2ff;padding:5px 7px;border-radius:5px;white-space:nowrap}.right-column{display:grid;gap:18px;grid-template-rows:auto 1fr}.info-card{padding:24px}.info-heading{display:flex;align-items:center;gap:12px;margin-bottom:25px}.info-heading p,.route-preview p{font-size:11px;color:#98a5b7;margin:5px 0 0}.icon-tile{width:39px;height:39px;border-radius:11px;display:grid;place-iteLine truncated
+13
View File
@@ -0,0 +1,13 @@
.system-card { margin-top: 20px; }
.system-card .info-heading { margin-bottom: 15px; }
.system-grid { display: grid; grid-template-columns: 140px 1fr; gap: 14px; padding: 16px 0; border-top: 1px solid #edf1f7; font-size: 12px; }
.system-grid span { color: #8292a8; }
.system-grid strong { font-weight: 600; color: #354969; }
.server-address { overflow-wrap: anywhere; color: #415574; font-size: 13px; margin: 0 0 11px; }
.service-state { display: inline-block; padding: 6px 9px; border-radius: 6px; background: #f2f5fa; color: #71819a; font-size: 11px; }
.service-state.reachable { background: #ebf8f2; color: #168861; }
.service-state.error { background: #fff1f0; color: #b34444; }
.system-card .input-row .save-button { background: #edf4ff; color: #3476f5; }
.system-card .input-row .save-button:hover { background: #dbe9ff; }
.input-row .save-button { background: #edf4ff; color: #3476f5; }
.http-warning { background: #fff7e6; color: #a0691b; border: 1px solid #fae5be; border-radius: 8px; padding: 10px; font-size: 12px; line-height: 1.6; }
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
export default defineConfig({
clearScreen: false,
server: { host: '127.0.0.1', port: 5176, strictPort: true },
envPrefix: ['VITE_'],
})