This commit is contained in:
2025-12-26 03:37:21 +00:00
commit 1f2ef54e03
13 changed files with 5606 additions and 0 deletions

85
src/config.rs Normal file
View File

@@ -0,0 +1,85 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerSettings {
pub local_port: u16,
pub proxy_type: String, // "SOCKS" or "HTTP"
#[serde(default = "default_enabled")]
pub enabled: bool,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub subscription_url: String,
pub xray_binary_path: String,
#[serde(default)]
pub server_settings: HashMap<String, ServerSettings>,
}
impl Default for Config {
fn default() -> Self {
Config {
subscription_url: String::new(),
xray_binary_path: String::new(),
server_settings: HashMap::new(),
}
}
}
impl Config {
/// Get the config file path in AppData
pub fn get_config_path() -> Result<PathBuf, String> {
// Get AppData\Roaming path
let appdata = std::env::var("APPDATA")
.map_err(|_| "Failed to get APPDATA environment variable".to_string())?;
let mut config_dir = PathBuf::from(appdata);
config_dir.push("win-test-tray");
// Create directory if it doesn't exist
if !config_dir.exists() {
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Failed to create config directory: {}", e))?;
}
config_dir.push("config.json");
Ok(config_dir)
}
/// Load config from AppData
pub fn load() -> Result<Config, String> {
let config_path = Self::get_config_path()?;
if !config_path.exists() {
return Ok(Config::default());
}
let content = fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read config file: {}", e))?;
let config: Config = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse config JSON: {}", e))?;
Ok(config)
}
/// Save config to AppData
pub fn save(&self) -> Result<(), String> {
let config_path = Self::get_config_path()?;
let json = serde_json::to_string_pretty(self)
.map_err(|e| format!("Failed to serialize config: {}", e))?;
fs::write(&config_path, json)
.map_err(|e| format!("Failed to write config file: {}", e))?;
Ok(())
}
}

179
src/main.rs Normal file
View File

