feat: GUI v2 redesign + CI/CD speedup

GUI (ostp-gui):
- Complete HTML rewrite: orbit rings, server badge, metrics bar, peek-key
- CSS design system v2: ambient blobs, glassmorphism card, richer token set
  orbit animation (connected/connecting states), breathing power button,
  modern toggle component with thumb, toast variants (ok/error/default)
- main.js: clean state machine, server badge, TUN/SOCKS5 mode label,
  peek-key toggle, toast variants, import link, uptime counter

CI/CD (.github/workflows/release.yml):
- Replaced swatinem/rust-cache with actions/cache@v4 (per-target key)
- Cache cross binary: skip reinstall on cache hit (~3 min saved per job)
- Cache tauri-cli binary: skip reinstall on cache hit (~2 min saved per GUI job)
- Added npm cache (cache-dependency-path: ostp-gui/package-lock.json)
- Removed redundant pre-flight cargo check step (duplicates build step)
- Cleaned up packaging scripts (inline vars, smaller surface area)
This commit is contained in:
ospab 2026-05-17 22:13:03 +03:00
parent 3a16373a31
commit ee14a60348
5 changed files with 1071 additions and 911 deletions

View File

@ -9,6 +9,12 @@ on:
permissions:
contents: write
# ── Global defaults ─────────────────────────────────────────────────────────
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
jobs:
publish-release-matrix:
name: Release for ${{ matrix.target }}
@ -17,21 +23,21 @@ jobs:
fail-fast: false
matrix:
include:
# ==========================================
# 🏁 WINDOWS ECOSYSTEM
# ==========================================
# ── Windows ──────────────────────────────────────────────────────
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact_name: ostp.exe
release_name: ostp-windows-amd64.zip
tun2socks_arch: windows-amd64
wintun_arch: amd64
- os: windows-latest
target: i686-pc-windows-msvc
artifact_name: ostp.exe
release_name: ostp-windows-386.zip
tun2socks_arch: windows-386
wintun_arch: x86
- os: windows-latest
target: aarch64-pc-windows-msvc
artifact_name: ostp.exe
@ -39,45 +45,47 @@ jobs:
tun2socks_arch: windows-arm64
wintun_arch: arm64
# ==========================================
# 🍏 APPLE DARWIN (macOS)
# ==========================================
# ── macOS ─────────────────────────────────────────────────────────
- os: macos-latest
target: x86_64-apple-darwin
artifact_name: ostp
release_name: ostp-darwin-amd64.tar.gz
tun2socks_arch: darwin-amd64
- os: macos-latest
target: aarch64-apple-darwin
artifact_name: ostp
release_name: ostp-darwin-arm64.tar.gz
tun2socks_arch: darwin-arm64
# ==========================================
# 🐧 LINUX & FreeBSD STANDARD
# ==========================================
# ── Linux native ──────────────────────────────────────────────────
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: ostp
release_name: ostp-linux-amd64.tar.gz
tun2socks_arch: linux-amd64
- os: ubuntu-latest
target: i686-unknown-linux-musl
artifact_name: ostp
release_name: ostp-linux-386.tar.gz
tun2socks_arch: linux-386
# ── Linux cross ───────────────────────────────────────────────────
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
artifact_name: ostp
release_name: ostp-linux-arm64.tar.gz
tun2socks_arch: linux-arm64
use_cross: true
- os: ubuntu-latest
target: armv7-unknown-linux-musleabihf
artifact_name: ostp
release_name: ostp-linux-armv7.tar.gz
tun2socks_arch: linux-armv7
use_cross: true
- os: ubuntu-latest
target: x86_64-unknown-freebsd
artifact_name: ostp
@ -85,9 +93,6 @@ jobs:
tun2socks_arch: freebsd-amd64
use_cross: true
# ==========================================
# 🛰️ ROUTER & SPECIAL ARCHITECTURES (Cross)
# ==========================================
- os: ubuntu-latest
target: mipsel-unknown-linux-musl
artifact_name: ostp
@ -95,6 +100,7 @@ jobs:
tun2socks_arch: linux-mipsle-softfloat
use_cross: true
toolchain: nightly
- os: ubuntu-latest
target: riscv64gc-unknown-linux-gnu
artifact_name: ostp
@ -102,9 +108,6 @@ jobs:
tun2socks_arch: linux-riscv64
use_cross: true
# ==========================================
# 🤖 MOBILE & EMBEDDED SUITE (Cross)
# ==========================================
- os: ubuntu-latest
target: aarch64-linux-android
artifact_name: ostp
@ -116,102 +119,102 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Initialize Rust ecosystem (Native Host)
if: ${{ !matrix.use_cross }}
# ── Rust toolchain ─────────────────────────────────────────────────────
- name: Setup Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ matrix.toolchain || 'stable' }}
targets: ${{ matrix.target }}
targets: ${{ !matrix.use_cross && matrix.target || '' }}
- name: Initialize Rust ecosystem (Cross Container Host)
if: ${{ matrix.use_cross }}
uses: dtolnay/rust-toolchain@stable
# ── Cargo cache (shared per target) ───────────────────────────────────
- name: Restore Cargo cache
uses: actions/cache@v4
with:
toolchain: ${{ matrix.toolchain || 'stable' }}
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: cargo-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-${{ matrix.target }}-
- name: Activate rust compilation caching
if: ${{ !matrix.use_cross }}
uses: swatinem/rust-cache@v2
- name: Setup local MUSL linker dependencies
# ── MUSL tools for native Linux musl builds ────────────────────────────
- name: Install musl-tools
if: ${{ matrix.os == 'ubuntu-latest' && !matrix.use_cross }}
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Pre-flight compilation check
# ── Native build ───────────────────────────────────────────────────────
- name: Build (native)
if: ${{ !matrix.use_cross }}
shell: bash
run: |
cargo check --target ${{ matrix.target }} --bin ostp 2>&1
echo "Pre-flight check passed."
run: cargo build --release --target ${{ matrix.target }} --bin ostp
- name: Execute Standard Native Compilation (Windows)
if: ${{ !matrix.use_cross && matrix.os == 'windows-latest' }}
run: |
cargo build --release --target ${{ matrix.target }} --bin ostp
- name: Execute Standard Native Compilation (Unix)
if: ${{ !matrix.use_cross && matrix.os != 'windows-latest' }}
shell: bash
run: |
cargo build --release --target ${{ matrix.target }} --bin ostp
- name: Execute Specialized Cross-Compilation
# ── Cross build ────────────────────────────────────────────────────────
- name: Restore cross binary cache
if: ${{ matrix.use_cross }}
run: |
cargo install cross --git https://github.com/cross-rs/cross.git
cross build --release --target ${{ matrix.target }} --bin ostp
id: cross-cache
uses: actions/cache@v4
with:
path: ~/.cargo/bin/cross
key: cross-bin-${{ runner.os }}-v1
- name: Inject Win32 Driver Dependencies (Windows)
- name: Install cross (if not cached)
if: ${{ matrix.use_cross && steps.cross-cache.outputs.cache-hit != 'true' }}
run: cargo install cross --git https://github.com/cross-rs/cross.git --locked
- name: Build (cross)
if: ${{ matrix.use_cross }}
run: cross build --release --target ${{ matrix.target }} --bin ostp
# ── Driver dependencies ────────────────────────────────────────────────
- name: Download tun2socks + wintun (Windows)
if: ${{ matrix.os == 'windows-latest' && matrix.tun2socks_arch }}
shell: pwsh
run: |
cd target/${{ matrix.target }}/release
$ProgressPreference = 'SilentlyContinue'
# 1. Acquire 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 "tun2socks.exe" -Force
# 2. Acquire 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 "." -Force
# Cleanup
Remove-Item "tun2socks.zip", "tun_temp", "wintun.zip", "wintun_temp" -Recurse -Force
$dir = "target/${{ matrix.target }}/release"
Invoke-WebRequest -Uri "https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-${{ matrix.tun2socks_arch }}.zip" -OutFile "$dir/t2s.zip"
Expand-Archive "$dir/t2s.zip" -DestinationPath "$dir/t2s_tmp" -Force
Get-ChildItem "$dir/t2s_tmp" -Filter "*.exe" -Recurse | Select-Object -First 1 | Copy-Item -Destination "$dir/tun2socks.exe"
Invoke-WebRequest -Uri "https://www.wintun.net/builds/wintun-0.14.1.zip" -OutFile "$dir/wt.zip"
Expand-Archive "$dir/wt.zip" -DestinationPath "$dir/wt_tmp" -Force
Get-ChildItem "$dir/wt_tmp" -Filter "wintun.dll" -Recurse | Where-Object { $_.FullName -match 'bin[\\/]${{ matrix.wintun_arch }}[\\/]' } | Copy-Item -Destination "$dir/"
Remove-Item "$dir/t2s.zip","$dir/t2s_tmp","$dir/wt.zip","$dir/wt_tmp" -Recurse -Force
- name: Inject Unix Driver Dependencies (Unix)
- name: Download tun2socks (Unix)
if: ${{ matrix.os != 'windows-latest' && matrix.tun2socks_arch }}
shell: bash
run: |
cd target/${{ matrix.target }}/release
# All platforms in tun2socks v2.6.0 use .zip packaging
dir="target/${{ matrix.target }}/release"
URL="https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-${{ matrix.tun2socks_arch }}.zip"
curl -f -L "$URL" -o "tun2socks.zip" || { echo "Failed to download tun2socks"; exit 0; }
unzip -o "tun2socks.zip"
find . -maxdepth 2 -name "tun2socks*" ! -name "*.zip" -type f -exec mv {} ./tun2socks \;
rm -f "tun2socks.zip"
chmod +x tun2socks || true
curl -fsSL "$URL" -o "$dir/t2s.zip" || exit 0
unzip -o "$dir/t2s.zip" -d "$dir/t2s_tmp"
find "$dir/t2s_tmp" -type f -name "tun2socks*" ! -name "*.zip" | head -1 | xargs -I{} cp {} "$dir/tun2socks"
chmod +x "$dir/tun2socks" 2>/dev/null || true
rm -rf "$dir/t2s.zip" "$dir/t2s_tmp"
- name: Package release artifact (Windows)
# ── Package ────────────────────────────────────────────────────────────
- name: Package (Windows)
if: ${{ matrix.os == 'windows-latest' }}
shell: pwsh
run: |
cd target/${{ matrix.target }}/release
$files = @("ostp.exe")
if (Test-Path "tun2socks.exe") { $files += "tun2socks.exe" }
if (Test-Path "wintun.dll") { $files += "wintun.dll" }
Compress-Archive -Path $files -DestinationPath ../../../${{ matrix.release_name }} -Force
$dir = "target/${{ matrix.target }}/release"
$files = @("$dir/ostp.exe")
if (Test-Path "$dir/tun2socks.exe") { $files += "$dir/tun2socks.exe" }
if (Test-Path "$dir/wintun.dll") { $files += "$dir/wintun.dll" }
Compress-Archive -Path $files -DestinationPath "${{ matrix.release_name }}" -Force
- name: Package release artifact (Unix Systems)
- name: Package (Unix)
if: ${{ matrix.os != 'windows-latest' }}
run: |
cd target/${{ matrix.target }}/release
FILES="${{ matrix.artifact_name }}"
if [ -f "tun2socks" ]; then
FILES="$FILES tun2socks"
fi
tar -czf ../../../${{ matrix.release_name }} $FILES
dir="target/${{ matrix.target }}/release"
FILES="$dir/${{ matrix.artifact_name }}"
[ -f "$dir/tun2socks" ] && FILES="$FILES $dir/tun2socks"
tar -czf "${{ matrix.release_name }}" -C "$dir" $(basename $FILES | tr '\n' ' ')
- name: Inject artifact to Global GitHub Release Assets
# ── Upload ─────────────────────────────────────────────────────────────
- name: Upload to GitHub Release
if: ${{ startsWith(github.ref, 'refs/tags/') }}
uses: softprops/action-gh-release@v2
with:
@ -219,11 +222,9 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ==========================================
# Windows GUI (Tauri) Build
# ==========================================
# ── Windows GUI (Tauri) ──────────────────────────────────────────────────
publish-gui:
name: GUI for Windows ${{ matrix.arch_label }}
name: GUI Windows ${{ matrix.arch_label }}
runs-on: windows-latest
strategy:
fail-fast: false
@ -247,58 +248,70 @@ jobs:
with:
targets: ${{ matrix.target }}
- name: Activate Rust caching
uses: swatinem/rust-cache@v2
- name: Restore Cargo cache
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
ostp-gui/src-tauri/target/
key: cargo-gui-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-gui-${{ matrix.target }}-
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: ostp-gui/package-lock.json
- name: Install Tauri CLI
run: cargo install tauri-cli --version "^2"
# Cache tauri-cli binary to skip reinstall every run
- name: Cache tauri-cli
id: tauri-cli-cache
uses: actions/cache@v4
with:
path: ~/.cargo/bin/cargo-tauri*
key: tauri-cli-v2-${{ runner.os }}
- name: Build Tun Helper
run: |
cargo build --release --target ${{ matrix.target }} -p ostp-tun-helper
- name: Install Tauri CLI (if not cached)
if: steps.tauri-cli-cache.outputs.cache-hit != 'true'
run: cargo install tauri-cli --version "^2" --locked
- name: Build Tauri application
- name: Build tun-helper
run: cargo build --release --target ${{ matrix.target }} -p ostp-tun-helper
- name: Build Tauri app
shell: pwsh
run: |
cd ostp-gui
cargo tauri build --target ${{ matrix.target }}
- name: Package GUI release
- name: Package GUI
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"
$exePath = Get-ChildItem "ostp-gui/src-tauri/target/${{ matrix.target }}/release" -Filter "ostp-gui.exe" | Select-Object -First 1
Copy-Item $exePath.FullName "$dist/ostp-gui.exe"
Copy-Item "target/${{ matrix.target }}/release/ostp-tun-helper.exe" "$dist/ostp-tun-helper.exe"
# 1.5 Copy the Tun Helper executable
Copy-Item "target/${{ matrix.target }}/release/ostp-tun-helper.exe" -Destination "$dist/ostp-tun-helper.exe"
Invoke-WebRequest "https://github.com/xjasonlyu/tun2socks/releases/download/v2.6.0/tun2socks-${{ matrix.tun2socks_arch }}.zip" -OutFile "t2s.zip"
Expand-Archive "t2s.zip" "t2s_tmp" -Force
Get-ChildItem "t2s_tmp" -Filter "*.exe" -Recurse | Select-Object -First 1 | Copy-Item -Destination "$dist/tun2socks.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
Invoke-WebRequest "https://www.wintun.net/builds/wintun-0.14.1.zip" -OutFile "wt.zip"
Expand-Archive "wt.zip" "wt_tmp" -Force
Get-ChildItem "wt_tmp" -Filter "wintun.dll" -Recurse | Where-Object { $_.FullName -match 'bin[\\/]${{ matrix.wintun_arch }}[\\/]' } | Copy-Item -Destination "$dist/"
# 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
Compress-Archive "$dist/*" "ostp-windows-gui-${{ matrix.arch_label }}.zip" -Force
Remove-Item "t2s.zip","t2s_tmp","wt.zip","wt_tmp",$dist -Recurse -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
- name: Upload GUI to release
if: ${{ startsWith(github.ref, 'refs/tags/') }}
uses: softprops/action-gh-release@v2
with:

View File

@ -2,159 +2,245 @@
<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>
<title>OSTP</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="app-container">
<!-- Dynamic Mesh Background Particles -->
<div class="mesh-bg"></div>
<div class="blur-overlay"></div>
<div class="app-root">
<!-- 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>
<!-- Ambient light blobs -->
<div class="ambient" aria-hidden="true">
<div class="blob blob-1"></div>
<div class="blob blob-2"></div>
<div class="blob blob-3"></div>
</div>
<div class="header-actions">
<button id="btn-lang" class="icon-btn lang-btn" aria-label="Language" title="Language">
<!-- ── HOME SCREEN ──────────────────────────────────────────── -->
<div id="home-screen" class="screen active">
<!-- Top bar -->
<header class="topbar">
<div class="brand">
<div class="brand-dot" id="brand-dot"></div>
<span class="brand-name">OSTP</span>
</div>
<div class="topbar-right">
<button id="btn-lang" class="pill-btn" 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>
<!-- Gear icon -->
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<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 4.68 15a1.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 9 4.68a1.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 19.4 9a1.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"/>
</svg>
</button>
</div>
</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">
<!-- Center stage -->
<main class="stage">
<!-- Orbit rings -->
<div class="orbit-wrap" id="orbit-wrap">
<div class="orbit orbit-1"></div>
<div class="orbit orbit-2"></div>
<div class="orbit orbit-3"></div>
<!-- Power button -->
<button id="btn-connect" class="power-btn" aria-label="Connect">
<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>
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>
<line x1="12" y1="2" x2="12" y2="12"/>
</svg>
</div>
</button>
</div>
<div class="status-display">
<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>
<!-- Status block -->
<div class="status-block">
<div id="status-text" class="status-label" data-i18n="status_disconnected">Disconnected</div>
<div id="uptime-text" class="status-sub" data-i18n="hint_tap">Tap to protect your traffic</div>
</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>
<!-- Server badge (shown when connected) -->
<div id="server-badge" class="server-badge hidden">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2"/>
<rect x="2" y="14" width="20" height="8" rx="2"/>
<line x1="6" y1="6" x2="6.01" y2="6"/>
<line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
<span id="server-badge-text"></span>
</div>
<div class="metric-data">
</main>
<!-- Traffic metrics bar -->
<footer class="metrics-bar">
<div class="metric">
<div class="metric-icon down-icon">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14M19 12l-7 7-7-7"/>
</svg>
</div>
<div class="metric-body">
<span class="metric-label" data-i18n="download">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" data-i18n="upload">Upload</span>
<span id="metric-up" class="metric-value">0.0 B</span>
</div>
</div>
</div>
<span id="metric-down" class="metric-value">0 B</span>
</div>
</div>
<!-- Settings Screen -->
<div class="metric-sep"></div>
<div class="metric">
<div class="metric-icon up-icon">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 19V5M5 12l7-7 7 7"/>
</svg>
</div>
<div class="metric-body">
<span class="metric-label" data-i18n="upload">Upload</span>
<span id="metric-up" class="metric-value">0 B</span>
</div>
</div>
<div class="metric-sep"></div>
<div class="metric">
<div class="metric-icon ping-icon">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 6l5.5 5.5L12 6l5.5 5.5L23 6"/>
<path d="M1 18l5.5-5.5L12 18l5.5-5.5L23 18"/>
</svg>
</div>
<div class="metric-body">
<span class="metric-label">Mode</span>
<span id="metric-mode" class="metric-value"></span>
</div>
</div>
</footer>
</div>
<!-- ── SETTINGS SCREEN ──────────────────────────────────────── -->
<div id="settings-screen" class="screen">
<header class="app-header">
<header class="topbar">
<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>
<svg width="18" height="18" viewBox="0 0 24 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 data-i18n="settings_title">Configuration</h2>
<div style="width: 40px;"></div>
<span class="topbar-title" data-i18n="settings_title">Configuration</span>
<div style="width:36px"></div>
</header>
<div class="settings-content">
<!-- Import Area -->
<div class="import-container glass">
<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 class="settings-body">
<!-- Quick import -->
<div class="import-row">
<input id="in-import-url"
class="import-input"
type="text"
data-i18n-placeholder="import_placeholder"
placeholder="Paste ostp:// share link..." />
<button id="btn-import-url" class="accent-btn" data-i18n="import_btn">Import</button>
</div>
<!-- Form Settings -->
<div class="editor-container glass scrollable">
<div class="form-group">
<label for="in-server" data-i18n="label_server">Server Address</label>
<input type="text" id="in-server" placeholder="host:port" />
<!-- Form card -->
<div class="card scrollable">
<div class="field-group">
<label class="field-label" for="in-server" data-i18n="label_server">Server Address</label>
<input id="in-server" class="field-input" type="text" placeholder="host:port" spellcheck="false" />
</div>
<div class="form-group">
<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 class="field-group">
<label class="field-label" for="in-key" data-i18n="label_key">Access Key</label>
<div class="input-wrap">
<input id="in-key" class="field-input has-icon" type="password" data-i18n-placeholder="ph_key" placeholder="Secure access key" spellcheck="false" />
<button class="peek-btn" id="btn-peek-key" tabindex="-1" aria-label="Show key">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
<div class="form-group">
<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 class="field-group">
<label class="field-label" for="in-socks" data-i18n="label_socks">Local Proxy</label>
<input id="in-socks" class="field-input" type="text" placeholder="127.0.0.1:1088" />
</div>
<div class="form-group">
<label for="in-dns" data-i18n="label_dns">Custom DNS Server</label>
<input type="text" id="in-dns" placeholder="8.8.8.8" />
<div class="field-group">
<label class="field-label" for="in-dns" data-i18n="label_dns">DNS Server</label>
<input id="in-dns" class="field-input" type="text" placeholder="1.1.1.1" />
</div>
<div class="form-group row-align">
<div class="label-stack">
<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>
<!-- Toggles -->
<div class="toggle-row">
<div class="toggle-text">
<span class="toggle-name" data-i18n="label_tun">TUN Mode</span>
<span class="toggle-hint" data-i18n="tun_hint">Route all system traffic</span>
</div>
<label class="switch">
<input type="checkbox" id="in-tun-mode">
<span class="slider round"></span>
<label class="toggle">
<input type="checkbox" id="in-tun-mode" />
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</label>
</div>
<div class="form-group row-align">
<div class="label-stack">
<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 class="toggle-row">
<div class="toggle-text">
<span class="toggle-name" data-i18n="label_debug">Debug Logs</span>
<span class="toggle-hint" data-i18n="debug_hint">Verbose output</span>
</div>
<label class="switch">
<input type="checkbox" id="in-debug">
<span class="slider round"></span>
<label class="toggle">
<input type="checkbox" id="in-debug" />
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</label>
</div>
<!-- Exclusions Section Divider -->
<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" data-i18n="excl_domains">Bypass Domains</label>
<textarea id="in-ex-domains" placeholder="example.com&#10;*.google.com" rows="2"></textarea>
<!-- Exclusions -->
<div class="section-head">
<span data-i18n="excl_title">Exclusions</span>
<span class="section-hint" data-i18n="excl_hint">one per line</span>
</div>
<div class="form-group">
<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 class="field-group">
<label class="field-label" for="in-ex-domains" data-i18n="excl_domains">Bypass Domains</label>
<textarea id="in-ex-domains" class="field-input mono" placeholder="example.com&#10;*.google.com" rows="2"></textarea>
</div>
<div class="form-group">
<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 class="field-group">
<label class="field-label" for="in-ex-ips" data-i18n="excl_ips">Bypass IPs / CIDR</label>
<textarea id="in-ex-ips" class="field-input mono" placeholder="192.168.1.0/24&#10;10.0.0.1" rows="2"></textarea>
</div>
<div class="field-group">
<label class="field-label" for="in-ex-processes" data-i18n="excl_processes">Bypass Processes</label>
<textarea id="in-ex-processes" class="field-input mono" placeholder="chrome.exe&#10;firefox.exe" rows="2"></textarea>
</div>
</div>
<div class="actions-container">
<button id="btn-save-config" class="primary-btn glass" data-i18n="save_btn">Save & Apply</button>
</div>
<div id="config-toast" class="toast" data-i18n="toast_saved">Configuration saved</div>
<!-- Save -->
<button id="btn-save-config" class="save-btn" data-i18n="save_btn">Save &amp; Apply</button>
</div>
</div>
<!-- Toast -->
<div id="toast" class="toast" role="status" aria-live="polite"></div>
</div>
<script type="module" src="main.js"></script>
</body>
</html>

