feat(gui): add fully native tauri windows gui with premium mobile layout, real-time statistics polling, in-app config editor, and graceful exit cleanup

This commit is contained in:
ospab 2026-05-15 22:01:20 +03:00
parent 2819a14189
commit 609564fdd9
34 changed files with 1253 additions and 11 deletions

View File

@ -4,6 +4,7 @@ members = [
"ostp-client", "ostp-client",
"ostp-server", "ostp-server",
"ostp-jni", "ostp", "ostp-jni", "ostp",
"ostp-gui/src-tauri"
] ]
resolver = "2" resolver = "2"

View File

@ -112,19 +112,34 @@ pub async fn run_client(config: crate::config::ClientConfig) -> Result<()> {
relaunch_as_admin()?; relaunch_as_admin()?;
} }
if config.mode == "tun" && !config.exclusions.processes.is_empty() {
println!("[ostp-client] WARNING: process exclusions are not supported in the current TUN implementation");
}
let (proxy_events_tx, proxy_events_rx) = mpsc::channel(10000);
let (client_msgs_tx, client_msgs_rx) = mpsc::channel(10000);
let metrics = Arc::new(BridgeMetrics { let metrics = Arc::new(BridgeMetrics {
bytes_sent: portable_atomic::AtomicU64::new(0), bytes_sent: portable_atomic::AtomicU64::new(0),
bytes_recv: portable_atomic::AtomicU64::new(0), bytes_recv: portable_atomic::AtomicU64::new(0),
}); });
let (shutdown_tx, shutdown_rx) = watch::channel(false);
tokio::spawn(async move {
if let Ok(_) = wait_for_shutdown_signal().await {
let _ = shutdown_tx.send(true);
}
});
run_client_core(config, metrics, shutdown_rx).await
}
pub async fn run_client_core(
config: crate::config::ClientConfig,
metrics: Arc<BridgeMetrics>,
mut shutdown_rx_ext: watch::Receiver<bool>,
) -> Result<()> {
if config.mode == "tun" && !config.exclusions.processes.is_empty() {
println!("[ostp-client] WARNING: process exclusions are not supported in the current TUN implementation");
}
let (proxy_events_tx, proxy_events_rx) = mpsc::channel(10000);
let (client_msgs_tx, client_msgs_rx) = mpsc::channel(10000);
let bridge = Bridge::new(&config, metrics)?; let bridge = Bridge::new(&config, metrics)?;
let (ui_tx, mut ui_rx) = mpsc::channel(512); let (ui_tx, mut ui_rx) = mpsc::channel(512);
@ -206,8 +221,9 @@ pub async fn run_client(config: crate::config::ClientConfig) -> Result<()> {
None None
}; };
// Wait for Ctrl-C / signal // Wait for external / UI shutdown signal
wait_for_shutdown_signal().await?; let _ = shutdown_rx_ext.changed().await;
let _ = cmd_tx.send(BridgeCommand::Shutdown).await; let _ = cmd_tx.send(BridgeCommand::Shutdown).await;
let _ = shutdown_tx.send(true); let _ = shutdown_tx.send(true);

View File

@ -17,6 +17,22 @@ pub async fn wait_for_shutdown_signal() -> Result<()> {
#[cfg(not(unix))] #[cfg(not(unix))]
pub async fn wait_for_shutdown_signal() -> Result<()> { pub async fn wait_for_shutdown_signal() -> Result<()> {
tokio::signal::ctrl_c().await?; #[cfg(target_os = "windows")]
{
use tokio::signal::windows::{ctrl_break, ctrl_c, ctrl_close};
let mut c_c = ctrl_c()?;
let mut c_close = ctrl_close()?;
let mut c_break = ctrl_break()?;
tokio::select! {
_ = c_c.recv() => {}
_ = c_close.recv() => {}
_ = c_break.recv() => {}
}
}
#[cfg(not(target_os = "windows"))]
{
tokio::signal::ctrl_c().await?;
}
Ok(()) Ok(())
} }

24
ostp-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?

7
ostp-gui/README.md Normal file
View File

@ -0,0 +1,7 @@
# Tauri + Vanilla
This template should help get you started developing with Tauri in vanilla HTML, CSS and Javascript.
## Recommended IDE Setup
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)

