Feature: VPN panel options, install/uninstall, and Tailscale auto-connect
VpnIndicator.qml gains ProtonVPN sign-in (with 2FA), location/Secure Core/NetShield/kill switch options, Mullvad options, and Install / Uninstall links for both clients via the new scripts/VpnPackage.sh (pkexec). NetworkIndicator.qml names the VPN the public IP is coming through, using a new activeVpnLabel on VpnExclusivity. Tailscale now connects on its own whenever none of Home (UniFi), ProtonVPN or Mullvad is connected, and still backs off when one is. VpnIndicator publishes vpnStatesKnown/vpnTransitioning so this waits for every provider's first poll and never fires mid-switch. A manual "off" sticks until a provider next connects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,11 +7,39 @@ import QtQuick
|
||||
// connected. This singleton is the shared signal between the two widget
|
||||
// files: VpnIndicator.qml writes each tunnel's live connection state here
|
||||
// as it polls them; TailscaleIndicator.qml reads it to back off (`tailscale
|
||||
// down`) rather than stay up alongside one, and to refuse turning Tailscale
|
||||
// back on while one is still connected.
|
||||
// down`) rather than stay up alongside one, to refuse turning Tailscale
|
||||
// back on while one is still connected, and to bring Tailscale up on its
|
||||
// own whenever none of them is.
|
||||
//
|
||||
// TailscaleIndicator.qml publishes its own state back here too, so
|
||||
// NetworkIndicator.qml can name whichever tunnel is up under the public IP.
|
||||
QtObject {
|
||||
property bool mullvadConnected: false
|
||||
property bool protonConnected: false
|
||||
property bool homeWgConnected: false
|
||||
readonly property bool commercialVpnActive: mullvadConnected || protonConnected || homeWgConnected
|
||||
|
||||
// Also published by VpnIndicator.qml: whether every provider has
|
||||
// reported its state at least once (until then the flags above are just
|
||||
// their defaults), and whether any of them is mid connect, disconnect or
|
||||
// switch (when "nothing connected" is only momentary). noVpnActive is
|
||||
// the settled "none of them is connected" that TailscaleIndicator.qml
|
||||
// auto-connects on.
|
||||
property bool vpnStatesKnown: false
|
||||
property bool vpnTransitioning: false
|
||||
readonly property bool noVpnActive: vpnStatesKnown && !commercialVpnActive && !vpnTransitioning
|
||||
|
||||
property bool tailscaleConnected: false
|
||||
// Name of the exit node in use, "" if none.
|
||||
property string tailscaleExitNode: ""
|
||||
|
||||
// e.g. "ProtonVPN", or "Tailscale (exit node: nas)"; "" when nothing is
|
||||
// up. Normally one name, but lists every tunnel it sees, since one can
|
||||
// briefly overlap another while switching.
|
||||
readonly property string activeVpnLabel: [
|
||||
mullvadConnected ? "Mullvad" : "",
|
||||
protonConnected ? "ProtonVPN" : "",
|
||||
homeWgConnected ? "Home (UniFi)" : "",
|
||||
tailscaleConnected ? "Tailscale" + (tailscaleExitNode ? " (exit node: " + tailscaleExitNode + ")" : "") : ""
|
||||
].filter(Boolean).join(", ")
|
||||
}
|
||||
|
||||
@@ -601,6 +601,17 @@ Item {
|
||||
publicIpProc.running = true
|
||||
}
|
||||
|
||||
// Same for a VPN coming up or going down — the public IP is then the
|
||||
// tunnel's exit (or no longer is).
|
||||
Connections {
|
||||
target: VpnExclusivity
|
||||
function onActiveVpnLabelChanged() {
|
||||
root.publicIp = "—"
|
||||
if (panel.visible && root.connectedDevice !== null && !publicIpProc.running)
|
||||
publicIpProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 60000
|
||||
repeat: true
|
||||
@@ -1049,6 +1060,30 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Which VPN (if any) the public IP above is coming through.
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: 4
|
||||
visible: root.connectedDevice !== null && VpnExclusivity.activeVpnLabel !== ""
|
||||
|
||||
Text {
|
||||
text: "VPN:"
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: Colors.muted
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width - x
|
||||
text: VpnExclusivity.activeVpnLabel
|
||||
elide: Text.ElideRight
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: Colors.foreground
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
// Captive portal banner — shown when the connectivity probe
|
||||
// above finds either a confirmed sign-in page or a
|
||||
// "limited" result standing between this network and the
|
||||
|
||||
@@ -52,11 +52,15 @@ Item {
|
||||
property bool _loginPending: false
|
||||
property bool _authUrlOpened: false
|
||||
|
||||
// Set when the VPN-exclusivity guard (below) forces Tailscale down —
|
||||
// as opposed to you turning it off yourself — so it knows to bring
|
||||
// Tailscale back up automatically once the other VPN disconnects,
|
||||
// without overriding a deliberate manual "off".
|
||||
property bool _forcedDownByVpn: false
|
||||
// Tailscale is the fallback whenever none of the VPN providers is
|
||||
// connected — see maybeAutoConnect(). _manualOff is set when you switch
|
||||
// it off yourself with no provider connected, so that sticks; it's
|
||||
// cleared once a provider connects, so Tailscale still comes back after
|
||||
// that one disconnects. _autoConnectTried allows one automatic `up` per
|
||||
// stretch of no provider being connected, so a failing one doesn't
|
||||
// retry (and re-report its failure) on every poll.
|
||||
property bool _manualOff: false
|
||||
property bool _autoConnectTried: false
|
||||
|
||||
// Other logged-in Tailscale accounts on this machine (`tailscale switch
|
||||
// --list`) — separate tailnets/identities you can flip between, not
|
||||
@@ -147,6 +151,8 @@ Item {
|
||||
function applyStatus(text) {
|
||||
const next = root.parseTailscaleStatus(text)
|
||||
root.status = next
|
||||
VpnExclusivity.tailscaleConnected = next.running
|
||||
VpnExclusivity.tailscaleExitNode = next.running && next.activeExitNode ? next.activeExitNode.name : ""
|
||||
if (root._desired !== -1 && next.running === (root._desired === 1)) root._desired = -1
|
||||
if (next.running) {
|
||||
root._loginPending = false
|
||||
@@ -159,7 +165,6 @@ Item {
|
||||
if (VpnExclusivity.commercialVpnActive && !root.busy) {
|
||||
root.busy = true
|
||||
root._desired = 0
|
||||
root._forcedDownByVpn = true
|
||||
root.actionStatus = "Tailscale stopped — a VPN provider is active"
|
||||
actionStatusTimer.restart()
|
||||
actionProc.command = ["tailscale", "down"]
|
||||
@@ -168,6 +173,22 @@ Item {
|
||||
} else if (next.needsLogin && next.authUrl !== "" && !root._authUrlOpened) {
|
||||
root.openAuthUrl(next.authUrl)
|
||||
}
|
||||
root.maybeAutoConnect()
|
||||
}
|
||||
|
||||
// Only acts on a confirmed "Stopped": not NeedsLogin (`tailscale up`
|
||||
// would open a login page in the browser unasked), nor a daemon that
|
||||
// isn't answering yet.
|
||||
function shouldAutoConnect() {
|
||||
return VpnExclusivity.noVpnActive && !root._manualOff && !root._autoConnectTried
|
||||
&& root.installed && !root.busy && root.status.backendState === "Stopped"
|
||||
}
|
||||
|
||||
// Waits out autoConnectTimer first rather than connecting right away —
|
||||
// see there.
|
||||
function maybeAutoConnect() {
|
||||
if (!root.shouldAutoConnect()) autoConnectTimer.stop()
|
||||
else if (!autoConnectTimer.running) autoConnectTimer.start()
|
||||
}
|
||||
|
||||
function openAuthUrl(url) {
|
||||
@@ -192,10 +213,10 @@ Item {
|
||||
|
||||
function toggleTailscale() {
|
||||
if (root.busy || !root.installed) return
|
||||
// Any manual action overrides the guard's own memory of why
|
||||
// Tailscale went down — a deliberate "off" here should stick even
|
||||
// once the VPN it deferred to eventually disconnects.
|
||||
root._forcedDownByVpn = false
|
||||
// A deliberate "off" with no VPN connected shouldn't be undone by
|
||||
// auto-connect straight away.
|
||||
root._manualOff = root.active && !VpnExclusivity.commercialVpnActive
|
||||
autoConnectTimer.stop()
|
||||
if (root.active) {
|
||||
root.busy = true
|
||||
root._desired = 0
|
||||
@@ -325,19 +346,34 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-resumes Tailscale once neither commercial VPN is connected
|
||||
// anymore — but only if it was this guard that took Tailscale down in
|
||||
// the first place, never overriding a manual "off".
|
||||
// A provider connecting starts things afresh for when it next
|
||||
// disconnects: a manual "off" from before no longer applies, and
|
||||
// auto-connect gets a new attempt.
|
||||
Connections {
|
||||
target: VpnExclusivity
|
||||
function onCommercialVpnActiveChanged() {
|
||||
if (!VpnExclusivity.commercialVpnActive && root._forcedDownByVpn && !root.active) {
|
||||
root._forcedDownByVpn = false
|
||||
root.actionStatus = "Resuming Tailscale — VPN disconnected"
|
||||
actionStatusTimer.restart()
|
||||
root.bringTailscaleUp()
|
||||
if (VpnExclusivity.commercialVpnActive) {
|
||||
root._manualOff = false
|
||||
root._autoConnectTried = false
|
||||
}
|
||||
}
|
||||
function onNoVpnActiveChanged() { root.maybeAutoConnect() }
|
||||
}
|
||||
|
||||
// A short grace period before auto-connecting, as a backstop against
|
||||
// momentary gaps vpnTransitioning doesn't cover (e.g. one provider's
|
||||
// disconnect finishing a moment before the next one's connect starts).
|
||||
Timer {
|
||||
id: autoConnectTimer
|
||||
interval: 3000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (!root.shouldAutoConnect()) return
|
||||
root._autoConnectTried = true
|
||||
root.bringTailscaleUp()
|
||||
root.actionStatus = "Connecting Tailscale — no VPN active"
|
||||
actionStatusTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
// Background poll, independent of whether the panel is open, so the bar
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
58
dotfiles/.config/quickshell/scripts/VpnPackage.sh
Executable file
58
dotfiles/.config/quickshell/scripts/VpnPackage.sh
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# Installs or uninstalls one of the VPN clients the bar's VPN panel drives
|
||||
# (VpnIndicator.qml's Install / Uninstall links), along with its system
|
||||
# service. Both packages are in Arch's official repos.
|
||||
#
|
||||
# Usage: ./VpnPackage.sh install|uninstall mullvad|proton
|
||||
#
|
||||
# This runs from the bar with no terminal to type a sudo password into, so
|
||||
# the root part goes through pkexec (the polkit agent's password dialog),
|
||||
# same as update-system.sh's Ghostty install. Exits 3 if that dialog is
|
||||
# dismissed.
|
||||
#
|
||||
# Services: proton-vpn-daemon's own pacman hooks enable proton.VPN.service
|
||||
# on install and disable it on removal. Mullvad's package only prints a
|
||||
# reminder, so its daemon is enabled/disabled here.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
action="${1:-}"
|
||||
vpn="${2:-}"
|
||||
|
||||
case "$vpn" in
|
||||
mullvad) pkg=mullvad-vpn-daemon ;;
|
||||
proton) pkg=proton-vpn-cli ;;
|
||||
*) echo "Usage: $0 install|uninstall mullvad|proton" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
case "$action" in
|
||||
install)
|
||||
root_cmd="pacman -S --needed --noconfirm $pkg"
|
||||
if [ "$vpn" = mullvad ]; then
|
||||
root_cmd+=" && systemctl enable --now mullvad-daemon.service"
|
||||
fi
|
||||
;;
|
||||
uninstall)
|
||||
# Take the tunnel down cleanly first, rather than have it pulled out
|
||||
# from under the network by the service stopping. For Mullvad, also
|
||||
# turn lockdown mode off — otherwise its block-everything firewall
|
||||
# rules can outlive the daemon.
|
||||
if [ "$vpn" = mullvad ] && command -v mullvad &> /dev/null; then
|
||||
mullvad lockdown-mode set off &> /dev/null || true
|
||||
mullvad disconnect --wait &> /dev/null || true
|
||||
elif [ "$vpn" = proton ] && command -v protonvpn &> /dev/null; then
|
||||
protonvpn disconnect &> /dev/null || true
|
||||
fi
|
||||
root_cmd="pacman -Rns --noconfirm $pkg"
|
||||
if [ "$vpn" = mullvad ]; then
|
||||
root_cmd="systemctl disable --now mullvad-daemon.service mullvad-early-boot-blocking.service &> /dev/null; $root_cmd"
|
||||
fi
|
||||
;;
|
||||
*) echo "Usage: $0 install|uninstall mullvad|proton" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
rc=0
|
||||
pkexec bash -c "$root_cmd" || rc=$?
|
||||
# 126: the password dialog was dismissed.
|
||||
if [ $rc -eq 126 ]; then echo "Cancelled" >&2; exit 3; fi
|
||||
exit $rc
|
||||
Reference in New Issue
Block a user