diff --git a/assets/translations/en.json b/assets/translations/en.json index 82e9dc1dd..a76340e13 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -237,6 +237,9 @@ "wifi": "Wi-Fi", "password-prompt": "Enter password", "password-prompt-for": "Enter password for {ssid}", + "vpn": "VPN", + "vpns": "VPNs", + "wireless": "Wireless", "unavailable-title": "NetworkManager unavailable", "unavailable-detail": "Install and enable NetworkManager to manage networks from here.", "no-networks": "No networks in range" diff --git a/src/dbus/network/network_service.cpp b/src/dbus/network/network_service.cpp index 60cb67cca..50910978e 100644 --- a/src/dbus/network/network_service.cpp +++ b/src/dbus/network/network_service.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include namespace { @@ -98,6 +99,9 @@ namespace { } // namespace const char* NetworkService::glyphForState(const NetworkState& state) noexcept { + if (state.vpnActive) { + return "shield-check"; + } if (state.kind == NetworkConnectivity::Wired) { return state.connected ? "ethernet" : "ethernet-off"; } @@ -197,18 +201,21 @@ void NetworkService::setChangeCallback(ChangeCallback callback) { m_changeCallba void NetworkService::refresh() { const auto previousAps = m_accessPoints; + const auto previousVpns = m_vpnConnections; const auto previousSaved = m_savedSsids; refreshAccessPoints(); + refreshVpnConnections(); refreshSavedConnections(); NetworkState next = readState(); const bool apsChanged = previousAps != m_accessPoints; + const bool vpnsChanged = previousVpns != m_vpnConnections; const bool savedChanged = previousSaved != m_savedSsids; const bool stateChanged = next != m_state; const bool wirelessEnabledChanged = next.wirelessEnabled != m_state.wirelessEnabled; const NetworkChangeOrigin origin = wirelessEnabledChanged ? consumeWirelessEnabledChangeOrigin(next.wirelessEnabled) : NetworkChangeOrigin::External; m_state = std::move(next); - if ((stateChanged || apsChanged || savedChanged) && m_changeCallback) { + if ((stateChanged || apsChanged || vpnsChanged || savedChanged) && m_changeCallback) { m_changeCallback(m_state, origin); } } @@ -301,6 +308,76 @@ bool NetworkService::activateAccessPoint(const AccessPointInfo& ap) { } } +bool NetworkService::activateVpnConnection(const VpnConnectionInfo& vpn) { + if (vpn.path.empty()) { + return false; + } + try { + // Async: ActivateConnection can involve polkit/agent interactions, and a + // synchronous call can stall the main loop while authorization is pending. + const std::string vpnName = vpn.name; + const std::string vpnPath = vpn.path; + m_nm->callMethodAsync("ActivateConnection") + .onInterface(k_nmInterface) + .withArguments(sdbus::ObjectPath{vpnPath}, sdbus::ObjectPath{"/"}, sdbus::ObjectPath{"/"}) + .uponReplyInvoke([vpnName, vpnPath](std::optional err, sdbus::ObjectPath activePath) { + if (err.has_value()) { + kLog.warn("ActivateConnection(vpn) failed name={} path={}: {}", vpnName, vpnPath, err->what()); + } else { + kLog.info("activating vpn name={} active={}", vpnName, std::string(activePath)); + } + }); + return true; + } catch (const sdbus::Error& e) { + kLog.warn("ActivateConnection(vpn) failed name={} path={} err={}", vpn.name, vpn.path, e.what()); + return false; + } +} + +bool NetworkService::deactivateVpnConnection(const VpnConnectionInfo& vpn) { + if (vpn.path.empty()) { + return false; + } + try { + std::vector activeConnections; + const sdbus::Variant activeVar = m_nm->getProperty("ActiveConnections").onInterface(k_nmInterface); + activeConnections = activeVar.get>(); + for (const auto& activePath : activeConnections) { + try { + auto active = sdbus::createProxy(m_bus.connection(), k_nmBusName, activePath); + const auto profilePath = + getPropertyOr(*active, k_nmActiveConnectionInterface, "Connection", sdbus::ObjectPath{}); + const auto activeState = getPropertyOr(*active, k_nmActiveConnectionInterface, "State", 0U); + if (profilePath != vpn.path || activeState != k_nmActiveConnectionStateActivated) { + continue; + } + // Async: DeactivateConnection on a system-owned profile is gated by polkit, + // and a sync call would freeze the main loop while the polkit agent prompts + // (or while polkit waits for an agent to register). Fire-and-forget here. + const std::string activePathStr = std::string(activePath); + const std::string vpnName = vpn.name; + m_nm->callMethodAsync("DeactivateConnection") + .onInterface(k_nmInterface) + .withArguments(sdbus::ObjectPath{activePathStr}) + .uponReplyInvoke([activePathStr, vpnName](std::optional err) { + if (err.has_value()) { + kLog.warn("DeactivateConnection(vpn) failed name={} active={}: {}", vpnName, activePathStr, + err->what()); + } else { + kLog.info("deactivated vpn name={} active={}", vpnName, activePathStr); + } + }); + return true; + } catch (const sdbus::Error&) { + } + } + } catch (const sdbus::Error& e) { + kLog.warn("DeactivateConnection(vpn) lookup failed path={}: {}", vpn.path, e.what()); + return false; + } + return false; +} + void NetworkService::setWirelessEnabled(bool enabled) { if (enabled != m_state.wirelessEnabled) { m_pendingLocalWirelessEnabled = enabled; @@ -517,6 +594,102 @@ void NetworkService::refreshSavedConnections() { m_savedSsids = std::move(next); } +void NetworkService::refreshVpnConnections() { + std::vector next; + std::set vpnProfilePaths; + try { + auto settings = sdbus::createProxy(m_bus.connection(), k_nmBusName, k_nmSettingsObjectPath); + std::vector connectionPaths; + settings->callMethod("ListConnections").onInterface(k_nmSettingsInterface).storeResultsTo(connectionPaths); + for (const auto& connectionPath : connectionPaths) { + try { + auto connection = sdbus::createProxy(m_bus.connection(), k_nmBusName, connectionPath); + std::map> cfg; + connection->callMethod("GetSettings").onInterface(k_nmSettingsConnectionInterface).storeResultsTo(cfg); + auto connIt = cfg.find("connection"); + if (connIt == cfg.end()) { + continue; + } + auto typeIt = connIt->second.find("type"); + if (typeIt == connIt->second.end()) { + continue; + } + std::string type; + try { + type = typeIt->second.get(); + } catch (const sdbus::Error&) { + continue; + } + + const bool hasVpnSection = cfg.contains("vpn"); + const bool vpnLikeType = type == "vpn" || type == "wireguard"; + if (!vpnLikeType && !hasVpnSection) { + continue; + } + + VpnConnectionInfo info; + info.path = std::string(connectionPath); + auto idIt = connIt->second.find("id"); + if (idIt != connIt->second.end()) { + try { + info.name = idIt->second.get(); + } catch (const sdbus::Error&) { + } + } + if (info.name.empty()) { + info.name = info.path; + } + info.active = false; + vpnProfilePaths.insert(info.path); + next.push_back(std::move(info)); + } catch (const sdbus::Error&) { + } + } + } catch (const sdbus::Error& e) { + kLog.debug("refreshVpnConnections: {}", e.what()); + } + + if (!next.empty()) { + try { + std::vector activeConnections; + const sdbus::Variant activeVar = m_nm->getProperty("ActiveConnections").onInterface(k_nmInterface); + activeConnections = activeVar.get>(); + for (const auto& activePath : activeConnections) { + try { + auto active = sdbus::createProxy(m_bus.connection(), k_nmBusName, activePath); + const auto state = getPropertyOr(*active, k_nmActiveConnectionInterface, "State", 0U); + if (state != k_nmActiveConnectionStateActivated) { + continue; + } + const auto profilePath = getPropertyOr(*active, k_nmActiveConnectionInterface, + "Connection", sdbus::ObjectPath{}); + const std::string profilePathStr = std::string(profilePath); + if (!vpnProfilePaths.contains(profilePathStr)) { + continue; + } + for (auto& vpn : next) { + if (vpn.path == profilePathStr) { + vpn.active = true; + break; + } + } + } catch (const sdbus::Error&) { + } + } + } catch (const sdbus::Error& e) { + kLog.debug("refreshVpnConnections active list: {}", e.what()); + } + } + + std::ranges::sort(next, [](const VpnConnectionInfo& a, const VpnConnectionInfo& b) { + if (a.active != b.active) { + return a.active; + } + return a.name < b.name; + }); + m_vpnConnections = std::move(next); +} + void NetworkService::ensureWifiDeviceSubscribed(const std::string& devicePath) { if (m_wifiDevices.contains(devicePath)) { return; @@ -776,6 +949,27 @@ NetworkState NetworkService::readState() { next.wirelessEnabled = getPropertyOr(*m_nm, k_nmInterface, "WirelessEnabled", false); next.scanning = m_scanning; + next.vpnActive = false; + + // Check primary connection: detect VPN type and connection state + if (m_activeConnection != nullptr) { + const auto type = + getPropertyOr(*m_activeConnection, k_nmActiveConnectionInterface, "Type", std::string{}); + next.vpnActive = (type == "vpn" || type == "wireguard"); + const auto state = getPropertyOr(*m_activeConnection, k_nmActiveConnectionInterface, "State", 0U); + next.connected = state == k_nmActiveConnectionStateActivated; + } + + // Also check if any VPN profile is active (in case it's not the primary connection) + if (!next.vpnActive) { + for (const auto& vpn : m_vpnConnections) { + if (vpn.active) { + next.vpnActive = true; + next.connected = true; + break; + } + } + } if (m_activeDevice == nullptr) { return next; @@ -788,11 +982,6 @@ NetworkState NetworkService::readState() { getPropertyOr(*m_activeDevice, k_nmDeviceInterface, "Ip4Config", sdbus::ObjectPath{}); next.ipv4 = firstIpv4FromConfig(m_bus.connection(), ip4ConfigPath); - if (m_activeConnection != nullptr) { - const auto state = getPropertyOr(*m_activeConnection, k_nmActiveConnectionInterface, "State", 0U); - next.connected = state == k_nmActiveConnectionStateActivated; - } - if (deviceType == k_nmDeviceTypeWifi) { next.kind = NetworkConnectivity::Wireless; if (m_activeAp != nullptr) { diff --git a/src/dbus/network/network_service.h b/src/dbus/network/network_service.h index d2632d808..70848295b 100644 --- a/src/dbus/network/network_service.h +++ b/src/dbus/network/network_service.h @@ -25,6 +25,14 @@ struct AccessPointInfo { bool operator==(const AccessPointInfo&) const = default; }; +struct VpnConnectionInfo { + std::string path; // NM Settings.Connection object path + std::string name; + bool active = false; + + bool operator==(const VpnConnectionInfo&) const = default; +}; + enum class NetworkConnectivity { Unknown = 0, None = 1, @@ -37,6 +45,7 @@ struct NetworkState { bool connected = false; bool wirelessEnabled = false; bool scanning = false; + bool vpnActive = false; // true if a VPN is the active connection std::string ssid; // Wi-Fi only std::string ipv4; // dotted-quad of first address; empty if none std::string interfaceName; // e.g. "wlan0", "eth0" @@ -65,6 +74,7 @@ public: [[nodiscard]] const NetworkState& state() const noexcept { return m_state; } [[nodiscard]] const std::vector& accessPoints() const noexcept { return m_accessPoints; } + [[nodiscard]] const std::vector& vpnConnections() const noexcept { return m_vpnConnections; } [[nodiscard]] static const char* glyphForState(const NetworkState& state) noexcept; [[nodiscard]] static const char* wifiGlyphForState(const NetworkState& state) noexcept; [[nodiscard]] static const char* wifiGlyphForSignal(std::uint8_t signal) noexcept; @@ -79,6 +89,10 @@ public: // handles that via a SecretAgent). bool activateAccessPoint(const AccessPointInfo& ap); + // Activate / deactivate a saved VPN connection profile. + bool activateVpnConnection(const VpnConnectionInfo& vpn); + bool deactivateVpnConnection(const VpnConnectionInfo& vpn); + // Enable / disable the Wi-Fi radio. void setWirelessEnabled(bool enabled); @@ -94,6 +108,7 @@ public: private: void refreshAccessPoints(); void refreshSavedConnections(); + void refreshVpnConnections(); void rebindActiveConnection(); void rebindActiveDevice(const std::string& devicePath); void rebindActiveAccessPoint(const std::string& apPath); @@ -113,6 +128,7 @@ private: std::string m_activeApPath; NetworkState m_state; std::vector m_accessPoints; + std::vector m_vpnConnections; std::vector m_savedSsids; bool m_scanning = false; std::int64_t m_scanBaselineLastScan = 0; diff --git a/src/render/text/glyph_registry.cpp b/src/render/text/glyph_registry.cpp index 0377ad13d..0453b4f9e 100644 --- a/src/render/text/glyph_registry.cpp +++ b/src/render/text/glyph_registry.cpp @@ -191,6 +191,7 @@ const std::unordered_map kIcons = { {"ethernet-off", 0xECCD}, {"ethernet-exclamation", 0xECCE}, {"ethernet-question", 0xECCF}, + {"shield-check", 0xEB22}, // Bluetooth devices {"bluetooth", 0xEA37}, diff --git a/src/shell/control_center/network_tab.cpp b/src/shell/control_center/network_tab.cpp index 43bfcef99..01cdcfb23 100644 --- a/src/shell/control_center/network_tab.cpp +++ b/src/shell/control_center/network_tab.cpp @@ -187,6 +187,120 @@ namespace { Signal<>::ScopedConnection m_paletteConn; }; + class VpnConnectionRow : public Flex { + public: + VpnConnectionRow(float scale, VpnConnectionInfo vpn, std::function onActivate, + std::function onDeactivate) + : m_vpn(std::move(vpn)), m_onActivate(std::move(onActivate)), m_onDeactivate(std::move(onDeactivate)) { + setDirection(FlexDirection::Horizontal); + setAlign(FlexAlign::Center); + setGap(Style::spaceSm * scale); + setPadding(Style::spaceSm * scale, Style::spaceMd * scale); + setMinHeight(kRowMinHeight * scale); + setRadius(Style::radiusMd * scale); + setFill(colorSpecFromRole(ColorRole::Surface)); + clearBorder(); + + auto kind = std::make_unique