mirror of https://github.com/ospab/ostp.git
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:
parent
074a3f6371
commit
69e4426152
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 *.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.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 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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue