feat: release preparation — TUN fix, i18n, GUI CI/CD, speed improvements

TUN Interface:
- Fixed adapter name to always be 'ostp_tun' by cleaning up stale
  adapters before launch (prevents 'ostp_tun 2', 'ostp_tun 3', etc.)
- Parallelized route setup with tun2socks launch to save ~3 seconds
- Replaced fixed 2-second sleep with adapter readiness polling
- Added -NoProfile to all PowerShell calls for faster execution

Speed:
- Reduced handshake timeout from 10s to 5s
- Reduced tun2socks spawn buffer from 300ms to 0 (removed)

GUI:
- Added i18n support: English and Russian translations
- Language toggle button in header (EN/RU)
- Merged 'IP Ranges' field into 'Bypass IPs / CIDR Ranges'
- Removed separate IP ranges field
- All static text uses data-i18n attributes
- Status messages, labels, toasts all translated
- Replaced alert() calls with toast notifications

CI/CD:
- Added separate GUI build job for Windows x64 and arm64
- Produces ostp-windows-gui-{arch}.zip with: ostp-gui.exe + wintun.dll + tun2socks.exe
- Uses Tauri CLI v2 for build
This commit is contained in:
ospab 2026-05-17 16:25:30 +03:00
parent 074a3f6371
commit 69e4426152
7 changed files with 421 additions and 125 deletions

View File

