Merge pull request #16 from Lab-8916100448256/error-cases

Display error cause instead of "No Device Connected" when failing to communicate with pcscd
This commit is contained in:
Suyog Tandel
2026-01-19 20:52:46 +05:30
committed by GitHub
9 changed files with 170 additions and 48 deletions
+23 -1
View File
@@ -1,6 +1,8 @@
/// Custom error types for Pico Forge application.
#[derive(Debug, thiserror::Error)]
pub enum PFError {
#[error("No device found")]
NoDevice,
#[error("PCSC Error: {0}")]
Pcsc(#[from] pcsc::Error),
#[error("IO/Hex Error: {0}")]
@@ -15,7 +17,27 @@ impl serde::Serialize for PFError {
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("PFError", 2)?;
match self {
PFError::NoDevice => {
state.serialize_field("type", "NoDevice")?;
state.serialize_field("message", "No device found")?;
}
PFError::Pcsc(err) => {
state.serialize_field("type", "Pcsc")?;
state.serialize_field("message", &err.to_string())?;
}
PFError::Io(msg) => {
state.serialize_field("type", "Io")?;
state.serialize_field("message", msg)?;
}
PFError::Device(msg) => {
state.serialize_field("type", "Device")?;
state.serialize_field("message", msg)?;
}
}
state.end()
}
}
+7 -7
View File
@@ -46,14 +46,14 @@ pub fn reboot(to_bootsel: bool) -> Result<String, PFError> {
#[tauri::command]
pub async fn get_credentials(pin: String) -> Result<Vec<StoredCredential>, String> {
tauri::async_runtime::spawn_blocking(move || {
fido::get_credentials(pin)
}).await.map_err(|e| e.to_string())?
tauri::async_runtime::spawn_blocking(move || fido::get_credentials(pin))
.await
.map_err(|e| e.to_string())?
}
#[tauri::command]
pub async fn delete_credential(pin: String, credential_id: String) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
fido::delete_credential(pin, credential_id)
}).await.map_err(|e| e.to_string())?
}
tauri::async_runtime::spawn_blocking(move || fido::delete_credential(pin, credential_id))
.await
.map_err(|e| e.to_string())?
}
+6 -3
View File
@@ -12,15 +12,18 @@ use std::io::Cursor;
/// Connects to the first available reader and selects the Rescue Applet
fn connect_and_select() -> Result<(pcsc::Card, Vec<u8>), PFError> {
let ctx = Context::establish(Scope::User)?;
let ctx = Context::establish(Scope::User).map_err(|e| {
log::error!("Failed to establish PCSC context: {}", e);
PFError::Pcsc(e)
})?;
let mut readers_buf = [0; 2048];
let mut readers = ctx.list_readers(&mut readers_buf)?;
// Use the first reader found
let reader = readers.next().ok_or_else(|| {
log::error!("No Smart Card Reader found");
PFError::Device("No Smart Card Reader found.".into())
log::info!("No Smart Card Reader found");
PFError::NoDevice
})?;
let card = ctx.connect(reader, ShareMode::Shared, Protocols::ANY)?;
@@ -0,0 +1,23 @@
<script lang="ts">
import * as Alert from "$lib/components/ui/alert";
import { TriangleAlert } from "@lucide/svelte";
import { device } from "$lib/device/manager.svelte";
interface Props {
message?: string;
}
let {
message = "Please connect your device and click Refresh to begin.",
}: Props = $props();
</script>
<Alert.Root variant={device.error ? "destructive" : "default"}>
<TriangleAlert class="h-4 w-4" />
<Alert.Title
>{device.error ? "Connection Error" : "No Device Connected"}</Alert.Title
>
<Alert.Description>
{device.error || message}
</Alert.Description>
</Alert.Root>
+19 -4
View File
@@ -15,6 +15,7 @@ class DeviceManager {
loading = $state(false);
connected = $state(false);
fidoInfo: FidoInfo | null = $state(null);
error: string | null = $state(null);
credentials: StoredCredential[] = $state([]);
unlocked = $state(false);
@@ -38,6 +39,7 @@ class DeviceManager {
async refresh() {
this.loading = true;
this.error = null;
try {
logger.add("Attempting to connect to device...", "info");
@@ -66,12 +68,25 @@ class DeviceManager {
logger.add(`Device Connected! Serial: ${this.info.serial}, FW: v${this.info.firmwareVersion}`, "success");
}
this.connected = true;
} catch (err) {
} catch (err: any) {
console.error("Connection failed:", err);
if (this.connected) {
logger.add(`Connection lost: ${err}`, "error");
// Handle structured error from Rust (PFError)
if (err && typeof err === "object" && err.type === "NoDevice") {
this.error = null;
this.connected = false;
// Don't log "No device" as an error to the user log system,
// it's a normal state when nothing is plugged in.
} else {
const msg = typeof err === "string" ? err : err.message || JSON.stringify(err);
this.error = msg;
if (this.connected) {
logger.add(`Connection lost: ${msg}`, "error");
} else {
logger.add(`Connection failed: ${msg}`, "error");
}
this.connected = false;
}
this.connected = false;
} finally {
this.loading = false;
}
+86 -18
View File
@@ -2,7 +2,19 @@
import { onMount } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Home, Info, KeyRound, Maximize, Minimize, Minus, RefreshCw, ScrollText, Settings, ShieldCheck, X } from "@lucide/svelte";
import {
Home,
Info,
KeyRound,
Maximize,
Minimize,
Minus,
RefreshCw,
ScrollText,
Settings,
ShieldCheck,
X,
} from "@lucide/svelte";
import type { Component } from "svelte";
import { Button } from "$lib/components/ui/button";
@@ -52,10 +64,10 @@
isMaximized = maximized;
});
};
handleResize();
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
@@ -66,8 +78,15 @@
<Sidebar.Root collapsible="icon">
<Sidebar.Header>
<div class="flex items-center gap-3 p-2">
<img src="/in.suyogtandel.picoforge.svg" alt="PicoForge Logo" class="h-12 w-12 shadow-sm" />
<span class="font-bold text-xl tracking-tight group-data-[collapsible=icon]:hidden">PicoForge</span>
<img
src="/in.suyogtandel.picoforge.svg"
alt="PicoForge Logo"
class="h-12 w-12 shadow-sm"
/>
<span
class="font-bold text-xl tracking-tight group-data-[collapsible=icon]:hidden"
>PicoForge</span
>
</div>
</Sidebar.Header>
@@ -78,7 +97,10 @@
<Sidebar.Menu>
{#each menuItems as item}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={currentView === item.view} onclick={() => onViewChange(item.view)}>
<Sidebar.MenuButton
isActive={currentView === item.view}
onclick={() => onViewChange(item.view)}
>
<item.icon />
<span>{item.label}</span>
</Sidebar.MenuButton>
@@ -89,18 +111,40 @@
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer class="border-t bg-background/50 p-2 group-data-[collapsible=icon]:p-2">
<Sidebar.Footer
class="border-t bg-background/50 p-2 group-data-[collapsible=icon]:p-2"
>
<div class="p-2 space-y-3 group-data-[collapsible=icon]:hidden">
<div class="flex items-center justify-between">
<span class="text-xs font-medium text-muted-foreground">Device Status</span>
<span class="text-xs font-medium text-muted-foreground"
>Device Status</span
>
{#if device.connected}
<Badge variant="default" class="bg-green-600 hover:bg-green-600 text-[10px] px-1.5 h-5">Online</Badge>
<Badge
variant="default"
class="bg-green-600 hover:bg-green-600 text-[10px] px-1.5 h-5"
>Online</Badge
>
{:else if device.error}
<Badge
variant="destructive"
class="bg-amber-600 hover:bg-amber-600 text-[10px] px-1.5 h-5"
>Error</Badge
>
{:else}
<Badge variant="destructive" class="text-[10px] px-1.5 h-5">Offline</Badge>
<Badge variant="destructive" class="text-[10px] px-1.5 h-5"
>Offline</Badge
>
{/if}
</div>
<Button variant="outline" size="sm" class="w-full gap-2" disabled={device.loading} onclick={() => device.refresh()}>
<Button
variant="outline"
size="sm"
class="w-full gap-2"
disabled={device.loading}
onclick={() => device.refresh()}
>
{#if device.loading}
<RefreshCw class="h-3.5 w-3.5 animate-spin" />
{:else}
@@ -109,28 +153,52 @@
Refresh
</Button>
</div>
<div class="hidden group-data-[collapsible=icon]:flex flex-col items-center justify-center p-2 gap-2">
<Button variant="ghost" size="icon" disabled={device.loading} onclick={() => device.refresh()}>
<div
class="hidden group-data-[collapsible=icon]:flex flex-col items-center justify-center p-2 gap-2"
>
<Button
variant="ghost"
size="icon"
disabled={device.loading}
onclick={() => device.refresh()}
>
<RefreshCw class="h-4 w-4 {device.loading ? 'animate-spin' : ''}" />
</Button>
<div class={`h-2 w-2 rounded-full ${device.connected ? "bg-green-500" : "bg-red-500"}`}></div>
<div
class={`h-2 w-2 rounded-full ${device.connected ? "bg-green-500" : device.error ? "bg-amber-500" : "bg-red-500"}`}
></div>
</div>
</Sidebar.Footer>
</Sidebar.Root>
<Sidebar.Inset>
<header data-tauri-drag-region class="h-10 bg-background border-b flex items-center justify-between px-2 select-none sticky top-0 z-10">
<header
data-tauri-drag-region
class="h-10 bg-background border-b flex items-center justify-between px-2 select-none sticky top-0 z-10"
>
<div class="flex items-center gap-2">
<Sidebar.Trigger class="h-8 w-8" />
<div class="text-xs font-medium text-muted-foreground pointer-events-none flex items-center gap-2"></div>
<div
class="text-xs font-medium text-muted-foreground pointer-events-none flex items-center gap-2"
></div>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon" class="h-8 w-8 hover:bg-muted" onclick={minimize}>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 hover:bg-muted"
onclick={minimize}
>
<Minus class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" class="h-8 w-8 hover:bg-muted" onclick={toggleMaximize}>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 hover:bg-muted"
onclick={toggleMaximize}
>
{#if isMaximized}
<Minimize class="h-3.5 w-3.5 rotate-180" />
{:else}
+2 -5
View File
@@ -15,6 +15,7 @@
import { Microchip, RefreshCw, Save, Settings, Tag, TriangleAlert, X, Key } from "@lucide/svelte";
import { configViewState as state } from "$lib/state/configState.svelte";
import NoDeviceStatus from "$lib/components/device/NoDeviceStatus.svelte";
</script>
<div class="space-y-6">
@@ -24,11 +25,7 @@
</div>
{#if !device.connected}
<Alert.Root>
<TriangleAlert class="h-4 w-4" />
<Alert.Title>No Device Connected</Alert.Title>
<Alert.Description>Connect your device to access configuration options.</Alert.Description>
</Alert.Root>
<NoDeviceStatus message="Connect your device to access configuration options." />
{:else}
<div class="grid gap-6 lg:grid-cols-2">
<Card.Root class="lg:col-span-2">
+2 -5
View File
@@ -8,6 +8,7 @@
import { device } from "$lib/device/manager.svelte";
import { Cpu, Lock, LockOpen, Microchip, ShieldCheck, TriangleAlert, Shield } from "@lucide/svelte";
import NoDeviceStatus from "$lib/components/device/NoDeviceStatus.svelte";
</script>
<div class="space-y-6">
@@ -17,11 +18,7 @@
</div>
{#if !device.connected}
<Alert.Root>
<TriangleAlert class="h-4 w-4" />
<Alert.Title>No Device Connected</Alert.Title>
<Alert.Description>Please connect your device and click Refresh to begin.</Alert.Description>
</Alert.Root>
<NoDeviceStatus />
{:else}
<div class="grid gap-6 md:grid-cols-2">
<Card.Root>
+2 -5
View File
@@ -12,6 +12,7 @@
import { TriangleAlert, KeyRound, Trash2, Lock, Unlock, Loader2, Shield } from "@lucide/svelte";
import { device } from "$lib/device/manager.svelte";
import type { StoredCredential } from "$lib/device/types.svelte";
import NoDeviceStatus from "$lib/components/device/NoDeviceStatus.svelte";
let loading = $state(false);
let pin = $state("");
@@ -108,11 +109,7 @@
</div>
{#if !device.connected}
<Alert.Root>
<TriangleAlert class="h-4 w-4" />
<Alert.Title>No Device Connected</Alert.Title>
<Alert.Description>Connect your pico-key to manage passkeys.</Alert.Description>
</Alert.Root>
<NoDeviceStatus message="Connect your pico-key to manage passkeys." />
{:else}
<Dialog.Root bind:open={showPinDialog}>
<Dialog.Content class="sm:max-w-md">