feat: add profile sync functionality and redesign desktop client UI
Add sync_profile command to fetch latest profile from backend without re-enrollment. Add DeviceView struct to EnrollResponse. Replace hardcoded "just now" timestamp with now_label helper using Unix epoch seconds. Add sync button to UI with loading state. Redesign client interface with top strip containing brand lockup and action buttons, hero surface with profile metadata tiles, body grid with login/status panels and resources sidebar
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
mod tunnel_manager;
|
mod tunnel_manager;
|
||||||
|
|
||||||
use std::{fs, path::PathBuf, sync::Mutex};
|
use std::{fs, path::PathBuf, sync::Mutex, time::{SystemTime, UNIX_EPOCH}};
|
||||||
|
|
||||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
use rand_core::OsRng;
|
use rand_core::OsRng;
|
||||||
@@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use tauri::{
|
use tauri::{
|
||||||
menu::{MenuBuilder, MenuItemBuilder},
|
menu::{MenuBuilder, MenuItemBuilder},
|
||||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
AppHandle, Manager, State, WebviewWindow, WindowEvent,
|
AppHandle, Manager, State, WebviewWindow, Window, WindowEvent,
|
||||||
};
|
};
|
||||||
use x25519_dalek::{PublicKey, StaticSecret};
|
use x25519_dalek::{PublicKey, StaticSecret};
|
||||||
|
|
||||||
@@ -80,11 +80,17 @@ struct EnrollRequest<'a> {
|
|||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct EnrollResponse {
|
struct EnrollResponse {
|
||||||
|
device: DeviceView,
|
||||||
peer: PeerView,
|
peer: PeerView,
|
||||||
profile: ProfileView,
|
profile: ProfileView,
|
||||||
resources: Vec<ResourceView>,
|
resources: Vec<ResourceView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DeviceView {
|
||||||
|
id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct PeerView {
|
struct PeerView {
|
||||||
#[serde(rename = "assigned_ip")]
|
#[serde(rename = "assigned_ip")]
|
||||||
@@ -180,7 +186,7 @@ async fn enroll_device(
|
|||||||
profile_revision: enroll.peer.profile_revision,
|
profile_revision: enroll.peer.profile_revision,
|
||||||
gateway_endpoint: enroll.peer.gateway.endpoint,
|
gateway_endpoint: enroll.peer.gateway.endpoint,
|
||||||
profile_path: profile_path.display().to_string(),
|
profile_path: profile_path.display().to_string(),
|
||||||
last_sync_time: "just now".into(),
|
last_sync_time: now_label(),
|
||||||
tunnel_strategy: tunnel_manager::current_tunnel_strategy().into(),
|
tunnel_strategy: tunnel_manager::current_tunnel_strategy().into(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,6 +213,65 @@ fn load_state(app: AppHandle, state: State<'_, AppState>) -> Result<Option<Enrol
|
|||||||
Ok(loaded.map(|value| value.enrollment))
|
Ok(loaded.map(|value| value.enrollment))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn sync_profile(app: AppHandle, state: State<'_, AppState>) -> Result<EnrollmentResult, String> {
|
||||||
|
let existing = {
|
||||||
|
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
||||||
|
session.clone().ok_or_else(|| "No enrolled profile is available yet".to_string())?
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = Client::builder()
|
||||||
|
.use_rustls_tls()
|
||||||
|
.build()
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(format!("{}/api/v1/me/profile", existing.server_url.trim_end_matches('/')))
|
||||||
|
.bearer_auth(&existing.access_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Profile sync failed: {}", err))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "<unable to read response body>".into());
|
||||||
|
return Err(format!("Profile sync failed with status {}: {}", status, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
let enroll = response
|
||||||
|
.json::<EnrollResponse>()
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("Unable to decode profile sync response: {}", err))?;
|
||||||
|
|
||||||
|
let profile_path = write_profile(&app, &enroll.profile.content)?;
|
||||||
|
let result = EnrollmentResult {
|
||||||
|
assigned_ip: enroll.peer.assigned_ip,
|
||||||
|
resources: enroll.resources.into_iter().map(|resource| resource.value).collect(),
|
||||||
|
profile_revision: enroll.peer.profile_revision,
|
||||||
|
gateway_endpoint: enroll.peer.gateway.endpoint,
|
||||||
|
profile_path: profile_path.display().to_string(),
|
||||||
|
last_sync_time: now_label(),
|
||||||
|
tunnel_strategy: tunnel_manager::current_tunnel_strategy().into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let session_state = SessionState {
|
||||||
|
access_token: existing.access_token,
|
||||||
|
refresh_token: existing.refresh_token,
|
||||||
|
server_url: existing.server_url,
|
||||||
|
profile_path: result.profile_path.clone(),
|
||||||
|
enrollment: result.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
write_session_state(&app, &session_state)?;
|
||||||
|
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
|
||||||
|
*session = Some(session_state);
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn connect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
fn connect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||||
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
|
||||||
@@ -230,6 +295,14 @@ fn generate_keypair() -> (String, String) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn now_label() -> String {
|
||||||
|
let seconds = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_secs())
|
||||||
|
.unwrap_or(0);
|
||||||
|
format!("synced at {}", seconds)
|
||||||
|
}
|
||||||
|
|
||||||
fn build_fingerprint(server_url: &str, username: &str, public_key: &str) -> String {
|
fn build_fingerprint(server_url: &str, username: &str, public_key: &str) -> String {
|
||||||
format!("nexavpn:{}:{}:{}", server_url, username, public_key)
|
format!("nexavpn:{}:{}:{}", server_url, username, public_key)
|
||||||
}
|
}
|
||||||
@@ -268,13 +341,13 @@ fn ensure_app_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
|||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn restore_main_window(window: &WebviewWindow) {
|
fn restore_main_window(window: &Window) {
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
let _ = window.unminimize();
|
let _ = window.unminimize();
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn hide_main_window(window: &WebviewWindow) {
|
fn hide_main_window(window: &Window) {
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +408,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![load_state, enroll_device, connect_tunnel, disconnect_tunnel])
|
.invoke_handler(tauri::generate_handler![load_state, enroll_device, sync_profile, connect_tunnel, disconnect_tunnel])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
type EnrollmentState = {
|
type EnrollmentState = {
|
||||||
@@ -23,11 +23,28 @@ function formatInvokeError(err: unknown, fallback: string) {
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentProfileLabel(state: EnrollmentState | null) {
|
||||||
|
if (!state) {
|
||||||
|
return "No profile provisioned";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.resources.includes("0.0.0.0/0")) {
|
||||||
|
return "Full tunnel";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.resources.length === 1) {
|
||||||
|
return "Single resource access";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Split tunnel (${state.resources.length} resources)`;
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [serverUrl, setServerUrl] = useState("http://localhost");
|
const [serverUrl, setServerUrl] = useState("http://localhost");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
const [state, setState] = useState<EnrollmentState | null>(null);
|
const [state, setState] = useState<EnrollmentState | null>(null);
|
||||||
@@ -42,6 +59,8 @@ export function App() {
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const profileLabel = useMemo(() => currentProfileLabel(state), [state]);
|
||||||
|
|
||||||
async function onSubmit(event: FormEvent) {
|
async function onSubmit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -59,6 +78,20 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSyncProfile() {
|
||||||
|
setSyncing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<EnrollmentState>("sync_profile");
|
||||||
|
setState(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(formatInvokeError(err, "Profile sync failed"));
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleConnection() {
|
async function toggleConnection() {
|
||||||
const command = connected ? "disconnect_tunnel" : "connect_tunnel";
|
const command = connected ? "disconnect_tunnel" : "connect_tunnel";
|
||||||
try {
|
try {
|
||||||
@@ -72,81 +105,161 @@ export function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="client-shell">
|
<div className="client-shell">
|
||||||
<div className="hero">
|
<div className="app-frame">
|
||||||
<p className="eyebrow">NexaVPN</p>
|
<div className="top-strip">
|
||||||
<h1>Private access without manual WireGuard setup.</h1>
|
<div className="brand-lockup">
|
||||||
<p className="lede">
|
<img src="/icon.png" alt="NexaVPN" />
|
||||||
Enter your VPN address, username, and password. NexaVPN provisions and stores the profile for you.
|
<div className="brand-copy">
|
||||||
</p>
|
<p className="eyebrow">NexaVPN</p>
|
||||||
</div>
|
<h1>Desktop Access Client</h1>
|
||||||
|
<p>Provisioned WireGuard access with profile sync and one-click reconnect.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="top-actions">
|
||||||
|
{state ? (
|
||||||
|
<button className="shell-button-secondary" disabled={syncing} onClick={onSyncProfile} type="button">
|
||||||
|
{syncing ? "Syncing..." : "Sync profile"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button className="shell-button" disabled={!state} onClick={toggleConnection} type="button">
|
||||||
|
{!state ? "Provision first" : connected ? "Disconnect" : "Connect"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{!state ? (
|
<section className="hero-surface">
|
||||||
<form className="panel" onSubmit={onSubmit}>
|
<div className="hero-copy">
|
||||||
<label>
|
<p className="eyebrow">Managed Tunnel</p>
|
||||||
VPN server address
|
<h2>Private access with fewer moving parts.</h2>
|
||||||
<input value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} />
|
<p>
|
||||||
</label>
|
NexaVPN signs you in, enrolls the device, stores the WireGuard profile locally, and lets you resync the
|
||||||
<label>
|
assigned access profile after policy changes.
|
||||||
Username
|
</p>
|
||||||
<input value={username} onChange={(event) => setUsername(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
{error ? <div className="error">{error}</div> : null}
|
|
||||||
<button disabled={loading} type="submit">
|
|
||||||
{loading ? "Provisioning..." : "Sign in"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<div className="panel status">
|
|
||||||
<div className="status-row">
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Connection</p>
|
|
||||||
<h2>{connected ? "Connected" : "Disconnected"}</h2>
|
|
||||||
</div>
|
|
||||||
<button onClick={toggleConnection}>{connected ? "Disconnect" : "Connect"}</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="details">
|
<div className="hero-meta">
|
||||||
<div>
|
<div className="meta-tile">
|
||||||
<span>Assigned VPN IP</span>
|
<span className="eyebrow">Current profile</span>
|
||||||
<strong>{state.assignedIp}</strong>
|
<strong>{profileLabel}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="meta-tile">
|
||||||
<span>Gateway</span>
|
<span className="eyebrow">Last sync</span>
|
||||||
<strong>{state.gatewayEndpoint}</strong>
|
<strong>{state?.lastSyncTime ?? "Not synced yet"}</strong>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Profile revision</span>
|
|
||||||
<strong>{state.profileRevision}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Last sync</span>
|
|
||||||
<strong>{state.lastSyncTime}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="details">
|
</section>
|
||||||
<div>
|
|
||||||
<span>Profile path</span>
|
<div className="body-grid">
|
||||||
<strong>{state.profilePath}</strong>
|
{!state ? (
|
||||||
|
<section className="login-panel">
|
||||||
|
<div className="surface-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Onboarding</p>
|
||||||
|
<h3>Sign in and provision this device</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<label>
|
||||||
|
VPN server address
|
||||||
|
<input value={serverUrl} onChange={(event) => setServerUrl(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input value={username} onChange={(event) => setUsername(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
{error ? <div className="error">{error}</div> : null}
|
||||||
|
<div className="login-card-actions">
|
||||||
|
<button className="shell-button" disabled={loading} type="submit">
|
||||||
|
{loading ? "Provisioning..." : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="status-panel">
|
||||||
|
<div className="status-top">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Connection</p>
|
||||||
|
<h3>{connected ? "Connected" : "Disconnected"}</h3>
|
||||||
|
<div className="status-state">
|
||||||
|
<span className={`status-dot ${connected ? "online" : ""}`} />
|
||||||
|
{connected ? "Tunnel active" : "Ready to connect"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="surface">
|
||||||
|
<div className="surface-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Session</p>
|
||||||
|
<h4>Provisioned device state</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="status-grid">
|
||||||
|
<div className="detail-card">
|
||||||
|
<span>Assigned VPN IP</span>
|
||||||
|
<strong>{state.assignedIp}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="detail-card">
|
||||||
|
<span>Gateway endpoint</span>
|
||||||
|
<strong>{state.gatewayEndpoint}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="detail-card">
|
||||||
|
<span>Profile revision</span>
|
||||||
|
<strong>{state.profileRevision}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="detail-card">
|
||||||
|
<span>Last sync</span>
|
||||||
|
<strong>{state.lastSyncTime}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="surface">
|
||||||
|
<div className="surface-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Profile</p>
|
||||||
|
<h4>Assigned access profile</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="profile-grid">
|
||||||
|
<div className="profile-card">
|
||||||
|
<span>Profile path</span>
|
||||||
|
<strong>{state.profilePath}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="profile-card">
|
||||||
|
<span>Tunnel strategy</span>
|
||||||
|
<strong>{state.tunnelStrategy}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <div className="error">{error}</div> : null}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<aside className="status-panel">
|
||||||
|
<div className="surface-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Resources</p>
|
||||||
|
<h4>Allowed destinations</h4>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<p>
|
||||||
<span>Tunnel strategy</span>
|
The current backend issues one effective profile per device. After policy changes, use <strong>Sync
|
||||||
<strong>{state.tunnelStrategy}</strong>
|
profile</strong> to pull the latest assigned access.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
|
||||||
{error ? <div className="error">{error}</div> : null}
|
|
||||||
<div>
|
|
||||||
<p className="eyebrow">Allowed resources</p>
|
|
||||||
<ul className="resource-list">
|
<ul className="resource-list">
|
||||||
{state.resources.map((resource) => (
|
{(state?.resources ?? ["No resources assigned yet"]).map((resource) => (
|
||||||
<li key={resource}>{resource}</li>
|
<li key={resource}>{resource}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +1,329 @@
|
|||||||
:root {
|
:root {
|
||||||
font-family: "Segoe UI", "SF Pro Text", sans-serif;
|
font-family: "Segoe UI", "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||||
color: #f5f7fb;
|
color: #eef4ff;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top, rgba(79, 208, 164, 0.18), transparent 25%),
|
radial-gradient(circle at 12% 10%, rgba(50, 196, 167, 0.2), transparent 24%),
|
||||||
linear-gradient(180deg, #08111f 0%, #0d1727 100%);
|
radial-gradient(circle at 90% 18%, rgba(89, 133, 255, 0.16), transparent 22%),
|
||||||
|
linear-gradient(180deg, #07101c 0%, #0c1524 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.client-shell {
|
.client-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-frame {
|
||||||
|
width: min(1120px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
gap: 22px;
|
||||||
gap: 24px;
|
|
||||||
padding: 32px 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero,
|
.top-strip {
|
||||||
.panel {
|
display: flex;
|
||||||
width: min(560px, 100%);
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.brand-lockup {
|
||||||
text-align: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-lockup img {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
color: #74e0b8;
|
margin: 0;
|
||||||
|
color: #75e3ba;
|
||||||
letter-spacing: 0.18em;
|
letter-spacing: 0.18em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.78rem;
|
font-size: 0.74rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lede {
|
.brand-copy h1,
|
||||||
color: #a9b8d3;
|
.hero-copy h2,
|
||||||
|
.status-panel h3 {
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.brand-copy p,
|
||||||
|
.hero-copy p,
|
||||||
|
.status-panel p,
|
||||||
|
.surface-header p,
|
||||||
|
.detail-card span,
|
||||||
|
.profile-card span {
|
||||||
|
margin: 0;
|
||||||
|
color: #9eb1d1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-button,
|
||||||
|
.shell-button-secondary {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-button {
|
||||||
|
background: linear-gradient(135deg, #74e0b8 0%, #1fb67a 100%);
|
||||||
|
color: #04131a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-button-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #eef4ff;
|
||||||
|
border: 1px solid rgba(177, 197, 229, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-button:disabled,
|
||||||
|
.shell-button-secondary:disabled {
|
||||||
|
opacity: 0.58;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-surface,
|
||||||
|
.surface,
|
||||||
|
.status-panel,
|
||||||
|
.login-panel {
|
||||||
|
background: rgba(11, 20, 35, 0.78);
|
||||||
|
border: 1px solid rgba(177, 197, 229, 0.12);
|
||||||
|
box-shadow: 0 24px 70px rgba(2, 8, 18, 0.32);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-surface {
|
||||||
|
border-radius: 30px;
|
||||||
|
padding: 28px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 0.8fr;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy h2 {
|
||||||
|
font-size: clamp(2rem, 4vw, 3.6rem);
|
||||||
|
line-height: 1.04;
|
||||||
|
max-width: 10ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy p {
|
||||||
|
max-width: 56ch;
|
||||||
|
font-size: 1.03rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-tile {
|
||||||
|
padding: 18px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(180deg, rgba(117, 227, 186, 0.08), rgba(117, 227, 186, 0.02));
|
||||||
|
border: 1px solid rgba(117, 227, 186, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-tile strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.3fr 0.7fr;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel,
|
||||||
|
.status-panel {
|
||||||
|
border-radius: 28px;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
border-radius: 24px;
|
display: grid;
|
||||||
background: rgba(12, 22, 38, 0.84);
|
gap: 20px;
|
||||||
border: 1px solid rgba(167, 185, 219, 0.14);
|
|
||||||
box-shadow: 0 24px 60px rgba(2, 8, 18, 0.36);
|
|
||||||
backdrop-filter: blur(16px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
.status-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
color: #c8d6ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ff8a7d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.online {
|
||||||
|
background: #74e0b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface {
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 18px;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid,
|
||||||
|
.profile-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card,
|
||||||
|
.profile-card {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(177, 197, 229, 0.1);
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card strong,
|
||||||
|
.profile-card strong {
|
||||||
|
font-size: 1rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-list li {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(117, 227, 186, 0.08);
|
||||||
|
border: 1px solid rgba(117, 227, 186, 0.15);
|
||||||
|
color: #dffaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-panel form {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
.login-panel label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: #c2cfe5;
|
color: #c2cfe5;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
.login-panel input {
|
||||||
border: 1px solid rgba(167, 185, 219, 0.16);
|
border: 1px solid rgba(177, 197, 229, 0.16);
|
||||||
background: rgba(7, 14, 27, 0.85);
|
background: rgba(7, 14, 27, 0.9);
|
||||||
color: #f5f7fb;
|
color: #f5f7fb;
|
||||||
border-radius: 14px;
|
border-radius: 16px;
|
||||||
padding: 14px 16px;
|
padding: 15px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
.login-card-actions {
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 13px 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: linear-gradient(135deg, #74e0b8 0%, #1fb67a 100%);
|
|
||||||
color: #04141a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
color: #ffb7b7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
display: grid;
|
|
||||||
gap: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-row,
|
|
||||||
.details {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
gap: 12px;
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.details div {
|
.error {
|
||||||
display: grid;
|
padding: 12px 14px;
|
||||||
gap: 6px;
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 115, 115, 0.08);
|
||||||
|
border: 1px solid rgba(255, 115, 115, 0.16);
|
||||||
|
color: #ffc3c3;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.details span {
|
@media (max-width: 960px) {
|
||||||
color: #9db0cf;
|
.hero-surface,
|
||||||
}
|
.body-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.resource-list {
|
.top-strip,
|
||||||
margin: 0;
|
.status-top,
|
||||||
padding-left: 18px;
|
.surface-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-grid,
|
||||||
|
.profile-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ use std::{
|
|||||||
process::{Command, ExitCode},
|
process::{Command, ExitCode},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
match run() {
|
match run() {
|
||||||
Ok(()) => ExitCode::SUCCESS,
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
@@ -33,14 +39,16 @@ fn run() -> Result<(), String> {
|
|||||||
fn connect(profile: &Path) -> Result<(), String> {
|
fn connect(profile: &Path) -> Result<(), String> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
|
ensure_windows_admin()?;
|
||||||
let wireguard = find_windows_wireguard()?;
|
let wireguard = find_windows_wireguard()?;
|
||||||
let status = Command::new(wireguard)
|
let output = Command::new(wireguard)
|
||||||
.arg("/installtunnelservice")
|
.arg("/installtunnelservice")
|
||||||
.arg(profile)
|
.arg(profile)
|
||||||
.status()
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
.map_err(|err| format!("unable to launch WireGuard runtime: {err}"))?;
|
.map_err(|err| format!("unable to launch WireGuard runtime: {err}"))?;
|
||||||
if !status.success() {
|
if !output.status.success() {
|
||||||
return Err(format!("WireGuard runtime connect failed with status {status}"));
|
return Err(format_windows_runtime_error("connect", &output));
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -66,18 +74,20 @@ fn connect(profile: &Path) -> Result<(), String> {
|
|||||||
fn disconnect(profile: &Path) -> Result<(), String> {
|
fn disconnect(profile: &Path) -> Result<(), String> {
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
|
ensure_windows_admin()?;
|
||||||
let wireguard = find_windows_wireguard()?;
|
let wireguard = find_windows_wireguard()?;
|
||||||
let tunnel_name = profile
|
let tunnel_name = profile
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.and_then(|value| value.to_str())
|
.and_then(|value| value.to_str())
|
||||||
.ok_or_else(|| "invalid profile filename".to_string())?;
|
.ok_or_else(|| "invalid profile filename".to_string())?;
|
||||||
let status = Command::new(wireguard)
|
let output = Command::new(wireguard)
|
||||||
.arg("/uninstalltunnelservice")
|
.arg("/uninstalltunnelservice")
|
||||||
.arg(tunnel_name)
|
.arg(tunnel_name)
|
||||||
.status()
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
.map_err(|err| format!("unable to launch WireGuard runtime: {err}"))?;
|
.map_err(|err| format!("unable to launch WireGuard runtime: {err}"))?;
|
||||||
if !status.success() {
|
if !output.status.success() {
|
||||||
return Err(format!("WireGuard runtime disconnect failed with status {status}"));
|
return Err(format_windows_runtime_error("disconnect", &output));
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -112,3 +122,35 @@ fn find_windows_wireguard() -> Result<PathBuf, String> {
|
|||||||
.find(|path| path.exists())
|
.find(|path| path.exists())
|
||||||
.ok_or_else(|| "required Windows tunnel runtime is not available".to_string())
|
.ok_or_else(|| "required Windows tunnel runtime is not available".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn ensure_windows_admin() -> Result<(), String> {
|
||||||
|
let status = Command::new("net")
|
||||||
|
.arg("session")
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.status()
|
||||||
|
.map_err(|err| format!("unable to determine Windows privilege level: {err}"))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("Administrator rights are required to activate the VPN tunnel on Windows. Start NexaVPN as Administrator for now.".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn format_windows_runtime_error(action: &str, output: &std::process::Output) -> String {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let details = if !stderr.trim().is_empty() {
|
||||||
|
stderr.trim()
|
||||||
|
} else {
|
||||||
|
stdout.trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
if details.is_empty() {
|
||||||
|
return format!("WireGuard runtime {} failed with status {}", action, output.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
format!("WireGuard runtime {} failed: {}", action, details)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user