Files
NexaVPN/desktop-client/src/App.tsx
nessi bbea4f8bd0 feat: add idempotent tunnel connection with state polling and already-running detection
Add tunnel_service_is_active check before Windows tunnel installation to skip if already running. Add is_already_running_error helper to detect "already installed and running" message in WireGuard output. Add wait_for_windows_tunnel_running that polls tunnel state up to 12 times with 500ms intervals after installation. Add describe_windows_tunnel_state for detailed error messages when tunnel fails to reach RUNNING state.
2026-03-18 07:53:38 +01:00

356 lines
11 KiB
TypeScript

import { FormEvent, useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
type EnrollmentState = {
assignedIp: string;
resources: string[];
profileRevision: number;
gatewayEndpoint: string;
profilePath: string;
lastSyncTime: string;
tunnelStrategy: string;
};
type TunnelMetrics = {
active: boolean;
rxBytes: number;
txBytes: number;
};
function formatInvokeError(err: unknown, fallback: string) {
if (typeof err === "string" && err.trim().length > 0) {
return err;
}
if (err instanceof Error && err.message.trim().length > 0) {
return err.message;
}
return fallback;
}
function isAlreadyRunningError(err: unknown) {
const message = formatInvokeError(err, "");
return message.toLowerCase().includes("already installed and running");
}
function currentProfileLabel(state: EnrollmentState | null) {
if (!state) {
return "Not 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)`;
}
function formatDataSize(bytes: number) {
if (!bytes) {
return "0 MB";
}
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value >= 100 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
}
export function App() {
const [serverUrl, setServerUrl] = useState("http://localhost");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const [state, setState] = useState<EnrollmentState | null>(null);
const [metrics, setMetrics] = useState<TunnelMetrics>({
active: false,
rxBytes: 0,
txBytes: 0
});
async function refreshTunnelMetrics() {
try {
const value = await invoke<TunnelMetrics>("tunnel_metrics");
setMetrics(value);
setConnected(value.active);
} catch {
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
}
}
useEffect(() => {
void invoke<EnrollmentState | null>("load_state")
.then(async (value) => {
if (value) {
setState(value);
await refreshTunnelMetrics();
}
})
.catch(() => undefined);
}, []);
useEffect(() => {
if (!state) {
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
return undefined;
}
void refreshTunnelMetrics();
const timer = window.setInterval(() => {
void refreshTunnelMetrics();
}, 5000);
return () => window.clearInterval(timer);
}, [state]);
const profileLabel = useMemo(() => currentProfileLabel(state), [state]);
async function waitForTunnelStatus(expected: boolean) {
for (let attempt = 0; attempt < 8; attempt += 1) {
try {
const value = await invoke<TunnelMetrics>("tunnel_metrics");
setMetrics(value);
setConnected(value.active);
if (value.active === expected) {
return value.active;
}
} catch {
if (!expected) {
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
return false;
}
}
await new Promise((resolve) => window.setTimeout(resolve, 500));
}
return invoke<TunnelMetrics>("tunnel_metrics")
.then((value) => {
setMetrics(value);
setConnected(value.active);
return value.active;
})
.catch(() => false);
}
async function onSubmit(event: FormEvent) {
event.preventDefault();
setLoading(true);
setError(null);
try {
const result = await invoke<EnrollmentState>("enroll_device", {
payload: { serverUrl, username, password }
});
setState(result);
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
} catch (err) {
setError(formatInvokeError(err, "Enrollment failed"));
} finally {
setLoading(false);
}
}
async function onSyncProfile() {
setSyncing(true);
setError(null);
try {
const result = await invoke<EnrollmentState>("sync_profile");
setState(result);
await refreshTunnelMetrics();
} catch (err) {
setError(formatInvokeError(err, "Profile sync failed"));
} finally {
setSyncing(false);
}
}
async function toggleConnection() {
const command = connected ? "disconnect_tunnel" : "connect_tunnel";
try {
await invoke(command);
const active = await waitForTunnelStatus(!connected);
setConnected(active);
if (!connected && !active) {
setError("Tunnel was installed, but no active interface could be verified yet.");
} else if (connected && active) {
setError("Tunnel could not be stopped cleanly yet.");
} else {
setError(null);
}
} catch (err) {
if (!connected && isAlreadyRunningError(err)) {
const active = await waitForTunnelStatus(true);
setConnected(active);
if (active) {
setError(null);
return;
}
}
setError(formatInvokeError(err, "Tunnel action failed"));
}
}
async function resetEnrollment() {
try {
await invoke("clear_session");
setConnected(false);
setState(null);
setMetrics({ active: false, rxBytes: 0, txBytes: 0 });
setError(null);
} catch (err) {
setError(formatInvokeError(err, "Unable to clear local profile"));
}
}
return (
<div className="client-shell">
<div className="app-frame">
<div className="top-strip">
<div className="brand-lockup">
<img src="/icon.png" alt="NexaVPN" />
<div className="brand-copy">
<p className="eyebrow">NexaVPN</p>
<h1>VPN Client</h1>
<p>{state ? "Private access client" : "Sign in to add this device"}</p>
</div>
</div>
<div className="top-actions">
{state ? (
<button className="shell-button-secondary" disabled={syncing} onClick={onSyncProfile} type="button">
{syncing ? "Syncing..." : "Sync"}
</button>
) : null}
{state ? (
<button className="shell-button-secondary" onClick={resetEnrollment} type="button">
Logout
</button>
) : null}
<button className="shell-button" disabled={!state} onClick={toggleConnection} type="button">
{!state ? "Provision first" : connected ? "Disconnect" : "Connect"}
</button>
</div>
</div>
<div className="body-grid">
{!state ? (
<section className="login-panel">
<div className="surface-header">
<div>
<p className="eyebrow">Sign in</p>
<h3>Provision 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">Status</p>
<h3>{connected ? "Connected" : "Disconnected"}</h3>
<div className="status-state">
<span className={`status-dot ${connected ? "online" : ""}`} />
{connected ? "Tunnel active" : "Ready"}
</div>
</div>
</div>
<div className="surface">
<div className="surface-header">
<div>
<p className="eyebrow">Connection</p>
<h4>Overview</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>Access</span>
<strong>{profileLabel}</strong>
</div>
<div className="detail-card">
<span>Last sync</span>
<strong>{state.lastSyncTime}</strong>
</div>
<div className="detail-card">
<span>Received</span>
<strong>{formatDataSize(metrics.rxBytes)}</strong>
</div>
<div className="detail-card">
<span>Sent</span>
<strong>{formatDataSize(metrics.txBytes)}</strong>
</div>
</div>
</div>
{error ? <div className="error">{error}</div> : null}
</section>
)}
<aside className="status-panel">
<div className="surface-header">
<div>
<p className="eyebrow">Access</p>
<h4>Resources</h4>
</div>
</div>
<p>Press <strong>Sync</strong> after policy changes.</p>
<ul className="resource-stack">
{(state?.resources ?? ["No resources assigned yet"]).map((resource) => (
<li key={resource}>{resource}</li>
))}
</ul>
{state ? (
<button className="shell-button-secondary" onClick={resetEnrollment} type="button">
Neu einbinden
</button>
) : null}
</aside>
</div>
</div>
</div>
);
}