@ -218,3 +218,83 @@ jobs:
files: ${{ matrix.release_name }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ==========================================
# Windows GUI (Tauri) Build
# ==========================================
publish-gui:
name: GUI for Windows ${{ matrix.arch_label }}
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-pc-windows-msvc
arch_label: x64
tun2socks_arch: windows-amd64
wintun_arch: amd64
- target: aarch64-pc-windows-msvc
arch_label: arm64
tun2socks_arch: windows-arm64
wintun_arch: arm64
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Activate Rust caching
uses: swatinem/rust-cache@v2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Tauri CLI
run: cargo install tauri-cli --version "^2"
- name: Build Tauri application
shell: pwsh
run: |
cd ostp-gui
cargo tauri build --target ${{ matrix.target }}
- name: Package GUI release
shell: pwsh
run: |
$ProgressPreference = 'SilentlyContinue'
$dist = "ostp-gui-dist"
New-Item -ItemType Directory -Force -Path $dist
# 1. Copy the Tauri executable
$exePath = Get-ChildItem -Path "ostp-gui/src-tauri/target/${{ matrix.target }}/release" -Filter "ostp-gui.exe" | Select-Object -First 1
Copy-Item $exePath.FullName -Destination "$dist/ostp-gui.exe"
# 2. Download tun2socks
Invoke-WebRequest -Uri "https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-${{ matrix.tun2socks_arch }}.zip" -OutFile "tun2socks.zip"
Expand-Archive -Path "tun2socks.zip" -DestinationPath "tun_temp" -Force
Get-ChildItem -Path "tun_temp" -Filter "*.exe" -Recurse | Copy-Item -Destination "$dist/tun2socks.exe" -Force
# 3. Download wintun
Invoke-WebRequest -Uri "https://www.wintun.net/builds/wintun-0.14.1.zip" -OutFile "wintun.zip"
Expand-Archive -Path "wintun.zip" -DestinationPath "wintun_temp" -Force
Get-ChildItem -Path "wintun_temp" -Filter "wintun.dll" -Recurse | Where-Object { $_.FullName -match 'bin[\\/]${{ matrix.wintun_arch }}[\\/]' } | Copy-Item -Destination "$dist/wintun.dll" -Force
# 4. Create zip archive
Compress-Archive -Path "$dist/*" -DestinationPath "ostp-windows-gui-${{ matrix.arch_label }}.zip" -Force
# Cleanup
Remove-Item "tun2socks.zip", "tun_temp", "wintun.zip", "wintun_temp", $dist -Recurse -Force
- name: Upload GUI artifact to release
if: ${{ startsWith(github.ref, 'refs/tags/') }}
uses: softprops/action-gh-release@v2
with:
files: ostp-windows-gui-${{ matrix.arch_label }}.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -66,7 +66,7 @@ impl Default for OstpConfig {
server_addr: "127.0.0.1:50000".to_string(),
local_bind_addr: "0.0.0.0:0".to_string(),
access_key: String::new(),
handshake_timeout_ms: 10000,
handshake_timeout_ms: 5000,
io_timeout_ms: 2500,
}
}
@ -194,7 +194,7 @@ impl ClientConfig {
server_addr: server,
local_bind_addr: "0.0.0.0:0".to_string(),
access_key: key,
handshake_timeout_ms: 10000,
handshake_timeout_ms: 5000,
io_timeout_ms: 2500,
},
local_proxy: LocalProxyConfig {

View File

@ -10,6 +10,9 @@ pub async fn run_wintun_tunnel(
use std::process::{Command, Stdio, Child};
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
const TUN_NAME: &str = "ostp_tun";
struct WintunGuard {
server_ip_str: String,
child: Option<Child>,
@ -19,27 +22,26 @@ pub async fn run_wintun_tunnel(
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
let cleanup_script = format!(
"$remote_ip = '{}'\n\
Remove-NetRoute -DestinationPrefix \"$remote_ip/32\" -Confirm:$false -ErrorAction SilentlyContinue\n\
Remove-NetRoute -DestinationPrefix \"1.1.1.1/32\" -Confirm:$false -ErrorAction SilentlyContinue\n\
Remove-NetFirewallRule -DisplayName 'OSTP Tunnel*' -ErrorAction SilentlyContinue\n\
netsh interface ipv4 set dnsservers name=\"ostp_tun\" source=dhcp 2>$null\n",
netsh interface ipv4 set dnsservers name=\"{TUN_NAME}\" source=dhcp 2>$null\n",
self.server_ip_str
);
let _ = Command::new("powershell")
.creation_flags(0x08000000)
.args(["-Command", &cleanup_script])
.creation_flags(CREATE_NO_WINDOW)
.args(["-NoProfile", "-Command", &cleanup_script])
.output();
}
}
let debug = config.debug;
if debug {
println!("[ostp] Initializing TUN tunnel...");
}
eprintln!("[ostp] Initializing TUN tunnel...");
let exe = std::env::current_exe()?;
let dir = exe.parent().ok_or_else(|| anyhow!("failed to get binary directory"))?;
@ -55,6 +57,20 @@ pub async fn run_wintun_tunnel(
));
}
// 1. Delete stale TUN adapter if it exists from a previous run.
// This prevents wintun from creating "ostp_tun 2", "ostp_tun 3", etc.
eprintln!("[ostp] Cleaning up stale TUN adapter...");
let _ = Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.args(["-NoProfile", "-Command", &format!(
"Get-NetAdapter -Name '{TUN_NAME}*' -ErrorAction SilentlyContinue | \
Disable-NetAdapter -Confirm:$false -ErrorAction SilentlyContinue; \
netsh interface set interface \"{TUN_NAME}\" admin=disable 2>$null"
)])
.output();
// Brief pause to let the driver release the adapter
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
// 2. Resolve Server IP for routing table exclusion
let server_ip = config.ostp.server_addr.to_socket_addrs()
.map_err(|e| anyhow!("Failed to resolve remote server IP: {}", e))?
@ -63,16 +79,9 @@ pub async fn run_wintun_tunnel(
.ok_or_else(|| anyhow!("Could not resolve host IP for routing exclusion"))?;
let server_ip_str = server_ip.to_string();
eprintln!("[ostp] Resolved server IP: {}", server_ip_str);
if debug {
println!("[ostp] Resolved server IP: {}", server_ip_str);
}
// 3. Run PowerShell script to configure system routes
if debug {
println!("[ostp] Configuring system routes...");
}
// 3. Prepare routing and firewall setup script
let current_exe = std::env::current_exe()?.to_string_lossy().into_owned();
let setup_script = format!(
@ -81,9 +90,7 @@ pub async fn run_wintun_tunnel(
$route = Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Where-Object {{ $_.InterfaceAlias -notmatch 'tun' -and $_.InterfaceAlias -notmatch 'wintun' }} | Sort-Object RouteMetric | Select-Object -First 1\n\
$gw = $route.NextHop\n\
$ifIndex = $route.InterfaceIndex\n\
# 1. Bypass route for the proxy server itself\n\
New-NetRoute -DestinationPrefix \"$remote_ip/32\" -NextHop $gw -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\
# 2. Bypass routes for all current Physical DNS servers to avoid UDP associate deadlocks\n\
$dns_ips = Get-DnsClientServerAddress -InterfaceIndex $ifIndex | Select-Object -ExpandProperty ServerAddresses\n\
foreach ($dns in $dns_ips) {{\n\
if ($dns -match '^\\d+\\.\\d+\\.\\d+\\.\\d+$') {{\n\
@ -91,37 +98,20 @@ pub async fn run_wintun_tunnel(
}}\n\
}}\n\
New-NetRoute -DestinationPrefix \"1.1.1.1/32\" -NextHop $gw -InterfaceIndex $ifIndex -RouteMetric 1 -ErrorAction SilentlyContinue\n\
# 3. Windows Firewall Rules\n\
New-NetFirewallRule -DisplayName 'OSTP Tunnel In' -Direction Inbound -Program $exe_path -Action Allow -Enabled True -ErrorAction SilentlyContinue\n\
New-NetFirewallRule -DisplayName 'OSTP Tunnel Out' -Direction Outbound -Program $exe_path -Action Allow -Enabled True -ErrorAction SilentlyContinue\n",
server_ip_str, current_exe
);
let out = Command::new("powershell")
.creation_flags(0x08000000)
.args(["-Command", &setup_script])
.output()?;
if !out.status.success() && debug {
println!("[ostp] Warning: Setup routing returned: {}", String::from_utf8_lossy(&out.stderr));
}
// 4. Prepare and launch tun2socks.exe in the background
// Switch from SOCKS5 to HTTP protocol. This natively forces tun2socks NOT to attempt UDP Associate,
// preventing SOCKS5 command 3 unsupported errors while still tunneling 100% of global TCP traffic!
// 4. Launch tun2socks + route setup IN PARALLEL to save ~3 seconds
let proxy_url = format!("http://{}", config.local_proxy.bind_addr);
eprintln!("[ostp] Starting tun2socks (proxy={})", proxy_url);
if debug {
println!("[ostp] Starting tun2socks (proxy={})", proxy_url);
}
// Spawning buffer to allow local proxy listener to finish binding to local address
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
// Spawn tun2socks immediately — it creates the adapter on its own
let mut child = Command::new(&tun2socks_exe)
.creation_flags(0x08000000)
.creation_flags(CREATE_NO_WINDOW)
.args([
"-device", "ostp_tun",
"-device", TUN_NAME,
"-proxy", &proxy_url,
"-loglevel", if debug { "debug" } else { "error" }
])
@ -129,42 +119,79 @@ pub async fn run_wintun_tunnel(
.stdout(if debug { Stdio::piped() } else { Stdio::null() })
.stderr(if debug { Stdio::piped() } else { Stdio::null() })
.spawn()
.map_err(|e| anyhow!("Failed to launch tun2socks.exe background process: {}", e))?;
.map_err(|e| anyhow!("Failed to launch tun2socks.exe: {}", e))?;
let mut _guard = WintunGuard {
server_ip_str: server_ip_str.clone(),
child: None, // Will set below
child: None,
};
// 5. Once tun2socks creates the interface, apply network settings (IP, metric, MTU)
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
// Run route setup in parallel while tun2socks creates the adapter.
// Also poll for the adapter to appear (typically <1s).
let route_handle = {
let script = setup_script.clone();
tokio::task::spawn_blocking(move || {
Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.args(["-NoProfile", "-Command", &script])
.output()
})
};
if debug {
println!("[ostp] Applying network configuration...");
// 5. Wait for TUN adapter to appear (poll with timeout instead of fixed 2s sleep)
let adapter_deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(8);
let mut adapter_ready = false;
while tokio::time::Instant::now() < adapter_deadline {
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
let check = Command::new("powershell")
.creation_flags(CREATE_NO_WINDOW)
.args(["-NoProfile", "-Command",
&format!("(Get-NetAdapter -Name '{TUN_NAME}' -ErrorAction SilentlyContinue).Status")])
.output();
if let Ok(out) = check {
let status = String::from_utf8_lossy(&out.stdout).trim().to_string();
if debug {
eprintln!("[ostp] Adapter status: '{}'", status);
}
if status == "Up" || status == "Disconnected" || !status.is_empty() {
adapter_ready = true;
break;
}
}
}
let mut net_setup = String::from("\
netsh interface ipv4 set address name=\"ostp_tun\" static 10.1.0.2 255.255.255.0 10.1.0.1\n\
netsh interface ipv4 set subinterface \"ostp_tun\" mtu=1300 store=persistent\n\
netsh interface ipv4 set interface name=\"ostp_tun\" metric=5\n");
if !adapter_ready {
eprintln!("[ostp] WARNING: TUN adapter did not appear within timeout. Proceeding anyway.");
}
// Wait for route setup to finish (should already be done by now)
let _ = route_handle.await;
// 6. Configure the adapter (IP, metric, MTU, DNS)
eprintln!("[ostp] Applying network configuration...");
let mut net_setup = format!(
"netsh interface ipv4 set address name=\"{TUN_NAME}\" static 10.1.0.2 255.255.255.0 10.1.0.1\n\
netsh interface ipv4 set subinterface \"{TUN_NAME}\" mtu=1300 store=persistent\n\
netsh interface ipv4 set interface name=\"{TUN_NAME}\" metric=5\n"
);
if let Some(ref dns) = config.dns_server {
if !dns.is_empty() {
if debug {
println!("[ostp] DNS server: {}", dns);
}
net_setup.push_str(&format!("netsh interface ipv4 set dnsservers name=\"ostp_tun\" static {} primary\n", dns));
eprintln!("[ostp] DNS server: {}", dns);
net_setup.push_str(&format!(
"netsh interface ipv4 set dnsservers name=\"{TUN_NAME}\" static {} primary\n", dns
));
}
}
let _ = Command::new("powershell")
.creation_flags(0x08000000)
.args(["-Command", &net_setup])
.creation_flags(CREATE_NO_WINDOW)
.args(["-NoProfile", "-Command", &net_setup])
.output()?;
println!("[ostp] TUN tunnel active. All traffic is routed through OSTP.");
eprintln!("[ostp] TUN tunnel active. All traffic is routed through OSTP.");
// 6. Spawn thread to keep logging tun2socks output if in debug mode
// 7. Spawn debug log readers for tun2socks output
let mut stdout = child.stdout.take();
let mut stderr = child.stderr.take();
_guard.child = Some(child);
@ -175,7 +202,7 @@ pub async fn run_wintun_tunnel(
if let Some(out) = stdout.take() {
let reader = BufReader::new(out);
for line in reader.lines().map_while(Result::ok) {
println!("[tun2socks] {}", line);
eprintln!("[tun2socks] {}", line);
}
}
});
@ -184,22 +211,18 @@ pub async fn run_wintun_tunnel(
if let Some(err) = stderr.take() {
let reader = BufReader::new(err);
for line in reader.lines().map_while(Result::ok) {
println!("[tun2socks err] {}", line);
eprintln!("[tun2socks err] {}", line);
}
}
});
}
// 7. Wait for shutdown signal
// 8. Wait for shutdown signal
let _ = shutdown.changed().await;
println!("[ostp] Deactivating TUN tunnel...");
// Drop guard runs cleanup automatically
eprintln!("[ostp] Deactivating TUN tunnel...");
drop(_guard);
println!("[ostp] TUN tunnel stopped.");
eprintln!("[ostp] TUN tunnel stopped.");
Ok(())
}