12
ostp-gui/package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "ostp-gui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}

7
ostp-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,29 @@
[package]
name = "ostp-gui"
version = "0.1.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
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "ostp_gui_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
anyhow = "1"
ostp-client = { path = "../../ostp-client" }
portable-atomic = "1"

View File

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

View File

@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -0,0 +1,272 @@
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle;
use serde::{Deserialize, Serialize};
use anyhow::Result;
use ostp_client::bridge::BridgeMetrics;
use portable_atomic::Ordering;
// Config deserialization matching ostp core
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(tag = "mode", rename_all = "lowercase")]
enum AppMode {
Server(serde_json::Value), // We ignore server config in GUI
Client(ClientConfigRaw),
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct UnifiedConfig {
#[serde(flatten)]
mode: AppMode,
log_level: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct ClientConfigRaw {
server: String,
access_key: String,
socks5_bind: Option<String>,
tun: Option<TunConfig>,
turn: Option<TurnConfigRaw>,
debug: Option<bool>,
exclude: Option<ExcludeConfig>,
mux: Option<MuxConfig>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct TunConfig {
enable: bool,
wintun_path: Option<String>,
ipv4_address: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct TurnConfigRaw {
enabled: bool,
server_addr: String,
username: Option<String>,
access_key: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct ExcludeConfig {
domains: Option<Vec<String>>,
ips: Option<Vec<String>>,
processes: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct MuxConfig {
enabled: Option<bool>,
sessions: Option<usize>,
}
#[derive(Serialize)]
struct UIMetrics {
bytes_sent: u64,
bytes_recv: u64,
}
struct AppStateInner {
shutdown_tx: Option<watch::Sender<bool>>,
metrics: Option<Arc<BridgeMetrics>>,
handle: Option<JoinHandle<Result<(), String>>>,
}
impl Drop for AppStateInner {
fn drop(&mut self) {
// Send final signal to ensure the core background threads exit immediately
// and activate Wintun routing cleanup Drop routines.
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(true);
}
}
}
struct AppState(Mutex<AppStateInner>);
fn get_config_path() -> PathBuf {
// Standard behavior: same dir as current exe, or fall back to current working dir
if let Ok(exe_path) = std::env::current_exe() {
if let Some(parent) = exe_path.parent() {
let path = parent.join("config.json");
if path.exists() {
return path;
}
}
}
PathBuf::from("config.json")
}
#[tauri::command]
async fn get_config() -> Result<String, String> {
let path = get_config_path();
if !path.exists() {
// Return default template if file missing
return Ok(r#"{
"mode": "client",
"log_level": "info",
"server": "127.0.0.1:50000",
"access_key": "your-secret-access-key-hex-or-base64",
"socks5_bind": "127.0.0.1:1088",
"tun": {
"enable": true,
"wintun_path": "./wintun.dll",
"ipv4_address": "10.1.0.2/24"
},
"debug": false
}"#.into());
}
std::fs::read_to_string(&path).map_err(|e| format!("Read error: {}", e))
}
#[tauri::command]
async fn save_config(json_content: String) -> Result<bool, String> {
// Validate formatting
let _parsed: UnifiedConfig = serde_json::from_str(&json_content)
.map_err(|e| format!("Invalid OSTP config JSON: {}", e))?;
let path = get_config_path();
std::fs::write(path, json_content).map_err(|e| format!("Write error: {}", e))?;
Ok(true)
}
#[tauri::command]
async fn get_tunnel_status(state: tauri::State<'_, AppState>) -> Result<bool, String> {
let guard = state.0.lock().await;
if let Some(ref handle) = guard.handle {
Ok(!handle.is_finished())
} else {
Ok(false)
}
}
#[tauri::command]
async fn get_metrics(state: tauri::State<'_, AppState>) -> Result<Option<UIMetrics>, String> {
let guard = state.0.lock().await;
if let Some(ref metrics) = guard.metrics {
Ok(Some(UIMetrics {
bytes_sent: metrics.bytes_sent.load(Ordering::Relaxed),
bytes_recv: metrics.bytes_recv.load(Ordering::Relaxed),
}))
} else {
Ok(None)
}
}
#[tauri::command]
async fn stop_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String> {
let mut guard = state.0.lock().await;
if let Some(tx) = guard.shutdown_tx.take() {
let _ = tx.send(true);
}
if let Some(handle) = guard.handle.take() {
let _ = handle.await;
}
guard.metrics = None;
Ok(true)
}
#[tauri::command]
async fn start_tunnel(state: tauri::State<'_, AppState>) -> Result<bool, String> {
// Ensure it's stopped first
let mut guard = state.0.lock().await;
if let Some(ref h) = guard.handle {
if !h.is_finished() {
return Ok(true); // Already running
}
}
let path = get_config_path();
if !path.exists() {
return Err("config.json not found. Go to Settings and configure your key first.".into());
}
let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?;
let unified: UnifiedConfig = serde_json::from_str(&content).map_err(|e| format!("Config parse error: {}", e))?;
let client_cfg = match unified.mode {
AppMode::Client(c) => c,
AppMode::Server(_) => return Err("Configuration is in Server mode. GUI only supports Client configurations.".into()),
};
// Translate to ostp_client domain struct
let is_tun_enabled = client_cfg.tun.as_ref().map(|t| t.enable).unwrap_or(false);
let turn_cfg = client_cfg.turn.as_ref();
let mapped_config = ostp_client::config::ClientConfig {
mode: if is_tun_enabled { "tun".to_string() } else { "proxy".to_string() },
debug: client_cfg.debug.unwrap_or(false),
ostp: ostp_client::config::OstpConfig {
server_addr: client_cfg.server.clone(),
local_bind_addr: "0.0.0.0:0".to_string(),
access_key: client_cfg.access_key.clone(),
handshake_timeout_ms: 5000,
io_timeout_ms: 5000,
},
local_proxy: ostp_client::config::LocalProxyConfig {
bind_addr: client_cfg.socks5_bind.clone().unwrap_or_else(|| "127.0.0.1:1088".to_string()),
connect_timeout_ms: 5000,
},
turn: ostp_client::config::TurnConfig {
enabled: turn_cfg.map(|t| t.enabled).unwrap_or(false),
server_addr: turn_cfg.and_then(|t| Some(t.server_addr.clone())).unwrap_or_default(),
username: turn_cfg.and_then(|t| t.username.clone()).unwrap_or_default(),
access_key: turn_cfg.and_then(|t| t.access_key.clone()).unwrap_or_default(),
},
exclusions: ostp_client::config::ExclusionConfig {
domains: client_cfg.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(),
ips: client_cfg.exclude.as_ref().and_then(|e| e.ips.clone()).unwrap_or_default(),
processes: client_cfg.exclude.as_ref().and_then(|e| e.processes.clone()).unwrap_or_default(),
},
multiplex: ostp_client::config::MultiplexConfig {
enabled: client_cfg.mux.as_ref().and_then(|m| m.enabled).unwrap_or(false),
sessions: client_cfg.mux.as_ref().and_then(|m| m.sessions).unwrap_or(1),
},
};
let metrics = Arc::new(BridgeMetrics {
bytes_sent: portable_atomic::AtomicU64::new(0),
bytes_recv: portable_atomic::AtomicU64::new(0),
});
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let metrics_clone = metrics.clone();
let engine_handle = tokio::spawn(async move {
match ostp_client::runner::run_client_core(mapped_config, metrics_clone, shutdown_rx).await {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
});
guard.shutdown_tx = Some(shutdown_tx);
guard.metrics = Some(metrics);
guard.handle = Some(engine_handle);
Ok(true)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let state = AppState(Mutex::new(AppStateInner {
shutdown_tx: None,
metrics: None,
handle: None,
}));
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(state)
.invoke_handler(tauri::generate_handler![
start_tunnel,
stop_tunnel,
get_tunnel_status,
get_metrics,
get_config,
save_config
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
ostp_gui_lib::run()
}

View File

@ -0,0 +1,34 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ostp-gui",
"version": "0.1.0",
"identifier": "com.ospab.ostp",
"build": {
"frontendDist": "../src"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "OSTP",
"width": 360,
"height": 740,
"resizable": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>

After

Width:  |  Height:  |  Size: 995 B

View File

@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

91
ostp-gui/src/index.html Normal file
View File

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="styles.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OSTP Client</title>
<script type="module" src="/main.js" defer></script>
</head>
<body>
<div class="app-container">
<!-- Dynamic Mesh Background Particles -->
<div class="mesh-bg"></div>
<div class="blur-overlay"></div>
<!-- Main Screen -->
<div id="home-screen" class="screen active">
<header class="app-header">
<div class="logo-container">
<div class="logo-icon"></div>
<h1>OSTP</h1>
</div>
<button id="btn-go-settings" class="icon-btn" aria-label="Settings">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
</button>
</header>
<div class="main-content">
<div class="power-button-container">
<div class="pulse-ring"></div>
<div class="pulse-ring delay-1"></div>
<button id="btn-connect" class="power-btn">
<div class="power-icon">
<svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"></path><line x1="12" y1="2" x2="12" y2="12"></line></svg>
</div>
</button>
</div>
<div class="status-display">
<span id="status-text" class="status-disconnected">Disconnected</span>
<span id="uptime-text" class="subtext">Tap to protect your traffic</span>
</div>
<div class="metrics-grid">
<div class="metric-card glass">
<div class="metric-icon down">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 5v14M19 12l-7 7-7-7"/></svg>
</div>
<div class="metric-data">
<span class="metric-label">Download</span>
<span id="metric-down" class="metric-value">0.0 B</span>
</div>
</div>
<div class="metric-card glass">
<div class="metric-icon up">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</div>
<div class="metric-data">
<span class="metric-label">Upload</span>
<span id="metric-up" class="metric-value">0.0 B</span>
</div>
</div>
</div>
</div>
</div>
<!-- Settings Screen -->
<div id="settings-screen" class="screen">
<header class="app-header">
<button id="btn-back" class="icon-btn" aria-label="Back">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
</button>
<h2>Configuration</h2>
<div style="width: 40px;"></div> <!-- Spacer for balance -->
</header>
<div class="settings-content">
<div class="editor-container glass">
<label for="config-editor">Client JSON Config</label>
<textarea id="config-editor" spellcheck="false" placeholder="Loading configuration..."></textarea>
</div>
<div class="actions-container">
<button id="btn-save-config" class="primary-btn glass">Save & Apply</button>
</div>
<div id="config-toast" class="toast">Config saved successfully!</div>
</div>
</div>
</div>
</body>
</html>

222
ostp-gui/src/main.js Normal file
View File

@ -0,0 +1,222 @@
const { invoke } = window.__TAURI__.core;
// State management
let appState = 'disconnected'; // 'disconnected', 'connecting', 'connected'
let pollInterval = null;
let elapsedSeconds = 0;
let elapsedTimer = null;
// DOM Elements
const btnConnect = document.getElementById('btn-connect');
const powerContainer = document.querySelector('.power-button-container');
const statusText = document.getElementById('status-text');
const uptimeText = document.getElementById('uptime-text');
const metricDown = document.getElementById('metric-down');
const metricUp = document.getElementById('metric-up');
const homeScreen = document.getElementById('home-screen');
const settingsScreen = document.getElementById('settings-screen');
const btnGoSettings = document.getElementById('btn-go-settings');
const btnBack = document.getElementById('btn-back');
const btnSaveConfig = document.getElementById('btn-save-config');
const configEditor = document.getElementById('config-editor');
const configToast = document.getElementById('config-toast');
// Utils
function formatBytes(bytes) {
if (bytes === 0) return '0.0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatTime(seconds) {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return [
hrs > 0 ? String(hrs).padStart(2, '0') : null,
String(mins).padStart(2, '0'),
String(secs).padStart(2, '0')
].filter(x => x !== null).join(':');
}
// State Updates
function setUIState(state) {
appState = state;
// Clean up classes
btnConnect.className = 'power-btn';
powerContainer.className = 'power-button-container';
statusText.className = '';
if (state === 'disconnected') {
statusText.textContent = 'Disconnected';
statusText.classList.add('status-disconnected');
uptimeText.textContent = 'Tap to protect your traffic';
clearInterval(pollInterval);
clearInterval(elapsedTimer);
pollInterval = null;
elapsedTimer = null;
elapsedSeconds = 0;
} else if (state === 'connecting') {
btnConnect.classList.add('connecting');
powerContainer.classList.add('connecting');
statusText.textContent = 'Connecting...';
statusText.classList.add('status-connecting');
uptimeText.textContent = 'Establishing secure tunnel';
} else if (state === 'connected') {
btnConnect.classList.add('connected');
powerContainer.classList.add('connected');
statusText.textContent = 'Protected';
statusText.classList.add('status-connected');
// Start poll timer
if (!pollInterval) {
pollInterval = setInterval(fetchMetrics, 1000);
}
// Start uptime timer
if (!elapsedTimer) {
elapsedSeconds = 0;
elapsedTimer = setInterval(() => {
elapsedSeconds++;
uptimeText.textContent = `Uptime: ${formatTime(elapsedSeconds)}`;
}, 1000);
}
}
}
// UI Event Handlers
async function handleToggleConnect() {
if (appState === 'disconnected') {
setUIState('connecting');
try {
const success = await invoke('start_tunnel');
if (success) {
// The start_tunnel call waits briefly or returns if spawn worked
// Backend will periodically check status. Let's monitor it.
monitorTunnelState();
} else {
alert('Failed to start tunnel process. Check config.json');
setUIState('disconnected');
}
} catch (err) {
alert('Error launching tunnel: ' + err);
setUIState('disconnected');
}
} else {
try {
await invoke('stop_tunnel');
} catch (err) {
console.error(err);
}
setUIState('disconnected');
}
}
async function monitorTunnelState() {
// Check status for up to 5 seconds to confirm it connects
let attempts = 0;
const check = async () => {
try {
const isAlive = await invoke('get_tunnel_status');
if (isAlive) {
setUIState('connected');
return true;
}
} catch (e) {}
attempts++;
if (attempts < 5 && appState === 'connecting') {
setTimeout(check, 1000);
} else if (appState === 'connecting') {
alert('Tunnel failed to stay alive. Make sure you run with Admin privileges if using TUN mode.');
setUIState('disconnected');
}
};
setTimeout(check, 1500); // Delay initial check to give it time to boot
}
async function fetchMetrics() {
try {
const stats = await invoke('get_metrics'); // Expected format: { bytes_sent: u64, bytes_recv: u64 }
if (stats) {
metricDown.textContent = formatBytes(stats.bytes_recv);
metricUp.textContent = formatBytes(stats.bytes_sent);
}
} catch (e) {
console.error('Failed to fetch metrics', e);
}
// Also verify process is still alive
try {
const isAlive = await invoke('get_tunnel_status');
if (!isAlive && appState === 'connected') {
setUIState('disconnected');
}
} catch (e) {}
}
function switchScreen(target) {
if (target === 'settings') {
loadConfigText();
homeScreen.classList.remove('active');
settingsScreen.classList.add('active');
} else {
settingsScreen.classList.remove('active');
homeScreen.classList.add('active');
}
}
async function loadConfigText() {
configEditor.value = 'Loading configuration...';
try {
const rawConfig = await invoke('get_config');
configEditor.value = rawConfig;
} catch (err) {
configEditor.value = '// Error loading configuration: ' + err;
}
}
async function handleSaveConfig() {
try {
const val = configEditor.value;
JSON.parse(val); // Validate JSON format first
const success = await invoke('save_config', { jsonContent: val });
if (success) {
showToast();
setTimeout(() => switchScreen('home'), 800);
}
} catch (err) {
alert('Invalid JSON or saving failed: ' + err.message);
}
}
function showToast() {
configToast.classList.add('show');
setTimeout(() => configToast.classList.remove('show'), 2000);
}
// Initialization
window.addEventListener('DOMContentLoaded', async () => {
btnConnect.addEventListener('click', handleToggleConnect);
btnGoSettings.addEventListener('click', () => switchScreen('settings'));
btnBack.addEventListener('click', () => switchScreen('home'));
btnSaveConfig.addEventListener('click', handleSaveConfig);
// Check current status on startup (reconnect UI if process already active)
try {
const isAlive = await invoke('get_tunnel_status');
if (isAlive) {
setUIState('connected');
} else {
setUIState('disconnected');
}
} catch (err) {
setUIState('disconnected');
}
});

485
ostp-gui/src/styles.css Normal file
View File

@ -0,0 +1,485 @@
/* OSTP Mobile Premium Theme */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono&display=swap');
:root {
--bg-gradient-1: #0c0c14;
--bg-gradient-2: #151626;
--accent-primary: #6366f1; /* Indigo */
--accent-glow: rgba(99, 102, 241, 0.4);
--accent-success: #10b981; /* Emerald */
--accent-success-glow: rgba(16, 185, 129, 0.4);
--accent-danger: #ef4444;
--glass-bg: rgba(255, 255, 255, 0.03);
--glass-border: rgba(255, 255, 255, 0.07);
--glass-highlight: rgba(255, 255, 255, 0.1);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
font-family: 'Inter', sans-serif;
color: var(--text-primary);
background: var(--bg-gradient-1);
overflow: hidden;
user-select: none;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at top right, #1c1c36, var(--bg-gradient-1));
}
.app-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
background: var(--bg-gradient-1);
}
/* Mesh Gradient Background Particles */
.mesh-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 0;
overflow: hidden;
}
.mesh-bg::before, .mesh-bg::after {
content: '';
position: absolute;
width: 300px;
height: 300px;
border-radius: 50%;
filter: blur(80px);
opacity: 0.15;
animation: float 20s infinite alternate ease-in-out;
}
.mesh-bg::before {
background: var(--accent-primary);
top: -50px;
left: -50px;
}
.mesh-bg::after {
background: #ec4899; /* Pink */
bottom: -50px;
right: -50px;
animation-delay: -10s;
}
@keyframes float {
0% { transform: translate(0, 0) scale(1); }
100% { transform: translate(80px, 50px) scale(1.2); }
}
.blur-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
backdrop-filter: blur(2px);
z-index: 1;
}
/* Glassmorphism Utility */
.glass {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
/* Screen Management */
.screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
display: flex;
flex-direction: column;
padding: 20px;
pointer-events: none;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.screen.active {
pointer-events: auto;
opacity: 1;
transform: translateY(0);
z-index: 3;
}
/* App Header */
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15px;
margin-bottom: 30px;
z-index: 10;
}
.logo-container {
display: flex;
align-items: center;
gap: 10px;
}
.logo-icon {
width: 12px;
height: 12px;
border-radius: 3px;
background: linear-gradient(135deg, var(--accent-primary), #a78bfa);
box-shadow: 0 0 15px var(--accent-glow);
}
h1 {
font-size: 1.2rem;
font-weight: 700;
letter-spacing: 1px;
}
h2 {
font-size: 1.1rem;
font-weight: 600;
}
/* Buttons */
.icon-btn {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
color: var(--text-secondary);
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
.icon-btn:hover {
background: var(--glass-highlight);
color: var(--text-primary);
transform: translateY(-1px);
}
.icon-btn:active {
transform: translateY(1px);
}
/* Main Content & Central Power Button */
.main-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
padding-bottom: 40px;
}
.power-button-container {
position: relative;
width: 180px;
height: 180px;
display: flex;
align-items: center;
justify-content: center;
}
.power-btn {
width: 140px;
height: 140px;
border-radius: 50%;
border: none;
background: radial-gradient(circle at 30% 30%, #2e304e, #161726);
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.5),
inset 0 1px 1px rgba(255, 255, 255, 0.1);
cursor: pointer;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.power-btn:hover {
transform: scale(1.03);
color: var(--text-primary);
}
.power-btn:active {
transform: scale(0.95);
}
.power-icon {
display: flex;
align-items: center;
justify-content: center;
transition: filter 0.4s ease;
}
/* Active/Connecting/Disconnected styling for power btn */
.power-btn.connected {
background: radial-gradient(circle at 30% 30%, #10b981, #065f46);
color: white;
box-shadow:
0 0 40px var(--accent-success-glow),
0 10px 25px -5px rgba(6, 95, 70, 0.5),
inset 0 1px 1px rgba(255, 255, 255, 0.3);
}
.power-btn.connecting {
background: radial-gradient(circle at 30% 30%, var(--accent-primary), #3730a3);
color: white;
animation: pulse-button 1.5s infinite alternate;
}
@keyframes pulse-button {
0% { box-shadow: 0 0 10px var(--accent-glow); }
100% { box-shadow: 0 0 35px var(--accent-glow); }
}
/* Outer pulsing rings */
.pulse-ring {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
border: 1px solid var(--glass-border);
opacity: 0;
z-index: 1;
}
.power-button-container.connected .pulse-ring {
animation: ripple 3s linear infinite;
border-color: var(--accent-success);
}
.power-button-container.connecting .pulse-ring {
animation: ripple 2s linear infinite;
border-color: var(--accent-primary);
}
.delay-1 {
animation-delay: 1s !important;
}
@keyframes ripple {
0% {
transform: scale(0.7);
opacity: 0.6;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
/* Status display text */
.status-display {
text-align: center;
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
#status-text {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: 0.5px;
transition: color 0.3s ease;
}
.status-disconnected { color: var(--text-secondary); }
.status-connecting { color: var(--accent-primary); }
.status-connected {
color: var(--accent-success);
text-shadow: 0 0 20px var(--accent-success-glow);
}
.subtext {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 300;
}
/* Metrics Panel */
.metrics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
width: 100%;
max-width: 320px;
}
.metric-card {
border-radius: 16px;
padding: 15px;
display: flex;
align-items: center;
gap: 12px;
}
.metric-icon {
width: 32px;
height: 32px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.metric-icon.down {
background: rgba(16, 185, 129, 0.1);
color: var(--accent-success);
}
.metric-icon.up {
background: rgba(99, 102, 241, 0.1);
color: var(--accent-primary);
}
.metric-data {
display: flex;
flex-direction: column;
gap: 2px;
}
.metric-label {
font-size: 0.75rem;
color: var(--text-secondary);
}
.metric-value {
font-size: 0.95rem;
font-weight: 600;
}
/* Settings Editor Section */
.settings-content {
display: flex;
flex-direction: column;
flex: 1;
gap: 20px;
overflow: hidden;
padding-bottom: 20px;
}
.editor-container {
flex: 1;
border-radius: 16px;
padding: 15px;
display: flex;
flex-direction: column;
gap: 10px;
overflow: hidden;
}
.editor-container label {
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: 500;
letter-spacing: 0.5px;
}
textarea {
flex: 1;
width: 100%;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--glass-border);
border-radius: 8px;
padding: 12px;
color: #e2e8f0;
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
line-height: 1.5;
resize: none;
outline: none;
transition: border-color 0.3s;
}
textarea:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.3);
}
.actions-container {
display: flex;
justify-content: center;
}
.primary-btn {
width: 100%;
max-width: 280px;
padding: 14px;
border-radius: 12px;
border: 1px solid var(--accent-primary);
background: linear-gradient(135deg, var(--accent-primary), #4338ca);
color: white;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3);
}
.primary-btn:hover {
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
transform: translateY(-1px);
}
.primary-btn:active {
transform: translateY(1px);
}
/* Toast Notifications */
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: #1e293b;
color: var(--text-primary);
padding: 12px 24px;
border-radius: 50px;
font-size: 0.85rem;
font-weight: 500;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
border: 1px solid var(--glass-border);
pointer-events: none;
opacity: 0;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
z-index: 100;
}
.toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}