@@ -0,0 +1,179 @@
#![windows_subsystem = "windows"] // Commented out for debugging
mod ui;
mod vpn;
mod config;
mod xray_manager;
use tray_icon::menu::{MenuEvent, MenuItem};
use tray_icon::TrayIcon;
use std::sync::{Arc, Mutex, LazyLock};
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(windows)]
use windows::{
Win32::{
Foundation::HWND,
UI::WindowsAndMessaging::*,
},
};
// Global tokio runtime for async operations
pub static TOKIO_RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
tokio::runtime::Runtime::new().expect("Failed to create tokio runtime")
});
// Flag to trigger menu update
pub static MENU_UPDATE_REQUESTED: AtomicBool = AtomicBool::new(false);
/// Request menu update (can be called from any thread)
pub fn request_menu_update() {
MENU_UPDATE_REQUESTED.store(true, Ordering::Relaxed);
}
/// Restart all xray servers based on current config
/// This stops all running servers and starts enabled ones
pub fn restart_xray_servers() {
// Stop all running servers first
TOKIO_RUNTIME.block_on(async {
let _ = xray_manager::stop_all_servers().await;
});
// Load config and start enabled servers
if let Ok(config) = config::Config::load() {
if !config.subscription_url.is_empty() && !config.xray_binary_path.is_empty() {
// Fetch subscription URIs synchronously
let subscription_uris = vpn::fetch_subscription_uris(&config.subscription_url);
let mut servers = vpn::fetch_and_process_vpn_list(&config.subscription_url);
vpn::assign_local_ports(&mut servers, &config.server_settings);
// Update global VPN_SERVERS state
if let Ok(mut global_servers) = vpn::VPN_SERVERS.lock() {
*global_servers = Some(servers.clone());
}
// Start enabled servers
TOKIO_RUNTIME.block_on(async {
for server in servers.iter() {
if server.enabled {
let server_key = server.get_server_key();
if let Some(settings) = config.server_settings.get(&server_key) {
if let Some(uri) = subscription_uris.get(&server_key) {
match xray_manager::start_server(
&server_key,
uri,
settings.local_port,
&settings.proxy_type,
&config.xray_binary_path,
).await {
Ok(_) => println!("Started server: {}", server.name),
Err(e) => eprintln!("Failed to start server {}: {}", server.name, e),
}
}
}
}
}
});
}
}
// Request menu update
request_menu_update();
}
/// Update tray icon menu with current running servers
pub fn update_tray_menu(tray_icon: &mut TrayIcon, settings_item: &MenuItem, quit_item: &MenuItem) {
let new_menu = ui::create_tray_menu_with_servers(settings_item, quit_item);
tray_icon.set_menu(Some(Box::new(new_menu)));
}
fn main() {
// Enable DPI awareness at process start
#[cfg(windows)]
unsafe {
use windows::Win32::UI::HiDpi::SetProcessDpiAwarenessContext;
let _ = SetProcessDpiAwarenessContext(
windows::Win32::UI::HiDpi::DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
);
}
// Auto-start servers on first launch
restart_xray_servers();
// Create menu items
let settings_item = MenuItem::new("Settings", true, None);
let quit_item = MenuItem::new("Exit", true, None);
// Create tray icon with running servers list
let mut tray_icon = ui::create_tray_icon_with_servers(&settings_item, &quit_item);
// Event handling
let menu_channel = MenuEvent::receiver();
// Shared state for settings window
#[cfg(windows)]
let settings_window: Arc<Mutex<Option<HWND>>> = Arc::new(Mutex::new(None));
// Windows message loop
#[cfg(windows)]
{
let settings_window_clone = settings_window.clone();
unsafe {
let mut msg = MSG::default();
// Process Windows messages
loop {
// Check if menu update requested
if MENU_UPDATE_REQUESTED.load(Ordering::Relaxed) {
update_tray_menu(&mut tray_icon, &settings_item, &quit_item);
MENU_UPDATE_REQUESTED.store(false, Ordering::Relaxed);
}
// Check for menu events first
if let Ok(event) = menu_channel.try_recv() {
if event.id == settings_item.id() {
// Open or focus settings window
let mut window = settings_window_clone.lock().unwrap();
if let Some(hwnd) = *window {
// Window already exists, bring it to front
if IsWindow(hwnd).as_bool() {
let _ = ShowWindow(hwnd, SW_RESTORE);
let _ = SetForegroundWindow(hwnd);
} else {
// Window was closed, create new one
*window = Some(ui::create_settings_window());
}
} else {
// Create new settings window
*window = Some(ui::create_settings_window());
}
} else if event.id == quit_item.id() {
// Stop all xray processes before exit
TOKIO_RUNTIME.block_on(async {
let _ = xray_manager::stop_all_servers().await;
});
break;
}
}
// Check if we need to update tray menu (poll for changes)
// This is not ideal but tray-icon doesn't support callbacks
// In a real app, you'd use a channel or event system
// Process Windows messages
let result = GetMessageW(&mut msg, None, 0, 0);
if result.0 == 0 {
// WM_QUIT received
break;
}
if result.0 > 0 {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
}
}
}

5
src/ui/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod tray;
pub mod settings_window;
pub use tray::{create_tray_icon_with_servers, create_tray_menu_with_servers};
pub use settings_window::create_settings_window;

1227
src/ui/settings_window.rs Normal file

File diff suppressed because it is too large Load Diff

143
src/ui/tray.rs Normal file
View File

@@ -0,0 +1,143 @@
use tray_icon::{
menu::{Menu, MenuItem, PredefinedMenuItem},
TrayIconBuilder,
};
pub fn create_tray_menu_with_servers(
settings_item: &MenuItem,
quit_item: &MenuItem,
) -> Menu {
// Create tray menu
let tray_menu = Menu::new();
// Add running servers section
let running_servers = crate::xray_manager::get_running_servers();
if !running_servers.is_empty() {
// Get server names from global VPN_SERVERS
if let Ok(global_servers) = crate::vpn::VPN_SERVERS.lock() {
if let Some(servers) = global_servers.as_ref() {
for server in servers {
let server_key = server.get_server_key();
if running_servers.contains(&server_key) {
let status_text = format!("{} ({}:{})", server.name, server.proxy_type, server.local_port);
let server_item = MenuItem::new(status_text, false, None);
tray_menu.append(&server_item).unwrap();
}
}
}
}
tray_menu.append(&PredefinedMenuItem::separator()).unwrap();
}
// Append settings and quit items
tray_menu.append_items(&[
settings_item,
&PredefinedMenuItem::separator(),
quit_item,
]).unwrap();
tray_menu
}
pub fn create_tray_icon_with_servers(
settings_item: &MenuItem,
quit_item: &MenuItem,
) -> tray_icon::TrayIcon {
// Create menu
let tray_menu = create_tray_menu_with_servers(settings_item, quit_item);
// Create icon (32x32 red square)
let icon = create_icon();
// Create tray icon with context menu
TrayIconBuilder::new()
.with_menu(Box::new(tray_menu))
.with_tooltip("VPN Manager")
.with_icon(icon)
.build()
.unwrap()
}
pub fn create_tray_icon(
settings_item: &MenuItem,
quit_item: &MenuItem,
) -> tray_icon::TrayIcon {
// Create tray menu
let tray_menu = Menu::new();
// Append items to menu
tray_menu.append_items(&[
settings_item,
&PredefinedMenuItem::separator(),
quit_item,
]).unwrap();
// Create icon (32x32 red square)
let icon = create_icon();
// Create tray icon with context menu
TrayIconBuilder::new()
.with_menu(Box::new(tray_menu))
.with_tooltip("VPN Manager")
.with_icon(icon)
.build()
.unwrap()
}
fn create_icon() -> tray_icon::Icon {
// Create yellow star icon 32x32
let width = 32;
let height = 32;
let mut rgba = Vec::with_capacity((width * height * 4) as usize);
// Define colors
let bg = [0, 0, 0, 0]; // Transparent background
let star = [255, 215, 0, 255]; // Gold/Yellow
let border = [218, 165, 32, 255]; // Darker gold
let cx = 16.0;
let cy = 16.0;
for y in 0..height {
for x in 0..width {
let px = x as f32;
let py = y as f32;
// Calculate angle and distance from center
let dx = px - cx;
let dy = py - cy;
let angle = dy.atan2(dx);
let dist = (dx * dx + dy * dy).sqrt();
// 5-pointed star calculation
// Star has 5 points, so we check angle modulo (2π/5)
let point_angle = (angle + std::f32::consts::PI * 2.5) % (std::f32::consts::PI * 2.0 / 5.0);
let normalized = point_angle / (std::f32::consts::PI * 2.0 / 5.0);
// Star shape: outer radius at points, inner radius between points
let outer_radius = 12.0;
let inner_radius = 5.0;
// Calculate radius at this angle (sine wave between inner and outer)
let target_radius = if normalized < 0.5 {
inner_radius + (outer_radius - inner_radius) * (normalized * 2.0)
} else {
outer_radius - (outer_radius - inner_radius) * ((normalized - 0.5) * 2.0)
};
// Check if point is inside star
let is_star = dist <= target_radius;
let is_border = dist <= target_radius + 0.8 && dist > target_radius - 0.5;
if is_star && !is_border {
rgba.extend_from_slice(&star);
} else if is_border {
rgba.extend_from_slice(&border);
} else {
rgba.extend_from_slice(&bg);
}
}
}
tray_icon::Icon::from_rgba(rgba, width, height).expect("Failed to create icon")
}

169
src/vpn/mod.rs Normal file
View File

@@ -0,0 +1,169 @@
use std::sync::Mutex;
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
// Global state for VPN servers
pub static VPN_SERVERS: Mutex<Option<Vec<VpnServer>>> = Mutex::new(None);
// VPN server information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VpnServer {
pub protocol: String,
pub address: String,
pub port: u16,
pub name: String,
pub enabled: bool,
pub local_port: u16, // User-defined local port
pub proxy_type: String, // "HTTP" or "SOCKS"
}
impl VpnServer {
/// Get unique server key for stable identification
pub fn get_server_key(&self) -> String {
format!("{}://{}:{}", self.protocol, self.address, self.port)
}
}
// Assign local ports to servers, preserving saved settings from config
pub fn assign_local_ports(servers: &mut [VpnServer], saved_settings: &std::collections::HashMap<String, crate::config::ServerSettings>) {
let mut used_ports = HashSet::new();
// First pass: assign saved settings (port + proxy type + enabled)
for server in servers.iter_mut() {
let key = server.get_server_key();
if let Some(settings) = saved_settings.get(&key) {
server.local_port = settings.local_port;
server.proxy_type = settings.proxy_type.clone();
server.enabled = settings.enabled;
used_ports.insert(settings.local_port);
}
}
// Second pass: assign new ports to servers without saved settings
let mut next_port = 1080;
for server in servers.iter_mut() {
if server.local_port == 0 { // Not assigned yet
while used_ports.contains(&next_port) {
next_port += 1;
}
server.local_port = next_port;
used_ports.insert(next_port);
next_port += 1;
}
}
}
// Fetch and process VPN subscription list
pub fn fetch_and_process_vpn_list(url: &str) -> Vec<VpnServer> {
let mut servers = Vec::new();
// Fetch content from URL
match reqwest::blocking::get(url) {
Ok(response) => {
match response.text() {
Ok(content) => {
// Decode from base64
match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, content.trim()) {
Ok(decoded_bytes) => {
match String::from_utf8(decoded_bytes) {
Ok(decoded_text) => {
// Parse VPN links
for line in decoded_text.lines() {
let trimmed = line.trim();
if let Some(server) = parse_vpn_uri(trimmed) {
servers.push(server);
}
}
}
Err(_) => {}
}
}
Err(_) => {}
}
}
Err(_) => {}
}
}
Err(_) => {}
}
servers
}
// Fetch subscription and return HashMap of server_key -> original_uri
pub fn fetch_subscription_uris(url: &str) -> std::collections::HashMap<String, String> {
let mut uris = std::collections::HashMap::new();
// Fetch content from URL
match reqwest::blocking::get(url) {
Ok(response) => {
match response.text() {
Ok(content) => {
// Decode from base64
match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, content.trim()) {
Ok(decoded_bytes) => {
match String::from_utf8(decoded_bytes) {
Ok(decoded_text) => {
// Parse VPN links
for line in decoded_text.lines() {
let trimmed = line.trim();
if let Some(server) = parse_vpn_uri(trimmed) {
let key = server.get_server_key();
uris.insert(key, trimmed.to_string());
}
}
}
Err(_) => {}
}
}
Err(_) => {}
}
}
Err(_) => {}
}
}
Err(_) => {}
}
uris
}
// Parse VPN URI using v2parser (supports vless, vmess, trojan, shadowsocks, socks)
fn parse_vpn_uri(uri: &str) -> Option<VpnServer> {
// Check if it's a supported protocol
let is_supported = uri.starts_with("vless://")
|| uri.starts_with("vmess://")
|| uri.starts_with("trojan://")
|| uri.starts_with("ss://")
|| uri.starts_with("shadowsocks://")
|| uri.starts_with("socks://");
if !is_supported {
return None;
}
// Use v2parser to get metadata
match std::panic::catch_unwind(|| v2parser::parser::get_metadata(uri)) {
Ok(metadata_json) => {
if let Ok(metadata) = serde_json::from_str::<serde_json::Value>(&metadata_json) {
let protocol = metadata["protocol"].as_str()?.to_uppercase();
let address = metadata["address"].as_str()?.to_string();
let port = metadata["port"].as_u64()? as u16;
let name = metadata["name"].as_str().unwrap_or("Unnamed").to_string();
Some(VpnServer {
protocol,
address,
port,
name,
enabled: false, // Default to disabled, will be enabled from config
local_port: 0, // Will be assigned by assign_local_ports
proxy_type: "SOCKS".to_string(), // Default to SOCKS
})
} else {
None
}
}
Err(_) => None,
}
}

