Files
NexaVPN/desktop-client/src/App.tsx
nessi 0ac93dfeb6 refactor: wrap tunnel connect/disconnect operations in spawn_blocking and add pending state UI feedback
Move tunnel_manager::connect and disconnect calls into spawn_blocking tasks to prevent blocking async runtime. Clone app handle and profile path before spawning. Add map_err for task join failures.

Add tunnelActionPending state to track in-progress tunnel operations. Pass busy prop to AppHeader and disable sync/logout/connect buttons during tunnel actions. Update connect button text to show "
2026-03-18 12:35:25 +01:00

305 lines
8.6 KiB
TypeScript

import { FormEvent, useEffect, useMemo, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { AppHeader } from "./components/AppHeader";
import { ResourcePanel } from "./components/ResourcePanel";
import { StatusCard } from "./components/StatusCard";
type AccessProfile = {
id: string;
name: string;
description: string;
fullTunnel: boolean;
destinations: string[];
};
type EnrollmentState = {
assignedIp: string;
resources: string[];
availableProfiles: AccessProfile[];
selectedProfileId: string | null;
profileRevision: number;
gatewayEndpoint: string;
profilePath: string;
lastSyncTime: string;
tunnelStrategy: string;
};
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";
}
const selectedProfile = state.availableProfiles.find((profile) => profile.id === state.selectedProfileId);
if (selectedProfile) {
return selectedProfile.name;
}
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() {
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 [selectingProfile, setSelectingProfile] = useState(false);
const [tunnelActionPending, setTunnelActionPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const [state, setState] = useState<EnrollmentState | null>(null);
async function refreshTunnelStatus() {
try {
const active = await invoke<boolean>("tunnel_status");
setConnected(active);
} catch {
setConnected(false);
}
}
useEffect(() => {
void invoke<EnrollmentState | null>("load_state")
.then(async (value) => {
if (value) {
setState(value);
await refreshTunnelStatus();
}
})
.catch(() => undefined);
}, []);
useEffect(() => {
if (!state) {
setConnected(false);
return undefined;
}
void refreshTunnelStatus();
const timer = window.setInterval(() => {
void refreshTunnelStatus();
}, 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 active = await invoke<boolean>("tunnel_status");
setConnected(active);
if (active === expected) {
return active;
}
} catch {
if (!expected) {
setConnected(false);
return false;
}
}
await new Promise((resolve) => window.setTimeout(resolve, 500));
}
return invoke<boolean>("tunnel_status")
.then(async (active) => {
setConnected(active);
return 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);
} 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 refreshTunnelStatus();
} catch (err) {
setError(formatInvokeError(err, "Profile sync failed"));
} finally {
setSyncing(false);
}
}
async function onSelectProfile(profileId: string) {
if (!state || profileId === state.selectedProfileId) {
return;
}
setSelectingProfile(true);
setError(null);
try {
const result = await invoke<EnrollmentState>("select_access_profile", { profileId });
setState(result);
await refreshTunnelStatus();
} catch (err) {
setError(formatInvokeError(err, "Profile selection failed"));
} finally {
setSelectingProfile(false);
}
}
async function toggleConnection() {
const command = connected ? "disconnect_tunnel" : "connect_tunnel";
setTunnelActionPending(true);
try {
if (!connected) {
const syncedState = await invoke<EnrollmentState>("connect_tunnel");
setState(syncedState);
} else {
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"));
} finally {
setTunnelActionPending(false);
}
}
async function resetEnrollment() {
try {
await invoke("clear_session");
setConnected(false);
setState(null);
setError(null);
} catch (err) {
setError(formatInvokeError(err, "Unable to clear local profile"));
}
}
return (
<div className="client-shell">
<div className="app-shell">
<AppHeader
busy={tunnelActionPending}
connected={connected}
enrolled={Boolean(state)}
onLogout={resetEnrollment}
onSync={onSyncProfile}
onToggleConnection={toggleConnection}
syncing={syncing}
/>
<div className="body-grid">
{!state ? (
<section className="login-card">
<div className="panel-head">
<div>
<p className="section-eyebrow">Sign in</p>
<h2>Provision device</h2>
</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-actions">
<button className="action-button action-button-primary" disabled={loading} type="submit">
{loading ? "Provisioning..." : "Sign in"}
</button>
</div>
</form>
</section>
) : (
<div className="main-column">
<StatusCard
accessLabel={profileLabel}
assignedIp={state.assignedIp}
connected={connected}
gatewayEndpoint={state.gatewayEndpoint}
lastSyncTime={state.lastSyncTime}
/>
{error ? <div className="error">{error}</div> : null}
</div>
)}
<ResourcePanel
connected={connected}
onReset={resetEnrollment}
onSelectProfile={onSelectProfile}
profileLabel={profileLabel}
profiles={state?.availableProfiles ?? []}
resources={state?.resources ?? []}
selectedProfileId={state?.selectedProfileId ?? null}
selectingProfile={selectingProfile || tunnelActionPending}
/>
</div>
</div>
</div>
);
}