introduce gui based on tauri (#52)

This commit is contained in:
Sijie.Sun 2024-04-14 23:29:34 +08:00 committed by GitHub
parent 50e14798d6
commit 0ddcda1b31
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 9824 additions and 1336 deletions

View File

@ -22,19 +22,25 @@ jobs:
include:
- TARGET: aarch64-unknown-linux-musl
OS: ubuntu-latest
GUI_TARGET: aarch64-unknown-linux-gnu
- TARGET: x86_64-unknown-linux-musl
OS: ubuntu-latest
GUI_TARGET: x86_64-unknown-linux-gnu
- TARGET: x86_64-apple-darwin
OS: macos-latest
GUI_TARGET: x86_64-apple-darwin
- TARGET: aarch64-apple-darwin
OS: macos-latest
GUI_TARGET: aarch64-apple-darwin
- TARGET: x86_64-pc-windows-msvc
OS: windows-latest
GUI_TARGET: x86_64-pc-windows-msvc
runs-on: ${{ matrix.OS }}
env:
NAME: easytier
TARGET: ${{ matrix.TARGET }}
OS: ${{ matrix.OS }}
GUI_TARGET: ${{ matrix.GUI_TARGET }}
steps:
- uses: actions/checkout@v3
- name: Setup protoc
@ -42,19 +48,36 @@ jobs:
with:
# GitHub repo token to use to avoid rate limiter
repo-token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: 21
cache: 'yarn'
cache-dependency-path: easytier-gui/yarn.lock
- name: Install Yarn
run: npm install -g yarn
- name: Cargo cache
uses: actions/cache@v4.0.0
with:
path: |
~/.cargo
./target
key: build-cargo-registry-${{matrix.TARGET}}
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Install rust target
run: |
# dependencies are only needed on ubuntu as that's the only place where
# we make cross-compilation
if [[ $OS =~ ^ubuntu.*$ ]]; then
sudo apt-get update && sudo apt-get install -qq crossbuild-essential-arm64 crossbuild-essential-armhf musl-tools
# for easytier-gui
sudo apt install libwebkit2gtk-4.0-dev \
build-essential \
curl \
wget \
file \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev
# curl -s musl.cc | grep mipsel
case $TARGET in
mipsel-unknown-linux-musl)
@ -87,8 +110,52 @@ jobs:
rustup install 1.75
rustup default 1.75
rustup target add $TARGET
rustup target add $GUI_TARGET
- name: Run build
run: cargo build --release --verbose --target $TARGET
- name: Install for aarch64 gui cross compile
run: |
# see https://tauri.app/v1/guides/building/linux/
if [[ $TARGET == "aarch64-unknown-linux-musl" ]]; then
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted" | sudo tee /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-updates multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy-backports main restricted universe multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=amd64] http://security.ubuntu.com/ubuntu/ jammy-security multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-updates multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-backports main restricted universe multiverse" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security main restricted" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security universe" | sudo tee -a /etc/apt/sources.list
echo "deb [arch=armhf,arm64] http://ports.ubuntu.com/ubuntu-ports jammy-security multiverse" | sudo tee -a /etc/apt/sources.list
sudo dpkg --add-architecture arm64
sudo apt-get update && sudo apt-get upgrade -y
sudo apt install libwebkit2gtk-4.0-dev:arm64
sudo apt install libssl-dev:arm64
echo "PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu/" >> "$GITHUB_ENV"
fi
- name: Run build GUI
run: |
cd easytier-gui
yarn install
if [[ $OS =~ ^ubuntu.*$ && ! $GUI_TARGET =~ ^x86_64.*$ ]]; then
# only build deb for non-x86_64 linux
yarn tauri build -- --target $GUI_TARGET --verbose --bundles deb
else
yarn tauri build -- --target $GUI_TARGET --verbose
fi
- name: Compress
run: |
mkdir -p ./artifacts/objects/
@ -104,8 +171,21 @@ jobs:
TAG=$GITHUB_SHA
fi
mv ./target/$TARGET/release/easytier-core"$SUFFIX" ./artifacts/objects/
mv ./target/$TARGET/release/easytier-gui"$SUFFIX" ./artifacts/objects/
mv ./target/$TARGET/release/easytier-cli"$SUFFIX" ./artifacts/objects/
# copy gui bundle, gui is built without specific target
if [[ $OS =~ ^windows.*$ ]]; then
mv ./target/$GUI_TARGET/release/bundle/nsis/*.exe ./artifacts/objects/
elif [[ $OS =~ ^macos.*$ ]]; then
mv ./target/$GUI_TARGET/release/bundle/dmg/*.dmg ./artifacts/objects/
elif [[ $OS =~ ^ubuntu.*$ ]]; then
mv ./target/$GUI_TARGET/release/bundle/deb/*.deb ./artifacts/objects/
if [[ $GUI_TARGET =~ ^x86_64.*$ ]]; then
# currently only x86 appimage is supported
mv ./target/$GUI_TARGET/release/bundle/appimage/*.AppImage ./artifacts/objects/
fi
fi
tar -cvf ./artifacts/$NAME-$TARGET-$TAG.tar -C ./artifacts/objects/ .
rm -rf ./artifacts/objects/
- name: Archive artifact
@ -146,6 +226,9 @@ jobs:
path: |
~/.cargo
./target
key: build-cargo-registry-test
key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }}
- name: Run tests
run: sudo -E env "PATH=$PATH" cargo test --verbose
run: |
sudo -E env "PATH=$PATH" cargo test --verbose
sudo chown -R $USER:$USER ./target
sudo chown -R $USER:$USER ~/.cargo

8
.gitignore vendored
View File

@ -4,10 +4,6 @@ debug/
target/
target-*/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
@ -24,3 +20,7 @@ flamegraph.svg
root-target
nohup.out
.DS_Store
components.d.ts

6262
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,10 @@
[workspace]
resolver = "2"
members = ["easytier", "easytier-gui"]
members = ["easytier", "easytier-gui/src-tauri"]
default-members = [ "easytier" ]
[profile.dev]
panic = "unwind"
[profile.release]
panic = "unwind"
lto = true

24
easytier-gui/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,48 +0,0 @@
[package]
name = "easytier-gui"
description = "A full meshed p2p VPN, connecting all your devices in one network with one command."
homepage = "https://github.com/KKRainbow/EasyTier"
repository = "https://github.com/KKRainbow/EasyTier"
version = "0.1.0"
edition = "2021"
authors = ["kkrainbow"]
keywords = ["vpn", "p2p", "network", "easytier"]
categories = ["network-programming"]
rust-version = "1.75"
license-file = "LICENSE"
readme = "README.md"
[dependencies]
easytier = { path = "../easytier" }
tokio = { version = "1", features = ["full"] }
anyhow = "1.0"
chrono = "0.4.37"
once_cell = "1.18.0"
dashmap = "5.5.3"
egui = { version = "0.27.2" }
egui-modal = "0.3.6"
humansize = "2.1.3"
eframe = { version = "0.27.2", features = [
"default",
"serde",
"persistence",
"wgpu"
] }
wgpu = { version = "0.19.3", features = [ "webgpu", "webgl"] }
# For image support:
egui_extras = { version = "0.27.2", features = ["default", "image"] }
env_logger = { version = "0.10", default-features = false, features = [
"auto-color",
"humantime",
] }
egui_tiles = "0.8.0"
derivative = "2.2.0"
serde = { version = "1.0", features = ["derive"] }
elevated-command = "1.1.2"

View File

@ -1 +0,0 @@
../LICENSE

