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 "
305 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|