Adding VPN support to the network control center tab

This commit is contained in:
Mathew-D
2026-05-09 11:32:36 -04:00
parent 44e85bb630
commit 89e4f54d5a
5 changed files with 311 additions and 3 deletions
+1
View File
@@ -237,6 +237,7 @@
"wifi": "Wi-Fi",
"password-prompt": "Enter password",
"password-prompt-for": "Enter password for {ssid}",
"vpns": "VPNs",
"unavailable-title": "NetworkManager unavailable",
"unavailable-detail": "Install and enable NetworkManager to manage networks from here.",
"no-networks": "No networks in range"
+137 -1
View File
@@ -9,6 +9,7 @@
#include <map>
#include <sdbus-c++/IProxy.h>
#include <sdbus-c++/Types.h>
#include <set>
#include <vector>
namespace {
@@ -197,18 +198,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 +305,54 @@ bool NetworkService::activateAccessPoint(const AccessPointInfo& ap) {
}
}
bool NetworkService::activateVpnConnection(const VpnConnectionInfo& vpn) {
if (vpn.path.empty()) {
return false;
}
try {
sdbus::ObjectPath activePath;
m_nm->callMethod("ActivateConnection")
.onInterface(k_nmInterface)
.withArguments(sdbus::ObjectPath{vpn.path}, sdbus::ObjectPath{"/"}, sdbus::ObjectPath{"/"})
.storeResultsTo(activePath);
kLog.info("activating vpn name={} active={}", vpn.name, 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<sdbus::ObjectPath> activeConnections;
const sdbus::Variant activeVar = m_nm->getProperty("ActiveConnections").onInterface(k_nmInterface);
activeConnections = activeVar.get<std::vector<sdbus::ObjectPath>>();
for (const auto& activePath : activeConnections) {
try {
auto active = sdbus::createProxy(m_bus.connection(), k_nmBusName, activePath);
const auto profilePath =
getPropertyOr<sdbus::ObjectPath>(*active, k_nmActiveConnectionInterface, "Connection", sdbus::ObjectPath{});
const auto activeState = getPropertyOr<std::uint32_t>(*active, k_nmActiveConnectionInterface, "State", 0U);
if (profilePath != vpn.path || activeState != k_nmActiveConnectionStateActivated) {
continue;
}
m_nm->callMethod("DeactivateConnection").onInterface(k_nmInterface).withArguments(activePath);
kLog.info("deactivated vpn name={} active={}", vpn.name, std::string(activePath));
return true;
} catch (const sdbus::Error&) {
}
}
} catch (const sdbus::Error& e) {
kLog.warn("DeactivateConnection(vpn) failed name={} path={} err={}", vpn.name, vpn.path, e.what());
return false;
}
return false;
}
void NetworkService::setWirelessEnabled(bool enabled) {
if (enabled != m_state.wirelessEnabled) {
m_pendingLocalWirelessEnabled = enabled;
@@ -517,6 +569,90 @@ void NetworkService::refreshSavedConnections() {
m_savedSsids = std::move(next);
}
void NetworkService::refreshVpnConnections() {
std::set<std::string> activeVpnPaths;
try {
std::vector<sdbus::ObjectPath> activeConnections;
const sdbus::Variant activeVar = m_nm->getProperty("ActiveConnections").onInterface(k_nmInterface);
activeConnections = activeVar.get<std::vector<sdbus::ObjectPath>>();
for (const auto& activePath : activeConnections) {
try {
auto active = sdbus::createProxy(m_bus.connection(), k_nmBusName, activePath);
const auto type = getPropertyOr<std::string>(*active, k_nmActiveConnectionInterface, "Type", std::string{});
const auto state = getPropertyOr<std::uint32_t>(*active, k_nmActiveConnectionInterface, "State", 0U);
if (type != "vpn" || state != k_nmActiveConnectionStateActivated) {
continue;
}
const auto profilePath =
getPropertyOr<sdbus::ObjectPath>(*active, k_nmActiveConnectionInterface, "Connection", sdbus::ObjectPath{});
if (!profilePath.empty() && profilePath != "/") {
activeVpnPaths.insert(std::string(profilePath));
}
} catch (const sdbus::Error&) {
}
}
} catch (const sdbus::Error& e) {
kLog.debug("refreshVpnConnections active list: {}", e.what());
}
std::vector<VpnConnectionInfo> next;
try {
auto settings = sdbus::createProxy(m_bus.connection(), k_nmBusName, k_nmSettingsObjectPath);
std::vector<sdbus::ObjectPath> 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<std::string, std::map<std::string, sdbus::Variant>> 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<std::string>();
} catch (const sdbus::Error&) {
continue;
}
if (type != "vpn") {
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<std::string>();
} catch (const sdbus::Error&) {
}
}
if (info.name.empty()) {
info.name = info.path;
}
info.active = activeVpnPaths.contains(info.path);
next.push_back(std::move(info));
} catch (const sdbus::Error&) {
}
}
} catch (const sdbus::Error& e) {
kLog.debug("refreshVpnConnections: {}", 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;
+15
View File
@@ -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,
@@ -65,6 +73,7 @@ public:
[[nodiscard]] const NetworkState& state() const noexcept { return m_state; }
[[nodiscard]] const std::vector<AccessPointInfo>& accessPoints() const noexcept { return m_accessPoints; }
[[nodiscard]] const std::vector<VpnConnectionInfo>& 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 +88,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 +107,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 +127,7 @@ private:
std::string m_activeApPath;
NetworkState m_state;
std::vector<AccessPointInfo> m_accessPoints;
std::vector<VpnConnectionInfo> m_vpnConnections;
std::vector<std::string> m_savedSsids;
bool m_scanning = false;
std::int64_t m_scanBaselineLastScan = 0;
+157 -2
View File
@@ -187,6 +187,120 @@ namespace {
Signal<>::ScopedConnection m_paletteConn;
};
class VpnConnectionRow : public Flex {
public:
VpnConnectionRow(float scale, VpnConnectionInfo vpn, std::function<void(const VpnConnectionInfo&)> onActivate,
std::function<void(const VpnConnectionInfo&)> 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<Label>();
kind->setText("VPN");
kind->setCaptionStyle();
kind->setFontSize(Style::fontSizeCaption * scale);
kind->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
addChild(std::move(kind));
auto name = std::make_unique<Label>();
name->setText(m_vpn.name);
name->setBold(m_vpn.active);
name->setFontSize(Style::fontSizeBody * scale);
name->setColor(colorSpecFromRole(ColorRole::OnSurface));
name->setFlexGrow(1.0f);
m_title = name.get();
addChild(std::move(name));
auto action = std::make_unique<Button>();
action->setVariant(m_vpn.active ? ButtonVariant::Destructive : ButtonVariant::Default);
action->setText(i18n::tr(m_vpn.active ? "control-center.network.disconnect" : "control-center.network.connect"));
action->setOnClick([this]() { triggerAction(); });
m_actionButton = static_cast<Button*>(addChild(std::move(action)));
auto area = std::make_unique<InputArea>();
area->setPropagateEvents(true);
area->setOnEnter([this](const InputArea::PointerData& /*data*/) { applyState(); });
area->setOnLeave([this]() { applyState(); });
area->setOnPress([this](const InputArea::PointerData& /*data*/) { applyState(); });
area->setOnClick([this](const InputArea::PointerData& /*data*/) { triggerAction(); });
m_inputArea = static_cast<InputArea*>(addChild(std::move(area)));
applyState();
m_paletteConn = paletteChanged().connect([this] { applyState(); });
}
void doLayout(Renderer& renderer) override {
if (m_inputArea == nullptr) {
return;
}
m_inputArea->setVisible(false);
Flex::doLayout(renderer);
m_inputArea->setVisible(true);
m_inputArea->setPosition(0.0f, 0.0f);
m_inputArea->setSize(width(), height());
if (m_actionButton != nullptr) {
const float areaWidth = std::max(0.0f, m_actionButton->x() - gap());
m_inputArea->setSize(areaWidth, height());
}
applyState();
}
LayoutSize doMeasure(Renderer& renderer, const LayoutConstraints& constraints) override {
return measureByLayout(renderer, constraints);
}
void doArrange(Renderer& renderer, const LayoutRect& rect) override { arrangeByLayout(renderer, rect); }
private:
void triggerAction() {
if (m_vpn.active) {
if (m_onDeactivate) {
m_onDeactivate(m_vpn);
}
} else {
if (m_onActivate) {
m_onActivate(m_vpn);
}
}
}
void applyState() {
const bool hov = m_inputArea != nullptr && m_inputArea->hovered();
const bool pressed = m_inputArea != nullptr && m_inputArea->pressed();
if (pressed) {
setFill(colorSpecFromRole(ColorRole::Primary));
setBorder(colorSpecFromRole(ColorRole::Primary), Style::borderWidth);
if (m_title != nullptr) {
m_title->setColor(colorSpecFromRole(ColorRole::OnPrimary));
}
return;
}
setFill(colorSpecFromRole(m_vpn.active ? ColorRole::SurfaceVariant : ColorRole::Surface));
if (hov) {
setBorder(colorSpecFromRole(ColorRole::Primary), Style::borderWidth);
} else {
clearBorder();
}
if (m_title != nullptr) {
m_title->setColor(colorSpecFromRole(ColorRole::OnSurface));
}
}
VpnConnectionInfo m_vpn;
std::function<void(const VpnConnectionInfo&)> m_onActivate;
std::function<void(const VpnConnectionInfo&)> m_onDeactivate;
Label* m_title = nullptr;
Button* m_actionButton = nullptr;
InputArea* m_inputArea = nullptr;
Signal<>::ScopedConnection m_paletteConn;
};
} // namespace
NetworkTab::NetworkTab(NetworkService* network, NetworkSecretAgent* secrets) : m_network(network), m_secrets(secrets) {
@@ -532,6 +646,19 @@ std::string NetworkTab::apListKey(const std::vector<AccessPointInfo>& aps) const
return key;
}
std::string NetworkTab::vpnListKey(const std::vector<VpnConnectionInfo>& vpns) const {
std::string key;
for (const auto& vpn : vpns) {
key += vpn.path;
key.push_back(':');
key += vpn.name;
key.push_back(':');
key += vpn.active ? '1' : '0';
key.push_back('\n');
}
return key;
}
void NetworkTab::rebuildApList(Renderer& renderer) {
uiAssertNotRendering("NetworkTab::rebuildApList");
if (m_list == nullptr || m_listScroll == nullptr) {
@@ -543,7 +670,9 @@ void NetworkTab::rebuildApList(Renderer& renderer) {
}
const auto& aps = m_network != nullptr ? m_network->accessPoints() : std::vector<AccessPointInfo>{};
const std::string nextKey = aps.empty() ? std::string("empty") : apListKey(aps);
const auto& vpns = m_network != nullptr ? m_network->vpnConnections() : std::vector<VpnConnectionInfo>{};
const std::string nextKey =
aps.empty() && vpns.empty() ? std::string("empty") : (apListKey(aps) + "\n---\n" + vpnListKey(vpns));
if (listWidth == m_lastListWidth && nextKey == m_lastListKey) {
return;
}
@@ -554,7 +683,7 @@ void NetworkTab::rebuildApList(Renderer& renderer) {
m_list->removeChild(m_list->children().front().get());
}
if (aps.empty()) {
if (aps.empty() && vpns.empty()) {
auto empty = std::make_unique<Label>();
empty->setText(m_network != nullptr ? i18n::tr("control-center.network.no-networks")
: i18n::tr("control-center.network.unavailable-title"));
@@ -580,6 +709,32 @@ void NetworkTab::rebuildApList(Renderer& renderer) {
});
m_list->addChild(std::move(row));
}
if (!vpns.empty()) {
auto section = std::make_unique<Label>();
section->setText(i18n::tr("control-center.network.vpns"));
section->setCaptionStyle();
section->setFontSize(Style::fontSizeCaption * contentScale());
section->setColor(colorSpecFromRole(ColorRole::OnSurfaceVariant));
m_list->addChild(std::move(section));
for (const auto& vpn : vpns) {
auto row = std::make_unique<VpnConnectionRow>(
contentScale(), vpn,
[this](const VpnConnectionInfo& clicked) {
if (m_network != nullptr) {
m_network->activateVpnConnection(clicked);
}
PanelManager::instance().refresh();
},
[this](const VpnConnectionInfo& clicked) {
if (m_network != nullptr) {
m_network->deactivateVpnConnection(clicked);
}
PanelManager::instance().refresh();
});
m_list->addChild(std::move(row));
}
}
}
m_list->layout(renderer);
}
+1
View File
@@ -34,6 +34,7 @@ private:
void showPasswordPrompt(const NetworkSecretAgent::SecretRequest& request);
void clearPasswordPrompt();
[[nodiscard]] std::string apListKey(const std::vector<AccessPointInfo>& aps) const;
[[nodiscard]] std::string vpnListKey(const std::vector<VpnConnectionInfo>& vpns) const;
NetworkService* m_network = nullptr;
NetworkSecretAgent* m_secrets = nullptr;