View File

@ -1,176 +1,208 @@
import { t, toggleLang, applyTranslations, getLang } from './i18n.js';
import { t, toggleLang, applyTranslations } from './i18n.js';
let invoke = () => {
console.warn('Tauri invoke is not available in this environment.');
return Promise.resolve(null);
};
if (window.__TAURI__ && window.__TAURI__.core) {
// ── Tauri invoke shim ────────────────────────────────────────────────────────
let invoke = () => Promise.resolve(null);
if (window.__TAURI__?.core) {
invoke = window.__TAURI__.core.invoke;
}
// State management
let appState = 'disconnected';
let pollInterval = null;
let elapsedSeconds = 0;
let elapsedTimer = null;
let rawConfigObj = null;
// ── State ────────────────────────────────────────────────────────────────────
let appState = 'disconnected'; // 'disconnected' | 'connecting' | 'connected'
let pollTimer = null;
let uptimeTimer = null;
let uptimeSecs = 0;
let rawConfig = null; // parsed config.json object
let serverAddr = ''; // current server address (for badge)
// 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');
// ── DOM refs ─────────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
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 configToast = document.getElementById('config-toast');
const btnLang = document.getElementById('btn-lang');
const homeScreen = $('home-screen');
const settingsScreen = $('settings-screen');
const btnConnect = $('btn-connect');
const orbitWrap = $('orbit-wrap');
const brandDot = $('brand-dot');
const statusLabel = $('status-text');
const statusSub = $('uptime-text');
const serverBadge = $('server-badge');
const serverBadgeTxt = $('server-badge-text');
const metricDown = $('metric-down');
const metricUp = $('metric-up');
const metricMode = $('metric-mode');
const toast = $('toast');
// Input Form Elements
const inImportUrl = document.getElementById('in-import-url');
const btnImportUrl = document.getElementById('btn-import-url');
const inServer = document.getElementById('in-server');
const inKey = document.getElementById('in-key');
const inSocks = document.getElementById('in-socks');
const inDns = document.getElementById('in-dns');
const inTunMode = document.getElementById('in-tun-mode');
const inDebug = document.getElementById('in-debug');
const btnGoSettings = $('btn-go-settings');
const btnBack = $('btn-back');
const btnLang = $('btn-lang');
const btnImport = $('btn-import-url');
const btnPeekKey = $('btn-peek-key');
const btnSave = $('btn-save-config');
const importInput = $('in-import-url');
const inServer = $('in-server');
const inKey = $('in-key');
const inSocks = $('in-socks');
const inDns = $('in-dns');
const inTun = $('in-tun-mode');
const inDebug = $('in-debug');
const inDomains = $('in-ex-domains');
const inIps = $('in-ex-ips');
const inProcesses = $('in-ex-processes');
// Exclusions Textareas
const inExDomains = document.getElementById('in-ex-domains');
const inExIps = document.getElementById('in-ex-ips');
const inExProcesses = document.getElementById('in-ex-processes');
// 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];
// ── Utilities ────────────────────────────────────────────────────────────────
function fmtBytes(b) {
if (!b || b === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log2(b) / 10), 4);
return (b / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1) + ' ' + units[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(':');
function fmtTime(s) {
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = n => String(n).padStart(2, '0');
return h > 0
? `${h}:${pad(m)}:${pad(sec)}`
: `${pad(m)}:${pad(sec)}`;
}
// State Updates
function setUIState(state) {
if (appState === state) return;
appState = state;
function splitLines(val) {
return val.split('\n').map(l => l.trim()).filter(Boolean);
}
// ── Toast ────────────────────────────────────────────────────────────────────
let toastTimer = null;
function showToast(msg, variant = '') {
toast.textContent = msg;
toast.className = 'toast show' + (variant ? ' is-' + variant : '');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toast.classList.remove('show');
}, 2400);
}
// ── State machine ────────────────────────────────────────────────────────────
function setState(next) {
if (appState === next) return;
appState = next;
// Reset all dynamic classes
btnConnect.className = 'power-btn';
powerContainer.className = 'power-button-container';
statusText.className = '';
orbitWrap.className = 'orbit-wrap';
brandDot.className = 'brand-dot';
statusLabel.className = 'status-label';
if (state === 'disconnected') {
statusText.textContent = t('status_disconnected');
statusText.classList.add('status-disconnected');
uptimeText.textContent = t('hint_tap');
if (next === 'disconnected') {
statusLabel.textContent = t('status_disconnected');
statusSub.textContent = t('hint_tap');
statusLabel.classList.add('');
serverBadge.classList.add('hidden');
metricDown.textContent = '0 B';
metricUp.textContent = '0 B';
metricMode.textContent = '—';
clearInterval(pollTimer);
clearInterval(uptimeTimer);
pollTimer = uptimeTimer = null;
uptimeSecs = 0;
clearInterval(pollInterval);
clearInterval(elapsedTimer);
pollInterval = null;
elapsedTimer = null;
elapsedSeconds = 0;
} else if (state === 'connecting') {
} else if (next === 'connecting') {
btnConnect.classList.add('connecting');
powerContainer.classList.add('connecting');
statusText.textContent = t('status_connecting');
statusText.classList.add('status-connecting');
uptimeText.textContent = t('hint_connecting');
orbitWrap.classList.add('connecting');
brandDot.classList.add('connecting');
statusLabel.classList.add('is-connecting');
statusLabel.textContent = t('status_connecting');
statusSub.textContent = t('hint_connecting');
serverBadge.classList.add('hidden');
clearInterval(uptimeTimer);
uptimeSecs = 0;
clearInterval(elapsedTimer);
elapsedTimer = null;
elapsedSeconds = 0;
} else if (state === 'connected') {
} else if (next === 'connected') {
btnConnect.classList.add('connected');
powerContainer.classList.add('connected');
statusText.textContent = t('status_connected');
statusText.classList.add('status-connected');
orbitWrap.classList.add('connected');
brandDot.classList.add('connected');
statusLabel.classList.add('is-connected');
statusLabel.textContent = t('status_connected');
if (!elapsedTimer) {
elapsedSeconds = 0;
elapsedTimer = setInterval(() => {
elapsedSeconds++;
uptimeText.textContent = `${t('hint_connected')} | ${formatTime(elapsedSeconds)}`;
// Show server badge
if (serverAddr) {
serverBadgeTxt.textContent = serverAddr;
serverBadge.classList.remove('hidden');
}
// Start uptime counter
if (!uptimeTimer) {
uptimeSecs = 0;
uptimeTimer = setInterval(() => {
uptimeSecs++;
statusSub.textContent = fmtTime(uptimeSecs);
}, 1000);
}
}
}
// UI Event Handlers
async function handleToggleConnect() {
// ── Polling ──────────────────────────────────────────────────────────────────
async function poll() {
try {
const code = await invoke('get_tunnel_status');
if (code === 0) { setState('disconnected'); return; }
else if (code === 1) setState('connecting');
else if (code === 2) setState('connected');
const metrics = await invoke('get_metrics');
if (metrics) {
metricDown.textContent = fmtBytes(metrics.bytes_recv);
metricUp.textContent = fmtBytes(metrics.bytes_sent);
}
} catch {
setState('disconnected');
}
}
function startPolling() {
clearInterval(pollTimer);
poll();
pollTimer = setInterval(poll, 1000);
}
// ── Connect / Disconnect ─────────────────────────────────────────────────────
async function handleToggle() {
if (appState === 'disconnected') {
setUIState('connecting');
// Read server address for badge before connecting
try {
const success = await invoke('start_tunnel');
if (success) {
startGlobalPolling();
const raw = await invoke('get_config');
const cfg = JSON.parse(raw);
serverAddr = cfg.server || '';
// Determine mode label
const isTun = cfg.tun?.enable;
metricMode.textContent = isTun ? 'TUN' : 'SOCKS5';
} catch { serverAddr = ''; }
setState('connecting');
try {
const ok = await invoke('start_tunnel');
if (ok) {
startPolling();
} else {
setUIState('disconnected');
setState('disconnected');
showToast(t('toast_error') || 'Failed to connect', 'error');
}
} catch (err) {
console.error('Tunnel start error:', err);
setUIState('disconnected');
setState('disconnected');
showToast(String(err), 'error');
}
} else {
try {
await invoke('stop_tunnel');
} catch (err) {
console.error(err);
}
setUIState('disconnected');
try { await invoke('stop_tunnel'); } catch { /* ignore */ }
setState('disconnected');
showToast(t('toast_disconnected') || 'Disconnected');
}
}
function startGlobalPolling() {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(uiSyncTick, 1000);
uiSyncTick();
}
async function uiSyncTick() {
try {
const statusCode = await invoke('get_tunnel_status');
if (statusCode === 0) {
setUIState('disconnected');
return;
} else if (statusCode === 1) {
setUIState('connecting');
} else if (statusCode === 2) {
setUIState('connected');
}
const stats = await invoke('get_metrics');
if (stats) {
metricDown.textContent = formatBytes(stats.bytes_recv);
metricUp.textContent = formatBytes(stats.bytes_sent);
}
} catch (e) {
console.error('Sync error', e);
setUIState('disconnected');
}
}
function switchScreen(target) {
if (target === 'settings') {
loadConfigIntoFields();
// ── Screen navigation ────────────────────────────────────────────────────────
function showScreen(name) {
if (name === 'settings') {
loadConfigIntoForm();
homeScreen.classList.remove('active');
settingsScreen.classList.add('active');
} else {
@ -179,161 +211,127 @@ function switchScreen(target) {
}
}
// Config Management
async function loadConfigIntoFields() {
// ── Config — load ─────────────────────────────────────────────────────────────
async function loadConfigIntoForm() {
try {
const rawStr = await invoke('get_config');
rawConfigObj = JSON.parse(rawStr);
const raw = await invoke('get_config');
rawConfig = JSON.parse(raw);
const c = rawConfig.mode === 'client' ? rawConfig : null;
if (!c) return;
const isClient = rawConfigObj.mode === 'client';
const clientConf = isClient ? rawConfigObj : null;
inServer.value = c.server || '';
inKey.value = c.access_key || '';
inSocks.value = c.socks5_bind || '127.0.0.1:1088';
inTun.checked = !!c.tun?.enable;
inDns.value = c.tun?.dns || '';
inDebug.checked = !!c.debug;
if (clientConf) {
inServer.value = clientConf.server || '';
inKey.value = clientConf.access_key || '';
inSocks.value = clientConf.socks5_bind || '127.0.0.1:1088';
const tunEnabled = clientConf.tun && clientConf.tun.enable;
inTunMode.checked = !!tunEnabled;
inDns.value = (clientConf.tun && clientConf.tun.dns) || '';
inDebug.checked = !!clientConf.debug;
// Load exclusions (arrays to multiline string)
const exc = clientConf.exclude || {};
inExDomains.value = (exc.domains || []).join('\n');
inExIps.value = (exc.ips || []).join('\n');
inExProcesses.value = (exc.processes || []).join('\n');
}
const ex = c.exclude || {};
inDomains.value = (ex.domains || []).join('\n');
inIps.value = (ex.ips || []).join('\n');
inProcesses.value = (ex.processes || []).join('\n');
} catch (err) {
console.error('Error loading config', err);
showToast(String(err), 'error');
}
}
function parseTextAreaToArray(val) {
return val.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
// ── Config — save ─────────────────────────────────────────────────────────────
async function handleSave() {
if (!rawConfig) rawConfig = { mode: 'client', log_level: 'info' };
const server = inServer.value.trim();
const key = inKey.value.trim();
if (!server) { showToast(t('err_server_req') || 'Server address required', 'error'); return; }
if (!key) { showToast(t('err_key_req') || 'Access key required', 'error'); return; }
rawConfig.mode = 'client';
rawConfig.server = server;
rawConfig.access_key = key;
rawConfig.socks5_bind = inSocks.value.trim() || null;
rawConfig.debug = inDebug.checked;
if (!rawConfig.tun) {
rawConfig.tun = { wintun_path: './wintun.dll', ipv4_address: '10.1.0.2/24' };
}
rawConfig.tun.enable = inTun.checked;
rawConfig.tun.dns = inDns.value.trim() || null;
async function handleSaveConfig() {
if (!rawConfigObj) rawConfigObj = { mode: 'client', log_level: 'info' };
rawConfigObj.mode = 'client';
rawConfigObj.server = inServer.value.trim();
rawConfigObj.access_key = inKey.value.trim();
rawConfigObj.socks5_bind = inSocks.value.trim() || null;
if (!rawConfigObj.tun) {
rawConfigObj.tun = {
wintun_path: "./wintun.dll",
ipv4_address: "10.1.0.2/24"
};
}
rawConfigObj.tun.enable = inTunMode.checked;
const dnsVal = inDns.value.trim();
rawConfigObj.tun.dns = dnsVal ? dnsVal : null;
rawConfigObj.debug = inDebug.checked;
// Save Exclusions
rawConfigObj.exclude = {
domains: parseTextAreaToArray(inExDomains.value),
ips: parseTextAreaToArray(inExIps.value),
processes: parseTextAreaToArray(inExProcesses.value)
rawConfig.exclude = {
domains: splitLines(inDomains.value),
ips: splitLines(inIps.value),
processes: splitLines(inProcesses.value),
};
// Validation
if (!rawConfigObj.server) {
showToast(t('err_server_req') || 'Server address is required');
return;
}
if (!rawConfigObj.access_key) {
showToast(t('err_key_req') || 'Access key is required');
return;
}
try {
const finalJson = JSON.stringify(rawConfigObj, null, 2);
const success = await invoke('save_config', { jsonContent: finalJson });
if (success) {
showToast(t('toast_saved'));
setTimeout(() => switchScreen('home'), 800);
const ok = await invoke('save_config', { jsonContent: JSON.stringify(rawConfig, null, 2) });
if (ok) {
showToast(t('toast_saved'), 'ok');
setTimeout(() => showScreen('home'), 700);
} else {
showToast(t('toast_error'), 'error');
}
} catch (err) {
showToast(t('toast_error') + ': ' + err);
showToast(String(err), 'error');
}
}
// OSTP URI Sharing Parser
function handleImportUrl() {
const urlStr = inImportUrl.value.trim();
if (!urlStr) return;
// ── Import share link ─────────────────────────────────────────────────────────
function handleImport() {
const raw = importInput.value.trim();
if (!raw) return;
try {
if (!urlStr.startsWith('ostp://')) {
throw new Error('Link must start with ostp://');
}
const url = new URL(urlStr);
const accessKey = decodeURIComponent(url.username);
const serverHost = url.host;
if (!accessKey || !serverHost) {
throw new Error('Incomplete parameters');
}
inServer.value = serverHost;
inKey.value = accessKey;
inImportUrl.value = '';
showToast(t('toast_imported'));
if (!raw.startsWith('ostp://')) throw new Error('Link must start with ostp://');
const url = new URL(raw);
const key = decodeURIComponent(url.username);
const host = url.host;
if (!key || !host) throw new Error('Incomplete link parameters');
inServer.value = host;
inKey.value = key;
importInput.value = '';
showToast(t('toast_imported'), 'ok');
} catch (err) {
showToast(t('toast_error') + ': ' + err.message);
showToast(err.message, 'error');
}
}
function showToast(message) {
configToast.textContent = message || t('toast_saved');
configToast.classList.add('show');
setTimeout(() => configToast.classList.remove('show'), 2000);
// ── Peek key ──────────────────────────────────────────────────────────────────
let peeking = false;
function togglePeek() {
peeking = !peeking;
inKey.type = peeking ? 'text' : 'password';
btnPeekKey.style.color = peeking
? 'var(--c-accent)'
: 'var(--c-txt-3)';
}
// Initialization
// ── Init ──────────────────────────────────────────────────────────────────────
window.addEventListener('DOMContentLoaded', async () => {
// Apply translations on load
applyTranslations();
setState('disconnected');
// Re-apply dynamic status text
setUIState(appState);
// Event wiring
btnConnect.addEventListener('click', handleToggle);
btnGoSettings.addEventListener('click', () => showScreen('settings'));
btnBack.addEventListener('click', () => showScreen('home'));
btnSave.addEventListener('click', handleSave);
btnImport.addEventListener('click', handleImport);
btnPeekKey.addEventListener('click', togglePeek);
importInput.addEventListener('keydown', e => { if (e.key === 'Enter') handleImport(); });
btnConnect.addEventListener('click', handleToggleConnect);
btnGoSettings.addEventListener('click', () => switchScreen('settings'));
btnBack.addEventListener('click', () => switchScreen('home'));
btnSaveConfig.addEventListener('click', handleSaveConfig);
btnImportUrl.addEventListener('click', handleImportUrl);
inImportUrl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') handleImportUrl();
});
// Language toggle
btnLang.addEventListener('click', () => {
toggleLang();
// Re-apply dynamic elements
const currentState = appState;
appState = ''; // Force refresh
setUIState(currentState);
// Refresh dynamic text without losing state
const cur = appState;
appState = '';
setState(cur);
document.getElementById('lang-label').textContent =
localStorage.getItem('ostp_lang') === 'ru' ? 'RU' : 'EN';
});
// Restore status on app open
try {
const statusCode = await invoke('get_tunnel_status');
if (statusCode > 0) {
startGlobalPolling();
} else {
setUIState('disconnected');
}
} catch (err) {
setUIState('disconnected');
}
const code = await invoke('get_tunnel_status');
if (code > 0) startPolling();
} catch { /* not in Tauri context */ }
});

File diff suppressed because it is too large Load Diff

@ -1 +1 @@
Subproject commit cd3d7d8dae142112d58bc216777b77a2ded83b4e
Subproject commit 64efa677aca2551b61f71f2168611d884619e5ca