View File

@ -0,0 +1,16 @@
# Tauri + Vue 3 + TypeScript
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
## Type Support For `.vue` Imports in TS
Since TypeScript cannot handle type information for `.vue` imports, they are shimmed to be a generic Vue component type by default. In most cases this is fine if you don't really care about component prop types outside of templates. However, if you wish to get actual prop types in `.vue` imports (for example to get props validation when using manual `h(...)` calls), you can enable Volar's Take Over mode by following these steps:
1. Run `Extensions: Show Built-in Extensions` from VS Code's command palette, look for `TypeScript and JavaScript Language Features`, then right click and select `Disable (Workspace)`. By default, Take Over mode will enable itself if the default TypeScript extension is disabled.
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).

Binary file not shown.

14
easytier-gui/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + Vue + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

37
easytier-gui/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "easytier-gui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^1",
"pinia": "^2.1.7",
"primeflex": "^3.3.1",
"primeicons": "^7.0.0",
"primevue": "^3.51.0",
"vue": "^3.3.4",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@tauri-apps/cli": "^1",
"@types/uuid": "^9.0.8",
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.19",
"naive-ui": "^2.38.1",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.0.2",
"unplugin-vue-components": "^0.26.0",
"uuid": "^9.0.1",
"vfonts": "^0.0.3",
"vite": "^5.0.0",
"vue-i18n": "^9.12.0",
"vue-tsc": "^1.8.5"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

7
easytier-gui/src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

View File

@ -0,0 +1,30 @@
[package]
name = "easytier-gui"
version = "0.0.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1", features = [] }
[dependencies]
tauri = { version = "1", features = [ "process-exit", "system-tray", "shell-open"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
easytier = { path = "../../easytier" }
tokio = { version = "1", features = ["full"] }
anyhow = "1.0"
chrono = { version = "0.4.37", features = ["serde"] }
once_cell = "1.18.0"
dashmap = "5.5.3"
privilege = "0.3"
[features]
# This feature is used for production builds or when a dev server is not specified, DO NOT REMOVE!!
custom-protocol = ["tauri/custom-protocol"]

Binary file not shown.

View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

View File

@ -17,8 +17,9 @@ use easytier::{
peer::GetIpListResponse,
},
};
use serde::{Deserialize, Serialize};
#[derive(Default, Clone)]
#[derive(Default, Clone, Debug, Serialize, Deserialize)]
pub struct MyNodeInfo {
pub virtual_ipv4: String,
pub ips: GetIpListResponse,
@ -198,10 +199,6 @@ impl EasyTierLauncher {
pub fn get_peers(&self) -> Vec<PeerInfo> {
self.data.peers.read().unwrap().clone()
}
pub fn running_cfg(&self) -> String {
self.running_cfg.clone()
}
}
impl Drop for EasyTierLauncher {

View File

@ -0,0 +1,353 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod launcher;
use std::{collections::BTreeMap, env::current_exe, process};
use anyhow::Context;
use chrono::{DateTime, Local};
use dashmap::DashMap;
use easytier::{
common::{
config::{ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader, VpnPortalConfig},
global_ctx::GlobalCtxEvent,
},
rpc::{PeerInfo, Route},
utils::{list_peer_route_pair, PeerRoutePair},
};
use launcher::{EasyTierLauncher, MyNodeInfo};
use serde::{Deserialize, Serialize};
use tauri::{
CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, SystemTrayMenuItem,
Window,
};
#[derive(Deserialize, Serialize, PartialEq, Debug)]
enum NetworkingMethod {
PublicServer,
Manual,
Standalone,
}
impl Default for NetworkingMethod {
fn default() -> Self {
NetworkingMethod::PublicServer
}
}
#[derive(Deserialize, Serialize, Debug, Default)]
struct NetworkConfig {
instance_id: String,
virtual_ipv4: String,
network_name: String,
network_secret: String,
networking_method: NetworkingMethod,
public_server_url: String,
peer_urls: Vec<String>,
proxy_cidrs: Vec<String>,
enable_vpn_portal: bool,
vpn_portal_listne_port: i32,
vpn_portal_client_network_addr: String,
vpn_portal_client_network_len: i32,
advanced_settings: bool,
listener_urls: Vec<String>,
rpc_port: i32,
}
impl NetworkConfig {
fn gen_config(&self) -> Result<TomlConfigLoader, anyhow::Error> {
let cfg = TomlConfigLoader::default();
cfg.set_id(
self.instance_id
.parse()
.with_context(|| format!("failed to parse instance id: {}", self.instance_id))?,
);
cfg.set_inst_name(self.network_name.clone());
cfg.set_network_identity(NetworkIdentity {
network_name: self.network_name.clone(),
network_secret: self.network_secret.clone(),
});
if self.virtual_ipv4.len() > 0 {
cfg.set_ipv4(
self.virtual_ipv4.parse().with_context(|| {
format!("failed to parse ipv4 address: {}", self.virtual_ipv4)
})?,
)
}
match self.networking_method {
NetworkingMethod::PublicServer => {
cfg.set_peers(vec![PeerConfig {
uri: self.public_server_url.parse().with_context(|| {
format!(
"failed to parse public server uri: {}",
self.public_server_url
)
})?,
}]);
}
NetworkingMethod::Manual => {
let mut peers = vec![];
for peer_url in self.peer_urls.iter() {
if peer_url.is_empty() {
continue;
}
peers.push(PeerConfig {
uri: peer_url
.parse()
.with_context(|| format!("failed to parse peer uri: {}", peer_url))?,
});
}
cfg.set_peers(peers);
}
NetworkingMethod::Standalone => {}
}
let mut listener_urls = vec![];
for listener_url in self.listener_urls.iter() {
if listener_url.is_empty() {
continue;
}
listener_urls.push(
listener_url
.parse()
.with_context(|| format!("failed to parse listener uri: {}", listener_url))?,
);
}
cfg.set_listeners(listener_urls);
for n in self.proxy_cidrs.iter() {
cfg.add_proxy_cidr(
n.parse()
.with_context(|| format!("failed to parse proxy network: {}", n))?,
);
}
cfg.set_rpc_portal(
format!("127.0.0.1:{}", self.rpc_port)
.parse()
.with_context(|| format!("failed to parse rpc portal port: {}", self.rpc_port))?,
);
if self.enable_vpn_portal {
let cidr = format!(
"{}/{}",
self.vpn_portal_client_network_addr, self.vpn_portal_client_network_len
);
cfg.set_vpn_portal_config(VpnPortalConfig {
client_cidr: cidr
.parse()
.with_context(|| format!("failed to parse vpn portal client cidr: {}", cidr))?,
wireguard_listen: format!("0.0.0.0:{}", self.vpn_portal_listne_port)
.parse()
.with_context(|| {
format!(
"failed to parse vpn portal wireguard listen port. {}",
self.vpn_portal_listne_port
)
})?,
});
}
Ok(cfg)
}
}
#[derive(Deserialize, Serialize, Debug)]
struct NetworkInstanceRunningInfo {
my_node_info: MyNodeInfo,
events: Vec<(DateTime<Local>, GlobalCtxEvent)>,
node_info: MyNodeInfo,
routes: Vec<Route>,
peers: Vec<PeerInfo>,
peer_route_pairs: Vec<PeerRoutePair>,
running: bool,
error_msg: Option<String>,
}
struct NetworkInstance {
config: TomlConfigLoader,
launcher: Option<EasyTierLauncher>,
}
impl NetworkInstance {
fn new(cfg: NetworkConfig) -> Result<Self, anyhow::Error> {
Ok(Self {
config: cfg.gen_config()?,
launcher: None,
})
}
fn is_easytier_running(&self) -> bool {
self.launcher.is_some() && self.launcher.as_ref().unwrap().running()
}
fn get_running_info(&self) -> Option<NetworkInstanceRunningInfo> {
if self.launcher.is_none() {
return None;
}
let launcher = self.launcher.as_ref().unwrap();
let peers = launcher.get_peers();
let routes = launcher.get_routes();
let peer_route_pairs = list_peer_route_pair(peers.clone(), routes.clone());
Some(NetworkInstanceRunningInfo {
my_node_info: launcher.get_node_info(),
events: launcher.get_events(),
node_info: launcher.get_node_info(),
routes,
peers,
peer_route_pairs,
running: launcher.running(),
error_msg: launcher.error_msg(),
})
}
fn start(&mut self) -> Result<(), anyhow::Error> {
if self.is_easytier_running() {
return Ok(());
}
let mut launcher = EasyTierLauncher::new();
launcher.start(|| Ok(self.config.clone()));
self.launcher = Some(launcher);
Ok(())
}
}
static INSTANCE_MAP: once_cell::sync::Lazy<DashMap<String, NetworkInstance>> =
once_cell::sync::Lazy::new(DashMap::new);
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
fn parse_network_config(cfg: &str) -> Result<String, String> {
let cfg: NetworkConfig = serde_json::from_str(cfg).map_err(|e| e.to_string())?;
let toml = cfg.gen_config().map_err(|e| e.to_string())?;
Ok(toml.dump())
}
#[tauri::command]
fn run_network_instance(cfg: &str) -> Result<String, String> {
let cfg: NetworkConfig = serde_json::from_str(cfg).map_err(|e| e.to_string())?;
if INSTANCE_MAP.contains_key(&cfg.instance_id) {
return Err("instance already exists".to_string());
}
let instance_id = cfg.instance_id.clone();
let mut instance = NetworkInstance::new(cfg).map_err(|e| e.to_string())?;
instance.start().map_err(|e| e.to_string())?;
println!("instance {} started", instance_id);
INSTANCE_MAP.insert(instance_id, instance);
Ok("".to_string())
}
#[tauri::command]
fn retain_network_instance(instance_ids: &str) -> Result<(), String> {
let instance_ids: Vec<String> =
serde_json::from_str(instance_ids).map_err(|e| e.to_string())?;
let _ = INSTANCE_MAP.retain(|k, _| instance_ids.contains(k));
println!(
"instance {:?} retained",
INSTANCE_MAP
.iter()
.map(|item| item.key().clone())
.collect::<Vec<_>>()
);
Ok(())
}
#[tauri::command]
fn collect_network_infos() -> Result<String, String> {
let mut ret = BTreeMap::new();
for instance in INSTANCE_MAP.iter() {
if let Some(info) = instance.get_running_info() {
ret.insert(instance.key().clone(), info);
}
}
Ok(serde_json::to_string(&ret).map_err(|e| e.to_string())?)
}
fn toggle_window_visibility(window: &Window) {
if window.is_visible().unwrap() {
window.hide().unwrap();
} else {
window.show().unwrap();
}
}
fn check_sudo() -> bool {
let is_elevated = privilege::user::privileged();
if !is_elevated {
let Ok(my_exe) = current_exe() else {
return true;
};
let mut elevated_cmd = privilege::runas::Command::new(my_exe);
let _ = elevated_cmd.force_prompt(true).gui(true).run();
}
is_elevated
}
fn main() {
if !check_sudo() {
process::exit(0);
}
let quit = CustomMenuItem::new("quit".to_string(), "Quit");
let hide = CustomMenuItem::new("hide".to_string(), "Show / Hide");
let tray_menu = SystemTrayMenu::new()
.add_item(quit)
.add_native_item(SystemTrayMenuItem::Separator)
.add_item(hide);
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
parse_network_config,
run_network_instance,
retain_network_instance,
collect_network_infos
])
.system_tray(SystemTray::new().with_menu(tray_menu))
.on_system_tray_event(|app, event| match event {
SystemTrayEvent::DoubleClick {
position: _,
size: _,
..
} => {
let window = app.get_window("main").unwrap();
toggle_window_visibility(&window);
}
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
"quit" => {
std::process::exit(0);
}
"hide" => {
let window = app.get_window("main").unwrap();
toggle_window_visibility(&window);
}
_ => {}
},
_ => {}
})
.on_window_event(|event| match event.event() {
tauri::WindowEvent::CloseRequested { api, .. } => {
event.window().hide().unwrap();
api.prevent_close();
}
_ => {}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -0,0 +1,49 @@
{
"build": {
"beforeDevCommand": "yarn dev",
"beforeBuildCommand": "yarn build",
"devPath": "http://localhost:1420",
"distDir": "../dist"
},
"package": {
"productName": "easytier-gui",
"version": "0.0.0"
},
"tauri": {
"allowlist": {
"all": false,
"shell": {
"all": false,
"open": true
},
"process": {
"exit": true
}
},
"windows": [
{
"title": "easytier-gui",
"width": 800,
"height": 600
}
],
"security": {
"csp": null
},
"systemTray": {
"iconPath": "icons/icon.ico",
"iconAsTemplate": true
},
"bundle": {
"active": true,
"targets": "all",
"identifier": "com.kkrainbow.easyiter-client",
"icon": [
"icons/icon.png",
"icons/icon.rgba",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
}

View File

@ -0,0 +1,16 @@
{
"tauri": {
"bundle": {
"externalBin": [],
"resources": [
"./wintun.dll",
"./Packet.dll"
],
"windows": {
"webviewInstallMode": {
"type": "embedBootstrapper"
}
}
}
}
}

Binary file not shown.

254
easytier-gui/src/App.vue Normal file
View File

@ -0,0 +1,254 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import Stepper from 'primevue/stepper';
import StepperPanel from 'primevue/stepperpanel';
import { useToast } from "primevue/usetoast";
import { i18n, loadLocaleFromLocalStorage, NetworkConfig, parseNetworkConfig,
useNetworkStore, runNetworkInstance, retainNetworkInstance, collectNetworkInfos,
changeLocale } from './main';
import Config from './components/Config.vue';
import Status from './components/Status.vue';
import { exit } from '@tauri-apps/api/process';
const visible = ref(false);
const tomlConfig = ref("");
const items = ref([
{
label: () => i18n.global.t('show_config'),
icon: 'pi pi-file-edit',
command: async () => {
try {
const ret = await parseNetworkConfig(networkStore.curNetwork);
tomlConfig.value = ret;
} catch (e: any) {
tomlConfig.value = e;
}
visible.value = true;
}
},
{
label: () => i18n.global.t('del_cur_network'),
icon: 'pi pi-times',
command: () => {
networkStore.delCurNetwork();
},
disabled: () => networkStore.networkList.length <= 1,
},
])
enum Severity {
None = "none",
Success = "success",
Info = "info",
Warn = "warn",
Error = "error",
}
const messageBarSeverity = ref(Severity.None);
const messageBarContent = ref("");
const toast = useToast();
const networkStore = useNetworkStore();
const addNewNetwork = () => {
networkStore.addNewNetwork();
networkStore.curNetwork = networkStore.lastNetwork;
}
const networkMenuName = (network: NetworkConfig) => {
return network.network_name + " (" + network.instance_id + ")";
}
networkStore.$subscribe(async () => {
networkStore.saveToLocalStroage();
try {
await parseNetworkConfig(networkStore.curNetwork);
messageBarSeverity.value = Severity.None;
} catch (e: any) {
messageBarContent.value = e;
messageBarSeverity.value = Severity.Error;
}
});
async function runNetworkCb(cfg: NetworkConfig, cb: (e: MouseEvent) => void) {
cb({} as MouseEvent);
networkStore.removeNetworkInstance(cfg.instance_id);
await retainNetworkInstance(networkStore.networkInstanceIds);
networkStore.addNetworkInstance(cfg.instance_id);
try {
await runNetworkInstance(cfg);
} catch (e: any) {
console.error(e);
toast.add({ severity: 'info', detail: e });
}
}
async function stopNetworkCb(cfg: NetworkConfig, cb: (e: MouseEvent) => void) {
console.log("stopNetworkCb", cfg, cb);
cb({} as MouseEvent);
networkStore.removeNetworkInstance(cfg.instance_id);
await retainNetworkInstance(networkStore.networkInstanceIds);
}
async function updateNetworkInfos() {
networkStore.updateWithNetworkInfos(await collectNetworkInfos());
}
let intervalId = 0;
onMounted(() => {
intervalId = setInterval(async () => {
await updateNetworkInfos();
}, 500);
});
onUnmounted(() => clearInterval(intervalId))
const curNetworkHasInstance = computed(() => {
return networkStore.networkInstanceIds.includes(networkStore.curNetworkId);
});
const activeStep = computed(() => {
return curNetworkHasInstance.value ? 1 : 0;
});
const setting_menu = ref();
const setting_menu_items = ref([
{
label: () => i18n.global.t('settings'),
items: [
{
label: () => i18n.global.t('exchange_language'),
icon: 'pi pi-refresh',
command: () => {
changeLocale((i18n.global.locale.value === 'en' ? 'cn' : 'en'));
}
},
{
label: () => i18n.global.t('exit'),
icon: 'pi pi-times',
command: async () => {
await exit(1);
}
}
]
}
]);
const toggle_setting_menu = (event: any) => {
setting_menu.value.toggle(event);
};
onMounted(async () => {
networkStore.loadFromLocalStorage();
loadLocaleFromLocalStorage();
});
</script>
<template>
<!-- <n-config-provider :theme="lightTheme"> -->
<div id="root" class="flex flex-column">
<Dialog v-model:visible="visible" modal header="Config File" :style="{ width: '70%' }">
<Panel>
<ScrollPanel style="width: 100%; height: 300px">
<pre>{{ tomlConfig }}</pre>
</ScrollPanel>
</Panel>
<Divider />
<div class="flex justify-content-end gap-2">
<Button type="button" :label="$t('close')" @click="visible = false"></Button>
</div>
</Dialog>
<div>
<Toolbar>
<template #start>
<div class="flex align-items-center gap-2">
<Button icon="pi pi-plus" class="mr-2" severity="primary" :label="$t('add_new_network')"
@click="addNewNetwork" />
</div>
</template>
<template #center>
<div class="min-w-80 mr-20">
<Dropdown v-model="networkStore.curNetwork" :options="networkStore.networkList"
:optionLabel="networkMenuName" :placeholder="$t('select_network')" :highlightOnSelect="true"
:checkmark="true" class="w-full md:w-32rem" />
</div>
</template>
<template #end>
<Button icon="pi pi-cog" class="mr-2" severity="secondary" aria-haspopup="true" @click="toggle_setting_menu"
:label="$t('settings')" aria-controls="overlay_setting_menu" />
<Menu ref="setting_menu" id="overlay_setting_menu" :model="setting_menu_items" :popup="true" />
</template>
</Toolbar>
</div>
<Stepper class="h-full overflow-y-auto" :active-step="activeStep">
<StepperPanel :header="$t('config_network')" class="w">
<template #content="{ nextCallback }">
<Config @run-network="runNetworkCb($event, nextCallback)" :instance-id="networkStore.curNetworkId"
:config-invalid="messageBarSeverity != Severity.None" />
</template>
</StepperPanel>
<StepperPanel :header="$t('running')">
<template #content="{ prevCallback }">
<div class="flex flex-column">
<Status :instance-id="networkStore.curNetworkId" />
</div>
<div class="flex pt-4 justify-content-center">
<Button label="Stop Network" severity="danger" icon="pi pi-arrow-left"
@click="stopNetworkCb(networkStore.curNetwork, prevCallback)" />
</div>
</template>
</StepperPanel>
</Stepper>
<div>
<Menubar :model="items" breakpoint="300px">
</Menubar>
<InlineMessage v-if="messageBarSeverity !== Severity.None" class="absolute bottom-0 right-0" severity="error">
{{ messageBarContent }}</InlineMessage>
</div>
</div>
</template>
<style scoped>
#root {
height: 100vh;
width: 100vw;
}
</style>
<style>
body {
height: 100vh;
width: 100vw;
padding: 0;
margin: 0;
overflow: hidden;
}
.p-menubar .p-menuitem {
margin: 0;
}
/*
.p-tabview-panel {
height: 100%;
} */
</style>
<script lang="ts">
</script>

View File

@ -0,0 +1,154 @@
<script setup lang="ts">
import InputGroup from "primevue/inputgroup";
import InputGroupAddon from "primevue/inputgroupaddon";
import { ref, defineProps, computed } from "vue";
import { i18n, useNetworkStore, NetworkingMethod } from "../main";
const networking_methods = ref([
{ value: NetworkingMethod.PublicServer, label: i18n.global.t('public_server') },
{ value: NetworkingMethod.Manual, label: i18n.global.t('manual') },
{ value: NetworkingMethod.Standalone, label: i18n.global.t('standalone') },
]);
const props = defineProps<{
configInvalid?: boolean,
instanceId?: string,
}>()
defineEmits(["runNetwork"]);
const networkStore = useNetworkStore();
const curNetwork = computed(() => {
if (props.instanceId) {
console.log("instanceId", props.instanceId);
const c = networkStore.networkList.find(n => n.instance_id == props.instanceId);
if (c != undefined) {
return c;
}
}
return networkStore.curNetwork;
});
const presetPublicServers = [
"tcp://easytier.public.kkrainbow.top:11010",
];
</script>
<template>
<div class="flex flex-column h-full">
<div class="flex flex-column">
<div class="w-10/12 max-w-fit self-center ">
<Panel header="Basic Settings">
<div class="flex flex-column gap-y-2">
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-column gap-2 basis-5/12 grow">
<label for="virtual_ip">{{ $t('virtual_ipv4') }}</label>
<InputGroup>
<InputText id="virtual_ip" v-model="curNetwork.virtual_ipv4" aria-describedby="virtual_ipv4-help" />
<InputGroupAddon>
<span>/24</span>
</InputGroupAddon>
</InputGroup>
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-column gap-2 basis-5/12 grow">
<label for="network_name">{{ $t('network_name') }}</label>
<InputText id="network_name" v-model="curNetwork.network_name" aria-describedby="network_name-help" />
</div>
<div class="flex flex-column gap-2 basis-5/12 grow">
<label for="network_secret">{{ $t('network_secret') }}</label>
<InputText id="network_secret" v-model="curNetwork.network_secret"
aria-describedby=" network_secret-help" />
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-column gap-2 basis-5/12 grow">
<label for="nm">{{ $t('networking_method') }}</label>
<div class="items-center flex flex-row p-fluid gap-x-1">
<Dropdown v-model="curNetwork.networking_method" :options="networking_methods" optionLabel="label"
optionValue="value" placeholder="Select Method" class="" />
<Chips id="chips" v-model="curNetwork.peer_urls"
:placeholder="$t('chips_placeholder', ['tcp://8.8.8.8:11010'])" separator=" " class="grow"
v-if="curNetwork.networking_method == NetworkingMethod.Manual" />
<Dropdown :editable="true" v-model="curNetwork.public_server_url" class="grow"
:options="presetPublicServers"
v-if="curNetwork.networking_method == NetworkingMethod.PublicServer" />
</div>
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap w-full">
<div class="flex flex-column gap-2 grow p-fluid">
<label for="username">{{ $t('proxy_cidrs') }}</label>
<Chips id="chips" v-model="curNetwork.proxy_cidrs"
:placeholder="$t('chips_placeholder', ['10.0.0.0/24'])" separator=" " class="w-full" />
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap ">
<div class="flex flex-column gap-2 grow">
<label for="username">VPN Portal</label>
<div class="items-center flex flex-row gap-x-4">
<ToggleButton onIcon="pi pi-check" offIcon="pi pi-times" v-model="curNetwork.enable_vpn_portal"
:onLabel="$t('off_text')" :offLabel="$t('on_text')" />
<div class="grow" v-if="curNetwork.enable_vpn_portal">
<InputGroup>
<InputText :placeholder="$t('vpn_portal_client_network')"
v-model="curNetwork.vpn_portal_client_network_addr" />
<InputGroupAddon>
<span>/{{ curNetwork.vpn_portal_client_network_len }}</span>
</InputGroupAddon>
</InputGroup>
</div>
<InputNumber :placeholder="$t('vpn_portal_listen_port')" class="" v-if="curNetwork.enable_vpn_portal"
:format="false" v-model="curNetwork.vpn_portal_listne_port" :min="0" :max="65535" />
</div>
</div>
</div>
</div>
</Panel>
<Divider />
<Panel :header="$t('advanced_settings')" toggleable>
<div class="flex flex-column gap-y-2">
<div class="flex flex-row gap-x-9 flex-wrap w-full">
<div class="flex flex-column gap-2 grow p-fluid">
<label for="listener_urls">{{ $t('listener_urls') }}</label>
<Chips id="listener_urls" v-model="curNetwork.listener_urls"
:placeholder="$t('chips_placeholder', ['tcp://1.1.1.1:11010'])" separator=" " class="w-full" />
</div>
</div>
<div class="flex flex-row gap-x-9 flex-wrap">
<div class="flex flex-column gap-2 basis-5/12 grow">
<label for="rpc_port">{{ $t('rpc_port') }}</label>
<InputNumber id="rpc_port" v-model="curNetwork.rpc_port" aria-describedby="username-help"
:format="false" :min="0" :max="65535" />
</div>
</div>
</div>
</Panel>
<Divider />
<div class="flex pt-4 justify-content-center">
<Button label="Run Network" icon="pi pi-arrow-right" iconPos="right" @click="$emit('runNetwork', curNetwork)"
:disabled="configInvalid" />
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,358 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useNetworkStore } from '../main';
const networkStore = useNetworkStore();
const props = defineProps<{
instanceId?: string,
}>()
const curNetwork = computed(() => {
if (props.instanceId) {
console.log("instanceId", props.instanceId);
const c = networkStore.networkList.find(n => n.instance_id == props.instanceId);
if (c != undefined) {
return c;
}
}
return networkStore.curNetwork;
});
let curNetworkInst = computed(() => {
return networkStore.networkInstances.find(n => n.instance_id == curNetwork.value.instance_id);
});
let peerRouteInfos = computed(() => {
if (curNetworkInst.value) {
return curNetworkInst.value.detail.peer_route_pairs;
}
return [];
});
let routeCost = (info: any) => {
if (info.route) {
const cost = info.route.cost;
return cost == 1 ? "p2p" : `relay(${cost})`
}
return '?';
};
function resolveObjPath(path: string, obj = self, separator = '.') {
var properties = Array.isArray(path) ? path : path.split(separator)
return properties.reduce((prev, curr) => prev?.[curr], obj)
}
let statsCommon = (info: any, field: string) => {
if (!info.peer) {
return undefined;
}
let conns = info.peer.conns;
return conns.reduce((acc: number, conn: any) => {
return acc + resolveObjPath(field, conn);
}, 0);
};
function humanFileSize(bytes: number, si = false, dp = 1) {
const thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {
return bytes + ' B';
}
const units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
let u = -1;
const r = 10 ** dp;
do {
bytes /= thresh;
++u;
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
return bytes.toFixed(dp) + ' ' + units[u];
}
let latencyMs = (info: any) => {
let lat_us_sum = statsCommon(info, 'stats.latency_us');
return lat_us_sum ? `${lat_us_sum / 1000 / info.peer.conns.length}ms` : '';
};
let txBytes = (info: any) => {
let tx = statsCommon(info, 'stats.tx_bytes');
return tx ? humanFileSize(tx) : '';
}
let rxBytes = (info: any) => {
let rx = statsCommon(info, 'stats.rx_bytes');
return rx ? humanFileSize(rx) : '';
}
let lossRate = (info: any) => {
let lossRate = statsCommon(info, 'loss_rate');
return lossRate != undefined ? `${Math.round(lossRate * 100)}%` : '';
}
const myNodeInfo = computed(() => {
if (!curNetworkInst.value) {
return {};
}
return curNetworkInst.value.detail?.my_node_info;
});
interface Chip {
label: string;
icon: string;
}
let myNodeInfoChips = computed(() => {
if (!curNetworkInst.value) {
return [];
}
let chips: Array<Chip> = [];
let my_node_info = curNetworkInst.value.detail?.my_node_info;
if (!my_node_info) {
return chips;
}
// local ipv4s
let local_ipv4s = my_node_info.ips?.interface_ipv4s;
for (let [idx, ip] of local_ipv4s?.entries()) {
chips.push({
label: `Local IPv4 ${idx}: ${ip}`,
icon: '',
} as Chip);
}
// local ipv6s
let local_ipv6s = my_node_info.ips?.interface_ipv6s;
for (let [idx, ip] of local_ipv6s?.entries()) {
chips.push({
label: `Local IPv6 ${idx}: ${ip}`,
icon: '',
} as Chip);
}
// public ip
let public_ip = my_node_info.ips?.public_ipv4;
if (public_ip) {
chips.push({
label: `Public IP: ${public_ip}`,
icon: '',
} as Chip);
}
// listeners:
let listeners = my_node_info.listeners;
for (let [idx, listener] of listeners?.entries()) {
chips.push({
label: `Listener ${idx}: ${listener}`,
icon: '',
} as Chip);
}
// udp nat type
enum NatType {
// has NAT; but own a single public IP, port is not changed
Unknown = 0,
OpenInternet = 1,
NoPAT = 2,
FullCone = 3,
Restricted = 4,
PortRestricted = 5,
Symmetric = 6,
SymUdpFirewall = 7,
};
let udpNatType: NatType = my_node_info.stun_info?.udp_nat_type;
if (udpNatType != undefined) {
let udpNatTypeStrMap = {
[NatType.Unknown]: 'Unknown',
[NatType.OpenInternet]: 'Open Internet',
[NatType.NoPAT]: 'No PAT',
[NatType.FullCone]: 'Full Cone',
[NatType.Restricted]: 'Restricted',
[NatType.PortRestricted]: 'Port Restricted',
[NatType.Symmetric]: 'Symmetric',
[NatType.SymUdpFirewall]: 'Symmetric UDP Firewall',
};
chips.push({
label: `UDP NAT Type: ${udpNatTypeStrMap[udpNatType]}`,
icon: '',
} as Chip);
}
return chips;
});
const globalSumCommon = (field: string) => {
let sum = 0;
if (!peerRouteInfos.value) {
return sum;
}
for (let info of peerRouteInfos.value) {
let tx = statsCommon(info, field);
if (tx) {
sum += tx;
}
}
return sum;
};
const txGlobalSum = () => {
return globalSumCommon('stats.tx_bytes');
};
const rxGlobalSum = () => {
return globalSumCommon('stats.rx_bytes');
}
const peerCount = computed(() => {
if (!peerRouteInfos.value) {
return 0;
}
return peerRouteInfos.value.length;
});
// calculate tx/rx rate every 2 seconds
let rateIntervalId = 0;
let rateInterval = 2000;
let prevTxSum = 0;
let prevRxSum = 0;
let txRate = ref('0');
let rxRate = ref('0');
onMounted(() => {
rateIntervalId = setInterval(() => {
let curTxSum = txGlobalSum();
txRate.value = humanFileSize((curTxSum - prevTxSum) / (rateInterval / 1000));
prevTxSum = curTxSum;
let curRxSum = rxGlobalSum();
rxRate.value = humanFileSize((curRxSum - prevRxSum) / (rateInterval / 1000));
prevRxSum = curRxSum;
}, rateInterval);
});
onUnmounted(() => {
clearInterval(rateIntervalId);
});
const dialogVisible = ref(false);
const dialogContent = ref('');
const showVpnPortalConfig = () => {
let my_node_info = myNodeInfo.value;
if (!my_node_info) {
return;
}
const url = "https://www.wireguardconfig.com/qrcode";
dialogContent.value = `${my_node_info.vpn_portal_cfg}\n\n # can generate QR code: ${url}`;
dialogVisible.value = true;
}
const showEventLogs = () => {
let detail = curNetworkInst.value?.detail;
if (!detail) {
return;
}
dialogContent.value = detail.events;
dialogVisible.value = true;
}
</script>
<template>
<div>
<Dialog v-model:visible="dialogVisible" modal header="Dialog" :style="{ width: '70%' }">
<Panel>
<ScrollPanel style="width: 100%; height: 400px">
<pre>{{ dialogContent }}</pre>
</ScrollPanel>
</Panel>
<Divider />
<div class="flex justify-content-end gap-2">
<Button type="button" label="Close" @click="dialogVisible = false"></Button>
</div>
</Dialog>
<Card v-if="curNetworkInst?.error_msg">
<template #title>Run Network Error</template>
<template #content>
<div class="flex flex-column gap-y-5">
<div class="text-red-500">
{{ curNetworkInst.error_msg }}
</div>
</div>
</template>
</Card>
<Card v-if="!curNetworkInst?.error_msg">
<template #title>{{ $t('my_node_info') }}</template>
<template #content>
<div class="flex w-full flex-column gap-y-5">
<div class="m-0 flex flex-row justify-center gap-x-5">
<div class="rounded-full w-36 h-36 flex flex-column align-items-center pt-4"
style="border: 1px solid green">
<div class="font-bold">
{{ $t('peer_count') }}
</div>
<div class="text-5xl mt-1">{{ peerCount }}</div>
</div>
<div class="rounded-full w-36 h-36 flex flex-column align-items-center pt-4"
style="border: 1px solid purple">
<div class="font-bold">
{{ $t('upload') }}
</div>
<div class="text-xl mt-2">{{ txRate }}/s</div>
</div>
<div class="rounded-full w-36 h-36 flex flex-column align-items-center pt-4"
style="border: 1px solid fuchsia">
<div class="font-bold">
{{ $t('download') }}
</div>
<div class="text-xl mt-2">{{ rxRate }}/s</div>
</div>
</div>
<div class="flex flex-row align-items-center flex-wrap w-full">
<Chip v-for="chip in myNodeInfoChips" :label="chip.label" :icon="chip.icon" class="mr-2 mt-2">
</Chip>
</div>
<div class="m-0 flex flex-row justify-center gap-x-5 text-sm" v-if="myNodeInfo">
<Button severity="info" :label="$t('show_vpn_portal_config')" @click="showVpnPortalConfig" />
<Button severity="info" :label="$t('show_event_log')" @click="showEventLogs" />
</div>
</div>
</template>
</Card>
<Divider />
<Card v-if="!curNetworkInst?.error_msg">
<template #title>{{ $t('peer_info') }}</template>
<template #content>
<DataTable :value="peerRouteInfos" tableStyle="min-width: 50rem">
<Column field="route.ipv4_addr" :header="$t('virtual_ipv4')"></Column>
<Column field="route.hostname" :header="$t('hostname')"></Column>
<Column :field="routeCost" :header="$t('route_cost')"></Column>
<Column :field="latencyMs" :header="$t('latency')"></Column>
<Column :field="txBytes" :header="$t('upload_bytes')"></Column>
<Column :field="rxBytes" :header="$t('download_bytes')"></Column>
<Column :field="lossRate" :header="$t('loss_rate')"></Column>
</DataTable>
</template>
</Card>
</div>
</template>

File diff suppressed because it is too large Load Diff

353
easytier-gui/src/main.ts Normal file
View File

@ -0,0 +1,353 @@
import "./styles.css";
import "primevue/resources/themes/aura-light-green/theme.css";
import "primeicons/primeicons.css";
import "primeflex/primeflex.css";
import { createPinia, defineStore } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { createApp } from "vue";
import PrimeVue from 'primevue/config';
import App from "./App.vue";
import { invoke } from "@tauri-apps/api/tauri";
import { v4 as uuidv4 } from 'uuid';
import ToastService from 'primevue/toastservice';
const pinia = createPinia()
export enum NetworkingMethod {
PublicServer = "PublicServer",
Manual = "Manual",
Standalone = "Standalone",
}
export interface NetworkConfig {
instance_id: string,
virtual_ipv4: string
network_name: string
network_secret: string
networking_method: NetworkingMethod,
public_server_url: string,
peer_urls: Array<string>,
proxy_cidrs: Array<string>,
enable_vpn_portal: boolean,
vpn_portal_listne_port: number,
vpn_portal_client_network_addr: string,
vpn_portal_client_network_len: number,
advanced_settings: boolean,
listener_urls: Array<string>,
rpc_port: number,
}
function default_network(): NetworkConfig {
return {
instance_id: uuidv4(),
virtual_ipv4: "",
network_name: "default",
network_secret: "",
networking_method: NetworkingMethod.PublicServer,
public_server_url: "tcp://easytier.public.kkrainbow.top:11010",
peer_urls: [],
proxy_cidrs: [],
enable_vpn_portal: false,
vpn_portal_listne_port: 22022,
vpn_portal_client_network_addr: "",
vpn_portal_client_network_len: 24,
advanced_settings: false,
listener_urls: [
"tcp://0.0.0.0:11010",
"udp://0.0.0.0:11010",
"wg://0.0.0.0:11011",
],
rpc_port: 15888,
}
}
export interface NetworkInstance {
instance_id: string,
running: boolean,
error_msg: string,
detail: any,
}
export const useNetworkStore = defineStore('network', {
state: () => {
const networkList = [default_network()];
return {
// for initially empty lists
networkList: networkList as NetworkConfig[],
// for data that is not yet loaded
curNetwork: networkList[0],
// uuid -> instance
instances: {} as Record<string, NetworkInstance>,
networkInfos: {} as Record<string, any>,
}
},
getters: {
lastNetwork(): NetworkConfig {
return this.networkList[this.networkList.length - 1];
},
curNetworkId(): string {
return this.curNetwork.instance_id;
},
networkInstances(): Array<NetworkInstance> {
return Object.values(this.instances);
},
networkInstanceIds(): Array<string> {
return Object.keys(this.instances);
}
},
actions: {
addNewNetwork() {
this.networkList.push(default_network());
},
delCurNetwork() {
const curNetworkIdx = this.networkList.indexOf(this.curNetwork);
this.networkList.splice(curNetworkIdx, 1);
const nextCurNetworkIdx = Math.min(curNetworkIdx, this.networkList.length - 1);
this.curNetwork = this.networkList[nextCurNetworkIdx];
},
removeNetworkInstance(instanceId: string) {
delete this.instances[instanceId];
},
addNetworkInstance(instanceId: string) {
this.instances[instanceId] = {
instance_id: instanceId,
running: false,
error_msg: "",
detail: {},
};
},
updateWithNetworkInfos(networkInfos: Record<string, any>) {
this.networkInfos = networkInfos;
for (const [instanceId, info] of Object.entries(networkInfos)) {
if (this.instances[instanceId] === undefined) {
this.addNetworkInstance(instanceId);
}
this.instances[instanceId].running = info["running"];
this.instances[instanceId].error_msg = info["error_msg"];
this.instances[instanceId].detail = info;
}
},
loadFromLocalStorage() {
const networkList = JSON.parse(localStorage.getItem("networkList") || '[]');
let result = [];
for (const cfg of networkList) {
result.push({
...default_network(),
...cfg,
});
}
if (result.length === 0) {
result.push(default_network());
}
this.networkList = result;
this.curNetwork = this.networkList[0];
},
saveToLocalStroage() {
localStorage.setItem("networkList", JSON.stringify(this.networkList));
}
}
})
export async function parseNetworkConfig(cfg: NetworkConfig): Promise<string> {
const ret: string = await invoke("parse_network_config", { cfg: JSON.stringify(cfg) });
return ret;
}
export async function runNetworkInstance(cfg: NetworkConfig) {
const ret: string = await invoke("run_network_instance", { cfg: JSON.stringify(cfg) });
return ret;
}
export async function retainNetworkInstance(instanceIds: Array<string>) {
const ret: string = await invoke("retain_network_instance", { instanceIds: JSON.stringify(instanceIds) });
return ret;
}
export async function collectNetworkInfos() {
const ret: string = await invoke("collect_network_infos", {});
return JSON.parse(ret);
}
import { createI18n } from 'vue-i18n'
const messages = {
en: {
"network": "Network",
"networking_method": "Networking Method",
"public_server": "Public Server",
"manual": "Manual",
"standalone": "Standalone",
"virtual_ipv4": "Virtual IPv4",
"network_name": "Network Name",
"network_secret": "Network Secret",
"public_server_url": "Public Server URL",
"peer_urls": "Peer URLs",
"proxy_cidrs": "Subnet Proxy CIDRs",
"enable_vpn_portal": "Enable VPN Portal",
"vpn_portal_listen_port": "VPN Portal Listen Port",
"vpn_portal_client_network": "Client Sub Network",
"advanced_settings": "Advanced Settings",
"listener_urls": "Listener URLs",
"rpc_port": "RPC Port",
"config_network": "Config Network",
"running": "Running",
"error_msg": "Error Message",
"detail": "Detail",
"add_new_network": "Add New Network",
"del_cur_network": "Delete Current Network",
"select_network": "Select Network",
"network_instances": "Network Instances",
"instance_id": "Instance ID",
"network_infos": "Network Infos",
"parse_network_config": "Parse Network Config",
"run_network_instance": "Run Network Instance",
"retain_network_instance": "Retain Network Instance",
"collect_network_infos": "Collect Network Infos",
"settings": "Settings",
"exchange_language": "切换中文",
"exit": "Exit",
"chips_placeholder": "e.g: {0}, press Enter to add",
"off_text": "Press to disable",
"on_text": "Press to enable",
"show_config": "Show Config",
"close": "Close",
"my_node_info": "My Node Info",
"peer_count": "Connected",
"upload": "Upload",
"download": "Download",
"show_vpn_portal_config": "Show VPN Portal Config",
"show_event_log": "Show Event Log",
"peer_info": "Peer Info",
"route_cost": "Route Cost",
"hostname": "Hostname",
"latency": "Latency",
"upload_bytes": "Upload",
"download_bytes": "Download",
"loss_rate": "Loss Rate",
},
cn: {
"network": "网络",
"networking_method": "网络方式",
"public_server": "公共服务器",
"manual": "手动",
"standalone": "独立",
"virtual_ipv4": "虚拟IPv4地址",
"network_name": "网络名称",
"network_secret": "网络密码",
"public_server_url": "公共服务器地址",
"peer_urls": "对等节点地址",
"proxy_cidrs": "子网代理CIDR",
"enable_vpn_portal": "启用VPN门户",
"vpn_portal_listen_port": "监听端口",
"vpn_portal_client_network": "客户端子网",
"advanced_settings": "高级设置",
"listener_urls": "监听地址",
"rpc_port": "RPC端口",
"config_network": "配置网络",
"running": "运行中",
"error_msg": "错误信息",
"detail": "详情",
"add_new_network": "添加新网络",
"del_cur_network": "删除当前网络",
"select_network": "选择网络",
"network_instances": "网络实例",
"instance_id": "实例ID",
"network_infos": "网络信息",
"parse_network_config": "解析网络配置",
"run_network_instance": "运行网络实例",
"retain_network_instance": "保留网络实例",
"collect_network_infos": "收集网络信息",
"settings": "设置",
"exchange_language": "Switch to English",
"exit": "退出",
"chips_placeholder": "例如: {0}, 按回车添加",
"off_text": "点击关闭",
"on_text": "点击开启",
"show_config": "显示配置",
"close": "关闭",
"my_node_info": "当前节点信息",
"peer_count": "已连接",
"upload": "上传",
"download": "下载",
"show_vpn_portal_config": "显示VPN门户配置",
"show_event_log": "显示事件日志",
"peer_info": "节点信息",
"hostname": "主机名",
"route_cost": "路由",
"latency": "延迟",
"upload_bytes": "上传",
"download_bytes": "下载",
"loss_rate": "丢包率",
}
}
function saveLocaleToLocalStorage(locale: string) {
localStorage.setItem("locale", locale);
}
export function loadLocaleFromLocalStorage(): string {
return localStorage.getItem("locale") || "en";
}
export const i18n = createI18n({
legacy: false,
locale: 'en', // set locale
fallbackLocale: 'cn', // set fallback locale
messages,
})
export function changeLocale(locale: 'en' | 'cn') {
i18n.global.locale.value = locale;
saveLocaleToLocalStorage(locale);
}
const app = createApp(App);
app.use(i18n, { useScope: 'global' })
app.use(pinia)
app.use(PrimeVue);
app.use(ToastService);
app.mount("#app");
export const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: "/", component: App }]
});

View File

@ -0,0 +1,33 @@
@layer tailwind-base, primevue, tailwind-utilities;
@layer tailwind-base {
@tailwind base;
}
@layer tailwind-utilities {
@tailwind components;
@tailwind utilities;
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 12px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: white;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.card {
background: var(--surface-card);
padding: 2rem;
border-radius: 10px;
margin-bottom: 1rem;
}

View File

@ -1,57 +0,0 @@
#[derive(Default)]
pub struct TextListOption {
pub hint: String,
}
pub fn text_list_ui(
ui: &mut egui::Ui,
id: &str,
texts: &mut Vec<String>,
option: Option<TextListOption>,
) {
let option = option.unwrap_or_default();
// convert text vec to (index, text) vec
let mut add_new_item = false;
let mut remove_idxs = vec![];
egui::Grid::new(id).max_col_width(200.0).show(ui, |ui| {
for i in 0..texts.len() {
egui::TextEdit::singleline(&mut texts[i])
.hint_text(&option.hint)
.show(ui);
ui.horizontal(|ui| {
if ui.button("").clicked() {
remove_idxs.push(i);
}
if i == texts.len() - 1 {
if ui.button("").clicked() {
add_new_item = true;
}
}
});
ui.end_row();
}
if texts.len() == 0 {
if ui.button("").clicked() {
add_new_item = true;
}
ui.end_row();
}
});
let new_texts = texts
.iter()
.enumerate()
.filter(|(i, _)| !remove_idxs.contains(i))
.map(|(_, t)| t.clone())
.collect::<Vec<String>>();
*texts = new_texts;
if add_new_item && texts.last().map(|t| !t.is_empty()).unwrap_or(true) {
texts.push("".to_string());
}
}

View File

@ -1,107 +0,0 @@
//! Source code example of how to create your own widget.
//! This is meant to be read as a tutorial, hence the plethora of comments.
/// iOS-style toggle switch:
///
/// ``` text
/// _____________
/// / /.....\
/// | |.......|
/// \_______\_____/
/// ```
///
/// ## Example:
/// ``` ignore
/// toggle_ui(ui, &mut my_bool);
/// ```
pub fn toggle_ui(ui: &mut egui::Ui, on: &mut bool) -> egui::Response {
// Widget code can be broken up in four steps:
// 1. Decide a size for the widget
// 2. Allocate space for it
// 3. Handle interactions with the widget (if any)
// 4. Paint the widget
// 1. Deciding widget size:
// You can query the `ui` how much space is available,
// but in this example we have a fixed size widget based on the height of a standard button:
let desired_size = ui.spacing().interact_size.y * egui::vec2(2.0, 1.0);
// 2. Allocating space:
// This is where we get a region of the screen assigned.
// We also tell the Ui to sense clicks in the allocated region.
let (rect, mut response) = ui.allocate_exact_size(desired_size, egui::Sense::click());
// 3. Interact: Time to check for clicks!
if response.clicked() {
*on = !*on;
response.mark_changed(); // report back that the value changed
}
// Attach some meta-data to the response which can be used by screen readers:
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, *on, ""));
// 4. Paint!
// Make sure we need to paint:
if ui.is_rect_visible(rect) {
// Let's ask for a simple animation from egui.
// egui keeps track of changes in the boolean associated with the id and
// returns an animated value in the 0-1 range for how much "on" we are.
let how_on = ui.ctx().animate_bool(response.id, *on);
// We will follow the current style by asking
// "how should something that is being interacted with be painted?".
// This will, for instance, give us different colors when the widget is hovered or clicked.
let visuals = ui.style().interact_selectable(&response, *on);
// All coordinates are in absolute screen coordinates so we use `rect` to place the elements.
let rect = rect.expand(visuals.expansion);
let radius = 0.5 * rect.height();
ui.painter()
.rect(rect, radius, visuals.bg_fill, visuals.bg_stroke);
// Paint the circle, animating it from left to right with `how_on`:
let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on);
let center = egui::pos2(circle_x, rect.center().y);
ui.painter()
.circle(center, 0.75 * radius, visuals.bg_fill, visuals.fg_stroke);
}
// All done! Return the interaction response so the user can check what happened
// (hovered, clicked, ...) and maybe show a tooltip:
response
}
/// Here is the same code again, but a bit more compact:
#[allow(dead_code)]
fn toggle_ui_compact(ui: &mut egui::Ui, on: &mut bool) -> egui::Response {
let desired_size = ui.spacing().interact_size.y * egui::vec2(2.0, 1.0);
let (rect, mut response) = ui.allocate_exact_size(desired_size, egui::Sense::click());
if response.clicked() {
*on = !*on;
response.mark_changed();
}
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, *on, ""));
if ui.is_rect_visible(rect) {
let how_on = ui.ctx().animate_bool(response.id, *on);
let visuals = ui.style().interact_selectable(&response, *on);
let rect = rect.expand(visuals.expansion);
let radius = 0.5 * rect.height();
ui.painter()
.rect(rect, radius, visuals.bg_fill, visuals.bg_stroke);
let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on);
let center = egui::pos2(circle_x, rect.center().y);
ui.painter()
.circle(center, 0.75 * radius, visuals.bg_fill, visuals.fg_stroke);
}
response
}
// A wrapper that allows the more idiomatic usage pattern: `ui.add(toggle(&mut my_bool))`
/// iOS-style toggle switch.
///
/// ## Example:
/// ``` ignore
/// ui.add(toggle(&mut my_bool));
/// ```
pub fn toggle(on: &mut bool) -> impl egui::Widget + '_ {
move |ui: &mut egui::Ui| toggle_ui(ui, on)
}