72
src/xray_manager.rs Normal file
View File

@@ -0,0 +1,72 @@
use std::collections::HashMap;
use std::sync::{Mutex, LazyLock};
use v2parser::xray_runner::XrayRunner;
use v2parser::parser;
// Global state for running xray processes
pub static XRAY_PROCESSES: LazyLock<Mutex<HashMap<String, XrayRunner>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Start xray server for a specific VPN server
/// Returns Ok if successful
pub async fn start_server(
server_key: &str,
uri: &str,
local_port: u16,
proxy_type: &str,
xray_binary_path: &str,
) -> Result<(), String> {
// Determine ports based on proxy type
let (socks_port, http_port) = match proxy_type {
"SOCKS" => (Some(local_port), None),
"HTTP" => (None, Some(local_port)),
_ => (Some(local_port), None), // Default to SOCKS
};
// Generate xray config from URI
let config_json = parser::create_json_config(uri, socks_port, http_port);
// Create and start xray runner
let mut runner = XrayRunner::new();
runner.start(&config_json, xray_binary_path)
.await
.map_err(|e| format!("Failed to start xray: {}", e))?;
// Store runner in global state
if let Ok(mut processes) = XRAY_PROCESSES.lock() {
processes.insert(server_key.to_string(), runner);
}
Ok(())
}
/// Stop xray server for a specific server
pub async fn stop_server(server_key: &str) -> Result<(), String> {
if let Ok(mut processes) = XRAY_PROCESSES.lock() {
if let Some(mut runner) = processes.remove(server_key) {
runner.stop()
.await
.map_err(|e| format!("Failed to stop xray: {}", e))?;
}
}
Ok(())
}
/// Stop all running xray servers
pub async fn stop_all_servers() -> Result<(), String> {
if let Ok(mut processes) = XRAY_PROCESSES.lock() {
for (_key, mut runner) in processes.drain() {
let _ = runner.stop().await; // Ignore errors during bulk shutdown
}
}
Ok(())
}
/// Get list of running server keys
pub fn get_running_servers() -> Vec<String> {
if let Ok(processes) = XRAY_PROCESSES.lock() {
processes.keys().cloned().collect()
} else {
Vec::new()
}
}