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 "
This commit is contained in:
2026-03-18 12:35:25 +01:00
parent 10dbd186ed
commit 0ac93dfeb6
3 changed files with 26 additions and 8 deletions

View File

@@ -393,20 +393,31 @@ async fn select_access_profile(app: AppHandle, profile_id: String) -> Result<Enr
#[tauri::command] #[tauri::command]
async fn connect_tunnel(app: AppHandle) -> Result<EnrollmentResult, String> { async fn connect_tunnel(app: AppHandle) -> Result<EnrollmentResult, String> {
let session_state = sync_current_session(&app).await?; let session_state = sync_current_session(&app).await?;
let result = tunnel_manager::connect(&app, std::path::Path::new(&session_state.profile_path)); let app_handle = app.clone();
let profile_path = session_state.profile_path.clone();
let result = tauri::async_runtime::spawn_blocking(move || {
tunnel_manager::connect(&app_handle, std::path::Path::new(&profile_path))
})
.await
.map_err(|err| format!("Unable to join tunnel connect task: {err}"))?;
refresh_tray_menu(&app); refresh_tray_menu(&app);
result?; result?;
Ok(session_state.enrollment) Ok(session_state.enrollment)
} }
#[tauri::command] #[tauri::command]
fn disconnect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { async fn disconnect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
let profile_path = { let profile_path = {
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())?;
let session = session.as_ref().ok_or_else(|| "No active session is available".to_string())?; let session = session.as_ref().ok_or_else(|| "No active session is available".to_string())?;
session.profile_path.clone() session.profile_path.clone()
}; };
let result = tunnel_manager::disconnect(&app, std::path::Path::new(&profile_path)); let app_handle = app.clone();
let result = tauri::async_runtime::spawn_blocking(move || {
tunnel_manager::disconnect(&app_handle, std::path::Path::new(&profile_path))
})
.await
.map_err(|err| format!("Unable to join tunnel disconnect task: {err}"))?;
refresh_tray_menu(&app); refresh_tray_menu(&app);
result result
} }

View File

@@ -69,6 +69,7 @@ export function App() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [selectingProfile, setSelectingProfile] = useState(false); const [selectingProfile, setSelectingProfile] = useState(false);
const [tunnelActionPending, setTunnelActionPending] = 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);
@@ -187,6 +188,7 @@ export function App() {
async function toggleConnection() { async function toggleConnection() {
const command = connected ? "disconnect_tunnel" : "connect_tunnel"; const command = connected ? "disconnect_tunnel" : "connect_tunnel";
setTunnelActionPending(true);
try { try {
if (!connected) { if (!connected) {
const syncedState = await invoke<EnrollmentState>("connect_tunnel"); const syncedState = await invoke<EnrollmentState>("connect_tunnel");
@@ -213,6 +215,8 @@ export function App() {
} }
} }
setError(formatInvokeError(err, "Tunnel action failed")); setError(formatInvokeError(err, "Tunnel action failed"));
} finally {
setTunnelActionPending(false);
} }
} }
@@ -231,6 +235,7 @@ export function App() {
<div className="client-shell"> <div className="client-shell">
<div className="app-shell"> <div className="app-shell">
<AppHeader <AppHeader
busy={tunnelActionPending}
connected={connected} connected={connected}
enrolled={Boolean(state)} enrolled={Boolean(state)}
onLogout={resetEnrollment} onLogout={resetEnrollment}
@@ -290,7 +295,7 @@ export function App() {
profiles={state?.availableProfiles ?? []} profiles={state?.availableProfiles ?? []}
resources={state?.resources ?? []} resources={state?.resources ?? []}
selectedProfileId={state?.selectedProfileId ?? null} selectedProfileId={state?.selectedProfileId ?? null}
selectingProfile={selectingProfile} selectingProfile={selectingProfile || tunnelActionPending}
/> />
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { ActionButton } from "./ActionButton"; import { ActionButton } from "./ActionButton";
type AppHeaderProps = { type AppHeaderProps = {
busy: boolean;
enrolled: boolean; enrolled: boolean;
connected: boolean; connected: boolean;
syncing: boolean; syncing: boolean;
@@ -10,6 +11,7 @@ type AppHeaderProps = {
}; };
export function AppHeader({ export function AppHeader({
busy,
enrolled, enrolled,
connected, connected,
syncing, syncing,
@@ -36,10 +38,10 @@ export function AppHeader({
<div className="header-actions-secondary"> <div className="header-actions-secondary">
{enrolled ? ( {enrolled ? (
<> <>
<ActionButton disabled={syncing} onClick={onSync} variant="secondary"> <ActionButton disabled={busy || syncing} onClick={onSync} variant="secondary">
{syncing ? "Syncing..." : "Sync"} {syncing ? "Syncing..." : "Sync"}
</ActionButton> </ActionButton>
<ActionButton onClick={onLogout} variant="ghost"> <ActionButton disabled={busy || syncing} onClick={onLogout} variant="ghost">
Logout Logout
</ActionButton> </ActionButton>
</> </>
@@ -47,11 +49,11 @@ export function AppHeader({
</div> </div>
<div className="header-actions-primary"> <div className="header-actions-primary">
<ActionButton <ActionButton
disabled={!enrolled} disabled={!enrolled || busy || syncing}
onClick={onToggleConnection} onClick={onToggleConnection}
variant={connected ? "danger" : "primary"} variant={connected ? "danger" : "primary"}
> >
{!enrolled ? "Provision first" : connected ? "Disconnect" : "Connect"} {!enrolled ? "Provision first" : busy ? connected ? "Disconnecting..." : "Connecting..." : connected ? "Disconnect" : "Connect"}
</ActionButton> </ActionButton>
</div> </div>
</div> </div>