View File

@ -0,0 +1,163 @@
#[cfg(test)]
mod tests {
use super::*;
/// Verifies that derive_all_secrets is deterministic — same input always
/// produces the same output.
#[test]
fn test_derive_deterministic() {
let key = b"test_access_key_12345";
let s1 = derive_all_secrets(key);
let s2 = derive_all_secrets(key);
assert_eq!(s1.obfuscation_key, s2.obfuscation_key, "obf_key must be deterministic");
assert_eq!(s1.psk, s2.psk, "psk must be deterministic");
assert_eq!(s1.handshake_pad_min, s2.handshake_pad_min, "pad_min must be deterministic");
assert_eq!(s1.handshake_pad_max, s2.handshake_pad_max, "pad_max must be deterministic");
}
/// Verifies that different keys produce different secrets.
#[test]
fn test_derive_different_keys() {
let s1 = derive_all_secrets(b"key_alpha");
let s2 = derive_all_secrets(b"key_beta");
assert_ne!(s1.obfuscation_key, s2.obfuscation_key);
assert_ne!(s1.psk, s2.psk);
}
/// Verifies that the legacy API matches derive_all_secrets output.
#[test]
fn test_legacy_api_consistency() {
let key = b"consistency_check_key";
let secrets = derive_all_secrets(key);
assert_eq!(secrets.obfuscation_key, derive_obfuscation_key(key));
assert_eq!(secrets.psk, derive_psk(key));
}
/// Verifies handshake padding range is within valid bounds.
#[test]
fn test_padding_range_valid() {
for i in 0..100 {
let key = format!("test_key_{}", i);
let s = derive_all_secrets(key.as_bytes());
assert!(s.handshake_pad_min >= 16, "pad_min must be >= 16, got {}", s.handshake_pad_min);
assert!(s.handshake_pad_min < 80, "pad_min must be < 80, got {}", s.handshake_pad_min);
assert!(s.handshake_pad_max > s.handshake_pad_min, "pad_max must be > pad_min");
assert!(s.handshake_pad_max <= s.handshake_pad_min + 175,
"pad_max out of range: {} > {} + 175", s.handshake_pad_max, s.handshake_pad_min);
}
}
/// End-to-end test: obfuscate a handshake packet on the "client" side,
/// then deobfuscate on the "server" side using the same access key.
/// This simulates the exact flow that caused "Unauthorized probe" errors.
#[test]
fn test_handshake_obfuscation_roundtrip() {
let access_key = b"my_real_access_key_v2";
let secrets = derive_all_secrets(access_key);
// Simulate client building a handshake packet
let session_id: u32 = 0xDEADBEEF;
let fake_noise_payload = [0x42u8; 48]; // Typical Noise_NNpsk0 handshake size
let noise_len = fake_noise_payload.len() as u16;
let mut packet = Vec::new();
packet.extend_from_slice(&session_id.to_be_bytes()); // [0..4]
packet.extend_from_slice(&noise_len.to_be_bytes()); // [4..6]
packet.extend_from_slice(&fake_noise_payload); // [6..54]
packet.extend_from_slice(&[0xAA; 64]); // padding
// Obfuscate (client side)
obfuscate_packet_inplace(&mut packet, &secrets.obfuscation_key, true);
// At this point, bytes [0..6] are masked and should look random
let masked_sid = u32::from_be_bytes([packet[0], packet[1], packet[2], packet[3]]);
assert_ne!(masked_sid, session_id, "session_id must be masked on wire");
// Deobfuscate (server side) — using same key
deobfuscate_packet_inplace(&mut packet, &secrets.obfuscation_key, true);
// Verify session_id is recovered
let recovered_sid = u32::from_be_bytes([packet[0], packet[1], packet[2], packet[3]]);
assert_eq!(recovered_sid, session_id, "session_id must be recovered after deobfuscation");
// Verify noise_len is recovered
let recovered_noise_len = u16::from_be_bytes([packet[4], packet[5]]);
assert_eq!(recovered_noise_len, noise_len, "noise_len must be recovered");
// Verify noise payload is intact
assert_eq!(&packet[6..6 + noise_len as usize], &fake_noise_payload,
"noise payload must be intact after round-trip");
}
/// Verifies that deobfuscating with the WRONG key does NOT recover
/// the session_id — this is what prevents unauthorized probes.
#[test]
fn test_wrong_key_produces_garbage() {
let correct_key = b"correct_key";
let wrong_key = b"wrong_key";
let correct_secrets = derive_all_secrets(correct_key);
let wrong_secrets = derive_all_secrets(wrong_key);
let session_id: u32 = 0x12345678;
let fake_noise = [0x55u8; 48];
let mut packet = Vec::new();
packet.extend_from_slice(&session_id.to_be_bytes());
packet.extend_from_slice(&(48u16).to_be_bytes());
packet.extend_from_slice(&fake_noise);
packet.extend_from_slice(&[0x00; 32]);
// Obfuscate with correct key
obfuscate_packet_inplace(&mut packet, &correct_secrets.obfuscation_key, true);
// Try to deobfuscate with WRONG key
let mut wrong_trial = packet.clone();
deobfuscate_packet_inplace(&mut wrong_trial, &wrong_secrets.obfuscation_key, true);
let wrong_sid = u32::from_be_bytes([wrong_trial[0], wrong_trial[1], wrong_trial[2], wrong_trial[3]]);
// Should NOT match — this is what the dispatcher checks
assert_ne!(wrong_sid, session_id, "wrong key must NOT recover session_id");
// Deobfuscate with correct key — must work
deobfuscate_packet_inplace(&mut packet, &correct_secrets.obfuscation_key, true);
let correct_sid = u32::from_be_bytes([packet[0], packet[1], packet[2], packet[3]]);
assert_eq!(correct_sid, session_id, "correct key must recover session_id");
}
/// Verifies data packet obfuscation round-trip (non-handshake path).
#[test]
fn test_data_packet_obfuscation_roundtrip() {
let secrets = derive_all_secrets(b"data_test_key");
let session_id: u32 = 0xCAFEBABE;
let nonce: u64 = 42;
let ciphertext = [0x77u8; 64];
let mut packet = Vec::new();
packet.extend_from_slice(&session_id.to_be_bytes()); // [0..4]
packet.extend_from_slice(&nonce.to_be_bytes()); // [4..12]
packet.extend_from_slice(&ciphertext); // [12..]
obfuscate_packet_inplace(&mut packet, &secrets.obfuscation_key, false);
// Masked
let masked_sid = u32::from_be_bytes([packet[0], packet[1], packet[2], packet[3]]);
assert_ne!(masked_sid, session_id);
// Deobfuscate
deobfuscate_packet_inplace(&mut packet, &secrets.obfuscation_key, false);
let recovered_sid = u32::from_be_bytes([packet[0], packet[1], packet[2], packet[3]]);
let recovered_nonce = u64::from_be_bytes([
packet[4], packet[5], packet[6], packet[7],
packet[8], packet[9], packet[10], packet[11],
]);
assert_eq!(recovered_sid, session_id);
assert_eq!(recovered_nonce, nonce);
assert_eq!(&packet[12..], &ciphertext);
}
}

