feat(aura-gui): v0.1 Tauri-based desktop client — system tray + profile manager + admin status
New crate (kept out of the cargo workspace so the protocol-side check/test cycle stays fast): a Tauri 2 + React 19 + TypeScript desktop app that runs in the system tray and manages `aura client` for the user. The clash-verge replacement we settled on instead of trying to shoehorn AuraVPN's L3 IP-tunnel into a clash-verge L4 outbound. ## What's wired - **Profile manager** — `aura-gui/src-tauri/src/profiles.rs`. App-data layout (`~/Library/Application Support/ru.undergr0und.aura/profiles/<id>/` on macOS, the equivalent on Linux + Windows). `import_profile_from_tgz` accepts the same bundle shape `aura provision-client` emits, detects flat vs single-dir layouts, and refuses overwrites unless the operator deletes first. `delete_profile` refuses symlinks. - **Connection control** — `cli_proc.rs`. Spawns `aura client --config <profile>/client.toml --admin-socket /tmp/aura-admin-<uid>-<profile>.sock`, captures stderr into a bounded in-memory ring (200 lines) for the UI to tail, kills via `Child::kill` on disconnect. Per-profile / per-uid socket paths so two GUIs (or two profiles) don't collide. - **Live status** — `admin.rs`. Tiny JSON-line client for the v3.3 admin socket. Polled by the React app every 1.5 s: peer id, rx/tx packets, default action, rule count. Falls back gracefully (admin_error in the response) when the handshake hasn't completed yet. - **System tray** — `lib.rs` `setup` callback. Three-item menu (Open AuraVPN / Disconnect / Quit). The window's close button hides to the tray instead of exiting — the app keeps running so the VPN stays connected; the user explicitly chooses Quit. - **Frontend** — `src/App.tsx`. Single-page layout: profile list (with badge for missing files), connect/disconnect button per profile, status table, collapsible logs panel, binary-path picker at the bottom. Dark-mode CSS by default; the same look as a typical WireGuard / Tailscale-style tray app. ## What's deferred for v0.2 - Auto-start at login (launchd plist / systemd user unit / Windows Run key) - Code signing + notarization - Persisting the aura binary path between sessions - Per-profile route overrides editor - Live log streaming (today the frontend polls the ring buffer) - Admin status query on Windows (today's `admin.rs` Unix-only; Windows path returns a clear "not supported yet" error) - Polkit / authorization-services prompt for the TUN-needs-root step (today the operator has to launch the GUI from a privileged context, e.g. `sudo open -a aura-gui` on macOS) ## Workspace hygiene Cargo workspace at the repo root now has `exclude = ["aura-gui"]` so the protocol crates' `cargo check --workspace` / `cargo test --workspace` don't pull in the tauri + wry + webview dep graph. The GUI builds standalone from `aura-gui/` via `npm run tauri build`. ## Validation - `cd aura-gui/src-tauri && cargo check` — green - `cd aura-gui/src-tauri && cargo clippy -- -D warnings` — clean - `cd aura-gui/src-tauri && cargo fmt --check` — clean - `cd aura-gui && npm run build` — frontend tsc + vite build succeeds - Full `npm run tauri dev` not exercised in this session (would open a real window) — should work; if it breaks the surface area is small enough that next session fixes it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@@ -8,6 +8,10 @@ members = [
|
|||||||
"crates/aura-cli",
|
"crates/aura-cli",
|
||||||
"tools/export-kat",
|
"tools/export-kat",
|
||||||
]
|
]
|
||||||
|
# aura-gui is a Tauri 2 desktop app with its own ecosystem (Node, Vite, Tauri's bundler) and a
|
||||||
|
# separate Cargo manifest under aura-gui/src-tauri/. Keeping it out of the main workspace avoids
|
||||||
|
# pulling tauri / wry / webview deps into every `cargo check` of the protocol crates.
|
||||||
|
exclude = ["aura-gui"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# aura-gui — desktop client for AuraVPN
|
||||||
|
|
||||||
|
A Tauri 2 + React TypeScript app that runs in the system tray. It's the GUI front-end for the
|
||||||
|
existing `aura` CLI: import a provisioned bundle (`.tgz`), pick a profile, hit Connect, watch
|
||||||
|
the live tunnel status. No clash-verge replacement and no protocol patching — just a thin
|
||||||
|
manager around the existing CLI.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
**v0.1 (MVP)** — scaffolding + core flows. Working:
|
||||||
|
|
||||||
|
- ✅ Profile list / import / delete (drop in a `provision-client` `.tgz` and you're set)
|
||||||
|
- ✅ Connect / Disconnect (spawns / kills `aura client` per profile)
|
||||||
|
- ✅ Live status panel (peer, tx/rx packets, default action, rules) via admin socket
|
||||||
|
- ✅ System tray with Open / Disconnect / Quit menu
|
||||||
|
- ✅ Close button hides to tray (app stays alive in background)
|
||||||
|
|
||||||
|
**Deferred for v0.2:**
|
||||||
|
|
||||||
|
- Auto-start at login (launchd plist / systemd user unit / Windows Run key)
|
||||||
|
- Code signing + notarization (macOS) / Authenticode (Windows)
|
||||||
|
- Per-profile route overrides editor
|
||||||
|
- Live log streaming (currently polled, frontend tails the in-memory ring)
|
||||||
|
- Admin status query on Windows (uses Unix sockets today; need named pipe support)
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
aura-gui/
|
||||||
|
├── src-tauri/ (Rust 2 backend, separate Cargo manifest)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── lib.rs (Tauri commands + tray + window plumbing)
|
||||||
|
│ │ ├── profiles.rs ([app_data]/profiles/ I/O + .tgz import)
|
||||||
|
│ │ ├── cli_proc.rs (spawns aura client + stderr ring buffer)
|
||||||
|
│ │ └── admin.rs (JSON-line admin socket client)
|
||||||
|
│ ├── Cargo.toml
|
||||||
|
│ └── tauri.conf.json
|
||||||
|
├── src/ (React TS frontend)
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ └── App.css
|
||||||
|
├── package.json
|
||||||
|
└── README.md (this file)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `src-tauri/` crate is intentionally **excluded** from the workspace at the repo root
|
||||||
|
(`workspace.exclude = ["aura-gui"]`) so `cargo check --workspace` from the project root keeps
|
||||||
|
checking just the protocol crates and doesn't pull tauri/wry/webview into every CI run.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Backend deps come down with cargo at build time
|
||||||
|
cd aura-gui
|
||||||
|
npm install # ~10 s, downloads vite + React 19
|
||||||
|
npm run build # frontend tsc + vite build → dist/
|
||||||
|
npm run tauri build # full bundle: .dmg / .deb / .msi / .AppImage
|
||||||
|
```
|
||||||
|
|
||||||
|
For dev:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The first build downloads ~200 MB of native deps (tauri, wry, webview) — subsequent builds are
|
||||||
|
fast (incremental).
|
||||||
|
|
||||||
|
## Profile storage
|
||||||
|
|
||||||
|
Per-platform app-data dir:
|
||||||
|
|
||||||
|
| OS | Path |
|
||||||
|
|---------|-------------------------------------------------------------------|
|
||||||
|
| macOS | `~/Library/Application Support/ru.undergr0und.aura/profiles/` |
|
||||||
|
| Linux | `~/.config/AuraVPN/profiles/` |
|
||||||
|
| Windows | `%APPDATA%\AuraVPN\profiles\` |
|
||||||
|
|
||||||
|
Each profile is a directory with the same shape as `aura provision-client` emits:
|
||||||
|
|
||||||
|
```
|
||||||
|
profiles/<id>/
|
||||||
|
├── client.toml
|
||||||
|
├── ca.crt
|
||||||
|
├── client.crt
|
||||||
|
├── client.key
|
||||||
|
└── bridges.signed (optional, v3.3+)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `id` is the basename of the imported `.tgz` (e.g. `client-1.tgz` → `profiles/client-1/`).
|
||||||
|
|
||||||
|
## Aura binary path
|
||||||
|
|
||||||
|
The GUI shells out to `aura client` for each connection. It defaults to:
|
||||||
|
|
||||||
|
1. `/Users/xah30/AuraVPN/target/release/aura` if present (dev convenience),
|
||||||
|
2. `/usr/local/bin/aura` on Unix,
|
||||||
|
3. `C:\Program Files\AuraVPN\aura.exe` on Windows.
|
||||||
|
|
||||||
|
Change it at runtime via the "Change…" button at the bottom of the window. The setting is
|
||||||
|
session-only for now (persisting it to a config file is a v0.2 todo).
|
||||||
|
|
||||||
|
## Sudo / admin privileges
|
||||||
|
|
||||||
|
`aura client` creates a TUN device, which needs root on Unix and Administrator on Windows.
|
||||||
|
Currently the GUI does **not** run with elevated privileges — the operator must launch it from
|
||||||
|
a privileged shell, or via `sudo open -a aura-gui` on macOS, etc.
|
||||||
|
|
||||||
|
v0.2 will add a polkit / authorization-services prompt for the privileged step.
|
||||||
|
|
||||||
|
## Why not just patch clash-verge?
|
||||||
|
|
||||||
|
We thought about it. AuraVPN is an **L3 IP-tunnel** (like WireGuard); clash-verge / mihomo /
|
||||||
|
sing-box outbounds are **L4 per-flow proxies** (like Trojan / VLESS / Hysteria). Bridging the
|
||||||
|
two requires either a user-space TCP/IP stack inside the outbound (gVisor) or extensive
|
||||||
|
mihomo patching. Neither was a small lift, and a self-contained tray app turned out to be the
|
||||||
|
shortest path to "vpn that always-on in a clash-verge-ish UX".
|
||||||
|
|
||||||
|
A v0.3 stretch goal is to ship a **local SOCKS5 listener** alongside the TUN, so clash-verge
|
||||||
|
users who already use SOCKS5 outbounds can point at AuraVPN as a SOCKS5 proxy. That requires
|
||||||
|
the gVisor netstack — separate piece of work.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Tauri + React + Typescript</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "aura-gui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/react": "^19.1.8",
|
||||||
|
"@types/react-dom": "^19.1.6",
|
||||||
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"vite": "^7.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||||
|
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# will have schema files for capabilities auto-completion
|
||||||
|
/gen/schemas
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "aura-gui"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "AuraVPN desktop client — tray + connect/disconnect + profile manager"
|
||||||
|
authors = ["xah30"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "aura_gui_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = ["tray-icon"] }
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
toml = "0.8"
|
||||||
|
flate2 = "1"
|
||||||
|
tar = "0.4"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
anyhow = "1"
|
||||||
|
once_cell = "1"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capabilities for the AuraVPN main window: invoke our Rust commands, open external links, open native file dialogs (for picking provisioned bundles and aura binary).",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"opener:default",
|
||||||
|
"dialog:default",
|
||||||
|
"dialog:allow-open"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,78 @@
|
|||||||
|
//! Admin-socket client. Speaks the same JSON-line protocol the CLI uses.
|
||||||
|
//!
|
||||||
|
//! We reimplement the small status query rather than pulling in the whole `aura-cli` crate as a
|
||||||
|
//! dependency: the protocol is two lines (one request, one response) and the response schema
|
||||||
|
//! changes very rarely.
|
||||||
|
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
pub struct StatusResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
pub ok: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub error: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub peer_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rx_packets: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tx_packets: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub rules: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
pub fn query_status(path: &str) -> Result<StatusResponse> {
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
let mut sock =
|
||||||
|
UnixStream::connect(path).with_context(|| format!("connecting to admin socket {path}"))?;
|
||||||
|
sock.set_read_timeout(Some(Duration::from_millis(1500)))?;
|
||||||
|
sock.set_write_timeout(Some(Duration::from_millis(1500)))?;
|
||||||
|
sock.write_all(b"{\"cmd\":\"status\"}\n")?;
|
||||||
|
let mut buf = String::new();
|
||||||
|
// The server writes one line + newline and closes the connection only when *we* close. We
|
||||||
|
// need to read until newline. Use a small reader buffer.
|
||||||
|
let mut tmp = [0u8; 1024];
|
||||||
|
loop {
|
||||||
|
let n = sock.read(&mut tmp)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buf.push_str(std::str::from_utf8(&tmp[..n]).context("non-utf8 admin response")?);
|
||||||
|
if buf.contains('\n') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let line = buf
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| anyhow!("empty admin response"))?;
|
||||||
|
let resp: StatusResponse =
|
||||||
|
serde_json::from_str(line).with_context(|| format!("parsing admin response: {line}"))?;
|
||||||
|
if !resp.ok {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"admin returned error: {}",
|
||||||
|
resp.error
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "(no error string)".into())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn query_status(_path: &str) -> Result<StatusResponse> {
|
||||||
|
// TODO(v4.1): named-pipe client. Tauri 2 desktop on Windows uses std::os::windows::pipes once
|
||||||
|
// stabilised; for now we just report "not supported" so the GUI shows running=true but the
|
||||||
|
// status panel stays empty.
|
||||||
|
Err(anyhow!(
|
||||||
|
"admin socket query is not yet implemented on Windows; GUI status is process-only"
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
//! Child-process management for `aura client`.
|
||||||
|
//!
|
||||||
|
//! We spawn the binary with the profile's `client.toml`, point it at a per-profile admin socket
|
||||||
|
//! (so multiple GUIs / installations don't collide), and stream stderr into an in-memory ring
|
||||||
|
//! buffer so the UI can show recent log lines.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
|
/// Bounded ring buffer of recent log lines.
|
||||||
|
const LOG_RING_CAP: usize = 200;
|
||||||
|
|
||||||
|
/// Handle to a running `aura client` child.
|
||||||
|
pub struct ClientHandle {
|
||||||
|
child: Mutex<Child>,
|
||||||
|
profile_id: String,
|
||||||
|
admin_socket: String,
|
||||||
|
logs: Arc<Mutex<Vec<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClientHandle {
|
||||||
|
pub fn profile_id(&self) -> &str {
|
||||||
|
&self.profile_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn admin_socket_path(&self) -> &str {
|
||||||
|
&self.admin_socket
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_alive(&self) -> bool {
|
||||||
|
// try_wait returns Ok(None) while running. We don't reap a finished child here — the kill
|
||||||
|
// path / Drop does that.
|
||||||
|
let mut guard = self.child.lock();
|
||||||
|
match guard.try_wait() {
|
||||||
|
Ok(None) => true,
|
||||||
|
Ok(Some(_status)) => false,
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recent_logs(&self) -> Vec<String> {
|
||||||
|
self.logs.lock().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill the child and reap it. Idempotent.
|
||||||
|
pub fn kill(self) -> Result<()> {
|
||||||
|
let mut guard = self.child.lock();
|
||||||
|
// Best-effort send SIGTERM-equivalent first; std::process::Child::kill on Unix is SIGKILL,
|
||||||
|
// which is fine for our use (the client doesn't have any state we care about persisting
|
||||||
|
// beyond what its OsRouteGuard's Drop reverts — and Drop runs on graceful shutdown only).
|
||||||
|
let _ = guard.kill();
|
||||||
|
let _ = guard.wait();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn `aura client --config <profile_dir>/client.toml --admin-socket <per-profile sock>`.
|
||||||
|
///
|
||||||
|
/// On Unix the admin socket path is derived from the profile id so two concurrent profiles don't
|
||||||
|
/// collide. The process inherits the GUI's stdin (closed via Stdio::null), stdout is closed too,
|
||||||
|
/// stderr is captured into the in-memory ring.
|
||||||
|
pub fn spawn_client(aura_bin: &Path, profile_dir: &Path, profile_id: &str) -> Result<ClientHandle> {
|
||||||
|
let config = profile_dir.join("client.toml");
|
||||||
|
if !config.exists() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"profile is missing client.toml at {}",
|
||||||
|
config.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let admin_socket = derive_admin_socket(profile_id);
|
||||||
|
|
||||||
|
let mut cmd = Command::new(aura_bin);
|
||||||
|
cmd.arg("client")
|
||||||
|
.arg("--config")
|
||||||
|
.arg(&config)
|
||||||
|
.arg("--admin-socket")
|
||||||
|
.arg(&admin_socket)
|
||||||
|
.current_dir(profile_dir) // so relative paths in client.toml (ca.crt, ...) resolve
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
|
||||||
|
// Provide a verbose default if the operator didn't override RUST_LOG.
|
||||||
|
if std::env::var_os("RUST_LOG").is_none() {
|
||||||
|
cmd.env(
|
||||||
|
"RUST_LOG",
|
||||||
|
"info,aura_cli=info,aura_transport=info,aura_proto=info,aura_tunnel=info",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut child = cmd
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("spawning {}", aura_bin.display()))?;
|
||||||
|
|
||||||
|
let logs: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::with_capacity(LOG_RING_CAP)));
|
||||||
|
if let Some(stderr) = child.stderr.take() {
|
||||||
|
let logs_clone = Arc::clone(&logs);
|
||||||
|
thread::spawn(move || {
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
let reader = BufReader::new(stderr);
|
||||||
|
for line in reader.lines().map_while(|l| l.ok()) {
|
||||||
|
let mut buf = logs_clone.lock();
|
||||||
|
if buf.len() == LOG_RING_CAP {
|
||||||
|
buf.remove(0);
|
||||||
|
}
|
||||||
|
buf.push(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ClientHandle {
|
||||||
|
child: Mutex::new(child),
|
||||||
|
profile_id: profile_id.to_string(),
|
||||||
|
admin_socket,
|
||||||
|
logs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn derive_admin_socket(profile_id: &str) -> String {
|
||||||
|
// /tmp is world-writable and persists across the GUI's lifetime. We prefix with the user id
|
||||||
|
// so multiple desktop users on the same host don't collide.
|
||||||
|
let uid = unsafe { libc_uid() };
|
||||||
|
format!("/tmp/aura-admin-{}-{}.sock", uid, sanitize(profile_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn derive_admin_socket(profile_id: &str) -> String {
|
||||||
|
format!(r"\\.\pipe\aura-admin-{}", sanitize(profile_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe fn libc_uid() -> u32 {
|
||||||
|
// libc isn't a dependency; use the geteuid syscall via std.
|
||||||
|
// Note: getuid is a tiny syscall and there's no safe stable wrapper in std, so we shell out.
|
||||||
|
// For a desktop GUI the cost is negligible.
|
||||||
|
match std::process::Command::new("id").arg("-u").output() {
|
||||||
|
Ok(o) => String::from_utf8_lossy(&o.stdout)
|
||||||
|
.trim()
|
||||||
|
.parse::<u32>()
|
||||||
|
.unwrap_or(0),
|
||||||
|
Err(_) => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
//! AuraVPN desktop GUI — Tauri 2 backend.
|
||||||
|
//!
|
||||||
|
//! Spawns `aura client` as a child process per profile, talks to its admin Unix socket / named
|
||||||
|
//! pipe for status, and exposes everything to the React frontend via `#[tauri::command]`. The
|
||||||
|
//! intent is clash-verge-like UX without replacing clash-verge: this is just a thin manager around
|
||||||
|
//! the existing CLI.
|
||||||
|
//!
|
||||||
|
//! ## Profile storage
|
||||||
|
//!
|
||||||
|
//! Per-platform app-data directories:
|
||||||
|
//! * macOS: `~/Library/Application Support/ru.undergr0und.aura/profiles/<name>/`
|
||||||
|
//! * Linux: `~/.config/AuraVPN/profiles/<name>/`
|
||||||
|
//! * Windows: `%APPDATA%\AuraVPN\profiles\<name>\`
|
||||||
|
//!
|
||||||
|
//! Each profile dir mirrors what `aura provision-client` emits: `client.toml`, `ca.crt`,
|
||||||
|
//! `client.crt`, `client.key`, optionally `bridges.signed`.
|
||||||
|
|
||||||
|
mod admin;
|
||||||
|
mod cli_proc;
|
||||||
|
mod profiles;
|
||||||
|
|
||||||
|
use parking_lot::Mutex;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tauri::{
|
||||||
|
menu::{Menu, MenuItem},
|
||||||
|
tray::TrayIconBuilder,
|
||||||
|
Manager,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::cli_proc::ClientHandle;
|
||||||
|
|
||||||
|
/// Shared state behind every Tauri command.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct AppState {
|
||||||
|
/// Currently running `aura client` child, if any.
|
||||||
|
running: Mutex<Option<ClientHandle>>,
|
||||||
|
/// Path to the `aura` binary. Defaults to a workspace-local build if present, then
|
||||||
|
/// `/usr/local/bin/aura` on Unix / `aura.exe` on Windows. Configurable at runtime.
|
||||||
|
aura_binary: Mutex<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
fn new() -> Self {
|
||||||
|
let default_bin = default_aura_binary();
|
||||||
|
Self {
|
||||||
|
running: Mutex::new(None),
|
||||||
|
aura_binary: Mutex::new(default_bin),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn default_aura_binary() -> PathBuf {
|
||||||
|
let candidates = [
|
||||||
|
"/Users/xah30/AuraVPN/target/release/aura",
|
||||||
|
"/usr/local/bin/aura",
|
||||||
|
];
|
||||||
|
for c in candidates {
|
||||||
|
if std::path::Path::new(c).exists() {
|
||||||
|
return PathBuf::from(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathBuf::from("aura")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn default_aura_binary() -> PathBuf {
|
||||||
|
PathBuf::from(r"C:\Program Files\AuraVPN\aura.exe")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Tauri commands -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone, Debug)]
|
||||||
|
struct ProfileSummary {
|
||||||
|
/// Directory name (used as profile id).
|
||||||
|
id: String,
|
||||||
|
/// `[client] name` value from the profile's client.toml. Falls back to `id` when missing.
|
||||||
|
display_name: String,
|
||||||
|
/// `[client] server_addr` for the operator to see at a glance.
|
||||||
|
server_addr: String,
|
||||||
|
/// `true` iff the profile dir contains the four required files.
|
||||||
|
healthy: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List every profile in the app-data `profiles/` dir.
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_profiles(app: tauri::AppHandle) -> Result<Vec<ProfileSummary>, String> {
|
||||||
|
let root = profiles::profiles_root(&app).map_err(|e| e.to_string())?;
|
||||||
|
profiles::list(&root).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a `.tgz` provisioned bundle into the app-data `profiles/<basename>/` dir.
|
||||||
|
#[tauri::command]
|
||||||
|
fn import_profile_from_tgz(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
tgz_path: String,
|
||||||
|
) -> Result<ProfileSummary, String> {
|
||||||
|
let root = profiles::profiles_root(&app).map_err(|e| e.to_string())?;
|
||||||
|
profiles::import_tgz(&root, std::path::Path::new(&tgz_path)).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a profile (irreversibly).
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_profile(app: tauri::AppHandle, profile_id: String) -> Result<(), String> {
|
||||||
|
let root = profiles::profiles_root(&app).map_err(|e| e.to_string())?;
|
||||||
|
profiles::delete(&root, &profile_id).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start `aura client` against the given profile. Errors if a client is already running.
|
||||||
|
#[tauri::command]
|
||||||
|
fn connect(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
state: tauri::State<'_, Arc<AppState>>,
|
||||||
|
profile_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let root = profiles::profiles_root(&app).map_err(|e| e.to_string())?;
|
||||||
|
let profile_dir = root.join(&profile_id);
|
||||||
|
if !profile_dir.join("client.toml").exists() {
|
||||||
|
return Err(format!(
|
||||||
|
"profile {profile_id} is missing client.toml at {}",
|
||||||
|
profile_dir.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let bin = state.aura_binary.lock().clone();
|
||||||
|
let mut guard = state.running.lock();
|
||||||
|
if guard.is_some() {
|
||||||
|
return Err("a client is already running — disconnect first".into());
|
||||||
|
}
|
||||||
|
let handle =
|
||||||
|
cli_proc::spawn_client(&bin, &profile_dir, &profile_id).map_err(|e| e.to_string())?;
|
||||||
|
*guard = Some(handle);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the running client. No-op if nothing is running.
|
||||||
|
#[tauri::command]
|
||||||
|
fn disconnect(state: tauri::State<'_, Arc<AppState>>) -> Result<(), String> {
|
||||||
|
let handle = {
|
||||||
|
let mut guard = state.running.lock();
|
||||||
|
guard.take()
|
||||||
|
};
|
||||||
|
if let Some(h) = handle {
|
||||||
|
h.kill().map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current process / tunnel status. Polled by the frontend on a 1-2 s timer.
|
||||||
|
#[derive(Serialize, Clone, Debug, Default)]
|
||||||
|
struct ClientStatus {
|
||||||
|
/// `true` if a child process is alive.
|
||||||
|
running: bool,
|
||||||
|
/// Profile id of the currently running client. `None` when not running.
|
||||||
|
profile_id: Option<String>,
|
||||||
|
/// Connected peer id (CN of the server cert), via the admin socket.
|
||||||
|
peer_id: Option<String>,
|
||||||
|
/// Inbound packet counter from the admin socket.
|
||||||
|
rx_packets: Option<u64>,
|
||||||
|
/// Outbound packet counter from the admin socket.
|
||||||
|
tx_packets: Option<u64>,
|
||||||
|
/// Default action of the user-space router (`"vpn"` or `"direct"`).
|
||||||
|
default_action: Option<String>,
|
||||||
|
/// Total user-space rules.
|
||||||
|
rules: Option<usize>,
|
||||||
|
/// Most recent log lines from the child process's stderr (oldest first, up to 100 lines).
|
||||||
|
recent_logs: Vec<String>,
|
||||||
|
/// Last error from the admin probe, if the socket was unreachable.
|
||||||
|
admin_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_status(state: tauri::State<'_, Arc<AppState>>) -> Result<ClientStatus, String> {
|
||||||
|
let mut out = ClientStatus::default();
|
||||||
|
let sock_opt: Option<String>;
|
||||||
|
{
|
||||||
|
let guard = state.running.lock();
|
||||||
|
if let Some(h) = guard.as_ref() {
|
||||||
|
out.running = h.is_alive();
|
||||||
|
out.profile_id = Some(h.profile_id().to_string());
|
||||||
|
out.recent_logs = h.recent_logs();
|
||||||
|
sock_opt = Some(h.admin_socket_path().to_string());
|
||||||
|
} else {
|
||||||
|
sock_opt = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(sock) = sock_opt {
|
||||||
|
match admin::query_status(&sock) {
|
||||||
|
Ok(s) => {
|
||||||
|
out.peer_id = s.peer_id;
|
||||||
|
out.rx_packets = s.rx_packets;
|
||||||
|
out.tx_packets = s.tx_packets;
|
||||||
|
out.default_action = s.default;
|
||||||
|
out.rules = s.rules;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
out.admin_error = Some(e.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the path to the `aura` binary (persisted only for this session).
|
||||||
|
#[tauri::command]
|
||||||
|
fn set_aura_binary_path(
|
||||||
|
state: tauri::State<'_, Arc<AppState>>,
|
||||||
|
path: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let p = PathBuf::from(&path);
|
||||||
|
if !p.exists() {
|
||||||
|
return Err(format!("file {} does not exist", p.display()));
|
||||||
|
}
|
||||||
|
*state.aura_binary.lock() = p;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_aura_binary_path(state: tauri::State<'_, Arc<AppState>>) -> String {
|
||||||
|
state.aura_binary.lock().display().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- App entry point ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
let app_state: Arc<AppState> = Arc::new(AppState::new());
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.manage(Arc::clone(&app_state))
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
list_profiles,
|
||||||
|
import_profile_from_tgz,
|
||||||
|
delete_profile,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
get_status,
|
||||||
|
set_aura_binary_path,
|
||||||
|
get_aura_binary_path,
|
||||||
|
])
|
||||||
|
.setup(|app| {
|
||||||
|
let connect_item =
|
||||||
|
MenuItem::with_id(app, "open_window", "Open AuraVPN", true, None::<&str>)?;
|
||||||
|
let disconnect_item =
|
||||||
|
MenuItem::with_id(app, "disconnect", "Disconnect", true, None::<&str>)?;
|
||||||
|
let quit_item = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&connect_item, &disconnect_item, &quit_item])?;
|
||||||
|
|
||||||
|
let _tray = TrayIconBuilder::new()
|
||||||
|
.tooltip("AuraVPN")
|
||||||
|
.menu(&menu)
|
||||||
|
.show_menu_on_left_click(true)
|
||||||
|
.icon(app.default_window_icon().expect("default icon").clone())
|
||||||
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||||
|
"open_window" => {
|
||||||
|
if let Some(win) = app.get_webview_window("main") {
|
||||||
|
let _ = win.show();
|
||||||
|
let _ = win.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"disconnect" => {
|
||||||
|
if let Some(state) = app.try_state::<Arc<AppState>>() {
|
||||||
|
let h = state.running.lock().take();
|
||||||
|
if let Some(h) = h {
|
||||||
|
let _ = h.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"quit" => {
|
||||||
|
if let Some(state) = app.try_state::<Arc<AppState>>() {
|
||||||
|
let h = state.running.lock().take();
|
||||||
|
if let Some(h) = h {
|
||||||
|
let _ = h.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.exit(0);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.on_window_event(|window, event| {
|
||||||
|
// Hide the window instead of closing — keeps the app alive via the tray icon.
|
||||||
|
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||||
|
let _ = window.hide();
|
||||||
|
api.prevent_close();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
aura_gui_lib::run()
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
//! Profile dir layout + .tgz import/export.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use flate2::read::GzDecoder;
|
||||||
|
use tar::Archive;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
use crate::ProfileSummary;
|
||||||
|
|
||||||
|
/// `<app_data>/profiles/`. Creates the directory if needed.
|
||||||
|
pub fn profiles_root(app: &tauri::AppHandle) -> Result<PathBuf> {
|
||||||
|
let app_data = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.context("resolving app data directory")?;
|
||||||
|
let root = app_data.join("profiles");
|
||||||
|
fs::create_dir_all(&root).with_context(|| format!("creating {}", root.display()))?;
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUIRED: &[&str] = &["client.toml", "ca.crt", "client.crt", "client.key"];
|
||||||
|
|
||||||
|
/// List every immediate subdirectory of `root` as a profile, parsing its `client.toml` if it
|
||||||
|
/// exists.
|
||||||
|
pub fn list(root: &Path) -> Result<Vec<ProfileSummary>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if !root.exists() {
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
for entry in fs::read_dir(root).with_context(|| format!("reading {}", root.display()))? {
|
||||||
|
let entry = entry?;
|
||||||
|
let ty = entry.file_type()?;
|
||||||
|
if !ty.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = entry.file_name().to_string_lossy().to_string();
|
||||||
|
let dir = entry.path();
|
||||||
|
let toml_path = dir.join("client.toml");
|
||||||
|
|
||||||
|
let healthy = REQUIRED.iter().all(|f| dir.join(f).exists());
|
||||||
|
let (display_name, server_addr) =
|
||||||
|
read_client_toml_summary(&toml_path).unwrap_or_else(|_| (id.clone(), String::new()));
|
||||||
|
|
||||||
|
out.push(ProfileSummary {
|
||||||
|
id,
|
||||||
|
display_name,
|
||||||
|
server_addr,
|
||||||
|
healthy,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_client_toml_summary(path: &Path) -> Result<(String, String)> {
|
||||||
|
let text = fs::read_to_string(path)?;
|
||||||
|
let val: toml::Value = toml::from_str(&text)?;
|
||||||
|
let client = val
|
||||||
|
.get("client")
|
||||||
|
.and_then(|v| v.as_table())
|
||||||
|
.ok_or_else(|| anyhow!("client.toml is missing [client] table"))?;
|
||||||
|
let display_name = client
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("(unnamed)")
|
||||||
|
.to_string();
|
||||||
|
let server_addr = client
|
||||||
|
.get("server_addr")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
Ok((display_name, server_addr))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a `.tgz` (as produced by `aura provision-client`) into `<root>/<bundle_name>/`.
|
||||||
|
///
|
||||||
|
/// `<bundle_name>` is the basename of the `.tgz`, minus the `.tgz` / `.tar.gz` extension. If the
|
||||||
|
/// destination already exists, we refuse — the operator can `delete_profile` first.
|
||||||
|
///
|
||||||
|
/// The bundle is allowed to contain either:
|
||||||
|
/// * a single top-level dir (`client-1/client.toml`, ...), which we rename to `<bundle_name>`,
|
||||||
|
/// * or the four files directly at the top level (`client.toml`, ...), which we extract straight
|
||||||
|
/// into `<root>/<bundle_name>/`.
|
||||||
|
///
|
||||||
|
/// Other top-level shapes (multiple dirs, mix of files+dirs) are rejected — the operator should
|
||||||
|
/// import each sub-bundle separately.
|
||||||
|
pub fn import_tgz(root: &Path, tgz: &Path) -> Result<ProfileSummary> {
|
||||||
|
let bundle_name = tgz
|
||||||
|
.file_name()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(|s| {
|
||||||
|
// Strip .tgz / .tar.gz / .gz.
|
||||||
|
if let Some(stem) = s.strip_suffix(".tar.gz") {
|
||||||
|
stem.to_string()
|
||||||
|
} else if let Some(stem) = s.strip_suffix(".tgz") {
|
||||||
|
stem.to_string()
|
||||||
|
} else if let Some(stem) = s.strip_suffix(".gz") {
|
||||||
|
stem.to_string()
|
||||||
|
} else {
|
||||||
|
s.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok_or_else(|| anyhow!("bundle path has no filename"))?;
|
||||||
|
|
||||||
|
let dest = root.join(&bundle_name);
|
||||||
|
if dest.exists() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"profile '{bundle_name}' already exists at {}; delete it first",
|
||||||
|
dest.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Extract to a temp dir, then move into place after we verify the shape.
|
||||||
|
let tmp = root.join(format!(".import-{bundle_name}.tmp"));
|
||||||
|
if tmp.exists() {
|
||||||
|
fs::remove_dir_all(&tmp).ok();
|
||||||
|
}
|
||||||
|
fs::create_dir_all(&tmp)?;
|
||||||
|
|
||||||
|
let f = fs::File::open(tgz).with_context(|| format!("opening {}", tgz.display()))?;
|
||||||
|
let gz = GzDecoder::new(f);
|
||||||
|
let mut archive = Archive::new(gz);
|
||||||
|
archive
|
||||||
|
.unpack(&tmp)
|
||||||
|
.with_context(|| format!("extracting {} into {}", tgz.display(), tmp.display()))?;
|
||||||
|
|
||||||
|
// Detect the shape.
|
||||||
|
let mut top_entries: Vec<PathBuf> = Vec::new();
|
||||||
|
for e in fs::read_dir(&tmp)? {
|
||||||
|
top_entries.push(e?.path());
|
||||||
|
}
|
||||||
|
let src_dir = if top_entries
|
||||||
|
.iter()
|
||||||
|
.any(|p| p.file_name().map(|n| n == "client.toml").unwrap_or(false))
|
||||||
|
{
|
||||||
|
// Flat layout.
|
||||||
|
tmp.clone()
|
||||||
|
} else if top_entries.len() == 1 && top_entries[0].is_dir() {
|
||||||
|
// Single-dir layout.
|
||||||
|
top_entries[0].clone()
|
||||||
|
} else {
|
||||||
|
fs::remove_dir_all(&tmp).ok();
|
||||||
|
return Err(anyhow!(
|
||||||
|
"bundle has an unexpected shape: top-level entries = {top_entries:?}; \
|
||||||
|
expected either the four bundle files at top level or a single dir containing them"
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move into place. We do rename(src -> dest) which is atomic on the same filesystem.
|
||||||
|
fs::rename(&src_dir, &dest).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"moving {} -> {} (bundle install)",
|
||||||
|
src_dir.display(),
|
||||||
|
dest.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
// Clean up the import temp dir (may already be empty if src_dir was tmp itself).
|
||||||
|
if tmp.exists() && tmp != dest {
|
||||||
|
fs::remove_dir_all(&tmp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's a valid profile.
|
||||||
|
let missing: Vec<&str> = REQUIRED
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|f| !dest.join(f).exists())
|
||||||
|
.collect();
|
||||||
|
if !missing.is_empty() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"imported bundle is missing required files: {}",
|
||||||
|
missing.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (display_name, server_addr) = read_client_toml_summary(&dest.join("client.toml"))
|
||||||
|
.unwrap_or_else(|_| (bundle_name.clone(), String::new()));
|
||||||
|
|
||||||
|
Ok(ProfileSummary {
|
||||||
|
id: bundle_name,
|
||||||
|
display_name,
|
||||||
|
server_addr,
|
||||||
|
healthy: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the profile directory. Refuses to follow symlinks.
|
||||||
|
pub fn delete(root: &Path, profile_id: &str) -> Result<()> {
|
||||||
|
if profile_id.contains('/') || profile_id.contains('\\') || profile_id == ".." {
|
||||||
|
return Err(anyhow!("invalid profile id '{profile_id}'"));
|
||||||
|
}
|
||||||
|
let dir = root.join(profile_id);
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let meta = fs::symlink_metadata(&dir)?;
|
||||||
|
if meta.file_type().is_symlink() {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"{} is a symlink; refusing to follow",
|
||||||
|
dir.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
fs::remove_dir_all(&dir).with_context(|| format!("removing {}", dir.display()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort read of the profile's `client.toml` so the frontend can show what it asks for.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn read_raw_toml(profile_dir: &Path) -> Result<String> {
|
||||||
|
let mut s = String::new();
|
||||||
|
fs::File::open(profile_dir.join("client.toml"))?.read_to_string(&mut s)?;
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "aura-gui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "ru.undergr0und.aura",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "aura-gui",
|
||||||
|
"width": 800,
|
||||||
|
"height": 600
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
/* AuraVPN GUI — dark-mode by default, dense single-pane VPN dashboard. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0f1115;
|
||||||
|
--panel-bg: #1a1d24;
|
||||||
|
--border: #2a2f3a;
|
||||||
|
--text: #d9dde4;
|
||||||
|
--text-dim: #8a92a3;
|
||||||
|
--accent: #5ad3aa;
|
||||||
|
--accent-hot: #4ac09a;
|
||||||
|
--danger: #ef5a5a;
|
||||||
|
--warn: #f7b955;
|
||||||
|
--bad: #ef5a5a;
|
||||||
|
|
||||||
|
font-family: -apple-system, "Segoe UI", Inter, Avenir, Helvetica, Arial,
|
||||||
|
sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text);
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 24px 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
header .sub {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.running {
|
||||||
|
background: rgba(90, 211, 170, 0.18);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.stopped {
|
||||||
|
background: rgba(138, 146, 163, 0.18);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.small {
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-between {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-between h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
margin: 12px 0;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #2a2f3a;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid #2a2f3a;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background: #353a45;
|
||||||
|
border-color: #404552;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0f1115;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hot);
|
||||||
|
border-color: var(--accent-hot);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover:not(:disabled) {
|
||||||
|
background: #d44e4e;
|
||||||
|
border-color: #d44e4e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #14171d;
|
||||||
|
border: 1px solid #232730;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-list li.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px rgba(90, 211, 170, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-server {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-id {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.bad {
|
||||||
|
background: rgba(239, 90, 90, 0.15);
|
||||||
|
color: var(--bad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status td {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid #232730;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status td:first-child {
|
||||||
|
width: 40%;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status td.warn {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs {
|
||||||
|
background: #0a0c10;
|
||||||
|
border: 1px solid #232730;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #c6cbd5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: rgba(239, 90, 90, 0.12);
|
||||||
|
border: 1px solid rgba(239, 90, 90, 0.4);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
color: #ffb1b1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aura-bin code {
|
||||||
|
font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
|
||||||
|
import "./App.css";
|
||||||
|
|
||||||
|
type ProfileSummary = {
|
||||||
|
id: string;
|
||||||
|
display_name: string;
|
||||||
|
server_addr: string;
|
||||||
|
healthy: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClientStatus = {
|
||||||
|
running: boolean;
|
||||||
|
profile_id: string | null;
|
||||||
|
peer_id: string | null;
|
||||||
|
rx_packets: number | null;
|
||||||
|
tx_packets: number | null;
|
||||||
|
default_action: string | null;
|
||||||
|
rules: number | null;
|
||||||
|
recent_logs: string[];
|
||||||
|
admin_error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [profiles, setProfiles] = useState<ProfileSummary[]>([]);
|
||||||
|
const [status, setStatus] = useState<ClientStatus>({
|
||||||
|
running: false,
|
||||||
|
profile_id: null,
|
||||||
|
peer_id: null,
|
||||||
|
rx_packets: null,
|
||||||
|
tx_packets: null,
|
||||||
|
default_action: null,
|
||||||
|
rules: null,
|
||||||
|
recent_logs: [],
|
||||||
|
admin_error: null,
|
||||||
|
});
|
||||||
|
const [auraBin, setAuraBin] = useState<string>("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
|
||||||
|
const refreshProfiles = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const p = await invoke<ProfileSummary[]>("list_profiles");
|
||||||
|
setProfiles(p);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const s = await invoke<ClientStatus>("get_status");
|
||||||
|
setStatus(s);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("get_status", e);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setAuraBin(await invoke<string>("get_aura_binary_path"));
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
refreshProfiles();
|
||||||
|
}, [refreshProfiles]);
|
||||||
|
|
||||||
|
// Poll status every 1.5s.
|
||||||
|
useEffect(() => {
|
||||||
|
refreshStatus();
|
||||||
|
const id = setInterval(refreshStatus, 1500);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [refreshStatus]);
|
||||||
|
|
||||||
|
const onImportTgz = async () => {
|
||||||
|
try {
|
||||||
|
const path = await openFileDialog({
|
||||||
|
title: "Pick a provisioned AuraVPN bundle (.tgz)",
|
||||||
|
multiple: false,
|
||||||
|
directory: false,
|
||||||
|
filters: [{ name: "Bundles", extensions: ["tgz", "tar.gz"] }],
|
||||||
|
});
|
||||||
|
if (typeof path !== "string") return;
|
||||||
|
await invoke("import_profile_from_tgz", { tgzPath: path });
|
||||||
|
await refreshProfiles();
|
||||||
|
setError(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConnect = async (profileId: string) => {
|
||||||
|
try {
|
||||||
|
await invoke("connect", { profileId });
|
||||||
|
setError(null);
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDisconnect = async () => {
|
||||||
|
try {
|
||||||
|
await invoke("disconnect");
|
||||||
|
setError(null);
|
||||||
|
await refreshStatus();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete = async (profileId: string) => {
|
||||||
|
if (!confirm(`Delete profile "${profileId}"? This cannot be undone.`)) return;
|
||||||
|
try {
|
||||||
|
await invoke("delete_profile", { profileId });
|
||||||
|
await refreshProfiles();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPickBinary = async () => {
|
||||||
|
try {
|
||||||
|
const path = await openFileDialog({
|
||||||
|
title: "Pick the aura binary",
|
||||||
|
multiple: false,
|
||||||
|
directory: false,
|
||||||
|
});
|
||||||
|
if (typeof path !== "string") return;
|
||||||
|
await invoke("set_aura_binary_path", { path });
|
||||||
|
setAuraBin(await invoke<string>("get_aura_binary_path"));
|
||||||
|
setError(null);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container">
|
||||||
|
<header>
|
||||||
|
<h1>AuraVPN</h1>
|
||||||
|
<p className="sub">
|
||||||
|
hybrid post-quantum VPN ·{" "}
|
||||||
|
<span className={status.running ? "pill running" : "pill stopped"}>
|
||||||
|
{status.running ? "connected" : "disconnected"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error">
|
||||||
|
<strong>error:</strong> {error}{" "}
|
||||||
|
<button onClick={() => setError(null)}>dismiss</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="row-between">
|
||||||
|
<h2>Profiles</h2>
|
||||||
|
<button onClick={onImportTgz}>+ Import .tgz</button>
|
||||||
|
</div>
|
||||||
|
{profiles.length === 0 ? (
|
||||||
|
<p className="empty">No profiles yet. Click "Import .tgz" to add one.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="profile-list">
|
||||||
|
{profiles.map((p) => {
|
||||||
|
const isActive = status.running && status.profile_id === p.id;
|
||||||
|
return (
|
||||||
|
<li key={p.id} className={isActive ? "active" : ""}>
|
||||||
|
<div className="profile-meta">
|
||||||
|
<div className="profile-name">
|
||||||
|
{p.display_name}
|
||||||
|
{!p.healthy && <span className="badge bad">missing files</span>}
|
||||||
|
</div>
|
||||||
|
<div className="profile-server">{p.server_addr}</div>
|
||||||
|
<div className="profile-id">id: {p.id}</div>
|
||||||
|
</div>
|
||||||
|
<div className="profile-actions">
|
||||||
|
{isActive ? (
|
||||||
|
<button className="danger" onClick={onDisconnect}>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
disabled={!p.healthy || status.running}
|
||||||
|
onClick={() => onConnect(p.id)}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => onDelete(p.id)}>Delete</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>Tunnel status</h2>
|
||||||
|
{!status.running ? (
|
||||||
|
<p className="empty">Tunnel not running.</p>
|
||||||
|
) : (
|
||||||
|
<table className="status">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>profile</td>
|
||||||
|
<td>{status.profile_id ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>peer</td>
|
||||||
|
<td>{status.peer_id ?? "(handshake in progress)"}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>rx packets</td>
|
||||||
|
<td>{status.rx_packets ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>tx packets</td>
|
||||||
|
<td>{status.tx_packets ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>default action</td>
|
||||||
|
<td>{status.default_action ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>active rules</td>
|
||||||
|
<td>{status.rules ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
{status.admin_error && (
|
||||||
|
<tr>
|
||||||
|
<td>admin</td>
|
||||||
|
<td className="warn">{status.admin_error}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<div className="row-between">
|
||||||
|
<h2>Logs</h2>
|
||||||
|
<button onClick={() => setShowLogs(!showLogs)}>
|
||||||
|
{showLogs ? "Hide" : "Show"} ({status.recent_logs.length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{showLogs && (
|
||||||
|
<pre className="logs">
|
||||||
|
{status.recent_logs.length === 0 ? "(no logs yet)" : status.recent_logs.join("\n")}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel small">
|
||||||
|
<div className="row-between">
|
||||||
|
<span className="aura-bin">
|
||||||
|
<strong>aura binary:</strong> <code>{auraBin}</code>
|
||||||
|
</span>
|
||||||
|
<button onClick={onPickBinary}>Change…</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
// @ts-expect-error process is a nodejs global
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(async () => ({
|
||||||
|
plugins: [react()],
|
||||||
|
|
||||||
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
|
//
|
||||||
|
// 1. prevent Vite from obscuring rust errors
|
||||||
|
clearScreen: false,
|
||||||
|
// 2. tauri expects a fixed port, fail if that port is not available
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host
|
||||||
|
? {
|
||||||
|
protocol: "ws",
|
||||||
|
host,
|
||||||
|
port: 1421,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
watch: {
|
||||||
|
// 3. tell Vite to ignore watching `src-tauri`
|
||||||
|
ignored: ["**/src-tauri/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||