feat(tls): manage ACME domain and add desktop client settings
No files matched your search
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -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 的协议实现。
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 845 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
opsx_vpn_client_lib::run();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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">● 界面预览 · 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">● 尚未连接</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>
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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_'],
|
||||
})
|
||||