Give the VPN a toggle, an indicator, and a way back
Turning on a WireGuard profile whose server was unreachable used to cost the whole network stack, and the only way out was nmcli typed into a terminal. Quickshell's Networking module has no VPN surface, so this arrives as the one sanctioned nmcli exception: a helper that lists, raises and lowers profiles, a service that watches NetworkManager for changes made anywhere, a quick settings tile (left-click toggles the most recently used profile, right-click picks among them), and a bar glyph while a tunnel is up. The safety property is in the helper, where it cannot be skipped: activation waits a bounded 25 seconds, and a failure is rolled back down and reported instead of leaving a black-hole default route. The contract pins exactly that, against a stateful stub NetworkManager. Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
This commit is contained in:
@@ -84,6 +84,15 @@ Pill {
|
||||
}
|
||||
}
|
||||
|
||||
// A tunnel that is up changes what every connection means, so it earns a
|
||||
// permanent glyph while active -- and its absence is the resting state,
|
||||
// same shape as Bluetooth below.
|
||||
StatusGlyph {
|
||||
visible: Vpn.anyActive
|
||||
glyph: "\u{F0582}" // md-vpn
|
||||
color: Theme.accent
|
||||
}
|
||||
|
||||
StatusGlyph {
|
||||
glyph: {
|
||||
if (root.muted || root.volume <= 0)
|
||||
|
||||
@@ -21,8 +21,8 @@ Item {
|
||||
implicitWidth: Theme.controlCenterWidth
|
||||
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
||||
|
||||
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is
|
||||
// open at a time, so the panel never grows past the screen.
|
||||
// "" | "wifi" | "vpn" | "bluetooth" | "sink" | "source". Only one detail
|
||||
// list is open at a time, so the panel never grows past the screen.
|
||||
property string expandedSection: ""
|
||||
|
||||
function expand(name: string): void {
|
||||
@@ -128,6 +128,25 @@ Item {
|
||||
onToggled: Connectivity.setWired(!Connectivity.wiredOn)
|
||||
}
|
||||
|
||||
// Only when a VPN profile is saved at all: a machine with none has
|
||||
// nothing to toggle, and the tile would be a control for absent
|
||||
// configuration -- same reasoning as the Ethernet tile above.
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
visible: Vpn.available
|
||||
icon: "network-vpn-symbolic"
|
||||
label: "VPN"
|
||||
active: Vpn.anyActive
|
||||
enabled: !Vpn.busy
|
||||
sublabel: {
|
||||
if (Vpn.busy)
|
||||
return "Working…";
|
||||
return Vpn.anyActive ? Vpn.activeSummary : "Off";
|
||||
}
|
||||
onToggled: Vpn.toggle()
|
||||
onExpanded: root.expand("vpn")
|
||||
}
|
||||
|
||||
Toggle {
|
||||
width: root.cellWidth
|
||||
icon: root.btAdapter && root.btAdapter.enabled ? "bluetooth-active-symbolic" : "bluetooth-disabled-symbolic"
|
||||
@@ -209,6 +228,16 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
width: content.width
|
||||
expanded: root.expandedSection === "vpn"
|
||||
|
||||
VpnList {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
width: content.width
|
||||
expanded: root.expandedSection === "bluetooth"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// VPN profile picker: every saved VPN or WireGuard connection, active first.
|
||||
// Clicking a row flips that one profile, so a machine with several tunnels
|
||||
// can switch without a trip through nmcli — which is the whole reason this
|
||||
// list exists (see services/Vpn.qml).
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
implicitHeight: list.implicitHeight
|
||||
|
||||
readonly property var profiles: {
|
||||
const list = Vpn.connections.slice();
|
||||
list.sort((a, b) => {
|
||||
if (a.active !== b.active)
|
||||
return a.active ? -1 : 1;
|
||||
return (a.name || "").localeCompare(b.name || "");
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
function stateText(profile): string {
|
||||
const kind = profile.kind === "wireguard" ? "WireGuard" : "VPN";
|
||||
return profile.active ? kind + " · Connected" : kind;
|
||||
}
|
||||
|
||||
ScrollColumn {
|
||||
id: list
|
||||
anchors.fill: parent
|
||||
maxHeight: 300
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.profiles.length === 0
|
||||
topPadding: 12
|
||||
bottomPadding: 12
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: "No VPN profiles"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.profiles
|
||||
|
||||
RowButton {
|
||||
required property var modelData
|
||||
|
||||
width: parent.width
|
||||
icon: "network-vpn-symbolic"
|
||||
iconFallback: "network-workgroup-symbolic"
|
||||
label: modelData.name
|
||||
sublabel: root.stateText(modelData)
|
||||
selected: modelData.active
|
||||
dimmed: Vpn.busy
|
||||
onClicked: Vpn.setActive(modelData.uuid, !modelData.active)
|
||||
}
|
||||
}
|
||||
|
||||
// Activation failures land here rather than vanishing: "the toggle
|
||||
// did nothing" was exactly the complaint that motivated this panel.
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: Vpn.lastError !== ""
|
||||
topPadding: 4
|
||||
bottomPadding: 8
|
||||
leftPadding: 12
|
||||
rightPadding: 12
|
||||
wrapMode: Text.Wrap
|
||||
text: Vpn.lastError
|
||||
color: Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,5 +16,6 @@ RecentExchange 1.0 RecentExchange.qml
|
||||
RowButton 1.0 RowButton.qml
|
||||
ScrollColumn 1.0 ScrollColumn.qml
|
||||
Section 1.0 Section.qml
|
||||
VpnList 1.0 VpnList.qml
|
||||
WifiList 1.0 WifiList.qml
|
||||
PowerProfileList 1.0 PowerProfileList.qml
|
||||
|
||||
Reference in New Issue
Block a user