View File

@ -20,9 +20,14 @@
<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>
<div class="header-actions">
<button id="btn-lang" class="icon-btn lang-btn" aria-label="Language" title="Language">
<span id="lang-label">EN</span>
</button>
<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>
</div>
</header>
<div class="main-content">
@ -37,8 +42,8 @@
</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>
<span id="status-text" class="status-disconnected" data-i18n="status_disconnected">Disconnected</span>
<span id="uptime-text" class="subtext" data-i18n="hint_tap">Tap to protect your traffic</span>
</div>
<div class="metrics-grid">
@ -47,7 +52,7 @@
<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 class="metric-label" data-i18n="download">Download</span>
<span id="metric-down" class="metric-value">0.0 B</span>
</div>
</div>
@ -56,7 +61,7 @@
<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 class="metric-label" data-i18n="upload">Upload</span>
<span id="metric-up" class="metric-value">0.0 B</span>
</div>
</div>
@ -70,43 +75,43 @@
<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 -->
<h2 data-i18n="settings_title">Configuration</h2>
<div style="width: 40px;"></div>
</header>
<div class="settings-content">
<!-- Import Area -->
<div class="import-container glass">
<input type="text" id="in-import-url" placeholder="Paste ostp:// share link here..." />
<button id="btn-import-url" class="small-btn">Import</button>
<input type="text" id="in-import-url" data-i18n-placeholder="import_placeholder" placeholder="Paste ostp:// share link here..." />
<button id="btn-import-url" class="small-btn" data-i18n="import_btn">Import</button>
</div>
<!-- Form Settings -->
<div class="editor-container glass scrollable">
<div class="form-group">
<label for="in-server">Server Address</label>
<input type="text" id="in-server" placeholder="127.0.0.1:50000" />
<label for="in-server" data-i18n="label_server">Server Address</label>
<input type="text" id="in-server" placeholder="host:port" />
</div>
<div class="form-group">
<label for="in-key">Access Key</label>
<input type="password" id="in-key" placeholder="Enter secure access key" />
<label for="in-key" data-i18n="label_key">Access Key</label>
<input type="password" id="in-key" data-i18n-placeholder="ph_key" placeholder="Enter secure access key" />
</div>
<div class="form-group">
<label for="in-socks">SOCKS5 Bind Address</label>
<label for="in-socks" data-i18n="label_socks">Local Proxy Address</label>
<input type="text" id="in-socks" placeholder="127.0.0.1:1088" />
</div>
<div class="form-group">
<label for="in-dns">Custom DNS Server</label>
<input type="text" id="in-dns" placeholder="8.8.8.8 (Optional)" />
<label for="in-dns" data-i18n="label_dns">Custom DNS Server</label>
<input type="text" id="in-dns" placeholder="8.8.8.8" />
</div>
<div class="form-group row-align">
<div class="label-stack">
<span class="toggle-label">TUN Tunnel Mode</span>
<span class="toggle-subtext">Route all system traffic (Admin req.)</span>
<span class="toggle-label" data-i18n="label_tun">TUN Tunnel Mode</span>
<span class="toggle-subtext" data-i18n="tun_hint">Route all system traffic (Admin req.)</span>
</div>
<label class="switch">
<input type="checkbox" id="in-tun-mode">
@ -116,8 +121,8 @@
<div class="form-group row-align">
<div class="label-stack">
<span class="toggle-label">Debug Logs</span>
<span class="toggle-subtext">Enable verbose internal event outputs</span>
<span class="toggle-label" data-i18n="label_debug">Debug Logs</span>
<span class="toggle-subtext" data-i18n="debug_hint">Enable verbose internal event outputs</span>
</div>
<label class="switch">
<input type="checkbox" id="in-debug">
@ -126,28 +131,28 @@
</div>
<!-- Exclusions Section Divider -->
<div class="section-divider">Exclusions <span class="divider-hint">(one per line)</span></div>
<div class="section-divider"><span data-i18n="excl_title">Exclusions</span> <span class="divider-hint" data-i18n="excl_hint">(one per line)</span></div>
<div class="form-group">
<label for="in-ex-domains">Bypass Domains</label>
<textarea id="in-ex-domains" placeholder="example.com" rows="2"></textarea>
<label for="in-ex-domains" data-i18n="excl_domains">Bypass Domains</label>
<textarea id="in-ex-domains" placeholder="example.com&#10;*.google.com" rows="2"></textarea>
</div>
<div class="form-group">
<label for="in-ex-ips">Bypass IP Ranges</label>
<textarea id="in-ex-ips" placeholder="192.168.1.0/24" rows="2"></textarea>
<label for="in-ex-ips" data-i18n="excl_ips">Bypass IPs / CIDR Ranges</label>
<textarea id="in-ex-ips" placeholder="192.168.1.0/24&#10;10.0.0.1" rows="2"></textarea>
</div>
<div class="form-group">
<label for="in-ex-processes">Bypass Processes (Proxy Mode)</label>
<textarea id="in-ex-processes" placeholder="chrome.exe" rows="2"></textarea>
<label for="in-ex-processes" data-i18n="excl_processes">Bypass Processes</label>
<textarea id="in-ex-processes" placeholder="chrome.exe&#10;firefox.exe" rows="2"></textarea>
</div>
</div>
<div class="actions-container">
<button id="btn-save-config" class="primary-btn glass">Save & Apply</button>
<button id="btn-save-config" class="primary-btn glass" data-i18n="save_btn">Save & Apply</button>
</div>
<div id="config-toast" class="toast">Config saved successfully!</div>
<div id="config-toast" class="toast" data-i18n="toast_saved">Configuration saved</div>
</div>
</div>
</div>