7
easytier-gui/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,31 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import Components from 'unplugin-vue-components/vite';
import { PrimeVueResolver } from 'unplugin-vue-components/resolvers';
// https://vitejs.dev/config/
export default defineConfig(async () => ({
plugins: [
vue(),
Components({
dts: true,
resolvers: [
PrimeVueResolver()
]
})],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
watch: {
// 3. tell vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));

1663
easytier-gui/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
#[auto_impl::auto_impl(Box, &)]
pub trait ConfigLoader: Send + Sync {
fn get_id(&self) -> uuid::Uuid;
fn set_id(&self, id: uuid::Uuid);
fn get_inst_name(&self) -> String;
fn set_inst_name(&self, name: String);
@ -111,7 +112,7 @@ pub struct Flags {
struct Config {
netns: Option<String>,
instance_name: Option<String>,
instance_id: Option<String>,
instance_id: Option<uuid::Uuid>,
ipv4: Option<String>,
network_identity: Option<NetworkIdentity>,
listeners: Option<Vec<url::Url>>,
@ -246,21 +247,17 @@ impl ConfigLoader for TomlConfigLoader {
let mut locked_config = self.config.lock().unwrap();
if locked_config.instance_id.is_none() {
let id = uuid::Uuid::new_v4();
locked_config.instance_id = Some(id.to_string());
locked_config.instance_id = Some(id);
id
} else {
uuid::Uuid::parse_str(locked_config.instance_id.as_ref().unwrap())
.with_context(|| {
format!(
"failed to parse instance id as uuid: {}, you can use this id: {}",
locked_config.instance_id.as_ref().unwrap(),
uuid::Uuid::new_v4()
)
})
.unwrap()
locked_config.instance_id.as_ref().unwrap().clone()
}
}
fn set_id(&self, id: uuid::Uuid) {
self.config.lock().unwrap().instance_id = Some(id);
}
fn get_network_identity(&self) -> NetworkIdentity {
self.config
.lock()

View File

@ -13,7 +13,7 @@ use super::{
pub type NetworkIdentity = crate::common::config::NetworkIdentity;
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum GlobalCtxEvent {
TunDeviceReady(String),

View File

@ -1,6 +1,8 @@
use serde::{Deserialize, Serialize};
use crate::rpc::cli::{NatType, PeerInfo, Route};
#[derive(Debug)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PeerRoutePair {
pub route: Route,
pub peer: Option<PeerInfo>,