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:
2026-10-22 11:14:46 +01:00
parent 5aaa3822a6
commit e544580963
5 changed files with 1202 additions and 228 deletions

View File

@@ -7,11 +7,39 @@ import QtQuick
// connected. This singleton is the shared signal between the two widget // connected. This singleton is the shared signal between the two widget
// files: VpnIndicator.qml writes each tunnel's live connection state here // files: VpnIndicator.qml writes each tunnel's live connection state here
// as it polls them; TailscaleIndicator.qml reads it to back off (`tailscale // as it polls them; TailscaleIndicator.qml reads it to back off (`tailscale
// down`) rather than stay up alongside one, and to refuse turning Tailscale // down`) rather than stay up alongside one, to refuse turning Tailscale
// back on while one is still connected. // 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 { QtObject {
property bool mullvadConnected: false property bool mullvadConnected: false
property bool protonConnected: false property bool protonConnected: false
property bool homeWgConnected: false property bool homeWgConnected: false
readonly property bool commercialVpnActive: mullvadConnected || protonConnected || homeWgConnected 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(", ")
} }

View File

@@ -601,6 +601,17 @@ Item {
publicIpProc.running = true 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 { Timer {
interval: 60000 interval: 60000
repeat: true 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 // Captive portal banner — shown when the connectivity probe
// above finds either a confirmed sign-in page or a // above finds either a confirmed sign-in page or a
// "limited" result standing between this network and the // "limited" result standing between this network and the

View File

@@ -52,11 +52,15 @@ Item {
property bool _loginPending: false property bool _loginPending: false
property bool _authUrlOpened: false property bool _authUrlOpened: false
// Set when the VPN-exclusivity guard (below) forces Tailscale down — // Tailscale is the fallback whenever none of the VPN providers is
// as opposed to you turning it off yourself — so it knows to bring // connected — see maybeAutoConnect(). _manualOff is set when you switch
// Tailscale back up automatically once the other VPN disconnects, // it off yourself with no provider connected, so that sticks; it's
// without overriding a deliberate manual "off". // cleared once a provider connects, so Tailscale still comes back after
property bool _forcedDownByVpn: false // 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 // Other logged-in Tailscale accounts on this machine (`tailscale switch
// --list`) — separate tailnets/identities you can flip between, not // --list`) — separate tailnets/identities you can flip between, not
@@ -147,6 +151,8 @@ Item {
function applyStatus(text) { function applyStatus(text) {
const next = root.parseTailscaleStatus(text) const next = root.parseTailscaleStatus(text)
root.status = next 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 (root._desired !== -1 && next.running === (root._desired === 1)) root._desired = -1
if (next.running) { if (next.running) {
root._loginPending = false root._loginPending = false
@@ -159,7 +165,6 @@ Item {
if (VpnExclusivity.commercialVpnActive && !root.busy) { if (VpnExclusivity.commercialVpnActive && !root.busy) {
root.busy = true root.busy = true
root._desired = 0 root._desired = 0
root._forcedDownByVpn = true
root.actionStatus = "Tailscale stopped — a VPN provider is active" root.actionStatus = "Tailscale stopped — a VPN provider is active"
actionStatusTimer.restart() actionStatusTimer.restart()
actionProc.command = ["tailscale", "down"] actionProc.command = ["tailscale", "down"]
@@ -168,6 +173,22 @@ Item {
} else if (next.needsLogin && next.authUrl !== "" && !root._authUrlOpened) { } else if (next.needsLogin && next.authUrl !== "" && !root._authUrlOpened) {
root.openAuthUrl(next.authUrl) 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) { function openAuthUrl(url) {
@@ -192,10 +213,10 @@ Item {
function toggleTailscale() { function toggleTailscale() {
if (root.busy || !root.installed) return if (root.busy || !root.installed) return
// Any manual action overrides the guard's own memory of why // A deliberate "off" with no VPN connected shouldn't be undone by
// Tailscale went down — a deliberate "off" here should stick even // auto-connect straight away.
// once the VPN it deferred to eventually disconnects. root._manualOff = root.active && !VpnExclusivity.commercialVpnActive
root._forcedDownByVpn = false autoConnectTimer.stop()
if (root.active) { if (root.active) {
root.busy = true root.busy = true
root._desired = 0 root._desired = 0
@@ -325,19 +346,34 @@ Item {
} }
} }
// Auto-resumes Tailscale once neither commercial VPN is connected // A provider connecting starts things afresh for when it next
// anymore — but only if it was this guard that took Tailscale down in // disconnects: a manual "off" from before no longer applies, and
// the first place, never overriding a manual "off". // auto-connect gets a new attempt.
Connections { Connections {
target: VpnExclusivity target: VpnExclusivity
function onCommercialVpnActiveChanged() { function onCommercialVpnActiveChanged() {
if (!VpnExclusivity.commercialVpnActive && root._forcedDownByVpn && !root.active) { if (VpnExclusivity.commercialVpnActive) {
root._forcedDownByVpn = false root._manualOff = false
root.actionStatus = "Resuming Tailscale — VPN disconnected" root._autoConnectTried = false
actionStatusTimer.restart()
root.bringTailscaleUp()
} }
} }
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 // Background poll, independent of whether the panel is open, so the bar

File diff suppressed because it is too large Load Diff

View 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