View File

@ -1,3 +1,5 @@
import { t, toggleLang, applyTranslations, getLang } from './i18n.js';
const { invoke } = window.__TAURI__.core;
// State management
@ -5,7 +7,7 @@ let appState = 'disconnected';
let pollInterval = null;
let elapsedSeconds = 0;
let elapsedTimer = null;
let rawConfigObj = null; // Cache original config object to preserve extra keys
let rawConfigObj = null;
// DOM Elements
const btnConnect = document.getElementById('btn-connect');
@ -21,6 +23,7 @@ const btnGoSettings = document.getElementById('btn-go-settings');
const btnBack = document.getElementById('btn-back');
const btnSaveConfig = document.getElementById('btn-save-config');
const configToast = document.getElementById('config-toast');
const btnLang = document.getElementById('btn-lang');
// Input Form Elements
const inImportUrl = document.getElementById('in-import-url');
@ -62,15 +65,14 @@ function setUIState(state) {
if (appState === state) return;
appState = state;
// Clean up classes
btnConnect.className = 'power-btn';
powerContainer.className = 'power-button-container';
statusText.className = '';
if (state === 'disconnected') {
statusText.textContent = 'Disconnected';
statusText.textContent = t('status_disconnected');
statusText.classList.add('status-disconnected');
uptimeText.textContent = 'Tap to protect your traffic';
uptimeText.textContent = t('hint_tap');
clearInterval(pollInterval);
clearInterval(elapsedTimer);
@ -81,9 +83,9 @@ function setUIState(state) {
} else if (state === 'connecting') {
btnConnect.classList.add('connecting');
powerContainer.classList.add('connecting');
statusText.textContent = 'Connecting...';
statusText.textContent = t('status_connecting');
statusText.classList.add('status-connecting');
uptimeText.textContent = 'Establishing secure tunnel';
uptimeText.textContent = t('hint_connecting');
clearInterval(elapsedTimer);
elapsedTimer = null;
@ -92,14 +94,14 @@ function setUIState(state) {
} else if (state === 'connected') {
btnConnect.classList.add('connected');
powerContainer.classList.add('connected');
statusText.textContent = 'Protected';
statusText.textContent = t('status_connected');
statusText.classList.add('status-connected');
if (!elapsedTimer) {
elapsedSeconds = 0;
elapsedTimer = setInterval(() => {
elapsedSeconds++;
uptimeText.textContent = `Uptime: ${formatTime(elapsedSeconds)}`;
uptimeText.textContent = `${t('hint_connected')} | ${formatTime(elapsedSeconds)}`;
}, 1000);
}
}
@ -114,11 +116,10 @@ async function handleToggleConnect() {
if (success) {
startGlobalPolling();
} else {
alert('Failed to start tunnel process.');
setUIState('disconnected');
}
} catch (err) {
alert('Error starting tunnel: ' + err);
console.error('Tunnel start error:', err);
setUIState('disconnected');
}
} else {
@ -197,8 +198,6 @@ async function loadConfigIntoFields() {
inExDomains.value = (exc.domains || []).join('\n');
inExIps.value = (exc.ips || []).join('\n');
inExProcesses.value = (exc.processes || []).join('\n');
} else {
alert('Loaded configuration is for OSTP Server. Please adjust manually.');
}
} catch (err) {
console.error('Error loading config', err);
@ -241,11 +240,9 @@ async function handleSaveConfig() {
// Validation
if (!rawConfigObj.server) {
alert('Server Address is required!');
return;
}
if (!rawConfigObj.access_key) {
alert('Access Key is required!');
return;
}
@ -253,15 +250,15 @@ async function handleSaveConfig() {
const finalJson = JSON.stringify(rawConfigObj, null, 2);
const success = await invoke('save_config', { jsonContent: finalJson });
if (success) {
showToast();
showToast(t('toast_saved'));
setTimeout(() => switchScreen('home'), 800);
}
} catch (err) {
alert('Saving failed: ' + err);
showToast(t('toast_error') + ': ' + err);
}
}
// OSTP URI Sharing Parser (Simplified: only extract HOST & KEY)
// OSTP URI Sharing Parser
function handleImportUrl() {
const urlStr = inImportUrl.value.trim();
if (!urlStr) return;
@ -276,30 +273,32 @@ function handleImportUrl() {
const serverHost = url.host;
if (!accessKey || !serverHost) {
throw new Error('Incomplete parameters: missing key or server address.');
throw new Error('Incomplete parameters');
}
// Update primary connection fields
inServer.value = serverHost;
inKey.value = accessKey;
inImportUrl.value = '';
inImportUrl.placeholder = 'Import successful!';
setTimeout(() => { inImportUrl.placeholder = 'Paste ostp:// share link here...'; }, 2000);
showToast(t('toast_imported'));
} catch (err) {
alert('Failed to parse ostp:// share link: ' + err.message);
showToast(t('toast_error') + ': ' + err.message);
}
}
function showToast() {
function showToast(message) {
configToast.textContent = message || t('toast_saved');
configToast.classList.add('show');
setTimeout(() => configToast.classList.remove('show'), 2000);
}
// Initialization
window.addEventListener('DOMContentLoaded', async () => {
// Apply translations on load
applyTranslations();
// Re-apply dynamic status text
setUIState(appState);
btnConnect.addEventListener('click', handleToggleConnect);
btnGoSettings.addEventListener('click', () => switchScreen('settings'));
btnBack.addEventListener('click', () => switchScreen('home'));
@ -310,6 +309,15 @@ window.addEventListener('DOMContentLoaded', async () => {
if (e.key === 'Enter') handleImportUrl();
});
// Language toggle
btnLang.addEventListener('click', () => {
toggleLang();
// Re-apply dynamic elements
const currentState = appState;
appState = ''; // Force refresh
setUIState(currentState);
});
try {
const statusCode = await invoke('get_tunnel_status');
if (statusCode > 0) {

View File

@ -191,6 +191,23 @@ h2 {
transform: translateY(1px);
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.lang-btn {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.5px;
min-width: 40px;
}
.lang-btn span {
font-family: 'Inter', sans-serif;
}
/* Main Content & Central Power Button */
.main-content {
flex: 1;