|
| 1 | +//! Tauri commands for the SMB diagnostics dashboard (debug-window only). |
| 2 | +//! |
| 3 | +//! Two commands: |
| 4 | +//! - [`list_smb_volumes`] — picker entries for the dashboard's volume selector |
| 5 | +//! - [`get_smb_diagnostics`] — snapshot of one volume's `smb2::SmbClient` |
| 6 | +//! |
| 7 | +//! The snapshot types are cmdr-side mirrors of `smb2::Diagnostics` & |
| 8 | +//! friends, with `specta::Type` derives so the typed bindings are |
| 9 | +//! end-to-end. Conversion is one `impl From<smb2::X> for XDto` per type. |
| 10 | +//! Two reasons we mirror instead of re-exporting: |
| 11 | +//! |
| 12 | +//! 1. `smb2` doesn't (and shouldn't) depend on `specta`. |
| 13 | +//! 2. We can pick a TS-friendly shape (e.g. `Duration` → milliseconds as |
| 14 | +//! `u64`, enums as `String`) without leaking Rust's `std::time::Duration` |
| 15 | +//! JSON shape (`{secs, nanos}`) to the frontend. |
| 16 | +
|
| 17 | +use crate::file_system::SmbVolume; |
| 18 | +use crate::file_system::get_volume_manager; |
| 19 | + |
| 20 | +// ── DTO mirror types ────────────────────────────────────────────────── |
| 21 | + |
| 22 | +#[derive(Debug, Clone, serde::Serialize, specta::Type)] |
| 23 | +pub struct SmbVolumeRef { |
| 24 | + pub volume_id: String, |
| 25 | + pub name: String, |
| 26 | + pub server: String, |
| 27 | + pub disconnected: bool, |
| 28 | +} |
| 29 | + |
| 30 | +#[derive(Debug, Clone, serde::Serialize, specta::Type)] |
| 31 | +pub struct SmbDiagnosticsDto { |
| 32 | + pub client: ClientInfoDto, |
| 33 | + pub primary: ConnectionDiagnosticsDto, |
| 34 | + pub extra_connections: Vec<ConnectionDiagnosticsDto>, |
| 35 | + pub dfs_cache: Vec<DfsCacheEntryDto>, |
| 36 | +} |
| 37 | + |
| 38 | +#[derive(Debug, Clone, serde::Serialize, specta::Type)] |
| 39 | +pub struct ClientInfoDto { |
| 40 | + pub primary_server: String, |
| 41 | + pub timeout_ms: u64, |
| 42 | + pub auto_reconnect: bool, |
| 43 | + pub dfs_enabled: bool, |
| 44 | + pub metrics: ClientMetricsDto, |
| 45 | +} |
| 46 | + |
| 47 | +#[derive(Debug, Clone, Copy, serde::Serialize, specta::Type)] |
| 48 | +pub struct ClientMetricsDto { |
| 49 | + pub reconnects: u64, |
| 50 | + pub dfs_referrals_resolved: u64, |
| 51 | + pub dfs_cache_hits: u64, |
| 52 | +} |
| 53 | + |
| 54 | +#[derive(Debug, Clone, serde::Serialize, specta::Type)] |
| 55 | +pub struct ConnectionDiagnosticsDto { |
| 56 | + pub server: String, |
| 57 | + pub negotiated: Option<NegotiatedSummaryDto>, |
| 58 | + pub credits: CreditInfoDto, |
| 59 | + pub signing: SigningInfoDto, |
| 60 | + pub encryption: EncryptionInfoDto, |
| 61 | + pub compression: CompressionInfoDto, |
| 62 | + pub rtt_estimate_ms: Option<f64>, |
| 63 | + pub disconnected: bool, |
| 64 | + pub dfs_trees: Vec<u32>, |
| 65 | + pub session: Option<SessionDiagnosticsDto>, |
| 66 | + pub metrics: MetricsSnapshotDto, |
| 67 | +} |
| 68 | + |
| 69 | +#[derive(Debug, Clone, serde::Serialize, specta::Type)] |
| 70 | +pub struct NegotiatedSummaryDto { |
| 71 | + pub dialect: String, |
| 72 | + pub max_read_size: u32, |
| 73 | + pub max_write_size: u32, |
| 74 | + pub max_transact_size: u32, |
| 75 | + pub server_guid_hex: String, |
| 76 | + pub signing_required: bool, |
| 77 | + pub capabilities_bits: u32, |
| 78 | + pub gmac_negotiated: bool, |
| 79 | + pub cipher: Option<String>, |
| 80 | + pub compression_supported: bool, |
| 81 | +} |
| 82 | + |
| 83 | +#[derive(Debug, Clone, Copy, serde::Serialize, specta::Type)] |
| 84 | +pub struct CreditInfoDto { |
| 85 | + pub available: u16, |
| 86 | + pub in_flight: u32, |
| 87 | + pub next_message_id: u64, |
| 88 | +} |
| 89 | + |
| 90 | +#[derive(Debug, Clone, serde::Serialize, specta::Type)] |
| 91 | +pub struct SigningInfoDto { |
| 92 | + pub active: bool, |
| 93 | + pub algorithm: Option<String>, |
| 94 | +} |
| 95 | + |
| 96 | +#[derive(Debug, Clone, serde::Serialize, specta::Type)] |
| 97 | +pub struct EncryptionInfoDto { |
| 98 | + pub active: bool, |
| 99 | + pub cipher: Option<String>, |
| 100 | +} |
| 101 | + |
| 102 | +#[derive(Debug, Clone, Copy, serde::Serialize, specta::Type)] |
| 103 | +pub struct CompressionInfoDto { |
| 104 | + pub requested: bool, |
| 105 | + pub negotiated: bool, |
| 106 | +} |
| 107 | + |
| 108 | +#[derive(Debug, Clone, serde::Serialize, specta::Type)] |
| 109 | +pub struct SessionDiagnosticsDto { |
| 110 | + pub session_id_hex: String, |
| 111 | + pub should_sign: bool, |
| 112 | + pub should_encrypt: bool, |
| 113 | + pub signing_algorithm: String, |
| 114 | +} |
| 115 | + |
| 116 | +#[derive(Debug, Clone, serde::Serialize, specta::Type)] |
| 117 | +pub struct DfsCacheEntryDto { |
| 118 | + pub path_prefix: String, |
| 119 | + pub target_count: u32, |
| 120 | + pub expires_in_ms: Option<u64>, |
| 121 | +} |
| 122 | + |
| 123 | +#[derive(Debug, Clone, Copy, Default, serde::Serialize, specta::Type)] |
| 124 | +pub struct MetricsSnapshotDto { |
| 125 | + pub requests_sent: u64, |
| 126 | + pub compound_requests_sent: u64, |
| 127 | + pub wire_bytes_sent: u64, |
| 128 | + pub explicit_cancels_sent: u64, |
| 129 | + pub responses_routed_ok: u64, |
| 130 | + pub responses_routed_err: u64, |
| 131 | + pub responses_late_after_drop: u64, |
| 132 | + pub responses_stray: u64, |
| 133 | + pub wire_bytes_received: u64, |
| 134 | + pub status_pending_loops: u64, |
| 135 | + pub unsolicited_notifications_received: u64, |
| 136 | + pub signature_failures: u64, |
| 137 | + pub decrypt_failures: u64, |
| 138 | + pub decompress_failures: u64, |
| 139 | + pub malformed_frames: u64, |
| 140 | + pub session_expired_events: u64, |
| 141 | + pub requests_returned_err: u64, |
| 142 | +} |
| 143 | + |
| 144 | +// ── Conversions ─────────────────────────────────────────────────────── |
| 145 | + |
| 146 | +impl From<smb2::Diagnostics> for SmbDiagnosticsDto { |
| 147 | + fn from(d: smb2::Diagnostics) -> Self { |
| 148 | + Self { |
| 149 | + client: d.client.into(), |
| 150 | + primary: d.primary.into(), |
| 151 | + extra_connections: d.extra_connections.into_iter().map(Into::into).collect(), |
| 152 | + dfs_cache: d.dfs_cache.into_iter().map(Into::into).collect(), |
| 153 | + } |
| 154 | + } |
| 155 | +} |
| 156 | + |
| 157 | +impl From<smb2::ClientInfo> for ClientInfoDto { |
| 158 | + fn from(c: smb2::ClientInfo) -> Self { |
| 159 | + Self { |
| 160 | + primary_server: c.primary_server, |
| 161 | + timeout_ms: c.timeout.as_millis() as u64, |
| 162 | + auto_reconnect: c.auto_reconnect, |
| 163 | + dfs_enabled: c.dfs_enabled, |
| 164 | + metrics: ClientMetricsDto { |
| 165 | + reconnects: c.metrics.reconnects, |
| 166 | + dfs_referrals_resolved: c.metrics.dfs_referrals_resolved, |
| 167 | + dfs_cache_hits: c.metrics.dfs_cache_hits, |
| 168 | + }, |
| 169 | + } |
| 170 | + } |
| 171 | +} |
| 172 | + |
| 173 | +impl From<smb2::ConnectionDiagnostics> for ConnectionDiagnosticsDto { |
| 174 | + fn from(c: smb2::ConnectionDiagnostics) -> Self { |
| 175 | + Self { |
| 176 | + server: c.server, |
| 177 | + negotiated: c.negotiated.map(Into::into), |
| 178 | + credits: CreditInfoDto { |
| 179 | + available: c.credits.available, |
| 180 | + in_flight: c.credits.in_flight as u32, |
| 181 | + next_message_id: c.credits.next_message_id, |
| 182 | + }, |
| 183 | + signing: SigningInfoDto { |
| 184 | + active: c.signing.active, |
| 185 | + algorithm: c.signing.algorithm.map(|a| format!("{:?}", a)), |
| 186 | + }, |
| 187 | + encryption: EncryptionInfoDto { |
| 188 | + active: c.encryption.active, |
| 189 | + cipher: c.encryption.cipher.map(|cph| format!("{:?}", cph)), |
| 190 | + }, |
| 191 | + compression: CompressionInfoDto { |
| 192 | + requested: c.compression.requested, |
| 193 | + negotiated: c.compression.negotiated, |
| 194 | + }, |
| 195 | + rtt_estimate_ms: c.rtt_estimate.map(|d| d.as_secs_f64() * 1000.0), |
| 196 | + disconnected: c.disconnected, |
| 197 | + dfs_trees: c.dfs_trees.into_iter().map(|t| t.0).collect(), |
| 198 | + session: c.session.map(Into::into), |
| 199 | + metrics: MetricsSnapshotDto { |
| 200 | + requests_sent: c.metrics.requests_sent, |
| 201 | + compound_requests_sent: c.metrics.compound_requests_sent, |
| 202 | + wire_bytes_sent: c.metrics.wire_bytes_sent, |
| 203 | + explicit_cancels_sent: c.metrics.explicit_cancels_sent, |
| 204 | + responses_routed_ok: c.metrics.responses_routed_ok, |
| 205 | + responses_routed_err: c.metrics.responses_routed_err, |
| 206 | + responses_late_after_drop: c.metrics.responses_late_after_drop, |
| 207 | + responses_stray: c.metrics.responses_stray, |
| 208 | + wire_bytes_received: c.metrics.wire_bytes_received, |
| 209 | + status_pending_loops: c.metrics.status_pending_loops, |
| 210 | + unsolicited_notifications_received: c |
| 211 | + .metrics |
| 212 | + .unsolicited_notifications_received, |
| 213 | + signature_failures: c.metrics.signature_failures, |
| 214 | + decrypt_failures: c.metrics.decrypt_failures, |
| 215 | + decompress_failures: c.metrics.decompress_failures, |
| 216 | + malformed_frames: c.metrics.malformed_frames, |
| 217 | + session_expired_events: c.metrics.session_expired_events, |
| 218 | + requests_returned_err: c.metrics.requests_returned_err, |
| 219 | + }, |
| 220 | + } |
| 221 | + } |
| 222 | +} |
| 223 | + |
| 224 | +impl From<smb2::NegotiatedSummary> for NegotiatedSummaryDto { |
| 225 | + fn from(n: smb2::NegotiatedSummary) -> Self { |
| 226 | + Self { |
| 227 | + dialect: format!("{:?}", n.dialect), |
| 228 | + max_read_size: n.max_read_size, |
| 229 | + max_write_size: n.max_write_size, |
| 230 | + max_transact_size: n.max_transact_size, |
| 231 | + server_guid_hex: format!( |
| 232 | + "{:08x}-{:04x}-{:04x}-{}", |
| 233 | + n.server_guid.data1, |
| 234 | + n.server_guid.data2, |
| 235 | + n.server_guid.data3, |
| 236 | + n.server_guid |
| 237 | + .data4 |
| 238 | + .iter() |
| 239 | + .map(|b| format!("{:02x}", b)) |
| 240 | + .collect::<String>(), |
| 241 | + ), |
| 242 | + signing_required: n.signing_required, |
| 243 | + capabilities_bits: n.capabilities.0, |
| 244 | + gmac_negotiated: n.gmac_negotiated, |
| 245 | + cipher: n.cipher.map(|c| format!("{:?}", c)), |
| 246 | + compression_supported: n.compression_supported, |
| 247 | + } |
| 248 | + } |
| 249 | +} |
| 250 | + |
| 251 | +impl From<smb2::SessionDiagnostics> for SessionDiagnosticsDto { |
| 252 | + fn from(s: smb2::SessionDiagnostics) -> Self { |
| 253 | + Self { |
| 254 | + session_id_hex: format!("{:016x}", s.session_id.0), |
| 255 | + should_sign: s.should_sign, |
| 256 | + should_encrypt: s.should_encrypt, |
| 257 | + signing_algorithm: format!("{:?}", s.signing_algorithm), |
| 258 | + } |
| 259 | + } |
| 260 | +} |
| 261 | + |
| 262 | +impl From<smb2::DfsCacheEntry> for DfsCacheEntryDto { |
| 263 | + fn from(e: smb2::DfsCacheEntry) -> Self { |
| 264 | + Self { |
| 265 | + path_prefix: e.path_prefix, |
| 266 | + target_count: e.target_count as u32, |
| 267 | + expires_in_ms: e.expires_in.map(|d| d.as_millis() as u64), |
| 268 | + } |
| 269 | + } |
| 270 | +} |
| 271 | + |
| 272 | +// ── Commands ────────────────────────────────────────────────────────── |
| 273 | + |
| 274 | +/// List every currently-registered SMB volume, with a one-line summary for |
| 275 | +/// the dashboard's volume picker. Returns an empty vec if no SMB volumes |
| 276 | +/// are mounted. A volume that's currently disconnected still shows up — |
| 277 | +/// `disconnected: true` indicates that, and the dashboard renders it |
| 278 | +/// distinctly so the user can see why diagnostics are stale. |
| 279 | +#[tauri::command] |
| 280 | +#[specta::specta] |
| 281 | +pub async fn list_smb_volumes() -> Vec<SmbVolumeRef> { |
| 282 | + let manager = get_volume_manager(); |
| 283 | + let ids: Vec<(String, String)> = manager.list_volumes(); |
| 284 | + let mut out = Vec::new(); |
| 285 | + for (id, name) in ids { |
| 286 | + let Some(vol) = manager.get(&id) else { continue }; |
| 287 | + // Hold the Arc<dyn Volume> across the await — downcast is sync, |
| 288 | + // returns a borrow tied to `vol`, so the await stays inside the |
| 289 | + // borrow's scope. |
| 290 | + if vol.as_any().downcast_ref::<SmbVolume>().is_none() { |
| 291 | + continue; |
| 292 | + } |
| 293 | + // Re-downcast to call the async diagnostics() method. Cheap. |
| 294 | + let server: String; |
| 295 | + let disconnected: bool; |
| 296 | + if let Some(smb) = vol.as_any().downcast_ref::<SmbVolume>() { |
| 297 | + match smb.diagnostics().await { |
| 298 | + Some(diag) => { |
| 299 | + server = diag.primary.server.clone(); |
| 300 | + disconnected = diag.primary.disconnected; |
| 301 | + } |
| 302 | + None => { |
| 303 | + server = String::new(); |
| 304 | + disconnected = true; |
| 305 | + } |
| 306 | + } |
| 307 | + } else { |
| 308 | + continue; |
| 309 | + } |
| 310 | + out.push(SmbVolumeRef { |
| 311 | + volume_id: id, |
| 312 | + name, |
| 313 | + server, |
| 314 | + disconnected, |
| 315 | + }); |
| 316 | + } |
| 317 | + out |
| 318 | +} |
| 319 | + |
| 320 | +/// Snapshot of the SMB client backing the given volume. |
| 321 | +/// |
| 322 | +/// Returns `Err(message)` if the volume id is unknown, the volume isn't |
| 323 | +/// an SMB volume, or the volume is currently disconnected (no client to |
| 324 | +/// snapshot). Always cheap — internally a handful of atomic loads and |
| 325 | +/// short-critical-section mutex copies; no network I/O. |
| 326 | +#[tauri::command] |
| 327 | +#[specta::specta] |
| 328 | +pub async fn get_smb_diagnostics(volume_id: String) -> Result<SmbDiagnosticsDto, String> { |
| 329 | + let manager = get_volume_manager(); |
| 330 | + let vol = manager |
| 331 | + .get(&volume_id) |
| 332 | + .ok_or_else(|| format!("no such volume: {volume_id}"))?; |
| 333 | + let smb_diag = { |
| 334 | + let smb = vol |
| 335 | + .as_any() |
| 336 | + .downcast_ref::<SmbVolume>() |
| 337 | + .ok_or_else(|| format!("volume {volume_id} is not an SMB volume"))?; |
| 338 | + smb.diagnostics().await |
| 339 | + }; |
| 340 | + let diag = smb_diag.ok_or_else(|| format!("volume {volume_id} is disconnected"))?; |
| 341 | + Ok(diag.into()) |
| 342 | +} |
0 commit comments