Skip to main content

tauri_plugin_network_manager/
desktop.rs

1use std::collections::HashMap;
2use std::sync::mpsc;
3use tauri::{plugin::PluginApi, AppHandle, Runtime};
4use uuid::Uuid;
5use zbus::names::InterfaceName;
6use zbus::zvariant::Value;
7
8use crate::error::Result;
9use crate::models::*;
10use crate::nm_helpers::NetworkManagerHelpers;
11
12impl<R: Runtime> VSKNetworkManager<'static, R> {
13    fn vpn_type_from_service_type(service_type: &str) -> VpnType {
14        match service_type {
15            "org.freedesktop.NetworkManager.openvpn" => VpnType::OpenVpn,
16            "org.freedesktop.NetworkManager.wireguard" => VpnType::WireGuard,
17            "org.freedesktop.NetworkManager.l2tp" => VpnType::L2tp,
18            "org.freedesktop.NetworkManager.pptp" => VpnType::Pptp,
19            "org.freedesktop.NetworkManager.sstp" => VpnType::Sstp,
20            "org.freedesktop.NetworkManager.strongswan" => VpnType::Ikev2,
21            "org.freedesktop.NetworkManager.fortisslvpn" => VpnType::Fortisslvpn,
22            "org.freedesktop.NetworkManager.openconnect" => VpnType::OpenConnect,
23            _ => VpnType::Generic,
24        }
25    }
26
27    fn service_type_from_vpn_type(vpn_type: &VpnType) -> &'static str {
28        match vpn_type {
29            VpnType::OpenVpn => "org.freedesktop.NetworkManager.openvpn",
30            VpnType::WireGuard => "org.freedesktop.NetworkManager.wireguard",
31            VpnType::L2tp => "org.freedesktop.NetworkManager.l2tp",
32            VpnType::Pptp => "org.freedesktop.NetworkManager.pptp",
33            VpnType::Sstp => "org.freedesktop.NetworkManager.sstp",
34            VpnType::Ikev2 => "org.freedesktop.NetworkManager.strongswan",
35            VpnType::Fortisslvpn => "org.freedesktop.NetworkManager.fortisslvpn",
36            VpnType::OpenConnect => "org.freedesktop.NetworkManager.openconnect",
37            VpnType::Generic => "org.freedesktop.NetworkManager.vpnc",
38        }
39    }
40
41    fn vpn_state_from_active_state(state: u32) -> VpnConnectionState {
42        match state {
43            1 => VpnConnectionState::Connecting,
44            2 => VpnConnectionState::Connected,
45            3 => VpnConnectionState::Disconnecting,
46            4 => VpnConnectionState::Disconnected,
47            _ => VpnConnectionState::Unknown,
48        }
49    }
50
51    fn extract_string_from_dict(
52        dict: &HashMap<String, zbus::zvariant::OwnedValue>,
53        key: &str,
54    ) -> Option<String> {
55        let value = dict.get(key)?;
56        let v: &zbus::zvariant::Value<'_> = value;
57        v.downcast_ref::<String>().ok()
58    }
59
60    fn extract_bool_from_dict(
61        dict: &HashMap<String, zbus::zvariant::OwnedValue>,
62        key: &str,
63    ) -> Option<bool> {
64        let value = dict.get(key)?;
65        let v: &zbus::zvariant::Value<'_> = value;
66        v.downcast_ref::<bool>().ok()
67    }
68
69    fn string_map_from_section(
70        settings: &HashMap<String, HashMap<String, zbus::zvariant::OwnedValue>>,
71        section_name: &str,
72    ) -> HashMap<String, String> {
73        let mut out = HashMap::new();
74        let section = match settings.get(section_name) {
75            Some(v) => v,
76            None => return out,
77        };
78
79        for (k, v) in section {
80            let val: &zbus::zvariant::Value<'_> = v;
81            if let Ok(s) = val.downcast_ref::<String>() {
82                out.insert(k.clone(), s);
83            }
84        }
85        out
86    }
87
88    fn list_connection_paths(&self) -> Result<Vec<zbus::zvariant::OwnedObjectPath>> {
89        let settings_proxy = zbus::blocking::Proxy::new(
90            &self.connection,
91            "org.freedesktop.NetworkManager",
92            "/org/freedesktop/NetworkManager/Settings",
93            "org.freedesktop.NetworkManager.Settings",
94        )?;
95
96        let connections: Vec<zbus::zvariant::OwnedObjectPath> =
97            settings_proxy.call("ListConnections", &())?;
98        Ok(connections)
99    }
100
101    fn get_connection_settings(
102        &self,
103        conn_path: &zbus::zvariant::OwnedObjectPath,
104    ) -> Result<HashMap<String, HashMap<String, zbus::zvariant::OwnedValue>>> {
105        let conn_proxy = zbus::blocking::Proxy::new(
106            &self.connection,
107            "org.freedesktop.NetworkManager",
108            conn_path.as_str(),
109            "org.freedesktop.NetworkManager.Settings.Connection",
110        )?;
111
112        let settings: HashMap<String, HashMap<String, zbus::zvariant::OwnedValue>> =
113            conn_proxy.call("GetSettings", &())?;
114        Ok(settings)
115    }
116
117    fn find_connection_path_by_uuid(&self, uuid: &str) -> Result<zbus::zvariant::OwnedObjectPath> {
118        let connections = self.list_connection_paths()?;
119
120        for conn_path in connections {
121            let settings = self.get_connection_settings(&conn_path)?;
122            let dict = match settings.get("connection") {
123                Some(v) => v,
124                None => continue,
125            };
126
127            if let Some(conn_uuid) = Self::extract_string_from_dict(dict, "uuid") {
128                if conn_uuid == uuid {
129                    return Ok(conn_path);
130                }
131            }
132        }
133
134        Err(crate::error::NetworkError::VpnProfileNotFound(
135            uuid.to_string(),
136        ))
137    }
138
139    fn vpn_profile_from_settings(
140        &self,
141        settings: &HashMap<String, HashMap<String, zbus::zvariant::OwnedValue>>,
142    ) -> Option<VpnProfile> {
143        let connection_dict = settings.get("connection")?;
144
145        let conn_type = Self::extract_string_from_dict(connection_dict, "type")?;
146        if conn_type != "vpn" {
147            return None;
148        }
149
150        let uuid = Self::extract_string_from_dict(connection_dict, "uuid")?;
151        let id =
152            Self::extract_string_from_dict(connection_dict, "id").unwrap_or_else(|| uuid.clone());
153        let interface_name = Self::extract_string_from_dict(connection_dict, "interface-name");
154        let autoconnect =
155            Self::extract_bool_from_dict(connection_dict, "autoconnect").unwrap_or(false);
156
157        let vpn_settings = Self::string_map_from_section(settings, "vpn");
158        let service_type = vpn_settings
159            .get("service-type")
160            .map(|s| s.as_str())
161            .unwrap_or("unknown");
162
163        Some(VpnProfile {
164            id,
165            uuid,
166            vpn_type: Self::vpn_type_from_service_type(service_type),
167            interface_name,
168            autoconnect,
169            editable: true,
170            last_error: None,
171        })
172    }
173
174    /// Get WiFi icon based on signal strength
175    fn get_wifi_icon(strength: u8) -> String {
176        match strength {
177            0..=25 => "network-wireless-signal-weak-symbolic".to_string(),
178            26..=50 => "network-wireless-signal-ok-symbolic".to_string(),
179            51..=75 => "network-wireless-signal-good-symbolic".to_string(),
180            76..=100 => "network-wireless-signal-excellent-symbolic".to_string(),
181            _ => "network-wireless-signal-none-symbolic".to_string(),
182        }
183    }
184
185    /// The icon for anything that is not Wi-Fi.
186    ///
187    /// Ethernet is one of these, not all of them: a VPN tunnel, a mobile modem
188    /// or a bridge are their own thing, and showing a cable for them tells the
189    /// person something untrue about how they are connected.
190    fn get_icon_for(connection_type: &str, is_connected: bool) -> String {
191        if !is_connected {
192            return "network-offline-symbolic".to_string();
193        }
194
195        match connection_type {
196            "Ethernet" => "network-wired-symbolic".to_string(),
197            "Modem" => "network-cellular-connected-symbolic".to_string(),
198            "VPN" | "WireGuard" => "network-vpn-symbolic".to_string(),
199            _ => "network-transmit-receive-symbolic".to_string(),
200        }
201    }
202
203    /// Create a new VSKNetworkManager instance
204    pub fn new(app: AppHandle<R>) -> Result<Self> {
205        let connection = zbus::blocking::Connection::system()?;
206        let proxy = zbus::blocking::fdo::PropertiesProxy::builder(&connection)
207            .destination("org.freedesktop.NetworkManager")?
208            .path("/org/freedesktop/NetworkManager")?
209            .build()?;
210
211        Ok(Self {
212            connection,
213            proxy,
214            app,
215        })
216    }
217
218    /// The connection the panel should describe: the one carrying the default
219    /// route, which is what NetworkManager calls the primary connection.
220    ///
221    /// Taking `ActiveConnections[0]` instead is how a laptop on Wi-Fi ended up
222    /// showing the wired icon: that list is in no particular order and holds
223    /// everything active — the loopback, a VPN tunnel, a bridge — so the panel
224    /// was describing whichever one happened to be first. On the machine this
225    /// was found on it was a `tun`, whose device type is neither ethernet nor
226    /// Wi-Fi, and everything that is not Wi-Fi used to be painted as wired.
227    fn primary_connection_path(&self) -> Option<zbus::zvariant::OwnedObjectPath> {
228        let primary = self
229            .proxy
230            .get(
231                InterfaceName::from_static_str_unchecked("org.freedesktop.NetworkManager"),
232                "PrimaryConnection",
233            )
234            .ok()?;
235
236        match primary.downcast_ref() {
237            // "/" is NetworkManager for "there is no default route right now",
238            // and then there is nothing to prefer over the active list.
239            Ok(Value::ObjectPath(path)) if path.as_str() != "/" => Some(path.into()),
240            _ => None,
241        }
242    }
243
244    pub fn get_current_network_state(&self) -> Result<NetworkInfo> {
245        // Get active connections
246        let active_connections_variant = self.proxy.get(
247            InterfaceName::from_static_str_unchecked("org.freedesktop.NetworkManager"),
248            "ActiveConnections",
249        )?;
250
251        let primary = self.primary_connection_path();
252
253        // If no active connections, return default
254        match active_connections_variant.downcast_ref() {
255            Ok(Value::Array(arr)) if !arr.is_empty() => {
256                let chosen = match (&primary, &arr[0]) {
257                    (Some(path), _) => path.as_ref().to_owned(),
258                    (None, Value::ObjectPath(path)) => path.to_owned(),
259                    _ => return Ok(NetworkInfo::default()),
260                };
261                let chosen = Value::ObjectPath(chosen);
262
263                match chosen {
264                    zbus::zvariant::Value::ObjectPath(ref path) => {
265                        // Get devices for this connection
266                        // Crear un proxy de propiedades para obtener las propiedades
267                        let properties_proxy =
268                            zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
269                                .destination("org.freedesktop.NetworkManager")?
270                                .path(path)?
271                                .build()?;
272
273                        let devices_variant = properties_proxy.get(
274                            InterfaceName::from_static_str_unchecked(
275                                "org.freedesktop.NetworkManager.Connection.Active",
276                            ),
277                            "Devices",
278                        )?;
279
280                        // Get the first device (if available)
281                        let device_path = match devices_variant.downcast_ref() {
282                            Ok(Value::Array(device_arr)) if !device_arr.is_empty() => {
283                                match device_arr[0] {
284                                    zbus::zvariant::Value::ObjectPath(ref dev_path) => {
285                                        dev_path.clone()
286                                    }
287                                    _ => return Ok(NetworkInfo::default()),
288                                }
289                            }
290                            _ => return Ok(NetworkInfo::default()),
291                        };
292
293                        // Retrieve connection details
294                        // Crear un proxy de propiedades para el dispositivo
295                        let device_properties_proxy =
296                            zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
297                                .destination("org.freedesktop.NetworkManager")?
298                                .path(&device_path)?
299                                .build()?;
300
301                        let connection_type = device_properties_proxy.get(
302                            InterfaceName::from_static_str_unchecked(
303                                "org.freedesktop.NetworkManager.Device",
304                            ),
305                            "DeviceType",
306                        )?;
307
308                        let state_variant = properties_proxy.get(
309                            InterfaceName::from_static_str_unchecked(
310                                "org.freedesktop.NetworkManager.Connection.Active",
311                            ),
312                            "State",
313                        )?;
314
315                        let is_connected = match state_variant.downcast_ref() {
316                            Ok(zbus::zvariant::Value::U32(state)) => state == 2, // 2 = ACTIVATED
317                            _ => false,
318                        };
319
320                        // Determine connection type. The numbers are
321                        // NMDeviceType; the ones named here are the ones with an
322                        // icon of their own, and anything else stays "Unknown"
323                        // rather than being drawn as something it is not.
324                        let connection_type_str = match connection_type.downcast_ref() {
325                            Ok(zbus::zvariant::Value::U32(device_type)) => match device_type {
326                                1 => "Ethernet".to_string(),
327                                2 => "WiFi".to_string(),
328                                8 => "Modem".to_string(),
329                                16 => "VPN".to_string(),
330                                29 => "WireGuard".to_string(),
331                                _ => "Unknown".to_string(),
332                            },
333                            _ => "Unknown".to_string(),
334                        };
335
336                        // Default network info
337                        let mut network_info = NetworkInfo {
338                            name: "Unknown".to_string(),
339                            ssid: "Unknown".to_string(),
340                            connection_type: connection_type_str.clone(),
341                            icon: "network-offline-symbolic".to_string(),
342                            ip_address: "0.0.0.0".to_string(),
343                            mac_address: "00:00:00:00:00:00".to_string(),
344                            signal_strength: 0,
345                            security_type: WiFiSecurityType::None,
346                            is_connected: is_connected
347                                && NetworkManagerHelpers::has_internet_connectivity(&self.proxy)?,
348                        };
349
350                        let hw_address_variant = device_properties_proxy.get(
351                            InterfaceName::from_static_str_unchecked(
352                                "org.freedesktop.NetworkManager.Device",
353                            ),
354                            "HwAddress",
355                        )?;
356
357                        network_info.mac_address = match hw_address_variant.downcast_ref() {
358                            Ok(zbus::zvariant::Value::Str(s)) => s.to_string(),
359                            _ => "00:00:00:00:00:00".to_string(),
360                        };
361
362                        // For WiFi networks, get additional details
363                        if connection_type_str == "WiFi" {
364                            // Get active access point
365                            // Crear un proxy de propiedades para el dispositivo inalámbrico
366                            let wireless_properties_proxy =
367                                zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
368                                    .destination("org.freedesktop.NetworkManager")?
369                                    .path(&device_path)?
370                                    .build()?;
371
372                            let active_ap_path = wireless_properties_proxy.get(
373                                InterfaceName::from_static_str_unchecked(
374                                    "org.freedesktop.NetworkManager.Device.Wireless",
375                                ),
376                                "ActiveAccessPoint",
377                            )?;
378
379                            if let Ok(zbus::zvariant::Value::ObjectPath(ap_path)) =
380                                active_ap_path.downcast_ref()
381                            {
382                                let _ap_proxy = zbus::blocking::Proxy::new(
383                                    &self.connection,
384                                    "org.freedesktop.NetworkManager",
385                                    &ap_path,
386                                    "org.freedesktop.NetworkManager.AccessPoint",
387                                )?;
388
389                                // Get SSID
390                                // Crear un proxy de propiedades para el punto de acceso
391                                let ap_properties_proxy =
392                                    zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
393                                        .destination("org.freedesktop.NetworkManager")?
394                                        .path(ap_path.as_str())?
395                                        .build()?;
396
397                                let ssid_variant = ap_properties_proxy.get(
398                                    InterfaceName::from_static_str_unchecked(
399                                        "org.freedesktop.NetworkManager.AccessPoint",
400                                    ),
401                                    "Ssid",
402                                )?;
403
404                                network_info.ssid = match ssid_variant.downcast_ref() {
405                                    Ok(v) => NetworkManagerHelpers::ssid_from_value(&v),
406                                    _ => "Unknown".to_string(),
407                                };
408                                network_info.name = network_info.ssid.clone();
409
410                                // Get signal strength
411                                let strength_variant = ap_properties_proxy.get(
412                                    InterfaceName::from_static_str_unchecked(
413                                        "org.freedesktop.NetworkManager.AccessPoint",
414                                    ),
415                                    "Strength",
416                                )?;
417
418                                network_info.signal_strength = match strength_variant.downcast_ref()
419                                {
420                                    Ok(zbus::zvariant::Value::U8(s)) => s,
421                                    _ => 0,
422                                };
423
424                                // Update icon based on signal strength
425                                network_info.icon =
426                                    Self::get_wifi_icon(network_info.signal_strength);
427
428                                // Determine security type using helper
429                                network_info.security_type =
430                                    NetworkManagerHelpers::detect_security_type(
431                                        &ap_properties_proxy,
432                                    )?;
433                            }
434                        } else {
435                            // Everything that is not Wi-Fi used to be drawn as
436                            // an ethernet cable, tunnels and modems included.
437                            network_info.icon =
438                                Self::get_icon_for(&connection_type_str, network_info.is_connected);
439                        }
440                        // Get IP configuration
441                        let ip4_config_path = device_properties_proxy.get(
442                            InterfaceName::from_static_str_unchecked(
443                                "org.freedesktop.NetworkManager.Device",
444                            ),
445                            "Ip4Config",
446                        )?;
447
448                        // Retrieve IP address if available
449                        if let Ok(zbus::zvariant::Value::ObjectPath(config_path)) =
450                            ip4_config_path.downcast_ref()
451                        {
452                            // Crear un proxy de propiedades para la configuración IP
453                            let ip_config_properties_proxy =
454                                zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
455                                    .destination("org.freedesktop.NetworkManager")?
456                                    .path(config_path)?
457                                    .build()?;
458
459                            let addresses_variant = ip_config_properties_proxy.get(
460                                InterfaceName::from_static_str_unchecked(
461                                    "org.freedesktop.NetworkManager.IP4Config",
462                                ),
463                                "Addresses",
464                            )?;
465
466                            if let Ok(Value::Array(addr_arr)) = addresses_variant.downcast_ref() {
467                                if let Some(Value::Array(ip_tuple)) = addr_arr.first() {
468                                    if !ip_tuple.is_empty() {
469                                        if let Value::U32(ip_int) = &ip_tuple[0] {
470                                            use std::net::Ipv4Addr;
471                                            network_info.ip_address =
472                                                Ipv4Addr::from((*ip_int).to_be()).to_string();
473                                        }
474                                    }
475                                }
476                            }
477                        }
478
479                        Ok(network_info)
480                    }
481                    _ => Ok(NetworkInfo::default()),
482                }
483            }
484            _ => Ok(NetworkInfo::default()),
485        }
486    }
487
488    /// Lightweight: get the SSID of the currently connected access point (if any).
489    /// Avoids the ~10 D-Bus calls that `get_current_network_state()` makes.
490    fn connected_wifi_ssid(&self) -> Result<Option<String>> {
491        let devices = self.wireless_device_paths()?;
492        for device_path in &devices {
493            let wireless_props = zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
494                .destination("org.freedesktop.NetworkManager")?
495                .path(device_path.as_str())?
496                .build()?;
497
498            let active_ap = wireless_props.get(
499                InterfaceName::from_static_str_unchecked(
500                    "org.freedesktop.NetworkManager.Device.Wireless",
501                ),
502                "ActiveAccessPoint",
503            )?;
504
505            if let Ok(zbus::zvariant::Value::ObjectPath(ap_path)) = active_ap.downcast_ref() {
506                if ap_path.as_str() == "/" {
507                    continue;
508                }
509                let ap_props = zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
510                    .destination("org.freedesktop.NetworkManager")?
511                    .path(ap_path.as_str())?
512                    .build()?;
513
514                let ssid_variant = ap_props.get(
515                    InterfaceName::from_static_str_unchecked(
516                        "org.freedesktop.NetworkManager.AccessPoint",
517                    ),
518                    "Ssid",
519                )?;
520
521                if let Ok(v) = ssid_variant.downcast_ref() {
522                    let ssid = NetworkManagerHelpers::ssid_from_value(&v);
523                    if !ssid.is_empty() && ssid != "Unknown" {
524                        return Ok(Some(ssid));
525                    }
526                }
527            }
528        }
529        Ok(None)
530    }
531
532    /// List available WiFi networks
533    pub fn list_wifi_networks(&self) -> Result<Vec<NetworkInfo>> {
534        let devices_variant = self.proxy.get(
535            InterfaceName::from_static_str_unchecked("org.freedesktop.NetworkManager"),
536            "Devices",
537        )?;
538
539        let mut networks = Vec::new();
540        let connected_ssid = self.connected_wifi_ssid()?;
541
542        if let Ok(zbus::zvariant::Value::Array(devices)) = devices_variant.downcast_ref() {
543            for device in devices.iter() {
544                if let zbus::zvariant::Value::ObjectPath(ref device_path) = device {
545                    let device_props =
546                        zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
547                            .destination("org.freedesktop.NetworkManager")?
548                            .path(device_path)?
549                            .build()?;
550
551                    let device_type_variant = device_props.get(
552                        InterfaceName::from_static_str_unchecked(
553                            "org.freedesktop.NetworkManager.Device",
554                        ),
555                        "DeviceType",
556                    )?;
557
558                    if let Ok(zbus::zvariant::Value::U32(device_type)) =
559                        device_type_variant.downcast_ref()
560                    {
561                        if device_type == 2u32 {
562                            let mac_address = match device_props
563                                .get(
564                                    InterfaceName::from_static_str_unchecked(
565                                        "org.freedesktop.NetworkManager.Device",
566                                    ),
567                                    "HwAddress",
568                                )?
569                                .downcast_ref()
570                            {
571                                Ok(zbus::zvariant::Value::Str(s)) => s.to_string(),
572                                _ => "00:00:00:00:00:00".to_string(),
573                            };
574
575                            let wireless_props =
576                                zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
577                                    .destination("org.freedesktop.NetworkManager")?
578                                    .path(device_path)?
579                                    .build()?;
580
581                            let access_points_variant = wireless_props.get(
582                                InterfaceName::from_static_str_unchecked(
583                                    "org.freedesktop.NetworkManager.Device.Wireless",
584                                ),
585                                "AccessPoints",
586                            )?;
587
588                            if let Ok(zbus::zvariant::Value::Array(aps)) =
589                                access_points_variant.downcast_ref()
590                            {
591                                for ap in aps.iter() {
592                                    if let zbus::zvariant::Value::ObjectPath(ref ap_path) = ap {
593                                        let ap_props =
594                                            zbus::blocking::fdo::PropertiesProxy::builder(
595                                                &self.connection,
596                                            )
597                                            .destination("org.freedesktop.NetworkManager")?
598                                            .path(ap_path)?
599                                            .build()?;
600
601                                        let ssid_variant = ap_props.get(
602                                            InterfaceName::from_static_str_unchecked(
603                                                "org.freedesktop.NetworkManager.AccessPoint",
604                                            ),
605                                            "Ssid",
606                                        )?;
607
608                                        let ssid = match ssid_variant.downcast_ref() {
609                                            Ok(v) => NetworkManagerHelpers::ssid_from_value(&v),
610                                            _ => "Unknown".to_string(),
611                                        };
612
613                                        let strength_variant = ap_props.get(
614                                            InterfaceName::from_static_str_unchecked(
615                                                "org.freedesktop.NetworkManager.AccessPoint",
616                                            ),
617                                            "Strength",
618                                        )?;
619
620                                        let strength = match strength_variant.downcast_ref() {
621                                            Ok(zbus::zvariant::Value::U8(s)) => s,
622                                            _ => 0,
623                                        };
624
625                                        let security_type =
626                                            NetworkManagerHelpers::detect_security_type(&ap_props)?;
627
628                                        let is_connected =
629                                            connected_ssid.as_deref() == Some(ssid.as_str());
630
631                                        let network_info = NetworkInfo {
632                                            name: ssid.clone(),
633                                            ssid,
634                                            connection_type: "wifi".to_string(),
635                                            icon: Self::get_wifi_icon(strength),
636                                            ip_address: "0.0.0.0".to_string(),
637                                            mac_address: mac_address.clone(),
638                                            signal_strength: strength,
639                                            security_type,
640                                            is_connected,
641                                        };
642
643                                        if !networks
644                                            .iter()
645                                            .any(|n: &NetworkInfo| n.ssid == network_info.ssid)
646                                        {
647                                            networks.push(network_info);
648                                        }
649                                    }
650                                }
651                            }
652                        }
653                    }
654                }
655            }
656        }
657
658        // De mayor a menor señal.
659        networks.sort_by_key(|red| std::cmp::Reverse(red.signal_strength));
660        Ok(networks)
661    }
662
663    /// Collect paths of all wireless (type=2) devices from NetworkManager
664    fn wireless_device_paths(&self) -> Result<Vec<zbus::zvariant::OwnedObjectPath>> {
665        let devices_variant = self.proxy.get(
666            InterfaceName::from_static_str_unchecked("org.freedesktop.NetworkManager"),
667            "Devices",
668        )?;
669
670        let mut paths = Vec::new();
671        if let Ok(zbus::zvariant::Value::Array(devices)) = devices_variant.downcast_ref() {
672            for device in devices.iter() {
673                if let zbus::zvariant::Value::ObjectPath(device_path) = device {
674                    let device_props =
675                        zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
676                            .destination("org.freedesktop.NetworkManager")?
677                            .path(device_path.as_str())?
678                            .build()?;
679
680                    let device_type_variant = device_props.get(
681                        InterfaceName::from_static_str_unchecked(
682                            "org.freedesktop.NetworkManager.Device",
683                        ),
684                        "DeviceType",
685                    )?;
686
687                    if let Ok(zbus::zvariant::Value::U32(device_type)) =
688                        device_type_variant.downcast_ref()
689                    {
690                        if device_type == 2u32 {
691                            let owned =
692                                zbus::zvariant::OwnedObjectPath::try_from(device_path.as_str())
693                                    .unwrap();
694                            paths.push(owned);
695                        }
696                    }
697                }
698            }
699        }
700        Ok(paths)
701    }
702
703    /// ¿El barrido ya terminó? NetworkManager marca el fin subiendo `LastScan`
704    /// por encima del valor que tenía antes de que lo pidiéramos. `-1` es "nunca
705    /// escaneó", así que nunca cuenta como terminado: seguimos esperando.
706    fn scan_settled(before: i64, current: i64) -> bool {
707        current != -1 && current != before
708    }
709
710    /// Lee `LastScan` (milisegundos de CLOCK_BOOTTIME, `-1` si nunca) de un
711    /// dispositivo inalámbrico. Cualquier fallo de lectura se devuelve como `-1`
712    /// para que el que espera siga esperando en vez de darse por satisfecho con
713    /// una lista a medio cocinar.
714    fn read_last_scan(props: &zbus::blocking::fdo::PropertiesProxy) -> i64 {
715        match props.get(
716            InterfaceName::from_static_str_unchecked(
717                "org.freedesktop.NetworkManager.Device.Wireless",
718            ),
719            "LastScan",
720        ) {
721            Ok(value) => match value.downcast_ref() {
722                Ok(zbus::zvariant::Value::I64(last)) => last,
723                _ => -1,
724            },
725            Err(_) => -1,
726        }
727    }
728
729    /// Request an explicit WiFi scan through NetworkManager and return a fresh list.
730    ///
731    /// `RequestScan` es "dispará y olvidate": NetworkManager arranca el barrido en
732    /// segundo plano y contesta en el acto, mientras el rastreo real tarda varios
733    /// segundos. Antes leíamos los `AccessPoints` apenas contestaba —o sea, la
734    /// lista del barrido *anterior*, vacía recién encendido el WiFi—, y por eso
735    /// "no encontraba nada". Ahora esperamos a que `LastScan` avance antes de leer.
736    pub fn rescan_wifi(&self) -> Result<Vec<NetworkInfo>> {
737        const SCAN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
738        const SCAN_POLL: std::time::Duration = std::time::Duration::from_millis(300);
739
740        let devices = self.wireless_device_paths()?;
741
742        if devices.is_empty() {
743            return Err(crate::error::NetworkError::OperationError(
744                "No wireless device available for scanning".to_string(),
745            ));
746        }
747
748        // ¿Algún dispositivo llegó a completar un barrido? Si ninguno lo hizo,
749        // la lista que devolveríamos sería la vieja, y el contrato del comando es
750        // entregar una fresca: en ese caso es un error, no un éxito con datos
751        // rancios.
752        let mut any_settled = false;
753
754        for device_path in &devices {
755            let wireless_props = zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
756                .destination("org.freedesktop.NetworkManager")?
757                .path(device_path.as_str())?
758                .build()?;
759
760            // Desde qué barrido estamos esperando el siguiente.
761            let last_scan_before = Self::read_last_scan(&wireless_props);
762
763            let wireless_proxy = zbus::blocking::Proxy::new(
764                &self.connection,
765                "org.freedesktop.NetworkManager",
766                device_path.as_str(),
767                "org.freedesktop.NetworkManager.Device.Wireless",
768            )?;
769
770            // `RequestScan` puede negarse si ya hay un barrido en curso o si el
771            // anterior fue hace muy poco. En los dos casos igual queremos esperar
772            // a que `LastScan` avance y leer recién entonces, así que no es un
773            // error: lo ignoramos a propósito.
774            let options: HashMap<String, zbus::zvariant::OwnedValue> = HashMap::new();
775            let _ = wireless_proxy.call::<_, _, ()>("RequestScan", &(options,));
776
777            // Esperar a que el barrido termine antes de leer la lista.
778            let deadline = std::time::Instant::now() + SCAN_TIMEOUT;
779            while std::time::Instant::now() < deadline {
780                if Self::scan_settled(last_scan_before, Self::read_last_scan(&wireless_props)) {
781                    any_settled = true;
782                    break;
783                }
784                std::thread::sleep(SCAN_POLL);
785            }
786        }
787
788        if !any_settled {
789            return Err(crate::error::NetworkError::OperationError(
790                "WiFi scan did not complete on any wireless device".to_string(),
791            ));
792        }
793
794        self.list_wifi_networks()
795    }
796
797    /// Connect to a WiFi network
798    pub fn connect_to_wifi(&self, config: WiFiConnectionConfig) -> Result<()> {
799        // Log connection attempt. No se loguea el usuario: en WPA-Enterprise es
800        // una credencial, y un log de depuración no es lugar para credenciales.
801        // Se registra sólo si vino uno, no cuál.
802        log::debug!(
803            "connect_to_wifi called: ssid='{}' security={:?} has_username={}",
804            config.ssid,
805            config.security_type,
806            config.username.is_some()
807        );
808
809        // Create connection settings
810        let mut connection_settings = HashMap::new();
811        let mut wifi_settings = HashMap::new();
812        let mut security_settings = HashMap::new();
813
814        // Set connection name and type
815        let mut connection = HashMap::new();
816        connection.insert("id".to_string(), Value::from(config.ssid.clone()));
817        connection.insert("type".to_string(), Value::from("802-11-wireless"));
818        connection_settings.insert("connection".to_string(), connection);
819
820        // Set WiFi settings
821        wifi_settings.insert(
822            "ssid".to_string(),
823            Value::from(config.ssid.as_bytes().to_vec()),
824        );
825        wifi_settings.insert("mode".to_string(), Value::from("infrastructure"));
826
827        // Set security settings based on security type
828        match config.security_type {
829            WiFiSecurityType::None => {
830                // No security settings needed
831            }
832            WiFiSecurityType::Wep => {
833                security_settings.insert("key-mgmt".to_string(), Value::from("none"));
834                if let Some(password) = config.password.clone() {
835                    security_settings.insert("wep-key0".to_string(), Value::from(password));
836                }
837            }
838            WiFiSecurityType::WpaPsk => {
839                security_settings.insert("key-mgmt".to_string(), Value::from("wpa-psk"));
840                if let Some(password) = config.password.clone() {
841                    security_settings.insert("psk".to_string(), Value::from(password));
842                }
843            }
844            WiFiSecurityType::WpaEap => {
845                security_settings.insert("key-mgmt".to_string(), Value::from("wpa-eap"));
846                if let Some(password) = config.password.clone() {
847                    security_settings.insert("password".to_string(), Value::from(password));
848                }
849                if let Some(username) = config.username.clone() {
850                    security_settings.insert("identity".to_string(), Value::from(username));
851                }
852            }
853            WiFiSecurityType::Wpa2Psk => {
854                security_settings.insert("key-mgmt".to_string(), Value::from("wpa-psk"));
855                security_settings.insert("proto".to_string(), Value::from(vec!["rsn"]));
856                if let Some(password) = config.password.clone() {
857                    security_settings.insert("psk".to_string(), Value::from(password));
858                }
859            }
860            WiFiSecurityType::Wpa3Psk => {
861                security_settings.insert("key-mgmt".to_string(), Value::from("sae"));
862                if let Some(password) = config.password.clone() {
863                    security_settings.insert("psk".to_string(), Value::from(password));
864                }
865            }
866        }
867
868        connection_settings.insert("802-11-wireless".to_string(), wifi_settings);
869        if !security_settings.is_empty() {
870            connection_settings.insert("802-11-wireless-security".to_string(), security_settings);
871        }
872
873        // Log constructed settings for debugging
874        log::debug!("connection_settings: {:#?}", connection_settings);
875
876        // Crear un proxy para NetworkManager
877        let nm_proxy = zbus::blocking::Proxy::new(
878            &self.connection,
879            "org.freedesktop.NetworkManager",
880            "/org/freedesktop/NetworkManager",
881            "org.freedesktop.NetworkManager",
882        )?;
883
884        // Llamar al método AddAndActivateConnection con autoselección de NM
885        let any_path = zbus::zvariant::OwnedObjectPath::try_from("/").unwrap();
886        let call_result: zbus::Result<(
887            zbus::zvariant::OwnedObjectPath,
888            zbus::zvariant::OwnedObjectPath,
889        )> = nm_proxy.call(
890            "AddAndActivateConnection",
891            &(connection_settings, &any_path, &any_path),
892        );
893
894        match call_result {
895            Ok((conn_path, active_path)) => {
896                log::info!(
897                    "AddAndActivateConnection succeeded for ssid='{}' conn='{}' active='{}'",
898                    config.ssid,
899                    conn_path.as_str(),
900                    active_path.as_str()
901                );
902            }
903            Err(e) => {
904                log::error!(
905                    "AddAndActivateConnection failed for ssid='{}': {:?}",
906                    config.ssid,
907                    e
908                );
909                return Err(e.into());
910            }
911        }
912
913        log::debug!("connect_to_wifi finished for ssid='{}'", config.ssid);
914
915        Ok(())
916    }
917
918    /// Toggle network state
919    pub fn toggle_network_state(&self, enabled: bool) -> Result<bool> {
920        let nm_proxy = zbus::blocking::Proxy::new(
921            &self.connection,
922            "org.freedesktop.NetworkManager",
923            "/org/freedesktop/NetworkManager",
924            "org.freedesktop.NetworkManager",
925        )?;
926
927        nm_proxy.set_property("NetworkingEnabled", enabled)?;
928
929        let current_state: bool = nm_proxy.get_property("NetworkingEnabled")?;
930        Ok(current_state)
931    }
932
933    /// Get wireless enabled state
934    pub fn get_wireless_enabled(&self) -> Result<bool> {
935        let nm_proxy = zbus::blocking::Proxy::new(
936            &self.connection,
937            "org.freedesktop.NetworkManager",
938            "/org/freedesktop/NetworkManager",
939            "org.freedesktop.NetworkManager",
940        )?;
941        Ok(nm_proxy.get_property("WirelessEnabled")?)
942    }
943
944    /// Set wireless enabled state
945    pub fn set_wireless_enabled(&self, enabled: bool) -> Result<()> {
946        let nm_proxy = zbus::blocking::Proxy::new(
947            &self.connection,
948            "org.freedesktop.NetworkManager",
949            "/org/freedesktop/NetworkManager",
950            "org.freedesktop.NetworkManager",
951        )?;
952        nm_proxy.set_property("WirelessEnabled", enabled)?;
953        Ok(())
954    }
955
956    /// Check if wireless device is available
957    pub fn is_wireless_available(&self) -> Result<bool> {
958        Ok(!self.wireless_device_paths()?.is_empty())
959    }
960
961    /// Listen for network changes
962    pub fn listen_network_changes(&self) -> Result<mpsc::Receiver<NetworkInfo>> {
963        let (tx, rx) = mpsc::channel();
964        let connection_clone = self.connection.clone();
965        let app_handle = self.app.clone();
966
967        std::thread::spawn(move || {
968            if let Ok(proxy) = zbus::blocking::Proxy::new(
969                &connection_clone,
970                "org.freedesktop.NetworkManager",
971                "/org/freedesktop/NetworkManager",
972                "org.freedesktop.NetworkManager",
973            ) {
974                if let Ok(signal) = proxy.receive_signal("StateChanged") {
975                    let proxy_props =
976                        zbus::blocking::fdo::PropertiesProxy::builder(&connection_clone)
977                            .destination("org.freedesktop.NetworkManager")
978                            .unwrap()
979                            .path("/org/freedesktop/NetworkManager")
980                            .unwrap()
981                            .build()
982                            .unwrap();
983
984                    for _msg in signal {
985                        let network_manager = VSKNetworkManager {
986                            connection: connection_clone.clone(),
987                            proxy: proxy_props.clone(),
988                            app: app_handle.clone(),
989                        };
990
991                        if let Ok(network_info) = network_manager.get_current_network_state() {
992                            if tx.send(network_info).is_err() {
993                                break;
994                            }
995                        }
996                    }
997                }
998            }
999        });
1000
1001        Ok(rx)
1002    }
1003
1004    /// Disconnect from the current WiFi network
1005    pub fn disconnect_from_wifi(&self) -> Result<()> {
1006        let nm_proxy = zbus::blocking::Proxy::new(
1007            &self.connection,
1008            "org.freedesktop.NetworkManager",
1009            "/org/freedesktop/NetworkManager",
1010            "org.freedesktop.NetworkManager",
1011        )?;
1012
1013        // Obtener las conexiones activas
1014        let active_connections_variant: zbus::zvariant::OwnedValue = self.proxy.get(
1015            InterfaceName::from_static_str_unchecked("org.freedesktop.NetworkManager"),
1016            "ActiveConnections",
1017        )?;
1018
1019        // Convertir el valor a un vector de ObjectPath
1020        let active_connections = match active_connections_variant.downcast_ref() {
1021            Ok(zbus::zvariant::Value::Array(arr)) => arr
1022                .iter()
1023                .filter_map(|v| match v {
1024                    zbus::zvariant::Value::ObjectPath(path) => {
1025                        Some(zbus::zvariant::OwnedObjectPath::from(path.to_owned()))
1026                    }
1027                    _ => None,
1028                })
1029                .collect::<Vec<zbus::zvariant::OwnedObjectPath>>(),
1030            _ => Vec::new(),
1031        };
1032
1033        if !active_connections.is_empty() {
1034            nm_proxy.call::<_, _, ()>("DeactivateConnection", &(&active_connections[0],))?;
1035            Ok(())
1036        } else {
1037            Ok(())
1038        }
1039    }
1040
1041    /// Get the list of saved WiFi networks
1042    pub fn get_saved_wifi_networks(&self) -> Result<Vec<NetworkInfo>> {
1043        // Crear un proxy para el servicio de configuración de NetworkManager
1044        let settings_proxy = zbus::blocking::Proxy::new(
1045            &self.connection,
1046            "org.freedesktop.NetworkManager",
1047            "/org/freedesktop/NetworkManager/Settings",
1048            "org.freedesktop.NetworkManager.Settings",
1049        )?;
1050
1051        // Obtener todas las conexiones guardadas
1052        let connections: Vec<zbus::zvariant::OwnedObjectPath> =
1053            settings_proxy.call("ListConnections", &())?;
1054        let mut saved_networks = Vec::new();
1055
1056        // Procesar cada conexión guardada
1057        for conn_path in connections {
1058            // Crear un proxy para cada conexión
1059            let conn_proxy = zbus::blocking::Proxy::new(
1060                &self.connection,
1061                "org.freedesktop.NetworkManager",
1062                conn_path.as_str(),
1063                "org.freedesktop.NetworkManager.Settings.Connection",
1064            )?;
1065
1066            // Obtener la configuración de la conexión como un HashMap
1067            let settings: std::collections::HashMap<String, zbus::zvariant::OwnedValue> =
1068                conn_proxy.call("GetSettings", &())?;
1069
1070            // Verificar si es una conexión WiFi
1071            if let Some(connection) = settings.get("connection") {
1072                let connection_dict = match connection.downcast_ref::<Value>().and_then(|v| {
1073                    v.downcast::<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>()
1074                }) {
1075                    Ok(dict) => dict,
1076                    _ => continue,
1077                };
1078
1079                // Verificar el tipo de conexión
1080                if let Some(conn_type) = connection_dict.get("type") {
1081                    let v_type: &zbus::zvariant::Value<'_> = conn_type;
1082                    let conn_type_str = match v_type.downcast_ref::<String>() {
1083                        Ok(s) => s,
1084                        _ => continue,
1085                    };
1086
1087                    // Si es una conexión WiFi, extraer la información
1088                    if conn_type_str == "802-11-wireless" {
1089                        let mut network_info = NetworkInfo {
1090                            connection_type: "wifi".to_string(),
1091                            ..Default::default()
1092                        };
1093
1094                        // Obtener el nombre de la conexión
1095                        if let Some(id) = connection_dict.get("id") {
1096                            let v_id: &zbus::zvariant::Value<'_> = id;
1097                            if let Ok(name) = v_id.downcast_ref::<String>() {
1098                                network_info.name = name;
1099                            }
1100                        }
1101
1102                        // Obtener el SSID
1103                        if let Some(wireless) = settings.get("802-11-wireless") {
1104                            let wireless_dict = match wireless.downcast_ref::<Value>()
1105                                .and_then(|v| v.downcast::<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>())
1106                            {
1107                                Ok(dict) => dict,
1108                                _ => continue,
1109                            };
1110
1111                            if let Some(ssid) = wireless_dict.get("ssid") {
1112                                let v_ssid: &zbus::zvariant::Value<'_> = ssid;
1113                                network_info.ssid = NetworkManagerHelpers::ssid_from_value(v_ssid);
1114                            }
1115                        }
1116
1117                        // Determinar el tipo de seguridad
1118                        if let Some(security) = settings.get("802-11-wireless-security") {
1119                            let security_dict = match security.downcast_ref::<Value>()
1120                                .and_then(|v| v.downcast::<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>())
1121                            {
1122                                Ok(dict) => dict,
1123                                _ => {
1124                                    network_info.security_type = WiFiSecurityType::None;
1125                                    saved_networks.push(network_info);
1126                                    continue;
1127                                },
1128                            };
1129
1130                            if let Some(key_mgmt) = security_dict.get("key-mgmt") {
1131                                let v_km: &zbus::zvariant::Value<'_> = key_mgmt;
1132                                if let Ok(key_mgmt_str) = v_km.downcast_ref::<String>() {
1133                                    match key_mgmt_str.as_str() {
1134                                        "none" => {
1135                                            network_info.security_type = WiFiSecurityType::None
1136                                        }
1137                                        "wpa-psk" => {
1138                                            network_info.security_type = WiFiSecurityType::WpaPsk
1139                                        }
1140                                        "wpa-eap" => {
1141                                            network_info.security_type = WiFiSecurityType::WpaEap
1142                                        }
1143                                        _ => network_info.security_type = WiFiSecurityType::None,
1144                                    }
1145                                }
1146                            }
1147                        } else {
1148                            network_info.security_type = WiFiSecurityType::None;
1149                        }
1150
1151                        // Agregar a la lista de redes guardadas
1152                        saved_networks.push(network_info);
1153                    }
1154                }
1155            }
1156        }
1157
1158        Ok(saved_networks)
1159    }
1160
1161    /// Delete a saved WiFi connection by SSID
1162    pub fn delete_wifi_connection(&self, ssid: &str) -> Result<bool> {
1163        // Crear un proxy para el servicio de configuración de NetworkManager
1164        let settings_proxy = zbus::blocking::Proxy::new(
1165            &self.connection,
1166            "org.freedesktop.NetworkManager",
1167            "/org/freedesktop/NetworkManager/Settings",
1168            "org.freedesktop.NetworkManager.Settings",
1169        )?;
1170
1171        // Obtener todas las conexiones guardadas
1172        let connections: Vec<zbus::zvariant::OwnedObjectPath> =
1173            settings_proxy.call("ListConnections", &())?;
1174
1175        // Procesar cada conexión guardada
1176        for conn_path in connections {
1177            // Crear un proxy para cada conexión
1178            let conn_proxy = zbus::blocking::Proxy::new(
1179                &self.connection,
1180                "org.freedesktop.NetworkManager",
1181                conn_path.as_str(),
1182                "org.freedesktop.NetworkManager.Settings.Connection",
1183            )?;
1184
1185            // Obtener la configuración de la conexión como un HashMap
1186            let settings: std::collections::HashMap<String, zbus::zvariant::OwnedValue> =
1187                conn_proxy.call("GetSettings", &())?;
1188
1189            // Verificar si es una conexión WiFi
1190            if let Some(connection) = settings.get("connection") {
1191                let connection_dict = match connection.downcast_ref::<Value>().and_then(|v| {
1192                    v.downcast::<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>()
1193                }) {
1194                    Ok(dict) => dict,
1195                    _ => continue,
1196                };
1197
1198                // Verificar el tipo de conexión
1199                if let Some(conn_type) = connection_dict.get("type") {
1200                    let v_type: &zbus::zvariant::Value<'_> = conn_type;
1201                    let conn_type_str = match v_type.downcast_ref::<String>() {
1202                        Ok(s) => s,
1203                        _ => continue,
1204                    };
1205
1206                    // Si es una conexión WiFi, verificar el SSID
1207                    if conn_type_str == "802-11-wireless" {
1208                        if let Some(wireless) = settings.get("802-11-wireless") {
1209                            let wireless_dict = match wireless.downcast_ref::<Value>()
1210                                .and_then(|v| v.downcast::<std::collections::HashMap<String, zbus::zvariant::OwnedValue>>())
1211                            {
1212                                Ok(dict) => dict,
1213                                _ => continue,
1214                            };
1215
1216                            if let Some(ssid_value) = wireless_dict.get("ssid") {
1217                                let v_ssid: &zbus::zvariant::Value<'_> = ssid_value;
1218                                let conn_ssid_str = NetworkManagerHelpers::ssid_from_value(v_ssid);
1219                                // Si el SSID coincide, eliminar la conexión
1220                                if conn_ssid_str == ssid {
1221                                    conn_proxy.call::<_, _, ()>("Delete", &())?;
1222                                    return Ok(true);
1223                                }
1224                            }
1225                        }
1226                    }
1227                }
1228            }
1229        }
1230
1231        // No se encontró ninguna conexión con el SSID especificado
1232        Ok(false)
1233    }
1234
1235    /// List saved VPN profiles from NetworkManager settings.
1236    pub fn list_vpn_profiles(&self) -> Result<Vec<VpnProfile>> {
1237        let connections = self.list_connection_paths()?;
1238        let mut profiles = Vec::new();
1239
1240        for conn_path in connections {
1241            let settings = self.get_connection_settings(&conn_path)?;
1242            if let Some(profile) = self.vpn_profile_from_settings(&settings) {
1243                profiles.push(profile);
1244            }
1245        }
1246
1247        profiles.sort_by(|a, b| a.id.cmp(&b.id));
1248        Ok(profiles)
1249    }
1250
1251    /// Get current VPN status from active connections.
1252    /// Whether an active connection is a tunnel, and how much of a VPN it is.
1253    ///
1254    /// NetworkManager only files as "vpn" what one of its own VPN plugins set
1255    /// up. A client that brings its own daemon — Twingate, Tailscale, ZeroTier,
1256    /// wg-quick — creates the interface itself, and NetworkManager sees a plain
1257    /// tun or wireguard device. It is still a VPN to whoever is using the
1258    /// machine: with Twingate connected, the control centre said there was no
1259    /// VPN while every packet was going through it.
1260    ///
1261    /// The number is how much of a VPN each kind is, for when there is more
1262    /// than one tunnel up: a profile NetworkManager manages says more than a
1263    /// tun device somebody else created.
1264    fn tunnel_priority(conn_type: &str) -> Option<u8> {
1265        match conn_type {
1266            "vpn" => Some(3),
1267            "wireguard" => Some(2),
1268            "tun" | "tap" | "ip-tunnel" => Some(1),
1269            _ => None,
1270        }
1271    }
1272
1273    /// The name to show for a tunnel NetworkManager did not create.
1274    ///
1275    /// Their connection id is the interface name, which tells nobody anything:
1276    /// "sdwan0" is Twingate, and there is nothing on the system that says so.
1277    /// The ones we know get their product name; the rest keep the id, which is
1278    /// still better than claiming there is no VPN.
1279    fn tunnel_display_name(id: &str, interface: Option<&str>) -> String {
1280        const CONOCIDAS: [(&str, &str); 7] = [
1281            ("sdwan", "Twingate"),
1282            ("tailscale", "Tailscale"),
1283            ("nordlynx", "NordVPN"),
1284            ("proton", "Proton VPN"),
1285            ("pvpn", "Proton VPN"),
1286            ("zt", "ZeroTier"),
1287            ("wg", "WireGuard"),
1288        ];
1289
1290        let nombre = interface.unwrap_or(id);
1291        for (prefijo, producto) in CONOCIDAS {
1292            if nombre.starts_with(prefijo) {
1293                return producto.to_string();
1294            }
1295        }
1296
1297        id.to_string()
1298    }
1299
1300    pub fn get_vpn_status(&self) -> Result<VpnStatus> {
1301        let active_connections_variant = self.proxy.get(
1302            InterfaceName::from_static_str_unchecked("org.freedesktop.NetworkManager"),
1303            "ActiveConnections",
1304        )?;
1305
1306        let mut mejor: Option<(u8, zbus::zvariant::OwnedObjectPath, String)> = None;
1307
1308        if let Ok(zbus::zvariant::Value::Array(arr)) = active_connections_variant.downcast_ref() {
1309            for value in arr.iter() {
1310                let active_path = match value {
1311                    zbus::zvariant::Value::ObjectPath(path) => path,
1312                    _ => continue,
1313                };
1314
1315                let active_props = zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
1316                    .destination("org.freedesktop.NetworkManager")?
1317                    .path(active_path)?
1318                    .build()?;
1319
1320                let conn_type_variant = active_props.get(
1321                    InterfaceName::from_static_str_unchecked(
1322                        "org.freedesktop.NetworkManager.Connection.Active",
1323                    ),
1324                    "Type",
1325                )?;
1326
1327                let conn_type = match conn_type_variant.downcast_ref() {
1328                    Ok(zbus::zvariant::Value::Str(v)) => v.to_string(),
1329                    _ => continue,
1330                };
1331
1332                let Some(prioridad) = Self::tunnel_priority(&conn_type) else {
1333                    continue;
1334                };
1335
1336                // `map_or` y no `is_none_or`: esta última es estable desde 1.82 y el
1337                // crate declara `rust-version = "1.77.2"`, así que con ella no
1338                // compilaba en el mínimo que promete.
1339                if mejor
1340                    .as_ref()
1341                    .map_or(true, |(previa, _, _)| prioridad > *previa)
1342                {
1343                    mejor = Some((
1344                        prioridad,
1345                        zbus::zvariant::OwnedObjectPath::from(active_path.to_owned()),
1346                        conn_type,
1347                    ));
1348                }
1349            }
1350        }
1351
1352        let Some((_, active_path, conn_type)) = mejor else {
1353            return Ok(VpnStatus::default());
1354        };
1355
1356        self.vpn_status_from_active(&active_path, &conn_type)
1357    }
1358
1359    /// Reads the status of one active tunnel.
1360    fn vpn_status_from_active(
1361        &self,
1362        active_path: &zbus::zvariant::OwnedObjectPath,
1363        conn_type: &str,
1364    ) -> Result<VpnStatus> {
1365        let mut status = VpnStatus::default();
1366
1367        let active_props = zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
1368            .destination("org.freedesktop.NetworkManager")?
1369            .path(active_path.as_ref())?
1370            .build()?;
1371
1372        let leer = |propiedad: &'static str| {
1373            active_props.get(
1374                InterfaceName::from_static_str_unchecked(
1375                    "org.freedesktop.NetworkManager.Connection.Active",
1376                ),
1377                propiedad,
1378            )
1379        };
1380
1381        let state = match leer("State")?.downcast_ref() {
1382            Ok(zbus::zvariant::Value::U32(v)) => v,
1383            _ => 0,
1384        };
1385        status.state = Self::vpn_state_from_active_state(state);
1386
1387        let id = match leer("Id")?.downcast_ref() {
1388            Ok(zbus::zvariant::Value::Str(v)) => v.to_string(),
1389            _ => String::new(),
1390        };
1391        status.active_profile_uuid = match leer("Uuid")?.downcast_ref() {
1392            Ok(zbus::zvariant::Value::Str(v)) => Some(v.to_string()),
1393            _ => None,
1394        };
1395
1396        // Una VPN de NetworkManager tiene nombre puesto por quien la creó; las
1397        // otras traen el nombre de la interfaz, que no le dice nada a nadie.
1398        status.managed_externally = conn_type != "vpn";
1399        status.interface = self.active_connection_interface(&active_props);
1400        status.active_profile_name = Some(if status.managed_externally {
1401            Self::tunnel_display_name(&id, status.interface.as_deref())
1402        } else {
1403            id.clone()
1404        });
1405        status.active_profile_id = Some(id);
1406
1407        if let Ok(zbus::zvariant::Value::ObjectPath(ip4_path)) = leer("Ip4Config")?.downcast_ref() {
1408            if ip4_path.as_str() != "/" {
1409                let ip4_props = zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
1410                    .destination("org.freedesktop.NetworkManager")?
1411                    .path(ip4_path)?
1412                    .build()?;
1413
1414                let leer_ip = |propiedad: &'static str| {
1415                    ip4_props.get(
1416                        InterfaceName::from_static_str_unchecked(
1417                            "org.freedesktop.NetworkManager.IP4Config",
1418                        ),
1419                        propiedad,
1420                    )
1421                };
1422
1423                if let Ok(gateway) = leer_ip("Gateway") {
1424                    status.gateway = match gateway.downcast_ref() {
1425                        Ok(zbus::zvariant::Value::Str(v)) if !v.is_empty() => Some(v.to_string()),
1426                        _ => None,
1427                    };
1428                }
1429
1430                // La dirección dentro del túnel: es lo que alguien mira para
1431                // saber con qué IP lo ve la red del otro lado.
1432                if let Ok(addresses) = leer_ip("Addresses") {
1433                    if let Ok(zbus::zvariant::Value::Array(arr)) = addresses.downcast_ref() {
1434                        if let Some(zbus::zvariant::Value::Array(tupla)) = arr.first() {
1435                            if let Some(zbus::zvariant::Value::U32(entero)) = tupla.first() {
1436                                use std::net::Ipv4Addr;
1437                                status.ip_address =
1438                                    Some(Ipv4Addr::from(entero.to_be()).to_string());
1439                            }
1440                        }
1441                    }
1442                }
1443            }
1444        }
1445
1446        Ok(status)
1447    }
1448
1449    /// The interface a tunnel came up on, asked to the device itself.
1450    fn active_connection_interface(
1451        &self,
1452        active_props: &zbus::blocking::fdo::PropertiesProxy,
1453    ) -> Option<String> {
1454        let devices = active_props
1455            .get(
1456                InterfaceName::from_static_str_unchecked(
1457                    "org.freedesktop.NetworkManager.Connection.Active",
1458                ),
1459                "Devices",
1460            )
1461            .ok()?;
1462
1463        let zbus::zvariant::Value::Array(arr) = devices.downcast_ref().ok()? else {
1464            return None;
1465        };
1466        let zbus::zvariant::Value::ObjectPath(device_path) = arr.first()? else {
1467            return None;
1468        };
1469
1470        let device_props = zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
1471            .destination("org.freedesktop.NetworkManager")
1472            .ok()?
1473            .path(device_path)
1474            .ok()?
1475            .build()
1476            .ok()?;
1477
1478        let interface = device_props
1479            .get(
1480                InterfaceName::from_static_str_unchecked("org.freedesktop.NetworkManager.Device"),
1481                "Interface",
1482            )
1483            .ok()?;
1484
1485        match interface.downcast_ref() {
1486            Ok(zbus::zvariant::Value::Str(v)) => Some(v.to_string()),
1487            _ => None,
1488        }
1489    }
1490
1491    /// Connect a VPN profile by UUID.
1492    pub fn connect_vpn(&self, uuid: String) -> Result<()> {
1493        let current_status = self.get_vpn_status()?;
1494        if current_status.state == VpnConnectionState::Connected
1495            && current_status.active_profile_uuid.as_deref() == Some(uuid.as_str())
1496        {
1497            return Err(crate::error::NetworkError::VpnAlreadyConnected(uuid));
1498        }
1499
1500        let conn_path = self.find_connection_path_by_uuid(&uuid)?;
1501
1502        let nm_proxy = zbus::blocking::Proxy::new(
1503            &self.connection,
1504            "org.freedesktop.NetworkManager",
1505            "/org/freedesktop/NetworkManager",
1506            "org.freedesktop.NetworkManager",
1507        )?;
1508
1509        let any_path = zbus::zvariant::OwnedObjectPath::try_from("/").unwrap();
1510        let activate_result: zbus::Result<zbus::zvariant::OwnedObjectPath> =
1511            nm_proxy.call("ActivateConnection", &(&conn_path, &any_path, &any_path));
1512
1513        match activate_result {
1514            Ok(_) => Ok(()),
1515            Err(e) => {
1516                let msg = e.to_string().to_lowercase();
1517                if msg.contains("secret") || msg.contains("authentication") {
1518                    Err(crate::error::NetworkError::VpnAuthFailed(e.to_string()))
1519                } else {
1520                    Err(crate::error::NetworkError::VpnActivationFailed(
1521                        e.to_string(),
1522                    ))
1523                }
1524            }
1525        }
1526    }
1527
1528    /// Disconnect VPN by UUID or disconnect active VPN if UUID is not provided.
1529    pub fn disconnect_vpn(&self, uuid: Option<String>) -> Result<()> {
1530        let active_connections_variant = self.proxy.get(
1531            InterfaceName::from_static_str_unchecked("org.freedesktop.NetworkManager"),
1532            "ActiveConnections",
1533        )?;
1534
1535        let mut target_active_connection: Option<zbus::zvariant::OwnedObjectPath> = None;
1536
1537        if let Ok(zbus::zvariant::Value::Array(arr)) = active_connections_variant.downcast_ref() {
1538            for value in arr.iter() {
1539                let active_path = match value {
1540                    zbus::zvariant::Value::ObjectPath(path) => {
1541                        zbus::zvariant::OwnedObjectPath::from(path.to_owned())
1542                    }
1543                    _ => continue,
1544                };
1545
1546                let active_props = zbus::blocking::fdo::PropertiesProxy::builder(&self.connection)
1547                    .destination("org.freedesktop.NetworkManager")?
1548                    .path(active_path.as_str())?
1549                    .build()?;
1550
1551                let conn_type_variant = active_props.get(
1552                    InterfaceName::from_static_str_unchecked(
1553                        "org.freedesktop.NetworkManager.Connection.Active",
1554                    ),
1555                    "Type",
1556                )?;
1557                let conn_type = match conn_type_variant.downcast_ref() {
1558                    Ok(zbus::zvariant::Value::Str(v)) => v.to_string(),
1559                    _ => continue,
1560                };
1561
1562                if conn_type != "vpn" {
1563                    continue;
1564                }
1565
1566                if let Some(target_uuid) = uuid.as_deref() {
1567                    let uuid_variant = active_props.get(
1568                        InterfaceName::from_static_str_unchecked(
1569                            "org.freedesktop.NetworkManager.Connection.Active",
1570                        ),
1571                        "Uuid",
1572                    )?;
1573                    let active_uuid = match uuid_variant.downcast_ref() {
1574                        Ok(zbus::zvariant::Value::Str(v)) => v.to_string(),
1575                        _ => continue,
1576                    };
1577
1578                    if active_uuid == target_uuid {
1579                        target_active_connection = Some(active_path.clone());
1580                        break;
1581                    }
1582                } else {
1583                    target_active_connection = Some(active_path.clone());
1584                    break;
1585                }
1586            }
1587        }
1588
1589        let target_active_connection = match target_active_connection {
1590            Some(path) => path,
1591            None => {
1592                if let Some(target_uuid) = uuid {
1593                    return Err(crate::error::NetworkError::VpnProfileNotFound(target_uuid));
1594                }
1595                return Err(crate::error::NetworkError::VpnNotActive);
1596            }
1597        };
1598
1599        let nm_proxy = zbus::blocking::Proxy::new(
1600            &self.connection,
1601            "org.freedesktop.NetworkManager",
1602            "/org/freedesktop/NetworkManager",
1603            "org.freedesktop.NetworkManager",
1604        )?;
1605
1606        nm_proxy.call::<_, _, ()>(
1607            "DeactivateConnection",
1608            &(target_active_connection.as_str(),),
1609        )?;
1610        Ok(())
1611    }
1612
1613    /// Create a new VPN profile in NetworkManager settings.
1614    pub fn create_vpn_profile(&self, config: VpnCreateConfig) -> Result<VpnProfile> {
1615        if config.id.trim().is_empty() {
1616            return Err(crate::error::NetworkError::VpnInvalidConfig(
1617                "id is required".to_string(),
1618            ));
1619        }
1620
1621        let uuid = Uuid::new_v4().to_string();
1622        let mut connection_section = HashMap::new();
1623        connection_section.insert("id".to_string(), Value::from(config.id.clone()));
1624        connection_section.insert("uuid".to_string(), Value::from(uuid.clone()));
1625        connection_section.insert("type".to_string(), Value::from("vpn"));
1626        connection_section.insert(
1627            "autoconnect".to_string(),
1628            Value::from(config.autoconnect.unwrap_or(false)),
1629        );
1630
1631        let mut vpn_section = HashMap::new();
1632        vpn_section.insert(
1633            "service-type".to_string(),
1634            Value::from(Self::service_type_from_vpn_type(&config.vpn_type)),
1635        );
1636
1637        if let Some(username) = config.username {
1638            vpn_section.insert("user-name".to_string(), Value::from(username));
1639        }
1640        if let Some(gateway) = config.gateway {
1641            vpn_section.insert("remote".to_string(), Value::from(gateway));
1642        }
1643        if let Some(ca_cert_path) = config.ca_cert_path {
1644            vpn_section.insert("ca".to_string(), Value::from(ca_cert_path));
1645        }
1646        if let Some(user_cert_path) = config.user_cert_path {
1647            vpn_section.insert("cert".to_string(), Value::from(user_cert_path));
1648        }
1649        if let Some(private_key_path) = config.private_key_path {
1650            vpn_section.insert("key".to_string(), Value::from(private_key_path));
1651        }
1652        if let Some(private_key_password) = config.private_key_password {
1653            vpn_section.insert(
1654                "key-password".to_string(),
1655                Value::from(private_key_password),
1656            );
1657        }
1658        if let Some(custom_settings) = config.settings {
1659            for (k, v) in custom_settings {
1660                vpn_section.insert(k, Value::from(v));
1661            }
1662        }
1663
1664        let mut vpn_secrets_section = HashMap::new();
1665        if let Some(password) = config.password {
1666            vpn_secrets_section.insert("password".to_string(), Value::from(password));
1667        }
1668        if let Some(custom_secrets) = config.secrets {
1669            for (k, v) in custom_secrets {
1670                vpn_secrets_section.insert(k, Value::from(v));
1671            }
1672        }
1673
1674        let mut settings: HashMap<String, HashMap<String, Value>> = HashMap::new();
1675        settings.insert("connection".to_string(), connection_section);
1676        settings.insert("vpn".to_string(), vpn_section);
1677        if !vpn_secrets_section.is_empty() {
1678            settings.insert("vpn-secrets".to_string(), vpn_secrets_section);
1679        }
1680
1681        let settings_proxy = zbus::blocking::Proxy::new(
1682            &self.connection,
1683            "org.freedesktop.NetworkManager",
1684            "/org/freedesktop/NetworkManager/Settings",
1685            "org.freedesktop.NetworkManager.Settings",
1686        )?;
1687
1688        let _created_path: zbus::zvariant::OwnedObjectPath =
1689            settings_proxy.call("AddConnection", &(settings,))?;
1690
1691        Ok(VpnProfile {
1692            id: config.id,
1693            uuid,
1694            vpn_type: config.vpn_type,
1695            interface_name: None,
1696            autoconnect: config.autoconnect.unwrap_or(false),
1697            editable: true,
1698            last_error: None,
1699        })
1700    }
1701
1702    /// Update an existing VPN profile by UUID.
1703    pub fn update_vpn_profile(&self, config: VpnUpdateConfig) -> Result<VpnProfile> {
1704        let conn_path = self.find_connection_path_by_uuid(&config.uuid)?;
1705        let existing_settings = self.get_connection_settings(&conn_path)?;
1706
1707        let existing_profile = self
1708            .vpn_profile_from_settings(&existing_settings)
1709            .ok_or_else(|| crate::error::NetworkError::VpnProfileNotFound(config.uuid.clone()))?;
1710
1711        let existing_vpn_settings = Self::string_map_from_section(&existing_settings, "vpn");
1712        let existing_vpn_secrets = Self::string_map_from_section(&existing_settings, "vpn-secrets");
1713
1714        // Start from the full current settings map to preserve unrelated sections
1715        // (IPv4/IPv6, routes, DNS, permissions, proxy, etc.).
1716        let mut settings: HashMap<String, HashMap<String, Value>> = HashMap::new();
1717        for (section_name, dict) in &existing_settings {
1718            let mut section_map: HashMap<String, Value> = HashMap::new();
1719            for (k, v) in dict {
1720                if let Ok(val) = v.downcast_ref::<Value>() {
1721                    section_map.insert(k.clone(), val);
1722                }
1723            }
1724            settings.insert(section_name.clone(), section_map);
1725        }
1726
1727        let connection_section = settings.entry("connection".to_string()).or_default();
1728        connection_section.insert(
1729            "id".to_string(),
1730            Value::from(config.id.clone().unwrap_or(existing_profile.id.clone())),
1731        );
1732        connection_section.insert("uuid".to_string(), Value::from(config.uuid.clone()));
1733        connection_section.insert("type".to_string(), Value::from("vpn"));
1734        connection_section.insert(
1735            "autoconnect".to_string(),
1736            Value::from(config.autoconnect.unwrap_or(existing_profile.autoconnect)),
1737        );
1738
1739        let service_type = existing_vpn_settings
1740            .get("service-type")
1741            .cloned()
1742            .unwrap_or_else(|| {
1743                Self::service_type_from_vpn_type(&existing_profile.vpn_type).to_string()
1744            });
1745
1746        let vpn_section = settings.entry("vpn".to_string()).or_default();
1747        vpn_section.insert("service-type".to_string(), Value::from(service_type));
1748
1749        let mut merged_settings = existing_vpn_settings;
1750        if let Some(username) = config.username {
1751            merged_settings.insert("user-name".to_string(), username);
1752        }
1753        if let Some(gateway) = config.gateway {
1754            merged_settings.insert("remote".to_string(), gateway);
1755        }
1756        if let Some(ca_cert_path) = config.ca_cert_path {
1757            merged_settings.insert("ca".to_string(), ca_cert_path);
1758        }
1759        if let Some(user_cert_path) = config.user_cert_path {
1760            merged_settings.insert("cert".to_string(), user_cert_path);
1761        }
1762        if let Some(private_key_path) = config.private_key_path {
1763            merged_settings.insert("key".to_string(), private_key_path);
1764        }
1765        if let Some(private_key_password) = config.private_key_password {
1766            merged_settings.insert("key-password".to_string(), private_key_password);
1767        }
1768        if let Some(custom_settings) = config.settings {
1769            for (k, v) in custom_settings {
1770                merged_settings.insert(k, v);
1771            }
1772        }
1773
1774        for (k, v) in merged_settings {
1775            vpn_section.insert(k, Value::from(v));
1776        }
1777
1778        let mut merged_secrets = existing_vpn_secrets;
1779        if let Some(password) = config.password {
1780            merged_secrets.insert("password".to_string(), password);
1781        }
1782        if let Some(custom_secrets) = config.secrets {
1783            for (k, v) in custom_secrets {
1784                merged_secrets.insert(k, v);
1785            }
1786        }
1787
1788        if merged_secrets.is_empty() {
1789            settings.remove("vpn-secrets");
1790        } else {
1791            let vpn_secrets_section = settings.entry("vpn-secrets".to_string()).or_default();
1792            vpn_secrets_section.clear();
1793            for (k, v) in merged_secrets {
1794                vpn_secrets_section.insert(k, Value::from(v));
1795            }
1796        }
1797
1798        let conn_proxy = zbus::blocking::Proxy::new(
1799            &self.connection,
1800            "org.freedesktop.NetworkManager",
1801            conn_path.as_str(),
1802            "org.freedesktop.NetworkManager.Settings.Connection",
1803        )?;
1804        conn_proxy.call::<_, _, ()>("Update", &(settings,))?;
1805
1806        let updated_settings = self.get_connection_settings(&conn_path)?;
1807        self.vpn_profile_from_settings(&updated_settings)
1808            .ok_or(crate::error::NetworkError::VpnProfileNotFound(config.uuid))
1809    }
1810
1811    /// Delete a VPN profile by UUID.
1812    pub fn delete_vpn_profile(&self, uuid: String) -> Result<()> {
1813        let conn_path = self.find_connection_path_by_uuid(&uuid)?;
1814
1815        let conn_proxy = zbus::blocking::Proxy::new(
1816            &self.connection,
1817            "org.freedesktop.NetworkManager",
1818            conn_path.as_str(),
1819            "org.freedesktop.NetworkManager.Settings.Connection",
1820        )?;
1821
1822        conn_proxy.call::<_, _, ()>("Delete", &())?;
1823        Ok(())
1824    }
1825}
1826
1827/// Initialize the network manager plugin
1828pub fn init(
1829    app: &AppHandle<tauri::Wry>,
1830    _api: PluginApi<tauri::Wry, ()>,
1831) -> Result<VSKNetworkManager<'static, tauri::Wry>> {
1832    VSKNetworkManager::new(app.clone())
1833}
1834
1835#[cfg(test)]
1836mod tests {
1837    use super::*;
1838
1839    #[test]
1840    fn vpn_state_deactivated_maps_to_disconnected() {
1841        assert_eq!(
1842            VSKNetworkManager::<tauri::Wry>::vpn_state_from_active_state(4),
1843            VpnConnectionState::Disconnected
1844        );
1845    }
1846
1847    /// El caso que motivó todo esto: con Twingate conectado, el centro de
1848    /// control decía que no había VPN. Twingate levanta su propio túnel, así
1849    /// que NetworkManager lo archiva como un `tun` cualquiera y no como una
1850    /// "vpn" suya.
1851    #[test]
1852    fn un_tun_ajeno_tambien_es_una_vpn() {
1853        type NM = VSKNetworkManager<'static, tauri::Wry>;
1854
1855        assert!(
1856            NM::tunnel_priority("tun").is_some(),
1857            "Twingate quedaba afuera"
1858        );
1859        assert!(NM::tunnel_priority("wireguard").is_some());
1860        assert!(NM::tunnel_priority("vpn").is_some());
1861
1862        // Y lo que no es un túnel sigue sin serlo: si el wifi contara como VPN,
1863        // el indicador estaría siempre encendido.
1864        assert!(NM::tunnel_priority("802-11-wireless").is_none());
1865        assert!(NM::tunnel_priority("ethernet").is_none());
1866        assert!(NM::tunnel_priority("loopback").is_none());
1867        assert!(NM::tunnel_priority("bridge").is_none());
1868    }
1869
1870    /// Con varios túneles arriba manda el que NetworkManager administra: es el
1871    /// único que además se puede conectar y desconectar desde el escritorio.
1872    #[test]
1873    fn una_vpn_de_networkmanager_le_gana_a_un_tun() {
1874        type NM = VSKNetworkManager<'static, tauri::Wry>;
1875        assert!(NM::tunnel_priority("vpn") > NM::tunnel_priority("wireguard"));
1876        assert!(NM::tunnel_priority("wireguard") > NM::tunnel_priority("tun"));
1877    }
1878
1879    /// Contra el NetworkManager de verdad: que las propiedades que leemos
1880    /// existan y digan lo que creemos. Es donde se esconden los errores de una
1881    /// interfaz D-Bus, y ningún test puro los encuentra.
1882    ///
1883    /// Ignorado por defecto porque necesita un sistema con NetworkManager y una
1884    /// VPN levantada: `cargo test -- --ignored --nocapture`.
1885    #[test]
1886    #[ignore]
1887    fn el_tunel_de_esta_maquina_se_ve_como_vpn() {
1888        type NM = VSKNetworkManager<'static, tauri::Wry>;
1889        use zbus::names::InterfaceName;
1890        use zbus::zvariant::Value;
1891
1892        let conexion = zbus::blocking::Connection::system().expect("sin bus del sistema");
1893        let nm = zbus::blocking::fdo::PropertiesProxy::builder(&conexion)
1894            .destination("org.freedesktop.NetworkManager")
1895            .unwrap()
1896            .path("/org/freedesktop/NetworkManager")
1897            .unwrap()
1898            .build()
1899            .expect("NetworkManager no está");
1900
1901        let activas = nm
1902            .get(
1903                InterfaceName::from_static_str_unchecked("org.freedesktop.NetworkManager"),
1904                "ActiveConnections",
1905            )
1906            .unwrap();
1907
1908        let mut encontrados = Vec::new();
1909        if let Ok(Value::Array(arr)) = activas.downcast_ref() {
1910            for valor in arr.iter() {
1911                let Value::ObjectPath(ruta) = valor else {
1912                    continue;
1913                };
1914                let props = zbus::blocking::fdo::PropertiesProxy::builder(&conexion)
1915                    .destination("org.freedesktop.NetworkManager")
1916                    .unwrap()
1917                    .path(ruta)
1918                    .unwrap()
1919                    .build()
1920                    .unwrap();
1921                let leer = |p: &'static str| {
1922                    props.get(
1923                        InterfaceName::from_static_str_unchecked(
1924                            "org.freedesktop.NetworkManager.Connection.Active",
1925                        ),
1926                        p,
1927                    )
1928                };
1929                let tipo = match leer("Type").unwrap().downcast_ref() {
1930                    Ok(Value::Str(v)) => v.to_string(),
1931                    _ => continue,
1932                };
1933                let id = match leer("Id").unwrap().downcast_ref() {
1934                    Ok(Value::Str(v)) => v.to_string(),
1935                    _ => String::new(),
1936                };
1937                match NM::tunnel_priority(&tipo) {
1938                    Some(prioridad) => {
1939                        let nombre = NM::tunnel_display_name(&id, Some(&id));
1940                        println!("túnel: id={id} tipo={tipo} prioridad={prioridad} se muestra como «{nombre}»");
1941                        encontrados.push(nombre);
1942                    }
1943                    None => println!("no es túnel: id={id} tipo={tipo}"),
1944                }
1945            }
1946        }
1947
1948        assert!(
1949            !encontrados.is_empty(),
1950            "ningún túnel detectado; levantá la VPN antes de correr esto"
1951        );
1952    }
1953
1954    /// «sdwan0» no le dice nada a nadie; «Twingate» sí.
1955    #[test]
1956    fn los_tuneles_conocidos_se_muestran_con_su_nombre() {
1957        type NM = VSKNetworkManager<'static, tauri::Wry>;
1958
1959        assert_eq!(
1960            NM::tunnel_display_name("sdwan0", Some("sdwan0")),
1961            "Twingate"
1962        );
1963        assert_eq!(
1964            NM::tunnel_display_name("tailscale0", Some("tailscale0")),
1965            "Tailscale"
1966        );
1967        assert_eq!(NM::tunnel_display_name("wg0", Some("wg0")), "WireGuard");
1968
1969        // Uno desconocido conserva el nombre que le puso NetworkManager: es
1970        // menos de lo que querríamos, pero muchísimo más que decir que no hay
1971        // ninguna VPN.
1972        assert_eq!(NM::tunnel_display_name("Oficina", Some("tun0")), "Oficina");
1973    }
1974
1975    /// Guardia de la regresión que motivó `fix/wifi-scan-wait`: antes `rescan_wifi`
1976    /// leía los `AccessPoints` apenas `RequestScan` contestaba, o sea la lista del
1977    /// barrido *anterior* —vacía recién encendido el WiFi—, y "no encontraba nada".
1978    /// El arreglo espera a que `LastScan` avance; `scan_settled` es quien decide si
1979    /// el barrido terminó, y estos casos clavan su contrato:
1980    ///
1981    /// - mismo valor ⇒ sigue el barrido viejo, hay que esperar (false);
1982    /// - valor mayor ⇒ terminó uno nuevo, se puede leer (true);
1983    /// - `-1` ("nunca escaneó") NUNCA cuenta como terminado, aunque haya cambiado:
1984    ///   ese es exactamente el estado que devolvía la lista vacía, así que si
1985    ///   alguien vuelve a tratar al `-1` como "listo" este test se pone en rojo.
1986    #[test]
1987    fn scan_settled_solo_es_cierto_cuando_termino_un_barrido_nuevo() {
1988        type NM = VSKNetworkManager<'static, tauri::Wry>;
1989
1990        // El mismo barrido de antes: todavía no terminó nada, hay que esperar.
1991        assert!(
1992            !NM::scan_settled(1000, 1000),
1993            "con el mismo LastScan no hay barrido nuevo: no se puede leer todavía"
1994        );
1995
1996        // `LastScan` avanzó: terminó un barrido nuevo y recién ahí se lee la lista.
1997        assert!(
1998            NM::scan_settled(1000, 1500),
1999            "LastScan avanzó: el barrido terminó y la lista ya es la fresca"
2000        );
2001
2002        // `-1` es "nunca escaneó": no importa desde dónde se espere, nunca está
2003        // listo. Es el caso que devolvía la lista vacía recién encendido el WiFi.
2004        assert!(
2005            !NM::scan_settled(-1, -1),
2006            "«nunca escaneó» no es un barrido terminado"
2007        );
2008        assert!(
2009            !NM::scan_settled(1000, -1),
2010            "volver a -1 (reset del contador) no es un barrido terminado: la regresión"
2011        );
2012    }
2013
2014    /// Contra el NetworkManager de verdad: pedir un barrido y comprobar que
2015    /// `LastScan` avanza dentro del tope, que es justo lo que `rescan_wifi` espera
2016    /// antes de leer la lista. Reproduce el ciclo a bajo nivel —leer `LastScan`,
2017    /// `RequestScan`, volver a leer— porque construir un `VSKNetworkManager`
2018    /// necesita un `AppHandle` de Tauri que en un test no existe.
2019    ///
2020    /// Ignorado por defecto: necesita un sistema con NetworkManager y una placa
2021    /// inalámbrica. `cargo test -- --ignored --nocapture`.
2022    #[test]
2023    #[ignore]
2024    fn el_barrido_real_hace_avanzar_last_scan() {
2025        type NM = VSKNetworkManager<'static, tauri::Wry>;
2026        use zbus::names::InterfaceName;
2027        use zbus::zvariant::Value;
2028
2029        let conexion = zbus::blocking::Connection::system().expect("sin bus del sistema");
2030        let nm = zbus::blocking::fdo::PropertiesProxy::builder(&conexion)
2031            .destination("org.freedesktop.NetworkManager")
2032            .unwrap()
2033            .path("/org/freedesktop/NetworkManager")
2034            .unwrap()
2035            .build()
2036            .expect("NetworkManager no está");
2037
2038        let devices = nm
2039            .get(
2040                InterfaceName::from_static_str_unchecked("org.freedesktop.NetworkManager"),
2041                "Devices",
2042            )
2043            .unwrap();
2044
2045        // Primer dispositivo inalámbrico (DeviceType == 2).
2046        let mut wireless_path = None;
2047        if let Ok(Value::Array(arr)) = devices.downcast_ref() {
2048            for valor in arr.iter() {
2049                let Value::ObjectPath(ruta) = valor else {
2050                    continue;
2051                };
2052                let props = zbus::blocking::fdo::PropertiesProxy::builder(&conexion)
2053                    .destination("org.freedesktop.NetworkManager")
2054                    .unwrap()
2055                    .path(ruta)
2056                    .unwrap()
2057                    .build()
2058                    .unwrap();
2059                let tipo = props.get(
2060                    InterfaceName::from_static_str_unchecked(
2061                        "org.freedesktop.NetworkManager.Device",
2062                    ),
2063                    "DeviceType",
2064                );
2065                if let Ok(valor) = tipo {
2066                    if let Ok(Value::U32(2)) = valor.downcast_ref() {
2067                        wireless_path = Some(ruta.to_string());
2068                        break;
2069                    }
2070                }
2071            }
2072        }
2073
2074        let ruta = wireless_path.expect("no hay placa inalámbrica; este test la necesita");
2075
2076        let props = zbus::blocking::fdo::PropertiesProxy::builder(&conexion)
2077            .destination("org.freedesktop.NetworkManager")
2078            .unwrap()
2079            .path(ruta.as_str())
2080            .unwrap()
2081            .build()
2082            .unwrap();
2083
2084        let antes = NM::read_last_scan(&props);
2085        println!("LastScan antes del barrido: {antes}");
2086
2087        let wireless_proxy = zbus::blocking::Proxy::new(
2088            &conexion,
2089            "org.freedesktop.NetworkManager",
2090            ruta.as_str(),
2091            "org.freedesktop.NetworkManager.Device.Wireless",
2092        )
2093        .unwrap();
2094        let options: std::collections::HashMap<String, zbus::zvariant::OwnedValue> =
2095            std::collections::HashMap::new();
2096        // Puede negarse si hay un barrido en curso: no es error, igual esperamos.
2097        let _ = wireless_proxy.call::<_, _, ()>("RequestScan", &(options,));
2098
2099        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
2100        let mut settled = false;
2101        while std::time::Instant::now() < deadline {
2102            let ahora = NM::read_last_scan(&props);
2103            if NM::scan_settled(antes, ahora) {
2104                println!("LastScan avanzó a {ahora}: el barrido terminó");
2105                settled = true;
2106                break;
2107            }
2108            std::thread::sleep(std::time::Duration::from_millis(300));
2109        }
2110
2111        assert!(
2112            settled,
2113            "LastScan no avanzó en 10 s: o la lógica de espera está rota o NM no barrió"
2114        );
2115    }
2116}