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
|
// 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(", ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
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